diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index f537b1b9d1..bf601c6f1a 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -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 diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MediaManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MediaManager.kt index 72068d2e6e..9f9cad7dec 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MediaManager.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MediaManager.kt @@ -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 { return context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) diff --git a/mobile/android/app/src/test/kotlin/app/alextran/immich/platform/MediaManagerTest.kt b/mobile/android/app/src/test/kotlin/app/alextran/immich/platform/MediaManagerTest.kt new file mode 100644 index 0000000000..48583eb399 --- /dev/null +++ b/mobile/android/app/src/test/kotlin/app/alextran/immich/platform/MediaManagerTest.kt @@ -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(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() + 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() + 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() + val selectionArgsSlot = slot>() + 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() + val mockUriVolume2 = mockk() + 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>() + 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>() + 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() + 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>() + 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): Cursor { + val mockCursor = mockk() + 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? = null, + selectionArgsSlot: CapturingSlot>? = null + ) { + every { + mockContentResolver.query( + eq(uri), + any(), + if (selectionSlot != null) capture(selectionSlot) else any(), + if (selectionArgsSlot != null) capture(selectionArgsSlot) else any(), + null + ) + } returns cursor + } + +}