mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
add android unit tests
This commit is contained in:
parent
30fc632ce8
commit
61316d94a9
@ -1,106 +1,122 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
id 'com.google.devtools.ksp'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
id 'com.google.devtools.ksp'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withInputStream { localProperties.load(it) }
|
||||
localPropertiesFile.withInputStream { localProperties.load(it) }
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
def keystoreProperties = new Properties()
|
||||
def keystorePropertiesFile = rootProject.file('key.properties')
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystorePropertiesFile.withInputStream { keystoreProperties.load(it) }
|
||||
keystorePropertiesFile.withInputStream { keystoreProperties.load(it) }
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion 35
|
||||
compileSdkVersion 35
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
coreLibraryDesugaringEnabled true
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
coreLibraryDesugaringEnabled true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "app.alextran.immich"
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 35
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
def keyAliasVal = System.getenv("ALIAS")
|
||||
def keyPasswordVal = System.getenv("ANDROID_KEY_PASSWORD")
|
||||
def storePasswordVal = System.getenv("ANDROID_STORE_PASSWORD")
|
||||
|
||||
keyAlias keyAliasVal ? keyAliasVal : keystoreProperties['keyAlias']
|
||||
keyPassword keyPasswordVal ? keyPasswordVal : keystoreProperties['keyPassword']
|
||||
storeFile file("../key.jks") ? file("../key.jks") : file(keystoreProperties['storeFile'])
|
||||
storePassword storePasswordVal ? storePasswordVal : keystoreProperties['storePassword']
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix '.debug'
|
||||
versionNameSuffix '-DEBUG'
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
namespace 'app.alextran.immich'
|
||||
|
||||
testOptions {
|
||||
unitTests.returnDefaultValues = true
|
||||
unitTests.all {
|
||||
testLogging {
|
||||
events "passed", "skipped", "failed", "standardOut", "standardError"
|
||||
outputs.upToDateWhen { false }
|
||||
showStandardStreams = true
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "app.alextran.immich"
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 35
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
def keyAliasVal = System.getenv("ALIAS")
|
||||
def keyPasswordVal = System.getenv("ANDROID_KEY_PASSWORD")
|
||||
def storePasswordVal = System.getenv("ANDROID_STORE_PASSWORD")
|
||||
|
||||
keyAlias keyAliasVal ? keyAliasVal : keystoreProperties['keyAlias']
|
||||
keyPassword keyPasswordVal ? keyPasswordVal : keystoreProperties['keyPassword']
|
||||
storeFile file("../key.jks") ? file("../key.jks") : file(keystoreProperties['storeFile'])
|
||||
storePassword storePasswordVal ? storePasswordVal : keystoreProperties['storePassword']
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix '.debug'
|
||||
versionNameSuffix '-DEBUG'
|
||||
}
|
||||
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
namespace 'app.alextran.immich'
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
def kotlin_version = '2.0.20'
|
||||
def kotlin_coroutines_version = '1.9.0'
|
||||
def work_version = '2.9.1'
|
||||
def concurrent_version = '1.2.0'
|
||||
def guava_version = '33.3.1-android'
|
||||
def glide_version = '4.16.0'
|
||||
def serialization_version = '1.8.1'
|
||||
def kotlin_version = '2.0.20'
|
||||
def kotlin_coroutines_version = '1.9.0'
|
||||
def work_version = '2.9.1'
|
||||
def concurrent_version = '1.2.0'
|
||||
def guava_version = '33.3.1-android'
|
||||
def glide_version = '4.16.0'
|
||||
def serialization_version = '1.8.1'
|
||||
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
||||
implementation "androidx.work:work-runtime-ktx:$work_version"
|
||||
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
|
||||
implementation "com.google.guava:guava:$guava_version"
|
||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$serialization_version"
|
||||
ksp "com.github.bumptech.glide:ksp:$glide_version"
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
||||
implementation "androidx.work:work-runtime-ktx:$work_version"
|
||||
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
|
||||
implementation "com.google.guava:guava:$guava_version"
|
||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$serialization_version"
|
||||
|
||||
testImplementation "junit:junit:4.13.2"
|
||||
testImplementation "io.mockk:mockk:1.13.10"
|
||||
|
||||
ksp "com.github.bumptech.glide:ksp:$glide_version"
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
|
||||
}
|
||||
|
||||
// This is uncommented in F-Droid build script
|
||||
|
@ -14,9 +14,9 @@ class MediaManager(context: Context) {
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
companion object {
|
||||
private const val SHARED_PREF_NAME = "Immich::MediaManager"
|
||||
private const val SHARED_PREF_MEDIA_STORE_VERSION_KEY = "MediaStore::getVersion"
|
||||
private const val SHARED_PREF_MEDIA_STORE_GEN_KEY = "MediaStore::getGeneration"
|
||||
const val SHARED_PREF_NAME = "Immich::MediaManager"
|
||||
const val SHARED_PREF_MEDIA_STORE_VERSION_KEY = "MediaStore::getVersion"
|
||||
const val SHARED_PREF_MEDIA_STORE_GEN_KEY = "MediaStore::getGeneration"
|
||||
|
||||
private fun getSavedGenerationMap(context: Context): Map<String, Long> {
|
||||
return context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
|
@ -0,0 +1,512 @@
|
||||
package app.alextran.immich.platform
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import app.alextran.immich.platform.MediaManager.Companion.SHARED_PREF_MEDIA_STORE_GEN_KEY
|
||||
import app.alextran.immich.platform.MediaManager.Companion.SHARED_PREF_MEDIA_STORE_VERSION_KEY
|
||||
import app.alextran.immich.platform.MediaManager.Companion.SHARED_PREF_NAME
|
||||
import io.mockk.Called
|
||||
import io.mockk.CapturingSlot
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.verify
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
|
||||
class MediaManagerTest {
|
||||
|
||||
private lateinit var mediaManager: MediaManager
|
||||
private lateinit var mockContext: Context
|
||||
private lateinit var mockPrefs: SharedPreferences
|
||||
private lateinit var mockEditor: SharedPreferences.Editor
|
||||
private lateinit var mockExternalUri: Uri
|
||||
private lateinit var mockContentResolver: android.content.ContentResolver
|
||||
|
||||
@JvmField
|
||||
@Rule
|
||||
val tempFolder: TemporaryFolder = TemporaryFolder()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockContext = mockk(relaxed = true)
|
||||
mockContentResolver = mockk<android.content.ContentResolver>(relaxed = true)
|
||||
every { mockContext.applicationContext } returns mockContext
|
||||
every { mockContext.contentResolver } returns mockContentResolver
|
||||
|
||||
mediaManager = MediaManager(mockContext)
|
||||
|
||||
mockPrefs = mockk(relaxed = true)
|
||||
mockEditor = mockk(relaxed = true)
|
||||
|
||||
every {
|
||||
mockContext.getSharedPreferences(
|
||||
SHARED_PREF_NAME,
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
} returns mockPrefs
|
||||
every { mockPrefs.edit() } returns mockEditor
|
||||
every { mockEditor.putString(any(), any()) } returns mockEditor
|
||||
every { mockEditor.remove(any()) } returns mockEditor
|
||||
every { mockEditor.apply() } answers { }
|
||||
|
||||
mockkStatic(Uri::class)
|
||||
mockExternalUri = mockk<Uri>()
|
||||
every { Uri.parse(any()) } returns mockExternalUri
|
||||
|
||||
mockkStatic(MediaStore::class)
|
||||
mockkStatic(MediaStore.Files::class)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearSyncCheckpoint removes keys from shared preferences`() {
|
||||
mediaManager.clearSyncCheckpoint()
|
||||
|
||||
verify { mockEditor.remove(SHARED_PREF_MEDIA_STORE_VERSION_KEY) }
|
||||
verify { mockEditor.remove(SHARED_PREF_MEDIA_STORE_GEN_KEY) }
|
||||
verify { mockEditor.apply() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shouldFullSync returns true when MediaStore version differs`() {
|
||||
every { MediaStore.getVersion(mockContext) } returns "v2"
|
||||
every { mockPrefs.getString(SHARED_PREF_MEDIA_STORE_VERSION_KEY, null) } returns "v1"
|
||||
|
||||
assertTrue(mediaManager.shouldFullSync())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shouldFullSync returns true when no version`() {
|
||||
every { MediaStore.getVersion(mockContext) } returns "v1"
|
||||
every { mockPrefs.getString(SHARED_PREF_MEDIA_STORE_VERSION_KEY, null) } returns null
|
||||
|
||||
assertTrue(mediaManager.shouldFullSync())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shouldFullSync returns false when MediaStore version is same`() {
|
||||
val currentVersion = "v2"
|
||||
every { MediaStore.getVersion(mockContext) } returns currentVersion
|
||||
every { mockPrefs.getString(SHARED_PREF_MEDIA_STORE_VERSION_KEY, null) } returns currentVersion
|
||||
|
||||
assertFalse(mediaManager.shouldFullSync())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getAssetIdsForAlbum queries content resolver and returns IDs`() {
|
||||
val albumId = "recent_id"
|
||||
|
||||
val mockCursor = mockk<Cursor>()
|
||||
every { mockCursor.moveToNext() } returnsMany listOf(true, true, false)
|
||||
every { mockCursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID) } returns 0
|
||||
every { mockCursor.getLong(0) } returnsMany listOf(123L, 456L)
|
||||
every { mockCursor.close() } answers { }
|
||||
|
||||
every { MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) } returns mockExternalUri
|
||||
every {
|
||||
mockContentResolver.query(
|
||||
eq(mockExternalUri),
|
||||
any(),
|
||||
eq("${MediaStore.Files.FileColumns.BUCKET_ID} = ? AND (${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"),
|
||||
eq(
|
||||
arrayOf(
|
||||
albumId,
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(),
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString()
|
||||
)
|
||||
),
|
||||
null
|
||||
)
|
||||
} returns mockCursor
|
||||
|
||||
val assetIds = mediaManager.getAssetIdsForAlbum(albumId)
|
||||
|
||||
assertEquals(listOf("123", "456"), assetIds)
|
||||
verify { mockCursor.close() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `checkpointSync stores current MediaStore version and generation`() {
|
||||
val testVersion = "v1"
|
||||
val volumeName = "external_primary"
|
||||
val generation = 12345L
|
||||
|
||||
every { MediaStore.getVersion(mockContext) } returns testVersion
|
||||
every { MediaStore.getExternalVolumeNames(mockContext) } returns setOf(volumeName)
|
||||
every { MediaStore.getGeneration(mockContext, volumeName) } returns generation
|
||||
|
||||
mediaManager.checkpointSync()
|
||||
|
||||
verify { mockEditor.putString(SHARED_PREF_MEDIA_STORE_VERSION_KEY, testVersion) }
|
||||
val expectedGenMapJson = Json.encodeToString(mapOf(volumeName to generation))
|
||||
verify { mockEditor.putString(eq(SHARED_PREF_MEDIA_STORE_GEN_KEY), expectedGenMapJson) }
|
||||
verify { mockEditor.apply() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMediaChanges returns no changes when generations and volumes are same`() {
|
||||
val volumeName = "external_primary"
|
||||
val generation = 100L
|
||||
val genMap = mapOf(volumeName to generation)
|
||||
every {
|
||||
mockPrefs.getString(
|
||||
SHARED_PREF_MEDIA_STORE_GEN_KEY,
|
||||
null
|
||||
)
|
||||
} returns Json.encodeToString(genMap)
|
||||
every { MediaStore.getExternalVolumeNames(mockContext) } returns setOf(volumeName)
|
||||
every { MediaStore.getGeneration(mockContext, volumeName) } returns generation
|
||||
|
||||
val result = mediaManager.getMediaChanges()
|
||||
|
||||
assertFalse(result.hasChanges)
|
||||
assertTrue(result.updates.isEmpty())
|
||||
assertTrue(result.deletes.isEmpty())
|
||||
verify { mockContentResolver wasNot Called }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMediaChanges detects new assets when generation increases`() {
|
||||
val volumeName = "external_primary"
|
||||
val oldGeneration = 100L
|
||||
val newGeneration = 101L
|
||||
val genMap = mapOf(volumeName to oldGeneration)
|
||||
|
||||
val tempFile = tempFolder.newFile("image.jpg")
|
||||
|
||||
every {
|
||||
mockPrefs.getString(SHARED_PREF_MEDIA_STORE_GEN_KEY, null)
|
||||
} returns Json.encodeToString(genMap)
|
||||
every { MediaStore.getExternalVolumeNames(mockContext) } returns setOf(volumeName)
|
||||
every { MediaStore.getGeneration(mockContext, volumeName) } returns newGeneration
|
||||
every { MediaStore.Files.getContentUri(volumeName) } returns mockExternalUri
|
||||
|
||||
val assetProperties = MockAssetProperties(
|
||||
id = 1L,
|
||||
path = tempFile.absolutePath,
|
||||
displayName = "image.jpg",
|
||||
dateTaken = 1678886400000L,
|
||||
dateAdded = 1678886400L,
|
||||
dateModified = 1678886500L,
|
||||
mediaType = MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE,
|
||||
bucketId = "bucket1",
|
||||
duration = 0L
|
||||
)
|
||||
val mockCursor = mockCursorForMediaItems(listOf(assetProperties))
|
||||
val selectionSlot = slot<String>()
|
||||
val selectionArgsSlot = slot<Array<String>>()
|
||||
mockContentResolverQuery(mockExternalUri, mockCursor, selectionSlot, selectionArgsSlot)
|
||||
|
||||
val result = mediaManager.getMediaChanges()
|
||||
|
||||
assertTrue(result.hasChanges)
|
||||
assertEquals(1, result.updates.size)
|
||||
assertEquals("1", result.updates[0].id)
|
||||
assertEquals("image.jpg", result.updates[0].name)
|
||||
assertEquals(1678886400L, result.updates[0].createdAt)
|
||||
assertEquals(1678886500000L, result.updates[0].updatedAt)
|
||||
assertTrue(result.deletes.isEmpty())
|
||||
|
||||
assertEquals(
|
||||
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?) AND (${MediaStore.MediaColumns.GENERATION_MODIFIED} > ? OR ${MediaStore.MediaColumns.GENERATION_ADDED} > ?)",
|
||||
selectionSlot.captured
|
||||
)
|
||||
assertEquals(oldGeneration.toString(), selectionArgsSlot.captured[2])
|
||||
assertEquals(oldGeneration.toString(), selectionArgsSlot.captured[3])
|
||||
|
||||
verify { mockCursor.close() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMediaChanges detects deleted assets when file path does not exist`() {
|
||||
val volumeName = "external_primary"
|
||||
val oldGeneration = 100L
|
||||
val newGeneration = 101L
|
||||
val genMap = mapOf(volumeName to oldGeneration)
|
||||
|
||||
every {
|
||||
mockPrefs.getString(SHARED_PREF_MEDIA_STORE_GEN_KEY, null)
|
||||
} returns Json.encodeToString(genMap)
|
||||
every { MediaStore.getExternalVolumeNames(mockContext) } returns setOf(volumeName)
|
||||
every { MediaStore.getGeneration(mockContext, volumeName) } returns newGeneration
|
||||
every { MediaStore.Files.getContentUri(volumeName) } returns mockExternalUri
|
||||
|
||||
val assetProperties = MockAssetProperties(
|
||||
id = 2L,
|
||||
path = "/path/to/deleted_image.jpg",
|
||||
displayName = "deleted_image.jpg",
|
||||
dateTaken = 0L, dateAdded = 0L, dateModified = 0L,
|
||||
mediaType = MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE,
|
||||
bucketId = "bucket_deleted",
|
||||
duration = 0L
|
||||
)
|
||||
val mockCursor = mockCursorForMediaItems(listOf(assetProperties))
|
||||
mockContentResolverQuery(mockExternalUri, mockCursor)
|
||||
|
||||
val result = mediaManager.getMediaChanges()
|
||||
|
||||
assertTrue(result.hasChanges)
|
||||
assertTrue(result.updates.isEmpty())
|
||||
assertEquals(1, result.deletes.size)
|
||||
assertEquals("2", result.deletes[0])
|
||||
verify { mockCursor.close() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMediaChanges handles multiple volumes with additions and deletions`() {
|
||||
val volume1Name = "external_primary"
|
||||
val volume2Name = "sd_card"
|
||||
val initialGenVolume1 = 100L
|
||||
val initialGenVolume2 = 50L
|
||||
val newGenVolume1 = 101L
|
||||
val newGenVolume2 = 51L
|
||||
|
||||
val initialGenMap = mapOf(volume1Name to initialGenVolume1, volume2Name to initialGenVolume2)
|
||||
every {
|
||||
mockPrefs.getString(SHARED_PREF_MEDIA_STORE_GEN_KEY, null)
|
||||
} returns Json.encodeToString(initialGenMap)
|
||||
|
||||
every { MediaStore.getExternalVolumeNames(mockContext) } returns setOf(volume1Name, volume2Name)
|
||||
every { MediaStore.getGeneration(mockContext, volume1Name) } returns newGenVolume1
|
||||
every { MediaStore.getGeneration(mockContext, volume2Name) } returns newGenVolume2
|
||||
|
||||
val mockUriVolume1 = mockk<Uri>()
|
||||
val mockUriVolume2 = mockk<Uri>()
|
||||
every { MediaStore.Files.getContentUri(volume1Name) } returns mockUriVolume1
|
||||
every { MediaStore.Files.getContentUri(volume2Name) } returns mockUriVolume2
|
||||
|
||||
val tempFile1 = tempFolder.newFile("image_vol1.jpg")
|
||||
val assetVol1 = MockAssetProperties(
|
||||
id = 10L,
|
||||
path = tempFile1.absolutePath,
|
||||
displayName = "image_vol1.jpg",
|
||||
dateTaken = 1678886400000L,
|
||||
dateAdded = 1678886400L,
|
||||
dateModified = 1678886500L,
|
||||
mediaType = MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE,
|
||||
bucketId = "bucket_vol1",
|
||||
duration = 0L
|
||||
)
|
||||
val mockCursorVol1 = mockCursorForMediaItems(listOf(assetVol1))
|
||||
val selectionArgsSlotVol1 = slot<Array<String>>()
|
||||
mockContentResolverQuery(
|
||||
mockUriVolume1,
|
||||
mockCursorVol1,
|
||||
selectionArgsSlot = selectionArgsSlotVol1
|
||||
)
|
||||
|
||||
val assetVol2Deleted = MockAssetProperties(
|
||||
id = 20L,
|
||||
path = "/path/to/deleted_vol2.jpg",
|
||||
displayName = "deleted_vol2.jpg",
|
||||
dateTaken = 0L,
|
||||
dateAdded = 0L,
|
||||
dateModified = 0L,
|
||||
mediaType = MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE,
|
||||
bucketId = "bucket_vol2_del",
|
||||
duration = 0L
|
||||
)
|
||||
val mockCursorVol2 = mockCursorForMediaItems(listOf(assetVol2Deleted))
|
||||
val selectionArgsSlotVol2 = slot<Array<String>>()
|
||||
mockContentResolverQuery(
|
||||
mockUriVolume2,
|
||||
mockCursorVol2,
|
||||
selectionArgsSlot = selectionArgsSlotVol2
|
||||
)
|
||||
|
||||
val result = mediaManager.getMediaChanges()
|
||||
|
||||
assertTrue(result.hasChanges)
|
||||
assertEquals(1, result.updates.size)
|
||||
assertEquals("10", result.updates[0].id)
|
||||
assertEquals("image_vol1.jpg", result.updates[0].name)
|
||||
|
||||
assertEquals(1, result.deletes.size)
|
||||
assertEquals("20", result.deletes[0])
|
||||
|
||||
assertEquals(initialGenVolume1.toString(), selectionArgsSlotVol1.captured[2])
|
||||
assertEquals(initialGenVolume1.toString(), selectionArgsSlotVol1.captured[3])
|
||||
assertEquals(initialGenVolume2.toString(), selectionArgsSlotVol2.captured[2])
|
||||
assertEquals(initialGenVolume2.toString(), selectionArgsSlotVol2.captured[3])
|
||||
|
||||
verify { mockCursorVol1.close() }
|
||||
verify { mockCursorVol2.close() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMediaChanges detects new volume`() {
|
||||
val volume1Name = "external_primary"
|
||||
val initialGenVolume1 = 100L
|
||||
val initialGenMap = mapOf(volume1Name to initialGenVolume1)
|
||||
|
||||
val volume2Name = "new_sd_card"
|
||||
val newGenVolume1 = 100L
|
||||
val newGenVolume2 = 5L
|
||||
|
||||
every {
|
||||
mockPrefs.getString(SHARED_PREF_MEDIA_STORE_GEN_KEY, null)
|
||||
} returns Json.encodeToString(initialGenMap)
|
||||
|
||||
every { MediaStore.getExternalVolumeNames(mockContext) } returns setOf(volume1Name, volume2Name)
|
||||
every { MediaStore.getGeneration(mockContext, volume1Name) } returns newGenVolume1
|
||||
every { MediaStore.getGeneration(mockContext, volume2Name) } returns newGenVolume2
|
||||
|
||||
val mockUriVolume2 = mockk<Uri>()
|
||||
every { MediaStore.Files.getContentUri(volume2Name) } returns mockUriVolume2
|
||||
|
||||
val tempFile2 = tempFolder.newFile("image_vol2.jpg")
|
||||
val assetVol2 = MockAssetProperties(
|
||||
id = 30L,
|
||||
path = tempFile2.absolutePath,
|
||||
displayName = "image_vol2.jpg",
|
||||
dateTaken = 0L,
|
||||
dateAdded = 1678886600L,
|
||||
dateModified = 1678886700L,
|
||||
mediaType = MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE,
|
||||
bucketId = "bucket_vol2",
|
||||
duration = 0L
|
||||
)
|
||||
val mockCursorVol2 = mockCursorForMediaItems(listOf(assetVol2))
|
||||
val selectionArgsSlotVol2 = slot<Array<String>>()
|
||||
mockContentResolverQuery(
|
||||
mockUriVolume2,
|
||||
mockCursorVol2,
|
||||
selectionArgsSlot = selectionArgsSlotVol2
|
||||
)
|
||||
|
||||
val result = mediaManager.getMediaChanges()
|
||||
|
||||
assertTrue(result.hasChanges)
|
||||
assertEquals(1, result.updates.size)
|
||||
assertEquals("30", result.updates[0].id)
|
||||
assertEquals(1678886600L, result.updates[0].createdAt)
|
||||
assertTrue(result.deletes.isEmpty())
|
||||
|
||||
assertEquals(
|
||||
"0",
|
||||
selectionArgsSlotVol2.captured[2]
|
||||
)
|
||||
assertEquals("0", selectionArgsSlotVol2.captured[3])
|
||||
|
||||
verify(exactly = 0) {
|
||||
mockContentResolver.query(
|
||||
eq(MediaStore.Files.getContentUri(volume1Name)),
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
null
|
||||
)
|
||||
}
|
||||
verify { mockCursorVol2.close() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMediaChanges handles removed volume`() {
|
||||
val volume1Name = "external_primary"
|
||||
val volume2Name = "sd_card_to_be_removed"
|
||||
val initialGenVolume1 = 100L
|
||||
val initialGenVolume2 = 50L
|
||||
|
||||
val initialGenMap = mapOf(volume1Name to initialGenVolume1, volume2Name to initialGenVolume2)
|
||||
every {
|
||||
mockPrefs.getString(
|
||||
SHARED_PREF_MEDIA_STORE_GEN_KEY,
|
||||
null
|
||||
)
|
||||
} returns Json.encodeToString(initialGenMap)
|
||||
|
||||
every { MediaStore.getExternalVolumeNames(mockContext) } returns setOf(volume1Name)
|
||||
every { MediaStore.getGeneration(mockContext, volume1Name) } returns initialGenVolume1
|
||||
|
||||
val result = mediaManager.getMediaChanges()
|
||||
|
||||
assertTrue(result.hasChanges)
|
||||
// No updates or deletes should be reported by this function for removed volumes,
|
||||
// as this is handled by the Dart side based on album removal.
|
||||
assertTrue(result.updates.isEmpty())
|
||||
assertTrue(result.deletes.isEmpty())
|
||||
|
||||
verify { mockContentResolver wasNot Called }
|
||||
}
|
||||
|
||||
private data class MockAssetProperties(
|
||||
val id: Long,
|
||||
val path: String,
|
||||
val displayName: String,
|
||||
val dateTaken: Long,
|
||||
val dateAdded: Long,
|
||||
val dateModified: Long,
|
||||
val mediaType: Int,
|
||||
val bucketId: String,
|
||||
val duration: Long
|
||||
)
|
||||
|
||||
private fun mockCursorForMediaItems(items: List<MockAssetProperties>): Cursor {
|
||||
val mockCursor = mockk<Cursor>()
|
||||
var currentIndex = -1
|
||||
|
||||
every { mockCursor.moveToNext() } answers {
|
||||
currentIndex++
|
||||
currentIndex < items.size
|
||||
}
|
||||
|
||||
if (items.isNotEmpty()) {
|
||||
every { mockCursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) } returns 0
|
||||
every { mockCursor.getLong(0) } answers { items[currentIndex].id }
|
||||
every { mockCursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA) } returns 1
|
||||
every { mockCursor.getString(1) } answers { items[currentIndex].path }
|
||||
every { mockCursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME) } returns 2
|
||||
every { mockCursor.getString(2) } answers { items[currentIndex].displayName }
|
||||
every { mockCursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN) } returns 3
|
||||
every { mockCursor.getLong(3) } answers { items[currentIndex].dateTaken }
|
||||
every { mockCursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED) } returns 4
|
||||
every { mockCursor.getLong(4) } answers { items[currentIndex].dateAdded }
|
||||
every { mockCursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) } returns 5
|
||||
every { mockCursor.getLong(5) } answers { items[currentIndex].dateModified }
|
||||
every { mockCursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE) } returns 6
|
||||
every { mockCursor.getInt(6) } answers { items[currentIndex].mediaType }
|
||||
every { mockCursor.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID) } returns 7
|
||||
every { mockCursor.getString(7) } answers { items[currentIndex].bucketId }
|
||||
every { mockCursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DURATION) } returns 8
|
||||
every { mockCursor.getLong(8) } answers { items[currentIndex].duration }
|
||||
}
|
||||
every { mockCursor.close() } answers { }
|
||||
return mockCursor
|
||||
}
|
||||
|
||||
private fun mockContentResolverQuery(
|
||||
uri: Uri,
|
||||
cursor: Cursor,
|
||||
selectionSlot: CapturingSlot<String>? = null,
|
||||
selectionArgsSlot: CapturingSlot<Array<String>>? = null
|
||||
) {
|
||||
every {
|
||||
mockContentResolver.query(
|
||||
eq(uri),
|
||||
any(),
|
||||
if (selectionSlot != null) capture(selectionSlot) else any(),
|
||||
if (selectionArgsSlot != null) capture(selectionArgsSlot) else any(),
|
||||
null
|
||||
)
|
||||
} returns cursor
|
||||
}
|
||||
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user