diff --git a/android/app/src/androidTest/assets/video-01.mp4 b/android/app/src/androidTest/assets/video-01.mp4 new file mode 100644 index 0000000..71d90c4 Binary files /dev/null and b/android/app/src/androidTest/assets/video-01.mp4 differ diff --git a/android/app/src/androidTest/assets/video-02.mp4 b/android/app/src/androidTest/assets/video-02.mp4 new file mode 100644 index 0000000..424c9f2 Binary files /dev/null and b/android/app/src/androidTest/assets/video-02.mp4 differ diff --git a/android/app/src/androidTest/assets/video-03.mp4 b/android/app/src/androidTest/assets/video-03.mp4 new file mode 100644 index 0000000..060d923 Binary files /dev/null and b/android/app/src/androidTest/assets/video-03.mp4 differ diff --git a/android/app/src/androidTest/assets/video-04.mp4 b/android/app/src/androidTest/assets/video-04.mp4 new file mode 100644 index 0000000..c12b721 Binary files /dev/null and b/android/app/src/androidTest/assets/video-04.mp4 differ diff --git a/android/app/src/androidTest/assets/video-05.mp4 b/android/app/src/androidTest/assets/video-05.mp4 new file mode 100644 index 0000000..21924c8 Binary files /dev/null and b/android/app/src/androidTest/assets/video-05.mp4 differ diff --git a/android/app/src/androidTest/assets/video-06.mp4 b/android/app/src/androidTest/assets/video-06.mp4 new file mode 100644 index 0000000..1e70c24 Binary files /dev/null and b/android/app/src/androidTest/assets/video-06.mp4 differ diff --git a/android/app/src/androidTest/assets/video-07.mp4 b/android/app/src/androidTest/assets/video-07.mp4 new file mode 100644 index 0000000..da70295 Binary files /dev/null and b/android/app/src/androidTest/assets/video-07.mp4 differ diff --git a/android/app/src/androidTest/assets/video-08.mp4 b/android/app/src/androidTest/assets/video-08.mp4 new file mode 100644 index 0000000..25b57ce Binary files /dev/null and b/android/app/src/androidTest/assets/video-08.mp4 differ diff --git a/android/app/src/androidTest/assets/video-09.mp4 b/android/app/src/androidTest/assets/video-09.mp4 new file mode 100644 index 0000000..25e9f81 Binary files /dev/null and b/android/app/src/androidTest/assets/video-09.mp4 differ diff --git a/android/app/src/androidTest/assets/video-10.mp4 b/android/app/src/androidTest/assets/video-10.mp4 new file mode 100644 index 0000000..335d83e Binary files /dev/null and b/android/app/src/androidTest/assets/video-10.mp4 differ diff --git a/android/app/src/androidTest/assets/video-11.mp4 b/android/app/src/androidTest/assets/video-11.mp4 new file mode 100644 index 0000000..fe1e3a1 Binary files /dev/null and b/android/app/src/androidTest/assets/video-11.mp4 differ diff --git a/android/app/src/androidTest/assets/video-12.mp4 b/android/app/src/androidTest/assets/video-12.mp4 new file mode 100644 index 0000000..730de2b Binary files /dev/null and b/android/app/src/androidTest/assets/video-12.mp4 differ diff --git a/android/app/src/androidTest/assets/video-13.mp4 b/android/app/src/androidTest/assets/video-13.mp4 new file mode 100644 index 0000000..3fd19b0 Binary files /dev/null and b/android/app/src/androidTest/assets/video-13.mp4 differ diff --git a/android/app/src/androidTest/assets/video-14.mp4 b/android/app/src/androidTest/assets/video-14.mp4 new file mode 100644 index 0000000..e5c55c7 Binary files /dev/null and b/android/app/src/androidTest/assets/video-14.mp4 differ diff --git a/android/app/src/androidTest/assets/video-15.mp4 b/android/app/src/androidTest/assets/video-15.mp4 new file mode 100644 index 0000000..8a4e820 Binary files /dev/null and b/android/app/src/androidTest/assets/video-15.mp4 differ diff --git a/android/app/src/androidTest/assets/video-16.mp4 b/android/app/src/androidTest/assets/video-16.mp4 new file mode 100644 index 0000000..616b6aa Binary files /dev/null and b/android/app/src/androidTest/assets/video-16.mp4 differ diff --git a/android/app/src/androidTest/assets/video-17.mp4 b/android/app/src/androidTest/assets/video-17.mp4 new file mode 100644 index 0000000..1e2a1ed Binary files /dev/null and b/android/app/src/androidTest/assets/video-17.mp4 differ diff --git a/android/app/src/androidTest/assets/video-18.mp4 b/android/app/src/androidTest/assets/video-18.mp4 new file mode 100644 index 0000000..2fa8b2c Binary files /dev/null and b/android/app/src/androidTest/assets/video-18.mp4 differ diff --git a/android/app/src/androidTest/assets/video-19.mp4 b/android/app/src/androidTest/assets/video-19.mp4 new file mode 100644 index 0000000..ce92046 Binary files /dev/null and b/android/app/src/androidTest/assets/video-19.mp4 differ diff --git a/android/app/src/androidTest/assets/video-20.mp4 b/android/app/src/androidTest/assets/video-20.mp4 new file mode 100644 index 0000000..eea0711 Binary files /dev/null and b/android/app/src/androidTest/assets/video-20.mp4 differ diff --git a/android/app/src/androidTest/java/com/galo/android/DeviceAlbumGridInstrumentedTest.kt b/android/app/src/androidTest/java/com/galo/android/DeviceAlbumGridInstrumentedTest.kt new file mode 100644 index 0000000..3ee7a47 --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/DeviceAlbumGridInstrumentedTest.kt @@ -0,0 +1,116 @@ +package com.galo.android + +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.galo.android.domain.media.MediaPermissions +import com.galo.android.ui.devicealbum.DeviceAlbumViewModel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.junit.runners.model.Statement + +/** + * The device album grid must refresh when MediaStore changes, so photos the sync + * engine materializes (or removes) appear without reopening the album. + */ +@RunWith(AndroidJUnit4::class) +class DeviceAlbumGridInstrumentedTest { + + private val mediaPermissionRule: TestRule = object : TestRule { + override fun apply(base: Statement, description: Description): Statement = object : Statement() { + override fun evaluate() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val pkg = instrumentation.targetContext.packageName + MediaPermissions.required(Build.VERSION.SDK_INT).forEach { permission -> + runCatching { instrumentation.uiAutomation.grantRuntimePermission(pkg, permission) } + } + base.evaluate() + } + } + } + + @get:Rule + val rules: TestRule = RuleChain.outerRule(mediaPermissionRule) + + private lateinit var context: Context + private lateinit var resolver: ContentResolver + private val created = mutableListOf() + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + resolver = context.contentResolver + } + + @After + fun tearDown() { + created.forEach { runCatching { resolver.delete(it, null, null) } } + } + + private fun insertImage(folder: String, name: String): Pair { + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, name) + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/$folder/") + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!! + val bitmap = Bitmap.createBitmap(120, 90, Bitmap.Config.ARGB_8888) + resolver.openOutputStream(uri)!!.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) } + bitmap.recycle() + values.clear() + values.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + created += uri + val bucket = resolver.query(uri, arrayOf(MediaStore.Images.Media.BUCKET_ID), null, null, null)!! + .use { cursor -> + cursor.moveToFirst() + cursor.getString(0) + } + return uri to bucket + } + + @Test + fun gridAppearsAndRefreshesOnMediaStoreChanges() = runBlocking { + val app = ApplicationProvider.getApplicationContext() + val container = app.container + + val folder = "GaloGrid-${System.nanoTime()}" + val (_, bucket) = insertImage(folder, "grid-${System.nanoTime()}.jpg") + val viewModel = DeviceAlbumViewModel(container, bucket) + + // The initial item is shown. + withTimeout(15_000) { + viewModel.state.first { it.items.any { media -> media.albumId == bucket } } + } + + // A new file appears in the bucket (as a materialized server photo would); + // the grid must pick it up without any manual reload. + val (_, bucket2) = insertImage(folder, "grid2-${System.nanoTime()}.jpg") + assertTrue("test setup must use one bucket", bucket == bucket2) + + withTimeout(20_000) { + viewModel.state.first { state -> + state.items.count { it.albumId == bucket } >= 2 + } + } + Unit + } +} diff --git a/android/app/src/androidTest/java/com/galo/android/OffloadInstrumentedTest.kt b/android/app/src/androidTest/java/com/galo/android/OffloadInstrumentedTest.kt index ce842aa..0785d1a 100644 --- a/android/app/src/androidTest/java/com/galo/android/OffloadInstrumentedTest.kt +++ b/android/app/src/androidTest/java/com/galo/android/OffloadInstrumentedTest.kt @@ -136,7 +136,7 @@ class OffloadInstrumentedTest { db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L)) db.syncDao().upsertAssets(listOf(asset)) - val result = OffloadManager(context, db.syncDao()).offload(asset) + val result = OffloadManager(context, db.syncDao()).offload(asset, setOf("photo1")) assertEquals(OffloadResult.Done, result) // The file is still there but now a capped preview. @@ -156,7 +156,7 @@ class OffloadInstrumentedTest { db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L)) db.syncDao().upsertAssets(listOf(asset)) - val result = OffloadManager(context, db.syncDao()).offload(asset) + val result = OffloadManager(context, db.syncDao()).offload(asset, setOf("photo1")) assertEquals(OffloadResult.Done, result) assertTrue("video row should be gone", !exists(uri)) @@ -185,7 +185,7 @@ class OffloadInstrumentedTest { val asset = assetOf(uri, bucket, "orig.jpg", "image/jpeg") val key = asset.assetKey db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L)) - assertEquals(OffloadResult.Done, OffloadManager(context, db.syncDao()).offload(asset)) + assertEquals(OffloadResult.Done, OffloadManager(context, db.syncDao()).offload(asset, setOf("photo1"))) // Simulate a reinstall: the app's index is gone. db.syncDao().clearAlbum(bucket) @@ -222,7 +222,7 @@ class OffloadInstrumentedTest { db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L)) db.syncDao().upsertAssets(listOf(asset)) - assertEquals(OffloadResult.Done, OffloadManager(context, db.syncDao()).offload(asset)) + assertEquals(OffloadResult.Done, OffloadManager(context, db.syncDao()).offload(asset, setOf("photo1"))) assertEquals(1600 to 1200, dimensionsOf(uri)) val original = Bitmap.createBitmap(2400, 1800, Bitmap.Config.ARGB_8888) diff --git a/android/app/src/androidTest/java/com/galo/android/SyncModesE2ETest.kt b/android/app/src/androidTest/java/com/galo/android/SyncModesE2ETest.kt new file mode 100644 index 0000000..64709aa --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/SyncModesE2ETest.kt @@ -0,0 +1,307 @@ +package com.galo.android + +import android.content.ContentResolver +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.sync.ContentHash +import com.galo.android.di.AppContainer +import com.galo.android.domain.media.MediaPermissions +import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.SyncMode +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.junit.runners.model.Statement + +/** + * Every synchronization mode (A/B/C) and the switches between them. Small, fast + * and functional; the volume coverage lives in [SyncVolumeE2ETest]. Against a + * running galo instance at 10.0.2.2:8088. + */ +@RunWith(AndroidJUnit4::class) +class SyncModesE2ETest { + + private val mediaPermissionRule: TestRule = object : TestRule { + override fun apply(base: Statement, description: Description): Statement = object : Statement() { + override fun evaluate() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val pkg = instrumentation.targetContext.packageName + MediaPermissions.required(Build.VERSION.SDK_INT).forEach { permission -> + runCatching { instrumentation.uiAutomation.grantRuntimePermission(pkg, permission) } + } + base.evaluate() + } + } + } + + @get:Rule + val rules: TestRule = RuleChain.outerRule(mediaPermissionRule) + + private lateinit var context: Context + private lateinit var resolver: ContentResolver + private val created = mutableListOf() + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + resolver = context.contentResolver + } + + @After + fun tearDown() { + created.forEach { runCatching { resolver.delete(it, null, null) } } + } + + private data class One(val uri: Uri, val bucket: String, val key: String, val bytes: ByteArray) + + private fun insertImage(folder: String): One { + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, "mode-${System.nanoTime()}.jpg") + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/$folder-${System.nanoTime()}/") + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!! + val bitmap = Bitmap.createBitmap(320, 240, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(android.graphics.Color.rgb(30, 90, 200)) + resolver.openOutputStream(uri)!!.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) } + bitmap.recycle() + values.clear() + values.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + created += uri + val bucket = resolver.query(uri, arrayOf(MediaStore.Images.Media.BUCKET_ID), null, null, null)!! + .use { cursor -> + cursor.moveToFirst() + cursor.getString(0) + } + val bytes = resolver.openInputStream(uri)!!.use { it.readBytes() } + return One(uri, bucket, DeviceAssetEntity.key(bucket, ContentUris.parseId(uri)), bytes) + } + + private suspend fun awaitState( + container: AppContainer, + bucket: String, + key: String, + state: LocalAssetState, + timeoutMs: Long = 90_000, + ): Boolean { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + val now = container.syncRepository.assets(bucket).firstOrNull { it.assetKey == key }?.state + if (now == state.name) return true + delay(500) + } + return false + } + + private suspend fun awaitPhotos(container: AppContainer, album: String, minimum: Int): Int { + val deadline = System.currentTimeMillis() + 90_000 + while (System.currentTimeMillis() < deadline) { + val count = runCatching { container.syncRemote.serverPhotos(album).size }.getOrDefault(0) + if (count >= minimum) return count + delay(500) + } + return runCatching { container.syncRemote.serverPhotos(album).size }.getOrDefault(0) + } + + private fun localHash(uri: Uri): String = TestMedia.hash(resolver, uri) + + // --- Mode A --------------------------------------------------------------- + + @Test + fun disabledModeIsLocalOnly() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + E2E.registerAuthenticated(container) + + val item = insertImage("GaloModesA") + assertNull("a disabled album has no link", container.syncRepository.currentLink(item.bucket)) + assertFalse("mode A must not contact the server", container.syncCoordinator.reconcile(item.bucket)) + assertEquals("the local original is untouched", localHash(item.uri), ContentHash.sha256(item.bytes.inputStream())) + } + + // --- Mode B --------------------------------------------------------------- + + @Test + fun copyModeUploadsAndKeepsTheOriginal() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "B ${System.currentTimeMillis()}") + + val item = insertImage("GaloModesB") + container.syncRepository.link(item.bucket, "B", album, SyncMode.COPY) + assertTrue(container.syncCoordinator.reconcile(item.bucket)) + + assertTrue("photo should reach the server", awaitPhotos(container, album, 1) >= 1) + assertTrue("asset stays uploaded", awaitState(container, item.bucket, item.key, LocalAssetState.UPLOADED)) + assertEquals("the original stays on the device", localHash(item.uri), ContentHash.sha256(item.bytes.inputStream())) + } + + // --- Mode C --------------------------------------------------------------- + + @Test + fun moveModeUploadsAndOffloads() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "C ${System.currentTimeMillis()}") + + val item = insertImage("GaloModesC") + container.syncRepository.link(item.bucket, "C", album, SyncMode.MOVE) + assertTrue(container.syncCoordinator.reconcile(item.bucket)) + + assertTrue("photo should reach the server", awaitPhotos(container, album, 1) >= 1) + assertTrue("the original is replaced by a preview", awaitState(container, item.bucket, item.key, LocalAssetState.PREVIEW)) + } + + // --- Switch B -> C -------------------------------------------------------- + + @Test + fun copyToMoveOffloadsExistingUploads() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "B2C ${System.currentTimeMillis()}") + + val item = insertImage("GaloModesB2C") + container.syncRepository.link(item.bucket, "B", album, SyncMode.COPY) + container.syncCoordinator.reconcile(item.bucket) + assertTrue(awaitPhotos(container, album, 1) >= 1) + assertTrue(awaitState(container, item.bucket, item.key, LocalAssetState.UPLOADED)) + + container.syncRepository.link(item.bucket, "B", album, SyncMode.MOVE) + container.syncCheck.checkGallery(item.bucket) + + assertTrue("switching to move offloads the upload", awaitState(container, item.bucket, item.key, LocalAssetState.PREVIEW)) + } + + // --- Switch C -> B -------------------------------------------------------- + + @Test + fun moveToCopyRestoresTheOriginal() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "C2B ${System.currentTimeMillis()}") + + val item = insertImage("GaloModesC2B") + container.syncRepository.link(item.bucket, "C", album, SyncMode.MOVE) + container.syncCoordinator.reconcile(item.bucket) + assertTrue(awaitPhotos(container, album, 1) >= 1) + assertTrue(awaitState(container, item.bucket, item.key, LocalAssetState.PREVIEW)) + + container.syncRepository.link(item.bucket, "C", album, SyncMode.COPY) + container.syncCheck.checkGallery(item.bucket) + + assertTrue("the original is restored locally", awaitState(container, item.bucket, item.key, LocalAssetState.LOCAL)) + assertEquals("the restored file is the original bytes", localHash(item.uri), ContentHash.sha256(item.bytes.inputStream())) + } + + // --- Switch to A (off), regression --------------------------------------- + + @Test + fun moveToOffStaysOffAfterRecovery() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "C2A ${System.currentTimeMillis()}") + + val item = insertImage("GaloModesC2A") + container.syncRepository.link(item.bucket, "C", album, SyncMode.MOVE) + container.syncCoordinator.reconcile(item.bucket) + assertTrue(awaitPhotos(container, album, 1) >= 1) + assertTrue(awaitState(container, item.bucket, item.key, LocalAssetState.PREVIEW)) + + container.syncRepository.unlink(item.bucket) + assertNull(container.syncRepository.currentLink(item.bucket)) + + container.syncCheck.checkGallery(item.bucket, recoverIndex = true) + assertNull("turning off must be sticky", container.syncRepository.currentLink(item.bucket)) + assertTrue(container.syncRepository.isSyncDisabled(item.bucket)) + } + + @Test + fun copyToOffStaysOffAfterRecovery() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "B2A ${System.currentTimeMillis()}") + + val item = insertImage("GaloModesB2A") + container.syncRepository.link(item.bucket, "B", album, SyncMode.COPY) + container.syncCoordinator.reconcile(item.bucket) + assertTrue(awaitPhotos(container, album, 1) >= 1) + + container.syncRepository.unlink(item.bucket) + container.syncCheck.checkGallery(item.bucket, recoverIndex = true) + + assertNull(container.syncRepository.currentLink(item.bucket)) + assertTrue(container.syncRepository.isSyncDisabled(item.bucket)) + } + + // --- Switch off -> B (re-enable) ----------------------------------------- + + @Test + fun offToCopyUploadsAgain() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "A2B ${System.currentTimeMillis()}") + + val item = insertImage("GaloModesA2B") + container.syncRepository.unlink(item.bucket) + assertTrue(container.syncRepository.isSyncDisabled(item.bucket)) + + container.syncRepository.link(item.bucket, "B", album, SyncMode.COPY) + assertFalse(container.syncRepository.isSyncDisabled(item.bucket)) + container.syncCoordinator.reconcile(item.bucket) + + assertTrue("re-enabled sync uploads again", awaitPhotos(container, album, 1) >= 1) + } + + // --- Reinstall-style recovery for a genuine setup ------------------------- + + @Test + fun recoveryRebuildsLinkWhenSyncWasNotTurnedOff() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "Recover ${System.currentTimeMillis()}") + + val item = insertImage("GaloModesRecover") + container.syncRepository.link(item.bucket, "C", album, SyncMode.MOVE) + container.syncCoordinator.reconcile(item.bucket) + assertTrue(awaitPhotos(container, album, 1) >= 1) + assertTrue(awaitState(container, item.bucket, item.key, LocalAssetState.PREVIEW)) + + container.syncRepository.forgetAlbum(item.bucket) + assertNull(container.syncRepository.currentLink(item.bucket)) + + val recovered = container.syncRecovery.recover(item.bucket) + assertTrue("markers must rebuild the link", recovered >= 1) + assertNotNull(container.syncRepository.currentLink(item.bucket)) + } +} diff --git a/android/app/src/androidTest/java/com/galo/android/SyncVolumeE2ETest.kt b/android/app/src/androidTest/java/com/galo/android/SyncVolumeE2ETest.kt new file mode 100644 index 0000000..4762417 --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/SyncVolumeE2ETest.kt @@ -0,0 +1,236 @@ +package com.galo.android + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.sync.ContentHash +import com.galo.android.data.sync.ProvenanceStamp +import com.galo.android.di.AppContainer +import com.galo.android.domain.media.MediaPermissions +import com.galo.android.domain.model.AssetOrigin +import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.SyncMode +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.junit.runners.model.Statement + +/** + * Dedicated volume coverage: 100 photos and 20 videos per test, both directions. + * + * - upload: the whole device album reaches the server through the drain queue; + * - materialize: a server album of the same size lands on the device (previews in + * move mode; server videos become posters). + * + * This suite is slow (every file is one HTTP request) — it intentionally lives + * apart from the fast functional tests. Against a running galo at 10.0.2.2:8088. + */ +@RunWith(AndroidJUnit4::class) +class SyncVolumeE2ETest { + + private val mediaPermissionRule: TestRule = object : TestRule { + override fun apply(base: Statement, description: Description): Statement = object : Statement() { + override fun evaluate() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val pkg = instrumentation.targetContext.packageName + MediaPermissions.required(Build.VERSION.SDK_INT).forEach { permission -> + runCatching { instrumentation.uiAutomation.grantRuntimePermission(pkg, permission) } + } + base.evaluate() + } + } + } + + @get:Rule + val rules: TestRule = RuleChain.outerRule(mediaPermissionRule) + + private lateinit var context: Context + private lateinit var resolver: ContentResolver + private val created = mutableListOf() + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + resolver = context.contentResolver + } + + @After + fun tearDown() { + created.forEach { runCatching { resolver.delete(it, null, null) } } + } + + private val total = TestMedia.PHOTO_COUNT + TestMedia.VIDEO_COUNT + + private fun anchor(folder: String): String { + val values = android.content.ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, "anchor-${System.nanoTime()}.jpg") + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/$folder-${System.nanoTime()}/") + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!! + android.graphics.Bitmap.createBitmap(96, 96, android.graphics.Bitmap.Config.ARGB_8888).let { + // A colour nothing else uses, so the anchor never dedups with a server photo. + it.eraseColor(android.graphics.Color.rgb(7, 200, 13)) + resolver.openOutputStream(uri)!!.use { out -> it.compress(android.graphics.Bitmap.CompressFormat.JPEG, 85, out) } + it.recycle() + } + values.clear() + values.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + created += uri + return resolver.query(uri, arrayOf(MediaStore.Images.Media.BUCKET_ID), null, null, null)!! + .use { cursor -> + cursor.moveToFirst() + cursor.getString(0) + } + } + + private fun countLocal(bucket: String, collection: Uri, prefix: String): Int = + resolver.query( + collection, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.BUCKET_ID} = ? AND ${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ?", + arrayOf(bucket, "$prefix%"), + null, + )?.use { it.count } ?: 0 + + private fun localUri(bucket: String, collection: Uri, name: String): Uri? = + resolver.query( + collection, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.BUCKET_ID} = ? AND ${MediaStore.MediaColumns.DISPLAY_NAME} = ?", + arrayOf(bucket, name), + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + android.content.ContentUris.withAppendedId(collection, cursor.getLong(0)) + } else { + null + } + } + + private suspend fun awaitServerCount(container: AppContainer, album: String, minimum: Int): Int { + val deadline = System.currentTimeMillis() + 900_000 + var last = -1 + while (System.currentTimeMillis() < deadline) { + last = runCatching { container.syncRemote.serverPhotos(album).size }.getOrDefault(-1) + if (last >= minimum) return last + delay(1_000) + } + return last + } + + private suspend fun awaitState( + container: AppContainer, + bucket: String, + key: String, + state: LocalAssetState, + ): Boolean { + val deadline = System.currentTimeMillis() + 600_000 + while (System.currentTimeMillis() < deadline) { + if (container.syncRepository.assets(bucket).firstOrNull { it.assetKey == key }?.state == state.name) return true + delay(1_000) + } + return false + } + + private suspend fun materializeUntil( + container: AppContainer, + bucket: String, + timeoutMs: Long = 480_000, + done: () -> Boolean, + ) { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + container.syncCoordinator.reconcile(bucket) + if (done()) return + delay(2_000) + } + } + + @Test + fun copyUploadsTheWholeDeviceAlbum() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "VolUp ${System.currentTimeMillis()}") + + val seeded = TestMedia.seed(resolver, "GaloVolUp") + created += seeded.uris + val bucket = seeded.bucket(resolver) + val first = seeded.photos.first() + val key = DeviceAssetEntity.key(bucket, android.content.ContentUris.parseId(first)) + val bytes = resolver.openInputStream(first)!!.use { it.readBytes() } + + container.syncRepository.link(bucket, "VolUp", album, SyncMode.COPY) + assertTrue(container.syncCoordinator.reconcile(bucket)) + + assertEquals("all media must reach the server", total, awaitServerCount(container, album, total)) + assertTrue("assets stay uploaded", awaitState(container, bucket, key, LocalAssetState.UPLOADED)) + assertEquals( + "the original stays on the device", + ContentHash.sha256(bytes.inputStream()), + TestMedia.hash(resolver, first), + ) + } + + @Test + fun copyMaterializesServerVolume() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "VolDown ${System.currentTimeMillis()}") + val bucket = anchor("GaloVolDown") + TestMedia.uploadServerMedia(token, album) + + container.syncRepository.link(bucket, "VolDown", album, SyncMode.COPY, mirrorFromCreatedAt = 0L) + val images = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + val videos = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + val deadline = System.currentTimeMillis() + 480_000 + var pass = 0 + while (System.currentTimeMillis() < deadline) { + container.syncCoordinator.reconcile(bucket) + val p = countLocal(bucket, images, "srv-photo-") + val v = countLocal(bucket, videos, "srv-video-") + android.util.Log.i( + "VolTest", + "pass=${pass++} photos=$p videos=$v server=${runCatching { container.syncRemote.serverPhotos(album).size }.getOrDefault(-1)}", + ) + if (p >= TestMedia.PHOTO_COUNT && v >= TestMedia.VIDEO_COUNT) break + delay(2_000) + } + + assertTrue( + "all server photos must be materialized", + countLocal(bucket, images, "srv-photo-") >= TestMedia.PHOTO_COUNT, + ) + assertTrue( + "all server videos must be materialized", + countLocal(bucket, videos, "srv-video-") >= TestMedia.VIDEO_COUNT, + ) + + val sample = localUri(bucket, images, "srv-photo-000.jpg") + assertTrue("a sample server photo must exist locally", sample != null) + assertTrue("materialized sample must be a mirror", ProvenanceStamp.read(context, sample!!)?.origin == AssetOrigin.MIRROR.name) + + val mirrored = container.syncRepository.assets(bucket) + .count { it.origin == AssetOrigin.MIRROR.name } + assertTrue("materialized items must be recorded as mirrors", mirrored >= TestMedia.PHOTO_COUNT) + } +} diff --git a/android/app/src/androidTest/java/com/galo/android/TestMedia.kt b/android/app/src/androidTest/java/com/galo/android/TestMedia.kt new file mode 100644 index 0000000..dafeab6 --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/TestMedia.kt @@ -0,0 +1,117 @@ +package com.galo.android + +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import android.net.Uri +import android.provider.MediaStore +import com.galo.android.data.sync.ContentHash +import java.io.ByteArrayOutputStream + +/** + * Shared media seeding for the dedicated volume test. Content is made unique per + * index so the checksum-based deduplication does not collapse it. + */ +object TestMedia { + + const val PHOTO_COUNT = 100 + const val VIDEO_COUNT = 20 + + /** A unique JPEG for [index] (distinct pixel colour, so distinct checksum). */ + fun jpeg(index: Int, size: Int = 96): ByteArray { + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val r = (index * 7) % 256 + val g = (index * 13) % 256 + val b = (index * 29) % 256 + bitmap.eraseColor(Color.rgb(r, g, b)) + val out = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 85, out) + bitmap.recycle() + return out.toByteArray() + } + + /** A unique video for [index] (bundled asset, distinct bytes each). */ + fun video(index: Int): ByteArray { + val name = "video-%02d.mp4".format(index.coerceIn(1, VIDEO_COUNT)) + return androidx.test.platform.app.InstrumentationRegistry.getInstrumentation() + .context.assets.open(name).use { input -> input.readBytes() } + } + + data class Seeded(val photos: List, val videos: List) { + val uris: List get() = photos + videos + fun bucket(resolver: ContentResolver): String = + resolver.query(photos.first(), arrayOf(MediaStore.Images.Media.BUCKET_ID), null, null, null)!! + .use { cursor -> + cursor.moveToFirst() + cursor.getString(0) + } + } + + /** Inserts [PHOTO_COUNT] photos and [VIDEO_COUNT] videos into DCIM/[folder]. */ + fun seed(resolver: ContentResolver, folder: String): Seeded { + // A unique bucket per call: leftovers from interrupted runs never leak in. + val path = "DCIM/$folder-${System.nanoTime()}/" + val photos = (0 until PHOTO_COUNT).map { index -> + insertImage(resolver, path, "photo-%03d.jpg".format(index), jpeg(index)) + } + val videos = (1..VIDEO_COUNT).map { index -> + insertVideo(resolver, path, "video-%02d.mp4".format(index), video(index)) + } + return Seeded(photos, videos) + } + + private fun insertImage( + resolver: ContentResolver, + relativePath: String, + name: String, + bytes: ByteArray, + ): Uri { + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, name) + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, relativePath) + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!! + resolver.openOutputStream(uri)!!.use { it.write(bytes) } + values.clear() + values.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + return uri + } + + private fun insertVideo( + resolver: ContentResolver, + relativePath: String, + name: String, + bytes: ByteArray, + ): Uri { + val values = ContentValues().apply { + put(MediaStore.Video.Media.DISPLAY_NAME, name) + put(MediaStore.Video.Media.MIME_TYPE, "video/mp4") + put(MediaStore.Video.Media.RELATIVE_PATH, relativePath) + put(MediaStore.Video.Media.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)!! + resolver.openOutputStream(uri)!!.use { it.write(bytes) } + values.clear() + values.put(MediaStore.Video.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + return uri + } + + fun hash(resolver: ContentResolver, uri: Uri): String = + resolver.openInputStream(uri)!!.use { ContentHash.sha256(it) } + + /** Uploads the full volume (photos + videos) straight to [album] on the server. */ + fun uploadServerMedia(token: String, album: String) { + for (i in 0 until PHOTO_COUNT) { + E2E.upload(token, album, "srv-photo-%03d.jpg".format(i), jpeg(i), "image/jpeg") + } + for (i in 1..VIDEO_COUNT) { + E2E.upload(token, album, "srv-video-%02d.mp4".format(i), video(i), "video/mp4") + } + } +} diff --git a/android/app/src/androidTest/java/com/galo/android/TwoWayMirrorE2ETest.kt b/android/app/src/androidTest/java/com/galo/android/TwoWayMirrorE2ETest.kt new file mode 100644 index 0000000..af67e2e --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/TwoWayMirrorE2ETest.kt @@ -0,0 +1,306 @@ +package com.galo.android + +import android.content.ContentResolver +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.exifinterface.media.ExifInterface +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.galo.android.data.sync.ProvenanceStamp +import com.galo.android.domain.media.MediaPermissions +import com.galo.android.domain.model.SyncMode +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.junit.runners.model.Statement +import java.io.File + +/** + * End-to-end two-way synchronization: a photo added to the target server album + * (as the web UI would) is materialized into the device album's MediaStore bucket + * with provenance, and deletions propagate both ways. Small and fast; volume + * coverage lives in [SyncVolumeE2ETest]. Against a running galo at 10.0.2.2:8088. + */ +@RunWith(AndroidJUnit4::class) +class TwoWayMirrorE2ETest { + + private val mediaPermissionRule: TestRule = object : TestRule { + override fun apply(base: Statement, description: Description): Statement = object : Statement() { + override fun evaluate() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val pkg = instrumentation.targetContext.packageName + MediaPermissions.required(Build.VERSION.SDK_INT).forEach { permission -> + runCatching { instrumentation.uiAutomation.grantRuntimePermission(pkg, permission) } + } + base.evaluate() + } + } + } + + @get:Rule + val rules: TestRule = RuleChain.outerRule(mediaPermissionRule) + + private lateinit var context: Context + private lateinit var resolver: ContentResolver + private val created = mutableListOf() + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + resolver = context.contentResolver + } + + @After + fun tearDown() { + created.forEach { runCatching { resolver.delete(it, null, null) } } + } + + /** Anchors the album bucket with one image and returns its bucket id. */ + private fun insertAnchor(): String { + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, "anchor-${System.nanoTime()}.jpg") + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/GaloTwoWay-${System.nanoTime()}/") + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!! + val bitmap = Bitmap.createBitmap(160, 120, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(android.graphics.Color.rgb(7, 200, 13)) + resolver.openOutputStream(uri)!!.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) } + bitmap.recycle() + values.clear() + values.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + created += uri + return resolver.query(uri, arrayOf(MediaStore.Images.Media.BUCKET_ID), null, null, null)!! + .use { cursor -> + cursor.moveToFirst() + cursor.getString(0) + } + } + + /** JPEG with a capture date, so the server reports `taken_at`. */ + private fun datedJpeg(): ByteArray { + val file = File(context.cacheDir, "web-${System.nanoTime()}.jpg") + file.writeBytes(E2E.jpegBytes(128)) + ExifInterface(file).apply { + setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, "2021:05:05 10:00:00") + saveAttributes() + } + val bytes = file.readBytes() + file.delete() + return bytes + } + + private fun awaitLocalFile(bucket: String, name: String, timeoutMs: Long = 60_000): Uri? { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + val uri = queryLocal(bucket, name) + if (uri != null) { + if (uri !in created) created += uri + return uri + } + Thread.sleep(300) + } + return null + } + + private fun queryLocal(bucket: String, name: String): Uri? = + resolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + arrayOf(MediaStore.Images.Media._ID), + "${MediaStore.Images.Media.BUCKET_ID} = ? AND ${MediaStore.Images.Media.DISPLAY_NAME} = ?", + arrayOf(bucket, name), + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + ContentUris.withAppendedId( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + cursor.getLong(0), + ) + } else { + null + } + } + + private fun dateTakenOf(uri: Uri): Long = + resolver.query(uri, arrayOf(MediaStore.Images.Media.DATE_TAKEN), null, null, null)!! + .use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else 0L } + + @Test + fun webPhotoIsMaterializedWithProvenanceAndDate() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "TwoWay ${System.currentTimeMillis()}") + val bucket = insertAnchor() + + E2E.upload(token, album, "web.jpg", datedJpeg(), "image/jpeg") + val serverPhoto = E2E.listPhotos(token, album).first { it.originalFilename == "web.jpg" } + + container.syncRepository.link(bucket, "TwoWay", album, SyncMode.COPY, mirrorFromCreatedAt = 0L) + container.syncCoordinator.reconcile(bucket) + + val local = awaitLocalFile(bucket, "web.jpg") + assertNotNull("the server photo must be materialized into the album bucket", local) + + val marker = ProvenanceStamp.read(context, local!!) + assertNotNull("materialized file must carry a provenance marker", marker) + assertTrue("origin must be MIRROR", marker!!.origin == "MIRROR") + assertTrue("provenance must point at the server photo", marker.serverPhotoId == serverPhoto.id) + + val exifDate = runCatching { + resolver.openInputStream(local).use { + if (it == null) null else ExifInterface(it).getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL) + } + }.getOrNull() + if (!serverPhoto.takenAt.isNullOrBlank()) { + assertTrue( + "capture date must be preserved on the device", + dateTakenOf(local) > 0L || !exifDate.isNullOrBlank(), + ) + } + } + + @Test + fun deletingTheLocalCopyKeepsTheServerPhotoAndRecreates() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "TwoWay del ${System.currentTimeMillis()}") + val bucket = insertAnchor() + + E2E.upload(token, album, "web.jpg", E2E.jpegBytes(128), "image/jpeg") + val serverPhoto = E2E.listPhotos(token, album).first { it.originalFilename == "web.jpg" } + container.syncRepository.link(bucket, "TwoWay", album, SyncMode.COPY, mirrorFromCreatedAt = 0L) + container.syncCoordinator.reconcile(bucket) + + val local = awaitLocalFile(bucket, "web.jpg") + assertNotNull(local) + + // The user deletes the downloaded copy from the gallery. The server photo + // is not this client's upload, so it must survive; the local copy returns. + resolver.delete(local!!, null, null) + container.syncCoordinator.reconcile(bucket) + + assertTrue( + "a lost local mirror must not delete a server photo", + runCatching { container.syncRemote.serverPhotos(album) }.getOrDefault(emptyList()) + .any { it.id == serverPhoto.id }, + ) + assertNotNull("the materialized copy must be recreated", awaitLocalFile(bucket, "web.jpg")) + } + + @Test + fun deletingOnTheServerRemovesTheLocalCopy() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "TwoWay srv ${System.currentTimeMillis()}") + val bucket = insertAnchor() + + E2E.upload(token, album, "web.jpg", E2E.jpegBytes(128), "image/jpeg") + val serverPhoto = E2E.listPhotos(token, album).first { it.originalFilename == "web.jpg" } + container.syncRepository.link(bucket, "TwoWay", album, SyncMode.COPY, mirrorFromCreatedAt = 0L) + container.syncCoordinator.reconcile(bucket) + + val local = awaitLocalFile(bucket, "web.jpg") + assertNotNull(local) + + E2E.deletePhoto(token, serverPhoto.id) + + var remaining: Uri? = queryLocal(bucket, "web.jpg") + val deadline = System.currentTimeMillis() + 30_000 + while (System.currentTimeMillis() < deadline && remaining != null) { + container.syncCoordinator.reconcile(bucket) + remaining = queryLocal(bucket, "web.jpg") + if (remaining != null) delay(300) + } + assertNull("a server deletion must remove the local materialized file", remaining) + } + + @Test + fun moveMaterializesVideoAsPoster() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "TwoWay vid ${System.currentTimeMillis()}") + val bucket = insertAnchor() + + E2E.upload(token, album, "vid.mp4", TestMedia.video(1), "video/mp4") + container.syncRepository.link(bucket, "TwoWay", album, SyncMode.MOVE, mirrorFromCreatedAt = 0L) + + var local = queryLocal(bucket, "vid-preview.jpg") + val deadline = System.currentTimeMillis() + 60_000 + while (System.currentTimeMillis() < deadline && local == null) { + container.syncCoordinator.reconcile(bucket) + local = queryLocal(bucket, "vid-preview.jpg") + if (local != null) created += local + delay(500) + } + assertNotNull("a server video must become a poster in move mode", local) + + val mirrored = container.syncRepository.assets(bucket) + .any { it.origin == "MIRROR" && it.state == "PREVIEW" } + assertTrue("the poster must be recorded as a mirror preview", mirrored) + } + + @Test + fun explicitDeleteOfAMirrorRemovesTheServerPhoto() = runBlocking { + val container = ApplicationProvider.getApplicationContext().container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val album = E2E.createAlbum(token, "TwoWay exp ${System.currentTimeMillis()}") + val bucket = insertAnchor() + + E2E.upload(token, album, "web.jpg", E2E.jpegBytes(128), "image/jpeg") + val serverPhoto = E2E.listPhotos(token, album).first { it.originalFilename == "web.jpg" } + container.syncRepository.link(bucket, "TwoWay", album, SyncMode.COPY, mirrorFromCreatedAt = 0L) + container.syncCoordinator.reconcile(bucket) + + val local = awaitLocalFile(bucket, "web.jpg") + assertNotNull(local) + + // An explicit in-app deletion: mark the intent and remove the local copy. + container.syncRepository.markExplicitDelete(bucket, serverPhoto.id) + resolver.delete(local!!, null, null) + container.syncCoordinator.reconcile(bucket) + + assertTrue( + "an explicit in-app deletion must remove the server photo", + awaitServerPhotoGone(container, album, serverPhoto.id), + ) + } + + private suspend fun awaitServerPhotoGone( + container: com.galo.android.di.AppContainer, + album: String, + photoId: String, + ): Boolean { + val deadline = System.currentTimeMillis() + 20_000 + while (System.currentTimeMillis() < deadline) { + val alive = runCatching { container.syncRemote.serverPhotos(album) }.getOrDefault(emptyList()) + .any { it.id == photoId } + if (!alive) return true + delay(300) + } + return runCatching { container.syncRemote.serverPhotos(album) }.getOrDefault(emptyList()) + .none { it.id == photoId } + } +} diff --git a/android/app/src/main/java/com/galo/android/data/db/GaloDatabase.kt b/android/app/src/main/java/com/galo/android/data/db/GaloDatabase.kt index 91f6e7c..59ad196 100644 --- a/android/app/src/main/java/com/galo/android/data/db/GaloDatabase.kt +++ b/android/app/src/main/java/com/galo/android/data/db/GaloDatabase.kt @@ -4,6 +4,8 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase import com.galo.android.data.db.dao.AlbumDao import com.galo.android.data.db.dao.PhotoDao import com.galo.android.data.db.dao.SyncDao @@ -11,6 +13,7 @@ import com.galo.android.data.db.dao.UploadDao import com.galo.android.data.db.entity.AlbumEntity import com.galo.android.data.db.entity.DeviceAlbumMetaEntity import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.db.entity.DeletedAssetEntity import com.galo.android.data.db.entity.PhotoEntity import com.galo.android.data.db.entity.SyncedAlbumEntity import com.galo.android.data.db.entity.UploadJobEntity @@ -23,8 +26,9 @@ import com.galo.android.data.db.entity.UploadJobEntity SyncedAlbumEntity::class, DeviceAssetEntity::class, DeviceAlbumMetaEntity::class, + DeletedAssetEntity::class, ], - version = 6, + version = 8, exportSchema = false, )abstract class GaloDatabase : RoomDatabase() { @@ -41,7 +45,43 @@ import com.galo.android.data.db.entity.UploadJobEntity fun create(context: Context): GaloDatabase = Room.databaseBuilder(context.applicationContext, GaloDatabase::class.java, NAME) + .addMigrations(MIGRATION_6_7, MIGRATION_7_8) .fallbackToDestructiveMigration() .build() + + /** + * Adds two-way mirroring state without losing the sync index: the + * provenance origin, the first-pass baseline and the deletion journal. + */ + val MIGRATION_6_7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE synced_albums ADD COLUMN mirrorFromCreatedAt INTEGER NOT NULL DEFAULT 0", + ) + db.execSQL( + "ALTER TABLE device_assets ADD COLUMN origin TEXT NOT NULL DEFAULT 'OWN'", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS deleted_assets (" + + "photoId TEXT NOT NULL, deviceAlbumId TEXT NOT NULL, " + + "status TEXT NOT NULL, createdAt INTEGER NOT NULL, " + + "PRIMARY KEY(photoId))", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_deleted_assets_deviceAlbumId " + + "ON deleted_assets (deviceAlbumId)", + ) + } + } + + /** + * Records whether the user explicitly turned synchronization off for an + * album, so provenance recovery does not resurrect the link. + */ + val MIGRATION_7_8 = object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE device_album_meta ADD COLUMN syncDisabledAt INTEGER") + } + } } } diff --git a/android/app/src/main/java/com/galo/android/data/db/dao/SyncDao.kt b/android/app/src/main/java/com/galo/android/data/db/dao/SyncDao.kt index 752e486..237e54c 100644 --- a/android/app/src/main/java/com/galo/android/data/db/dao/SyncDao.kt +++ b/android/app/src/main/java/com/galo/android/data/db/dao/SyncDao.kt @@ -5,6 +5,7 @@ import androidx.room.Query import androidx.room.Upsert import com.galo.android.data.db.entity.DeviceAlbumMetaEntity import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.db.entity.DeletedAssetEntity import com.galo.android.data.db.entity.SyncedAlbumEntity import kotlinx.coroutines.flow.Flow @@ -97,4 +98,24 @@ interface SyncDao { @Query("DELETE FROM device_assets WHERE deviceAlbumId = :deviceAlbumId") suspend fun clearAlbum(deviceAlbumId: String) + + // --- Deletion journal (tombstones) ------------------------------------- + + @Query("SELECT * FROM deleted_assets WHERE deviceAlbumId = :deviceAlbumId") + suspend fun getDeletedAssets(deviceAlbumId: String): List + + @Query("SELECT * FROM deleted_assets") + suspend fun getAllDeletedAssets(): List + + @Query("SELECT * FROM deleted_assets WHERE photoId = :photoId") + suspend fun getDeletedAsset(photoId: String): DeletedAssetEntity? + + @Upsert + suspend fun upsertDeletedAsset(asset: DeletedAssetEntity) + + @Query("DELETE FROM deleted_assets WHERE photoId = :photoId") + suspend fun deleteDeletedAsset(photoId: String) + + @Query("DELETE FROM deleted_assets WHERE deviceAlbumId = :deviceAlbumId") + suspend fun clearDeletedAssets(deviceAlbumId: String) } diff --git a/android/app/src/main/java/com/galo/android/data/db/entity/SyncEntities.kt b/android/app/src/main/java/com/galo/android/data/db/entity/SyncEntities.kt index a467e06..514fc7d 100644 --- a/android/app/src/main/java/com/galo/android/data/db/entity/SyncEntities.kt +++ b/android/app/src/main/java/com/galo/android/data/db/entity/SyncEntities.kt @@ -1,5 +1,6 @@ package com.galo.android.data.db.entity +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @@ -12,6 +13,11 @@ data class SyncedAlbumEntity( val serverAlbumId: String, val mode: String, val updatedAt: Long, + /** + * Two-way mirroring baseline: only server photos created at or after this + * epoch (millis) are materialized. 0 means "the whole current album". + */ + @ColumnInfo(defaultValue = "0") val mirrorFromCreatedAt: Long = 0L, ) /** @@ -24,6 +30,12 @@ data class DeviceAlbumMetaEntity( val displayName: String?, val coverUri: String?, val updatedAt: Long, + /** + * When the user explicitly turned synchronization off (or unlinked), the + * album is local-only until they enable sync again. Provenance recovery must + * not resurrect the link from the offloaded previews' markers. + */ + val syncDisabledAt: Long? = null, ) /** @@ -51,8 +63,29 @@ data class DeviceAssetEntity( val serverPhotoId: String?, val uploadedAt: Long?, val state: String, + /** Uploaded by this client (`OWN`) or materialized from the server (`MIRROR`). */ + @ColumnInfo(defaultValue = "OWN") val origin: String = "OWN", ) { companion object { fun key(deviceAlbumId: String, mediaStoreId: Long): String = "$deviceAlbumId/$mediaStoreId" } } + +/** + * A deletion made on the device that still has to be confirmed on the server. + * Keeps a locally removed element from being materialized again before its + * server photo is gone, and makes delete events idempotent. + */ +@Entity(tableName = "deleted_assets", indices = [Index("deviceAlbumId")]) +data class DeletedAssetEntity( + @PrimaryKey val photoId: String, + val deviceAlbumId: String, + /** `PENDING` while the server delete is not confirmed; `FAILED` after a refusal. */ + val status: String, + val createdAt: Long, +) { + companion object { + const val PENDING = "PENDING" + const val FAILED = "FAILED" + } +} diff --git a/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt b/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt index 796daca..c1f2c86 100644 --- a/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt +++ b/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt @@ -3,6 +3,7 @@ package com.galo.android.data.repo import com.galo.android.data.db.dao.SyncDao import com.galo.android.data.db.entity.DeviceAlbumMetaEntity import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.db.entity.DeletedAssetEntity import com.galo.android.data.db.entity.SyncedAlbumEntity import com.galo.android.data.media.MediaStoreDeviceSource import com.galo.android.domain.model.DeviceAlbum @@ -57,6 +58,7 @@ class SyncRepository( deviceAlbumName: String, serverAlbumId: String, mode: SyncMode, + mirrorFromCreatedAt: Long? = null, ) = dao.upsertSyncedAlbum( SyncedAlbumEntity( deviceAlbumId = deviceAlbumId, @@ -64,10 +66,71 @@ class SyncRepository( serverAlbumId = serverAlbumId, mode = mode.name, updatedAt = System.currentTimeMillis(), + // Keep an existing two-way baseline; a brand-new link starts at "now" + // (only what appears from this point on), which is the safe default. + mirrorFromCreatedAt = mirrorFromCreatedAt + ?: dao.getSyncedAlbum(deviceAlbumId)?.mirrorFromCreatedAt + ?: System.currentTimeMillis(), ), - ) + ).also { + // Enabling sync clears any earlier explicit "off", so recovery may work. + markSyncDisabled(deviceAlbumId, false) + } - suspend fun unlink(deviceAlbumId: String) = dao.deleteSyncedAlbum(deviceAlbumId) + /** Sets the two-way mirroring baseline: 0 downloads the whole album. */ + suspend fun setMirrorFrom(deviceAlbumId: String, from: Long) { + val current = dao.getSyncedAlbum(deviceAlbumId) ?: return + dao.upsertSyncedAlbum(current.copy(mirrorFromCreatedAt = from)) + } + + /** True when the user explicitly turned synchronization off for this album. */ + suspend fun isSyncDisabled(deviceAlbumId: String): Boolean = + dao.getAlbumMeta(deviceAlbumId)?.syncDisabledAt != null + + /** + * Records an explicit in-app deletion of a tracked photo, so the coordinator + * deletes it on the server. A local copy merely going missing (gallery cleanup, + * moved file) is not recorded and is re-materialized instead. + */ + suspend fun markExplicitDelete(deviceAlbumId: String, photoId: String) { + dao.upsertDeletedAsset( + DeletedAssetEntity( + photoId = photoId, + deviceAlbumId = deviceAlbumId, + status = DeletedAssetEntity.PENDING, + createdAt = System.currentTimeMillis(), + ), + ) + } + + /** + * Records that the user turned synchronization off (or unlinked) the album, + * so provenance recovery does not recreate the link from preview markers. + */ + suspend fun markSyncDisabled(deviceAlbumId: String, disabled: Boolean) { + val current = dao.getAlbumMeta(deviceAlbumId) + if (disabled) { + dao.upsertAlbumMeta( + DeviceAlbumMetaEntity( + deviceAlbumId = deviceAlbumId, + displayName = current?.displayName, + coverUri = current?.coverUri, + updatedAt = System.currentTimeMillis(), + syncDisabledAt = System.currentTimeMillis(), + ), + ) + } else if (current?.syncDisabledAt != null) { + dao.upsertAlbumMeta(current.copy(syncDisabledAt = null, updatedAt = System.currentTimeMillis())) + } + } + + suspend fun unlink(deviceAlbumId: String) { + dao.deleteSyncedAlbum(deviceAlbumId) + dao.clearDeletedAssets(deviceAlbumId) + // Remember the explicit "off" so the next provenance recovery does not + // recreate the link from the offloaded previews' markers. + markSyncDisabled(deviceAlbumId, true) + } // --- Local album metadata (display name, cover) ------------------------ @@ -91,6 +154,7 @@ class SyncRepository( displayName = name?.trim()?.takeIf { it.isNotEmpty() }, coverUri = current?.coverUri, updatedAt = System.currentTimeMillis(), + syncDisabledAt = current?.syncDisabledAt, ), ) } @@ -104,6 +168,7 @@ class SyncRepository( displayName = current?.displayName, coverUri = coverUri, updatedAt = System.currentTimeMillis(), + syncDisabledAt = current?.syncDisabledAt, ), ) } @@ -114,6 +179,7 @@ class SyncRepository( */ suspend fun forgetAlbum(deviceAlbumId: String) { dao.clearAlbum(deviceAlbumId) + dao.clearDeletedAssets(deviceAlbumId) dao.deleteAlbumMeta(deviceAlbumId) dao.deleteSyncedAlbum(deviceAlbumId) } diff --git a/android/app/src/main/java/com/galo/android/data/sync/ApiSyncRemote.kt b/android/app/src/main/java/com/galo/android/data/sync/ApiSyncRemote.kt index f9191d1..fe22a54 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/ApiSyncRemote.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/ApiSyncRemote.kt @@ -1,12 +1,75 @@ package com.galo.android.data.sync +import com.galo.android.data.network.ApiClient import com.galo.android.data.network.GaloApi +import com.galo.android.data.network.dto.PhotoDto +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Request +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +/** + * The object URL to download for a server photo: the original in copy mode, + * otherwise the lightest available preview (for video, a poster frame). + */ +internal fun pickVariant(photo: PhotoDto, full: Boolean): String? = + if (full) { + photo.downloadUrl ?: photo.originalUrl + } else { + photo.previewUrl ?: photo.mediumUrl ?: photo.thumbUrl + } /** [SyncRemote] backed by the galo REST API. */ -class ApiSyncRemote(private val api: GaloApi) : SyncRemote { +class ApiSyncRemote( + private val api: GaloApi, + private val apiClient: ApiClient, +) : SyncRemote { override suspend fun serverChecksums(serverAlbumId: String): Map = - api.albumChecksums(serverAlbumId) + serverPhotos(serverAlbumId).mapNotNull { photo -> + photo.checksum?.let { it to photo.id } + }.toMap() + + override suspend fun serverPhotos(serverAlbumId: String): List { + val out = ArrayList() + var cursor: String? = null + while (true) { + val page = api.photos(serverAlbumId, cursor = cursor, limit = 100) + page.photos.forEach { p -> + out += ServerPhoto( + id = p.id, + checksum = p.originalChecksum?.ifBlank { null }, + displayName = p.originalFilename, + mimeType = p.mimeType, + isVideo = p.kind == "video", + takenAtMillis = parseTakenAt(p.takenAt), + createdAtMillis = parseInstant(p.createdAt) ?: 0L, + width = p.width, + height = p.height, + size = p.size, + ) + } + val next = page.nextCursor + if (next.isNullOrBlank()) break + cursor = next + } + return out + } + + override suspend fun fetchPhoto(photo: ServerPhoto, full: Boolean): ByteArray? = + withContext(Dispatchers.IO) { + val dto = runCatching { api.photo(photo.id) }.getOrNull() ?: return@withContext null + val raw = pickVariant(dto, full) + val url = apiClient.objectUrls.resolve(raw) ?: return@withContext null + runCatching { + apiClient.client.newCall(Request.Builder().url(url).build()).execute().use { response -> + if (!response.isSuccessful) null else response.body?.bytes() + } + }.getOrNull() + } override suspend fun renamePhoto(photoId: String, newName: String) { api.renamePhoto(photoId, newName) @@ -19,4 +82,24 @@ class ApiSyncRemote(private val api: GaloApi) : SyncRemote { override suspend fun movePhotos(photoIds: List, serverAlbumId: String) { api.movePhotos(photoIds, serverAlbumId) } + + private companion object { + val EXIF_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss") + val PLAIN_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + + /** ISO instant, EXIF or plain datetime; null when unparseable. */ + fun parseInstant(raw: String?): Long? { + val value = raw?.trim()?.ifBlank { null } ?: return null + runCatching { return Instant.parse(value).toEpochMilli() } + runCatching { + return LocalDateTime.parse(value, EXIF_FORMAT).toInstant(ZoneOffset.UTC).toEpochMilli() + } + runCatching { + return LocalDateTime.parse(value, PLAIN_FORMAT).toInstant(ZoneOffset.UTC).toEpochMilli() + } + return null + } + + fun parseTakenAt(raw: String?): Long? = parseInstant(raw) + } } diff --git a/android/app/src/main/java/com/galo/android/data/sync/DeviceMediaObserver.kt b/android/app/src/main/java/com/galo/android/data/sync/DeviceMediaObserver.kt index 34ff56c..d73cdaf 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/DeviceMediaObserver.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/DeviceMediaObserver.kt @@ -10,23 +10,34 @@ import android.provider.MediaStore /** * Watches MediaStore while the app process is alive and notifies on changes so * synchronization can run promptly in the foreground. Background changes are - * covered by the periodic worker. + * covered by the periodic worker. Bursts of changes (for example materializing or + * offloading a whole album) are coalesced into a single notification after + * [debounceMs], so one file write does not trigger a full synchronization pass. */ class DeviceMediaObserver( private val context: Context, private val onChanged: () -> Unit, + private val debounceMs: Long = 1_500, ) { - private val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { + private val handler = Handler(Looper.getMainLooper()) + private val notify = Runnable { onChanged() } + + private val observer = object : ContentObserver(handler) { override fun onChange(selfChange: Boolean) { - onChanged() + schedule() } override fun onChange(selfChange: Boolean, uri: Uri?) { - onChanged() + schedule() } } + private fun schedule() { + handler.removeCallbacks(notify) + handler.postDelayed(notify, debounceMs) + } + fun start() { runCatching { context.contentResolver.registerContentObserver( @@ -44,5 +55,6 @@ class DeviceMediaObserver( fun stop() { runCatching { context.contentResolver.unregisterContentObserver(observer) } + handler.removeCallbacks(notify) } } diff --git a/android/app/src/main/java/com/galo/android/data/sync/LocalAssetProvider.kt b/android/app/src/main/java/com/galo/android/data/sync/LocalAssetProvider.kt index 9634102..229c13f 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/LocalAssetProvider.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/LocalAssetProvider.kt @@ -8,6 +8,7 @@ import com.galo.android.data.media.MediaStoreDeviceSource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.InputStream +import java.util.concurrent.ConcurrentHashMap /** Supplies the device's current content to the synchronization engine. */ interface LocalAssetProvider { @@ -34,7 +35,7 @@ class DeviceLocalAssetProvider( displayName = media.displayName, mimeType = media.mimeType, kind = media.kind, - contentHash = hash(media.uri), + contentHash = hashCached(media.uri, media.size, media.dateModified), uri = media.uri, ) } @@ -46,6 +47,23 @@ class DeviceLocalAssetProvider( override fun openStream(uri: String): InputStream? = runCatching { context.contentResolver.openInputStream(Uri.parse(uri)) }.getOrNull() + private data class CachedHash(val size: Long, val dateModified: Long, val hash: String?) + + // Hashing a large album every pass is expensive; MediaStore updates + // DATE_MODIFIED on any write, so an unchanged (size, dateModified) pair lets + // us reuse the previous sha256 instead of re-reading the whole file. + private val hashCache = ConcurrentHashMap() + + private fun hashCached(uri: String, size: Long, dateModified: Long): String? { + val cached = hashCache[uri] + if (cached != null && cached.size == size && cached.dateModified == dateModified) { + return cached.hash + } + val hash = hash(uri) + hashCache[uri] = CachedHash(size, dateModified, hash) + return hash + } + private fun hash(uri: String): String? = runCatching { openStream(uri)?.use { ContentHash.sha256(it) } }.getOrNull() } diff --git a/android/app/src/main/java/com/galo/android/data/sync/MirrorManager.kt b/android/app/src/main/java/com/galo/android/data/sync/MirrorManager.kt new file mode 100644 index 0000000..2fe4e73 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/sync/MirrorManager.kt @@ -0,0 +1,174 @@ +package com.galo.android.data.sync + +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.provider.MediaStore +import com.galo.android.data.media.MediaStoreDeviceSource +import com.galo.android.domain.model.SyncMode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File + +/** Outcome of materializing one server photo on the device. */ +sealed interface MirrorResult { + data class Done(val mediaStoreId: Long, val uri: String) : MirrorResult + + /** Nothing could be downloaded or written. */ + data object Failed : MirrorResult +} + +/** Writes server photos into a device album's MediaStore bucket, and removes them. */ +interface AssetMirror { + suspend fun materialize( + deviceAlbumId: String, + serverAlbumId: String, + mode: SyncMode, + photo: ServerPhoto, + ): MirrorResult + + /** + * Removes a materialized file from the gallery when its server photo is gone. + * @return true when the media is gone; false when the app does not own it. + */ + suspend fun remove(uri: String): Boolean +} + +/** + * Downloads a server photo and inserts it into the album's MediaStore bucket: + * the original in copy mode, a light preview (poster for video) in move mode. + * The inserted file carries an `origin=MIRROR` provenance marker and the capture + * date, and belongs to the app, so a later server deletion can remove it without + * a system consent dialog. + */ +class MirrorManager( + private val context: Context, + private val remote: SyncRemote, + private val deviceSource: MediaStoreDeviceSource, +) : AssetMirror { + + private val mutex = Mutex() + + override suspend fun remove(uri: String): Boolean = withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.delete(Uri.parse(uri), null, null) > 0 + }.getOrDefault(false) + } + + override suspend fun materialize( + deviceAlbumId: String, + serverAlbumId: String, + mode: SyncMode, + photo: ServerPhoto, + ): MirrorResult = withContext(Dispatchers.IO) { + mutex.withLock { + val full = mode == SyncMode.COPY + val bytes = runCatching { remote.fetchPhoto(photo, full) }.getOrNull() + if (bytes == null || bytes.isEmpty()) { + android.util.Log.w(TAG, "fetch failed ${photo.id} video=${photo.isVideo} full=$full") + return@withLock MirrorResult.Failed + } + runCatching { insert(deviceAlbumId, serverAlbumId, mode, photo, bytes) } + .onFailure { android.util.Log.w(TAG, "insert failed ${photo.id}: ${it.message}") } + .getOrDefault(MirrorResult.Failed) + } + } + + private fun insert( + deviceAlbumId: String, + serverAlbumId: String, + mode: SyncMode, + photo: ServerPhoto, + bytes: ByteArray, + ): MirrorResult { + val asVideo = photo.isVideo && mode == SyncMode.COPY + val relativePath = relativePathOf(deviceAlbumId) + val mime = when { + asVideo -> photo.mimeType?.takeIf { it.startsWith("video/") } ?: "video/mp4" + photo.isVideo -> "image/jpeg" + else -> photo.mimeType?.takeIf { it.startsWith("image/") } ?: "image/jpeg" + } + val displayName = displayNameOf(photo, asVideo) + + val collection = if (asVideo) { + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } else { + MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.MIME_TYPE, mime) + put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) + put(MediaStore.MediaColumns.IS_PENDING, 1) + photo.takenAtMillis?.let { put(MediaStore.MediaColumns.DATE_TAKEN, it) } + } + val inserted = context.contentResolver.insert(collection, values) ?: return MirrorResult.Failed + + val temp = tempFile(displayName) + try { + temp.writeBytes(bytes) + runCatching { + ProvenanceStamp.write( + temp, + Provenance( + serverAlbumId = serverAlbumId, + serverPhotoId = photo.id, + originalHash = photo.checksum, + deviceAlbumId = deviceAlbumId, + mode = mode.name, + assetKey = null, + origin = "MIRROR", + ), + ) + } + context.contentResolver.openOutputStream(inserted)?.use { out -> + temp.inputStream().use { it.copyTo(out) } + } ?: return MirrorResult.Failed + values.clear() + values.put(MediaStore.MediaColumns.IS_PENDING, 0) + context.contentResolver.update(inserted, values, null, null) + // MediaProvider may recompute DATE_TAKEN when the pending flag clears, + // so set the capture date again in a separate update. + photo.takenAtMillis?.let { taken -> + val date = android.content.ContentValues().apply { + put(MediaStore.MediaColumns.DATE_TAKEN, taken) + } + context.contentResolver.update(inserted, date, null, null) + } + } finally { + temp.delete() + } + return MirrorResult.Done( + mediaStoreId = android.content.ContentUris.parseId(inserted), + uri = inserted.toString(), + ) + } + + /** Writes the poster/original next to the album's existing files. */ + private fun relativePathOf(deviceAlbumId: String): String = + deviceSource.items(deviceAlbumId) + .firstOrNull { it.relativePath.isNotBlank() } + ?.relativePath + ?: "DCIM/" + + private fun displayNameOf(photo: ServerPhoto, asVideo: Boolean): String { + val base = photo.displayName.ifBlank { photo.id } + return when { + asVideo -> base + photo.isVideo -> base.substringBeforeLast('.') + "-preview.jpg" + else -> if (base.contains('.')) base else "$base.jpg" + } + } + + private fun tempFile(name: String): File { + val dir = File(context.cacheDir, "mirror").apply { mkdirs() } + val suffix = name.substringAfterLast('.', "bin") + return File(dir, "mirror-${System.nanoTime()}.$suffix") + } + + private companion object { + const val TAG = "MirrorManager" + } +} diff --git a/android/app/src/main/java/com/galo/android/data/sync/OffloadManager.kt b/android/app/src/main/java/com/galo/android/data/sync/OffloadManager.kt index 1b420de..77a6b66 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/OffloadManager.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/OffloadManager.kt @@ -200,6 +200,7 @@ class OffloadManager( // this back to rebuild the link after a reinstall. mode = syncMode ?: SyncMode.MOVE.name, assetKey = asset.assetKey, + origin = asset.origin, ) private suspend fun markPreview(asset: DeviceAssetEntity) { diff --git a/android/app/src/main/java/com/galo/android/data/sync/ProvenanceStamp.kt b/android/app/src/main/java/com/galo/android/data/sync/ProvenanceStamp.kt index 8a5e271..5d532c7 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/ProvenanceStamp.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/ProvenanceStamp.kt @@ -16,6 +16,8 @@ data class Provenance( val deviceAlbumId: String?, val mode: String?, val assetKey: String?, + /** `OWN` when this client uploaded the element, `MIRROR` when downloaded. */ + val origin: String? = null, ) /** @@ -49,6 +51,7 @@ object ProvenanceStamp { append('|').append("device=").append(p.deviceAlbumId ?: "") append('|').append("mode=").append(p.mode ?: "") append('|').append("asset=").append(p.assetKey ?: "") + append('|').append("origin=").append(p.origin ?: "") } fun decode(raw: String?): Provenance? { @@ -66,6 +69,7 @@ object ProvenanceStamp { deviceAlbumId = map["device"]?.ifBlank { null }, mode = map["mode"]?.ifBlank { null }, assetKey = map["asset"]?.ifBlank { null }, + origin = map["origin"]?.ifBlank { null }, ) } diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt index 64f5a59..0db5c2c 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt @@ -1,8 +1,10 @@ package com.galo.android.data.sync import com.galo.android.data.db.dao.SyncDao +import com.galo.android.data.db.entity.DeletedAssetEntity import com.galo.android.data.db.entity.DeviceAssetEntity import com.galo.android.data.db.entity.SyncedAlbumEntity +import com.galo.android.domain.model.AssetOrigin import com.galo.android.domain.model.LocalAssetState import com.galo.android.domain.model.SyncMode import com.galo.android.domain.sync.SyncPolicy @@ -19,9 +21,11 @@ sealed interface RetargetResult { } /** - * Reconciles a device album with its server album. Mode A (DISABLED) is local - * only and performs no server requests; the other modes upload missing content, - * dedup by checksum, propagate renames and update the local index. + * Reconciles a device album with its server album as a two-way mirror. Mode A + * (DISABLED) is local only and performs no server requests; the other modes + * upload missing content and materialize server photos (originals in copy mode, + * previews in move mode), dedup by checksum, propagate renames and deletions in + * both directions, and update the local index. */ class SyncCoordinator( private val dao: SyncDao, @@ -29,6 +33,7 @@ class SyncCoordinator( private val remote: SyncRemote, private val uploadQueue: SyncUploadQueue, private val offloader: AssetOffloader, + private val mirror: AssetMirror, ) { /** Serializes reconciles so rapid triggers never double-upload or double-offload. */ @@ -45,19 +50,42 @@ class SyncCoordinator( // A mode can be chosen before a server album; nothing to sync until then. if (link.serverAlbumId.isBlank()) return false - val serverChecksums = remote.serverChecksums(link.serverAlbumId) - val livePhotoIds = serverChecksums.values.toSet() + // If the album could not be read, do NOT treat it as empty: an empty list + // would make the engine think every server photo is gone and delete the + // local mirrors. Skip the pass instead. + val serverPhotos = runCatching { remote.serverPhotos(link.serverAlbumId) }.getOrNull() + ?: return false + val serverChecksums = serverPhotos.mapNotNull { photo -> + photo.checksum?.let { it to photo.id } + }.toMap() + val livePhotoIds = serverPhotos.mapTo(HashSet()) { it.id } + + // A tombstone whose photo is gone from the server is confirmed: stop + // hiding it. Anything still pending keeps the element from coming back. + clearConfirmedTombstones(deviceAlbumId, livePhotoIds) + val assets = local.assets(deviceAlbumId) + val localKeys = assets.mapTo(HashSet()) { it.assetKey } + val tombstones = dao.getDeletedAssets(deviceAlbumId).mapTo(HashSet()) { it.photoId } // Reflect lost/returned server copies before planning, so an offloaded // preview whose photo is gone is seen as ORPHANED and handled as such. - val index = reflectLostCopies(dao.getAssets(deviceAlbumId), livePhotoIds).map { - IndexedAsset(it.assetKey, it.serverPhotoId, it.contentHash, it.displayName, it.state) + val reflected = reflectLostCopies(dao.getAssets(deviceAlbumId), livePhotoIds) + // Materialized elements are reconciled against the server set. Rows with an + // explicit deletion are left for the planner to delete on the server. + reconcileMirrors(reflected, livePhotoIds, localKeys, tombstones) + val indexEntities = dao.getAssets(deviceAlbumId) + + val index = indexEntities.map { + IndexedAsset(it.assetKey, it.serverPhotoId, it.contentHash, it.displayName, it.state, it.origin) } val hashesOutside = local.hashesOutside(deviceAlbumId) - val actions = SyncPlanner.plan(assets, index, serverChecksums, hashesOutside) + val actions = SyncPlanner.plan(assets, index, serverChecksums, hashesOutside, tombstones) for (action in actions) apply(deviceAlbumId, link.serverAlbumId, mode, action, livePhotoIds) + // Two-way: bring the target album's photos onto the device. + materializeMissing(deviceAlbumId, link, mode, serverPhotos, assets, indexEntities) + // Mode C: assets already on the server (e.g. after switching from copies) // are replaced with their offloaded versions, right away, but only while // their copy is confirmed in the target album. @@ -79,7 +107,8 @@ class SyncCoordinator( newServerAlbumId: String, mode: SyncMode, ): RetargetResult = mutex.withLock { - val previous = dao.getSyncedAlbum(deviceAlbumId) + val prior = dao.getSyncedAlbum(deviceAlbumId) + val previous = prior ?.serverAlbumId ?.takeIf { it.isNotBlank() } val recordedPhotoIds = dao.getAssets(deviceAlbumId) @@ -121,6 +150,7 @@ class SyncCoordinator( serverAlbumId = newServerAlbumId, mode = mode.name, updatedAt = System.currentTimeMillis(), + mirrorFromCreatedAt = prior?.mirrorFromCreatedAt ?: System.currentTimeMillis(), ), ) reconcileLocked(deviceAlbumId) @@ -143,12 +173,14 @@ class SyncCoordinator( /** * Reflects the loss or return of the server copy behind an offloaded preview: * a preview whose server photo is gone becomes ORPHANED, and one whose photo is - * back becomes PREVIEW again. Returns the updated index rows for planning. + * back becomes PREVIEW again. Materialized elements are handled separately. + * Returns the updated index rows for planning. */ private suspend fun reflectLostCopies( entities: List, livePhotoIds: Set, ): List = entities.map { entity -> + if (entity.origin == AssetOrigin.MIRROR.name) return@map entity val photoId = entity.serverPhotoId ?: return@map entity val live = photoId in livePhotoIds when { @@ -166,6 +198,128 @@ class SyncCoordinator( } } + /** + * Reconciles materialized (server-origin) elements: + * - if the server photo is gone, the local mirror is removed (two-way deletion); + * - if the server photo is still there but the local copy is missing, the stale + * index row is dropped so the photo is materialized again — the client must + * never delete a server photo just because it lost its own copy of it. + */ + private suspend fun reconcileMirrors( + entities: List, + livePhotoIds: Set, + localKeys: Set, + tombstones: Set, + ) { + for (entity in entities) { + if (entity.origin != AssetOrigin.MIRROR.name) continue + val photoId = entity.serverPhotoId ?: continue + // An explicit in-app deletion is handled by the planner (delete on the + // server); do not drop the row here or the deletion would be lost. + if (photoId in tombstones) continue + if (photoId in livePhotoIds) { + if (entity.assetKey !in localKeys) dao.deleteAsset(entity.assetKey) + continue + } + val removed = runCatching { mirror.remove(entity.uri) }.getOrDefault(false) + if (removed) { + dao.deleteAsset(entity.assetKey) + } else { + dao.markState(entity.assetKey, LocalAssetState.ORPHANED.name) + } + } + } + + /** Clears tombstones whose server photo is gone (delete confirmed). */ + private suspend fun clearConfirmedTombstones(deviceAlbumId: String, livePhotoIds: Set) { + for (tombstone in dao.getDeletedAssets(deviceAlbumId)) { + if (tombstone.photoId !in livePhotoIds) dao.deleteDeletedAsset(tombstone.photoId) + } + } + + /** + * Materializes the server photos that are not represented locally yet, are + * not pending deletion, and whose content is not already on the device. Only + * photos at or after the link's baseline are considered. + */ + private suspend fun materializeMissing( + deviceAlbumId: String, + link: SyncedAlbumEntity, + mode: SyncMode, + serverPhotos: List, + assets: List, + indexEntities: List, + ) { + // Several device albums may point at one server album (n:1). Materialize + // into exactly one of them (a stable choice) so the gallery never gets a + // duplicate from the same server photo. + if (!isMirrorTarget(deviceAlbumId, link.serverAlbumId)) return + + val represented = indexEntities.mapNotNull { it.serverPhotoId }.toSet() + val deviceHashes = HashSet() + assets.forEach { it.contentHash?.let(deviceHashes::add) } + indexEntities.forEach { it.contentHash?.let(deviceHashes::add) } + val tombstones = dao.getDeletedAssets(deviceAlbumId).mapTo(HashSet()) { it.photoId } + + val from = link.mirrorFromCreatedAt + val eligible = serverPhotos.filter { + from == 0L || it.createdAtMillis == 0L || it.createdAtMillis >= from + } + val toDownload = SyncPlanner.mirrorPlan(eligible, represented, deviceHashes, tombstones) + if (toDownload.isNotEmpty()) { + android.util.Log.i( + TAG, + "materialize plan=${toDownload.size} server=${serverPhotos.size} represented=${represented.size}", + ) + } + + for (photo in toDownload) { + val result = runCatching { + mirror.materialize(deviceAlbumId, link.serverAlbumId, mode, photo) + }.getOrNull() ?: continue + if (result !is MirrorResult.Done) continue + recordMirror(deviceAlbumId, mode, photo, result) + } + } + + /** + * Among all device albums linked to [serverAlbumId], the first by id owns + * materialization. Stable, so a photo is never written into two buckets. + */ + private suspend fun isMirrorTarget(deviceAlbumId: String, serverAlbumId: String): Boolean { + val links = dao.getSyncedAlbumsForServer(serverAlbumId) + if (links.size <= 1) return true + return links.minByOrNull { it.deviceAlbumId }?.deviceAlbumId == deviceAlbumId + } + + /** Records a materialized server photo in the index with `origin=MIRROR`. */ + private suspend fun recordMirror( deviceAlbumId: String, + mode: SyncMode, + photo: ServerPhoto, + result: MirrorResult.Done, + ) { + val state = if (mode == SyncMode.MOVE) LocalAssetState.PREVIEW else LocalAssetState.UPLOADED + dao.upsertAssets( + listOf( + DeviceAssetEntity( + assetKey = DeviceAssetEntity.key(deviceAlbumId, result.mediaStoreId), + deviceAlbumId = deviceAlbumId, + mediaStoreId = result.mediaStoreId, + uri = result.uri, + displayName = photo.displayName, + mimeType = photo.mimeType, + size = photo.size, + dateModified = 0L, + contentHash = photo.checksum, + serverPhotoId = photo.id, + uploadedAt = null, + state = state.name, + origin = AssetOrigin.MIRROR.name, + ), + ), + ) + } + /** * Offloads assets that are already on the server but still have their original * locally (mode C). Retries assets left as "awaiting free" from a previous pass, @@ -224,14 +378,27 @@ class SyncCoordinator( } is SyncAction.RenameOnServer -> { - remote.renamePhoto(action.photoId, action.newName) + runCatching { remote.renamePhoto(action.photoId, action.newName) } dao.updateDisplayNameByServerPhoto(action.photoId, action.newName) } is SyncAction.DeleteOnServer -> { // A photo already gone from the server is only reflected locally: // the device original is never touched and nothing is re-deleted. - if (action.photoId in livePhotoIds) remote.deletePhoto(action.photoId) + if (action.photoId in livePhotoIds) { + val ok = runCatching { remote.deletePhoto(action.photoId) }.isSuccess + // Record the deletion so the element is not materialized again + // before the server confirms it. A refusal is kept as FAILED so + // it is not retried forever and stays visible as a problem. + dao.upsertDeletedAsset( + DeletedAssetEntity( + photoId = action.photoId, + deviceAlbumId = deviceAlbumId, + status = if (ok) DeletedAssetEntity.PENDING else DeletedAssetEntity.FAILED, + createdAt = System.currentTimeMillis(), + ), + ) + } dao.deleteAsset(action.assetKey) } } @@ -263,4 +430,8 @@ class SyncCoordinator( ), ) } + + private companion object { + const val TAG = "SyncCoordinator" + } } diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncPlan.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncPlan.kt index 2f71937..555cd88 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/SyncPlan.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncPlan.kt @@ -1,5 +1,6 @@ package com.galo.android.data.sync +import com.galo.android.domain.model.AssetOrigin import com.galo.android.domain.model.LocalAssetState import com.galo.android.domain.model.MediaKind @@ -21,6 +22,8 @@ data class IndexedAsset( val contentHash: String?, val displayName: String, val state: String = "", + /** `OWN` (uploaded by this client) or `MIRROR` (materialized from the server). */ + val origin: String = "OWN", ) /** A change to apply to the server album. */ @@ -54,6 +57,7 @@ object SyncPlanner { index: List, serverChecksums: Map, hashesPresentElsewhere: Set, + explicitlyDeleted: Set = emptySet(), ): List { val actions = mutableListOf() val livePhotoIds = serverChecksums.values.toSet() @@ -103,6 +107,10 @@ object SyncPlanner { for (indexed in index) { val photoId = indexed.serverPhotoId ?: continue if (indexed.assetKey in localKeys) continue + // A materialized (server-origin) photo is deleted on the server only on + // an explicit in-app deletion (a tombstone). A merely missing local copy + // is not an intent to delete: the coordinator recreates it instead. + if (indexed.origin == AssetOrigin.MIRROR.name && photoId !in explicitlyDeleted) continue val hash = indexed.contentHash if (hash != null && hash in hashesPresentElsewhere) continue // The remote delete only happens while this photo is in the target @@ -112,4 +120,20 @@ object SyncPlanner { return actions } + + /** + * Server photos that must be materialized on the device: every photo of the + * target album that is not already represented locally, not pending deletion, + * and whose content is not already present on the device (dedup). + */ + fun mirrorPlan( + serverPhotos: List, + representedPhotoIds: Set, + deviceHashes: Set, + tombstonedPhotoIds: Set, + ): List = serverPhotos.filter { photo -> + photo.id !in representedPhotoIds && + photo.id !in tombstonedPhotoIds && + (photo.checksum == null || photo.checksum !in deviceHashes) + } } diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncRecovery.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncRecovery.kt index 99fb41a..5c4e239 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/SyncRecovery.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncRecovery.kt @@ -6,6 +6,7 @@ import com.galo.android.data.db.dao.SyncDao import com.galo.android.data.db.entity.DeviceAssetEntity import com.galo.android.data.db.entity.SyncedAlbumEntity import com.galo.android.data.media.MediaStoreDeviceSource +import com.galo.android.domain.model.AssetOrigin import com.galo.android.domain.model.LocalAssetState import com.galo.android.domain.model.SyncMode import kotlinx.coroutines.Dispatchers @@ -25,6 +26,10 @@ class SyncRecovery( /** @return how many assets were recovered from their markers. */ suspend fun recover(deviceAlbumId: String): Int = withContext(Dispatchers.IO) { + // The user explicitly turned synchronization off for this album: the + // preview markers must not resurrect the link they unlinked. + if (dao.getAlbumMeta(deviceAlbumId)?.syncDisabledAt != null) return@withContext 0 + var recovered = 0 for (media in deviceSource.items(deviceAlbumId)) { val marker = ProvenanceStamp.read(context, Uri.parse(media.uri)) ?: continue @@ -45,6 +50,7 @@ class SyncRecovery( serverPhotoId = photoId, uploadedAt = null, state = LocalAssetState.PREVIEW.name, + origin = AssetOrigin.from(marker.origin).name, ), ), ) diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncRemote.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncRemote.kt index e010fca..5e519dc 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/SyncRemote.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncRemote.kt @@ -1,10 +1,39 @@ package com.galo.android.data.sync +/** + * A server photo of the target album as the synchronization engine sees it. + * Enough to decide whether it must be materialized on the device and to fetch + * the right variant. + */ +data class ServerPhoto( + val id: String, + val checksum: String?, + val displayName: String, + val mimeType: String?, + val isVideo: Boolean, + /** Capture time in epoch millis, when known. */ + val takenAtMillis: Long?, + /** Server creation time in epoch millis; 0 when unknown. */ + val createdAtMillis: Long, + val width: Int, + val height: Int, + val size: Long, +) + /** Server-side operations the synchronization engine needs. */ interface SyncRemote { /** Control sums of the server photos in an album, keyed by checksum. */ suspend fun serverChecksums(serverAlbumId: String): Map + /** The album's photos with the metadata needed to materialize them. */ + suspend fun serverPhotos(serverAlbumId: String): List + + /** + * Downloads a server photo: the original when [full] is true, otherwise a + * light preview (for video, a poster frame). + */ + suspend fun fetchPhoto(photo: ServerPhoto, full: Boolean): ByteArray? + suspend fun renamePhoto(photoId: String, newName: String) suspend fun deletePhoto(photoId: String) diff --git a/android/app/src/main/java/com/galo/android/data/upload/UploadRepository.kt b/android/app/src/main/java/com/galo/android/data/upload/UploadRepository.kt index b7368ec..ff47124 100644 --- a/android/app/src/main/java/com/galo/android/data/upload/UploadRepository.kt +++ b/android/app/src/main/java/com/galo/android/data/upload/UploadRepository.kt @@ -9,7 +9,6 @@ import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager -import androidx.work.workDataOf import com.galo.android.data.db.dao.UploadDao import com.galo.android.data.db.entity.UploadJobEntity import com.galo.android.domain.model.SyncUpload @@ -71,9 +70,9 @@ class UploadRepository( convertedPath = null, ) dao.upsert(job) - UploadScheduler.enqueue(context, job.id, wifiOnly) queued++ } + if (queued > 0) UploadScheduler.enqueueDrain(context) return queued } @@ -112,17 +111,22 @@ class UploadRepository( syncMode = mode, ) dao.upsert(job) - UploadScheduler.enqueue(context, job.id, wifiOnly) + UploadScheduler.enqueueDrain(context) } /** Re-schedules unfinished work after a restart and clears stale RUNNING status. */ suspend fun requeuePending() { dao.resetRunning() - dao.getPending().forEach { job -> - UploadScheduler.enqueue(context, job.id, job.wifiOnly) - } + if (dao.getPending().isNotEmpty()) UploadScheduler.enqueueDrain(context) } + /** Pending entries in queue order; the draining worker processes them. */ + suspend fun pendingJobs(): List = dao.getPending() + + suspend fun hasPending(): Boolean = dao.getPending().isNotEmpty() + + suspend fun resetRunning() = dao.resetRunning() + suspend fun getById(id: String): UploadJobEntity? = dao.getById(id) /** True when a sync upload for this asset is already queued or running. */ @@ -161,22 +165,20 @@ class UploadRepository( object UploadScheduler { - const val KEY_JOB_ID = "job_id" + /** One draining job processes the whole queue, instead of one job per file. */ + const val UNIQUE_DRAIN = "galo-upload-drain" - fun enqueue(context: Context, jobId: String, wifiOnly: Boolean) { + fun enqueueDrain(context: Context) { val constraints = Constraints.Builder() - .setRequiredNetworkType( - if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED, - ) + .setRequiredNetworkType(NetworkType.CONNECTED) .build() val request = OneTimeWorkRequestBuilder() - .setInputData(workDataOf(KEY_JOB_ID to jobId)) .setConstraints(constraints) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.SECONDS) .addTag(TAG_UPLOAD) .build() WorkManager.getInstance(context) - .enqueueUniqueWork(jobId, ExistingWorkPolicy.KEEP, request) + .enqueueUniqueWork(UNIQUE_DRAIN, ExistingWorkPolicy.KEEP, request) } const val TAG_UPLOAD = "galo-upload" diff --git a/android/app/src/main/java/com/galo/android/data/upload/UploadWorker.kt b/android/app/src/main/java/com/galo/android/data/upload/UploadWorker.kt index 76bfe92..80d2923 100644 --- a/android/app/src/main/java/com/galo/android/data/upload/UploadWorker.kt +++ b/android/app/src/main/java/com/galo/android/data/upload/UploadWorker.kt @@ -2,6 +2,8 @@ package com.galo.android.data.upload import android.content.Context import android.content.pm.ServiceInfo +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import android.net.Uri import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker @@ -23,58 +25,89 @@ import java.io.File import java.io.IOException /** - * Uploads a single queue entry as one multipart POST. Using a foreground - * notification while running lets large/video uploads continue when the app is - * backgrounded; transient failures retry with WorkManager's exponential backoff - * up to [MAX_ATTEMPTS]. + * Drains the persistent upload queue: one WorkManager job processes every pending + * entry sequentially (one file per HTTP request, per the server's fail-fast, + * size-capped uploads). A single draining job avoids the JobScheduler limits and + * foreground-service churn of one job per file, so a large album uploads reliably. */ class UploadWorker( appContext: Context, params: WorkerParameters, ) : CoroutineWorker(appContext, params) { + private enum class Outcome { DONE, RETRY } + override suspend fun doWork(): Result { val app = applicationContext as? GaloApplication ?: return Result.failure() val container = app.container val repository = container.uploadRepository - val jobId = inputData.getString(UploadScheduler.KEY_JOB_ID) ?: return Result.failure() - val job = repository.getById(jobId) ?: return Result.success() - if (job.status == UploadStatus.SUCCESS.name) return Result.success() + // A previous run may have been killed mid-file; those entries are re-queued. + repository.resetRunning() + val pending = repository.pendingJobs() + if (pending.isEmpty()) return Result.success() - val attempt = runAttemptCount + var retry = false + for (job in pending) { + // A job limited to Wi-Fi is skipped while on a metered network. + if (job.wifiOnly && !isUnmetered()) { + retry = true + continue + } + if (process(container, repository, job) == Outcome.RETRY) retry = true + } + // Entries added while this pass ran (or transient failures) need another pass. + if (repository.hasPending()) retry = true + return if (retry) Result.retry() else Result.success() + } + + private suspend fun process( + container: AppContainer, + repository: UploadRepository, + job: UploadJobEntity, + ): Outcome { + val jobId = job.id + val attempt = job.attempts repository.updateState(jobId, UploadStatus.RUNNING, job.progress, null, attempt, job.convertedPath) var tempPath = job.convertedPath + var lastPercent = -1 return try { - setForeground(foregroundInfo(jobId, job.filename, 0)) + setForegroundBestEffort(job.filename, 0) val uri = Uri.parse(job.uri) - val (filename, body) = prepareBody(uri, job.filename, job.mimeType, job.size) { percent -> - onProgress(repository, jobId, job.filename, percent, attempt, tempPath) - }.also { if (it.tempPath != null) tempPath = it.tempPath } + val prepared = prepareBody(uri, job.filename, job.mimeType, job.size) { percent -> + if (percent != lastPercent) { + lastPercent = percent + runBlocking(Dispatchers.IO) { + repository.updateState(jobId, UploadStatus.RUNNING, percent, null, attempt, tempPath) + setForegroundBestEffort(job.filename, percent) + } + } + } + if (prepared.tempPath != null) tempPath = prepared.tempPath - val photo = container.api.upload(job.albumId, filename, body) + val photo = container.api.upload(job.albumId, prepared.filename, prepared.body) container.photoRepository.upsert(photo) recordProvenance(container, job, photo.id) repository.updateState(jobId, UploadStatus.SUCCESS, 100, null, attempt, tempPath) tempPath?.let { runCatching { File(it).delete() } } - Result.success() + Outcome.DONE } catch (e: Exception) { val message = describe(e) if (attempt + 1 >= MAX_ATTEMPTS) { repository.updateState(jobId, UploadStatus.ERROR, job.progress, message, attempt, tempPath) - Result.failure() + Outcome.DONE } else { - repository.updateState(jobId, UploadStatus.QUEUED, job.progress, message, attempt, tempPath) - Result.retry() + repository.updateState(jobId, UploadStatus.QUEUED, job.progress, message, attempt + 1, tempPath) + Outcome.RETRY } } } /** - * For a job created by the device-album sync, records the created server photo - * as the provenance of the source asset so later passes can dedup and delete. + * Records the created server photo as provenance of the source asset (sync + * jobs) and, in mode C, offloads the original right after the upload. */ private suspend fun recordProvenance(container: AppContainer, job: UploadJobEntity, photoId: String) { val deviceAlbumId = job.deviceAlbumId ?: return @@ -92,12 +125,8 @@ class UploadWorker( serverPhotoId = photoId, state = state, ) - // Mode C: as soon as the object is uploaded, replace the local original - // with the offloaded (preview) version. Media the app does not own needs - // the OS write consent, which is requested from the album screen. if (mode == SyncMode.MOVE) { container.syncRepository.getAsset(assetKey)?.let { asset -> - // The upload just succeeded, so the copy is live in the target. runCatching { container.offloadManager.offload(asset, setOf(photoId)) } } } @@ -162,23 +191,16 @@ class UploadWorker( return -1L } - private fun onProgress( - repository: UploadRepository, - jobId: String, - filename: String, - percent: Int, - attempt: Int, - tempPath: String?, - ) { - if (percent == lastPercent) return - lastPercent = percent - runBlocking(Dispatchers.IO) { - repository.updateState(jobId, UploadStatus.RUNNING, percent, null, attempt, tempPath) - setForeground(foregroundInfo(jobId, filename, percent)) - } + /** + * Foreground is best-effort: the OS may refuse to start a foreground service + * from the background. The upload still runs; it just may not show a persistent + * notification in that case. + */ + private suspend fun setForegroundBestEffort(filename: String, percent: Int) { + runCatching { setForeground(foregroundInfo(filename, percent)) } } - private fun foregroundInfo(jobId: String, filename: String, percent: Int): ForegroundInfo { + private fun foregroundInfo(filename: String, percent: Int): ForegroundInfo { val notification = NotificationCompat.Builder(applicationContext, GaloApplication.UPLOAD_CHANNEL_ID) .setSmallIcon(android.R.drawable.stat_sys_upload) .setContentTitle("Загрузка в galo") @@ -188,12 +210,19 @@ class UploadWorker( .setProgress(100, percent, percent == 0) .build() return ForegroundInfo( - jobId.hashCode(), + NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, ) } + /** True when the active network is unmetered (Wi-Fi/ethernet). */ + private fun isUnmetered(): Boolean { + val manager = applicationContext.getSystemService(ConnectivityManager::class.java) ?: return true + val capabilities = manager.getNetworkCapabilities(manager.activeNetwork) ?: return false + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + } + private fun describe(e: Exception): String = when (e) { is ApiException -> ErrorMapper.userMessage(e.error) else -> e.message ?: "Ошибка загрузки" @@ -202,10 +231,8 @@ class UploadWorker( private fun percent(sent: Long, total: Long): Int = if (total > 0) ((sent * 100) / total).toInt().coerceIn(0, 100) else 0 - @Volatile - private var lastPercent = -1 - private companion object { const val MAX_ATTEMPTS = 3 + const val NOTIFICATION_ID = 42 } } diff --git a/android/app/src/main/java/com/galo/android/di/AppContainer.kt b/android/app/src/main/java/com/galo/android/di/AppContainer.kt index a88beaf..733ad14 100644 --- a/android/app/src/main/java/com/galo/android/di/AppContainer.kt +++ b/android/app/src/main/java/com/galo/android/di/AppContainer.kt @@ -26,6 +26,7 @@ import com.galo.android.data.sync.ApiOriginalFetcher import com.galo.android.data.sync.ApiSyncRemote import com.galo.android.data.sync.DeviceLocalAssetProvider import com.galo.android.data.sync.DeviceMediaObserver +import com.galo.android.data.sync.MirrorManager import com.galo.android.data.sync.OffloadManager import com.galo.android.data.sync.RepositorySyncUploadQueue import com.galo.android.data.sync.RestoreCoordinator @@ -95,10 +96,12 @@ class AppContainer(context: Context) { val syncRepository = SyncRepository(database.syncDao(), deviceSource) - val syncRemote: SyncRemote = ApiSyncRemote(api) + val syncRemote: SyncRemote = ApiSyncRemote(api, apiClient) val offloadManager = OffloadManager(appContext, database.syncDao()) + val mirrorManager = MirrorManager(appContext, syncRemote, deviceSource) + val syncCoordinator = SyncCoordinator( dao = database.syncDao(), local = DeviceLocalAssetProvider(appContext, database.syncDao(), deviceSource), @@ -108,6 +111,7 @@ class AppContainer(context: Context) { wifiOnly = { settingsState?.wifiOnlyUploads == true }, ), offloader = offloadManager, + mirror = mirrorManager, ) val restoreManager = RestoreManager( @@ -124,6 +128,16 @@ class AppContainer(context: Context) { /** Unified per-gallery check and fix (startup + album open). */ val syncCheck = SyncCheck(syncRepository, syncRecovery, restoreCoordinator, syncCoordinator) + /** + * Watches MediaStore for this process so screens can refresh when the sync + * engine materializes or removes files. The caller stops it (return value). + */ + fun observeMediaChanges(onChanged: () -> Unit): () -> Unit { + val observer = DeviceMediaObserver(appContext, onChanged) + observer.start() + return { observer.stop() } + } + fun start() { scope.launch { settingsRepository.flow.collect { settings -> @@ -140,7 +154,10 @@ class AppContainer(context: Context) { } } runCatching { - DeviceMediaObserver(appContext) { runCatching { SyncScheduler.runNow(appContext) } }.start() + DeviceMediaObserver( + context = appContext, + onChanged = { runCatching { SyncScheduler.runNow(appContext) } }, + ).start() } } diff --git a/android/app/src/main/java/com/galo/android/domain/model/Sync.kt b/android/app/src/main/java/com/galo/android/domain/model/Sync.kt index 8881549..ea24ca5 100644 --- a/android/app/src/main/java/com/galo/android/domain/model/Sync.kt +++ b/android/app/src/main/java/com/galo/android/domain/model/Sync.kt @@ -22,6 +22,25 @@ enum class SyncMode { } } +/** + * How an element of a device album came to be: uploaded by this client, or + * materialized from a server photo during two-way synchronization. Deletion, + * offload and recovery rely on it instead of the mere presence of a server link. + */ +enum class AssetOrigin { + /** Uploaded by this client. */ + OWN, + + /** Downloaded from the server to mirror the target album. */ + MIRROR, + ; + + companion object { + fun from(raw: String?): AssetOrigin = + entries.firstOrNull { it.name == raw } ?: OWN + } +} + /** Where a device asset currently lives relative to the server. */ enum class LocalAssetState { /** Original present locally, nothing uploaded yet. */ @@ -71,4 +90,6 @@ data class DeviceAsset( val serverPhotoId: String?, val uploadedAt: Long?, val state: LocalAssetState, + /** Uploaded by this client or materialized from the server. */ + val origin: AssetOrigin = AssetOrigin.OWN, ) diff --git a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt index f4ed8ad..e7d50aa 100644 --- a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt +++ b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt @@ -120,6 +120,9 @@ const val TAG_FREE_BANNER = "device_free_banner" const val TAG_FREE_BANNER_BUTTON = "device_free_banner_button" const val TAG_NEW_ALBUM_NAME = "device_new_album_name" const val TAG_CREATE_TARGET = "device_create_target" +const val TAG_MIRROR_SCOPE = "device_mirror_scope" +const val TAG_MIRROR_SCOPE_ONLY_NEW = "device_mirror_scope_only_new" +const val TAG_MIRROR_SCOPE_ALL = "device_mirror_scope_all" @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -136,6 +139,7 @@ fun DeviceAlbumScreen( val state by viewModel.state.collectAsStateWithLifecycle() val role by viewModel.role.collectAsStateWithLifecycle() val notice by viewModel.notice.collectAsStateWithLifecycle() + val mirrorChoice by viewModel.mirrorChoice.collectAsStateWithLifecycle() val prefs by viewModel.preferences.collectAsStateWithLifecycle() val meta by viewModel.albumMeta.collectAsStateWithLifecycle() var viewerIndex by remember { mutableStateOf(null) } @@ -281,6 +285,14 @@ fun DeviceAlbumScreen( onDismiss = { deletePhotoId = null }, ) } + + if (mirrorChoice) { + MirrorScopeDialog( + onOnlyNew = { viewModel.chooseMirrorScope(downloadAll = false) }, + onDownloadAll = { viewModel.chooseMirrorScope(downloadAll = true) }, + onDismiss = viewModel::dismissMirrorChoice, + ) + } } @OptIn(ExperimentalMaterial3Api::class) @@ -835,10 +847,44 @@ private fun LostCopyBanner(count: Int) { } } +/** + * First-pass choice for two-way synchronization: only photos that appear from now + * on, or the whole current server album. + */ +@Composable +fun MirrorScopeDialog( + onOnlyNew: () -> Unit, + onDownloadAll: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TAG_MIRROR_SCOPE), + title = { Text("Скачать содержимое альбома?") }, + text = { + Text( + "Новые фото с сервера будут появляться здесь автоматически. " + + "Скачать и уже существующие фото целевого альбома?", + ) + }, + confirmButton = { + TextButton( + onClick = onDownloadAll, + modifier = Modifier.testTag(TAG_MIRROR_SCOPE_ALL), + ) { Text("Скачать всё") } + }, + dismissButton = { + TextButton( + onClick = onOnlyNew, + modifier = Modifier.testTag(TAG_MIRROR_SCOPE_ONLY_NEW), + ) { Text("Только новые") } + }, + ) +} + /** Confirms a mode change and explains what the mode does. */ @Composable -fun ModeConfirmDialog( - mode: SyncMode, +fun ModeConfirmDialog( mode: SyncMode, onConfirm: () -> Unit, onDismiss: () -> Unit, ) { diff --git a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt index b3f379e..9658699 100644 --- a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt +++ b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt @@ -72,6 +72,32 @@ class DeviceAlbumViewModel( /** A one-off message for the user (for example a refused target change). */ val notice: StateFlow = _notice.asStateFlow() + private val _mirrorChoice = MutableStateFlow(false) + + /** True while the first-pass choice ("only new" vs "download all") is shown. */ + val mirrorChoice: StateFlow = _mirrorChoice.asStateFlow() + + fun dismissMirrorChoice() { + _mirrorChoice.value = false + } + + /** + * Sets the two-way mirroring baseline: only new server photos from now on, or + * the whole current album. + */ + fun chooseMirrorScope(downloadAll: Boolean) { + _mirrorChoice.value = false + viewModelScope.launch { + container.syncRepository.setMirrorFrom( + deviceAlbumId, + if (downloadAll) 0L else System.currentTimeMillis(), + ) + reconcileNow() + } + } + + private var stopMediaObserver: (() -> Unit)? = null + fun dismissNotice() { _notice.value = null } @@ -112,6 +138,17 @@ class DeviceAlbumViewModel( init { load() + // Refresh the grid when MediaStore changes, so materialized server photos + // and removals appear without reopening the album. + stopMediaObserver = container.observeMediaChanges { + viewModelScope.launch { load() } + } + } + + override fun onCleared() { + stopMediaObserver?.invoke() + stopMediaObserver = null + super.onCleared() } fun load() { @@ -218,8 +255,12 @@ class DeviceAlbumViewModel( fun deleteServerPhoto(photoId: String) { viewModelScope.launch { + // Explicit in-app deletion: record the intent so the local mirror is not + // recreated, then remove the photo on the server. + container.syncRepository.markExplicitDelete(deviceAlbumId, photoId) runCatching { container.api.deletePhoto(photoId) } .onSuccess { container.photoRepository.remove(photoId) } + reconcileNow() } } @@ -294,6 +335,8 @@ class DeviceAlbumViewModel( viewModelScope.launch { // Persist the chosen mode even before a server album is picked, so the // choice survives leaving and reopening the album. + val hadTarget = container.syncRepository.currentLink(deviceAlbumId) + ?.serverAlbumId?.isNotBlank() == true val target = state.value.serverAlbumId ?: container.syncRepository.currentLink(deviceAlbumId)?.serverAlbumId ?: "" @@ -305,7 +348,10 @@ class DeviceAlbumViewModel( ) refreshRole() // Nothing to sync until a server album is chosen. - if (target.isNotBlank()) reconcileNow() + if (target.isNotBlank()) { + if (!hadTarget) _mirrorChoice.value = true + reconcileNow() + } } } @@ -325,6 +371,7 @@ class DeviceAlbumViewModel( if (result.strandedCopies > 0) { _notice.value = "Фото без серверной копии: ${result.strandedCopies}." } + _mirrorChoice.value = mode != SyncMode.DISABLED } is RetargetResult.Refused -> _notice.value = result.reason @@ -348,7 +395,10 @@ class DeviceAlbumViewModel( mode = mode, ) ) { - is RetargetResult.Linked -> refreshRole() + is RetargetResult.Linked -> { + refreshRole() + _mirrorChoice.value = mode != SyncMode.DISABLED + } is RetargetResult.Refused -> _notice.value = result.reason } } diff --git a/android/app/src/test/java/com/galo/android/data/db/MigrationTest.kt b/android/app/src/test/java/com/galo/android/data/db/MigrationTest.kt new file mode 100644 index 0000000..6718313 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/db/MigrationTest.kt @@ -0,0 +1,115 @@ +package com.galo.android.data.db + +import android.content.Context +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The two-way mirroring migration must preserve the existing sync index: the + * origin column defaults to OWN for rows written before mirroring existed, the + * baseline starts at 0 and the deletion journal table appears. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class MigrationTest { + + @Test + fun `migration 6 to 7 adds mirroring state without losing rows`() { + val context = ApplicationProvider.getApplicationContext() + val config = SupportSQLiteOpenHelper.Configuration.builder(context) + .name(null) + .callback( + object : SupportSQLiteOpenHelper.Callback(6) { + override fun onCreate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS synced_albums (" + + "deviceAlbumId TEXT NOT NULL, deviceAlbumName TEXT NOT NULL, " + + "serverAlbumId TEXT NOT NULL, mode TEXT NOT NULL, " + + "updatedAt INTEGER NOT NULL, PRIMARY KEY(deviceAlbumId))", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS device_assets (" + + "assetKey TEXT NOT NULL, deviceAlbumId TEXT NOT NULL, " + + "mediaStoreId INTEGER NOT NULL, uri TEXT NOT NULL, " + + "displayName TEXT NOT NULL, mimeType TEXT, size INTEGER NOT NULL, " + + "dateModified INTEGER NOT NULL, contentHash TEXT, " + + "serverPhotoId TEXT, uploadedAt INTEGER, state TEXT NOT NULL, " + + "PRIMARY KEY(assetKey))", + ) + } + + override fun onUpgrade(db: SupportSQLiteDatabase, old: Int, new: Int) = Unit + }, + ) + .build() + val helper = FrameworkSQLiteOpenHelperFactory().create(config) + try { + val db = helper.writableDatabase + db.execSQL("INSERT INTO synced_albums VALUES ('b1','Camera','srv1','COPY',1)") + db.execSQL( + "INSERT INTO device_assets VALUES " + + "('b1/1','b1',1,'u','n','image/jpeg',1,1,'h','p1',null,'UPLOADED')", + ) + + GaloDatabase.MIGRATION_6_7.migrate(db) + + assertEquals("OWN", stringOf(db, "SELECT origin FROM device_assets WHERE assetKey='b1/1'")) + assertEquals( + 0L, + longOf(db, "SELECT mirrorFromCreatedAt FROM synced_albums WHERE deviceAlbumId='b1'"), + ) + assertEquals(0L, longOf(db, "SELECT COUNT(*) FROM deleted_assets")) + // The preserved asset is still there. + assertEquals(1L, longOf(db, "SELECT COUNT(*) FROM device_assets")) + } finally { + helper.close() + } + } + + private fun stringOf(db: SupportSQLiteDatabase, sql: String): String? = + db.query(sql).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + + private fun longOf(db: SupportSQLiteDatabase, sql: String): Long = + db.query(sql).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else -1L } + + @Test + fun `migration 7 to 8 adds the sync-disabled marker preserving metadata`() { + val context = ApplicationProvider.getApplicationContext() + val config = SupportSQLiteOpenHelper.Configuration.builder(context) + .name(null) + .callback( + object : SupportSQLiteOpenHelper.Callback(7) { + override fun onCreate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS device_album_meta (" + + "deviceAlbumId TEXT NOT NULL, displayName TEXT, coverUri TEXT, " + + "updatedAt INTEGER NOT NULL, PRIMARY KEY(deviceAlbumId))", + ) + } + + override fun onUpgrade(db: SupportSQLiteDatabase, old: Int, new: Int) = Unit + }, + ) + .build() + val helper = FrameworkSQLiteOpenHelperFactory().create(config) + try { + val db = helper.writableDatabase + db.execSQL("INSERT INTO device_album_meta VALUES ('b1','Camera','content://c',1)") + + GaloDatabase.MIGRATION_7_8.migrate(db) + + assertEquals(1L, longOf(db, "SELECT COUNT(*) FROM device_album_meta")) + assertEquals("Camera", stringOf(db, "SELECT displayName FROM device_album_meta WHERE deviceAlbumId='b1'")) + assertEquals(1L, longOf(db, "SELECT COUNT(*) FROM device_album_meta WHERE syncDisabledAt IS NULL")) + } finally { + helper.close() + } + } +} diff --git a/android/app/src/test/java/com/galo/android/data/db/SyncDaoTest.kt b/android/app/src/test/java/com/galo/android/data/db/SyncDaoTest.kt index bfff3ec..cbdf3a0 100644 --- a/android/app/src/test/java/com/galo/android/data/db/SyncDaoTest.kt +++ b/android/app/src/test/java/com/galo/android/data/db/SyncDaoTest.kt @@ -5,6 +5,7 @@ import androidx.room.Room import androidx.test.core.app.ApplicationProvider import com.galo.android.data.db.dao.SyncDao import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.db.entity.DeletedAssetEntity import com.galo.android.data.db.entity.SyncedAlbumEntity import com.galo.android.domain.model.LocalAssetState import com.galo.android.domain.model.SyncMode @@ -91,4 +92,44 @@ class SyncDaoTest { dao.deleteAsset(key) assertNull(dao.getAsset(key)) } + + @Test + fun `deletion tombstones are stored and cleared`() = runBlocking { + val tombstone = DeletedAssetEntity( + photoId = "p1", + deviceAlbumId = "bucket1", + status = DeletedAssetEntity.PENDING, + createdAt = 1L, + ) + dao.upsertDeletedAsset(tombstone) + + assertEquals("p1", dao.getDeletedAsset("p1")?.photoId) + assertEquals(1, dao.getDeletedAssets("bucket1").size) + + dao.deleteDeletedAsset("p1") + assertNull(dao.getDeletedAsset("p1")) + + dao.upsertDeletedAsset(tombstone) + dao.clearDeletedAssets("bucket1") + assertEquals(0, dao.getDeletedAssets("bucket1").size) + } + + @Test + fun `an asset defaults to origin OWN and keeps MIRROR when set`() = runBlocking { + dao.upsertAssets( + listOf( + DeviceAssetEntity( + DeviceAssetEntity.key("bucket1", 1L), "bucket1", 1L, "u", "n", + "image/jpeg", 1L, 1L, null, null, null, LocalAssetState.LOCAL.name, + ), + DeviceAssetEntity( + DeviceAssetEntity.key("bucket1", 2L), "bucket1", 2L, "u", "n", + "image/jpeg", 1L, 1L, "c", "p1", null, LocalAssetState.PREVIEW.name, + origin = "MIRROR", + ), + ), + ) + assertEquals("OWN", dao.getAsset(DeviceAssetEntity.key("bucket1", 1L))?.origin) + assertEquals("MIRROR", dao.getAsset(DeviceAssetEntity.key("bucket1", 2L))?.origin) + } } diff --git a/android/app/src/test/java/com/galo/android/data/repo/SyncRepositoryTest.kt b/android/app/src/test/java/com/galo/android/data/repo/SyncRepositoryTest.kt index 3b8d5ac..cd1ae99 100644 --- a/android/app/src/test/java/com/galo/android/data/repo/SyncRepositoryTest.kt +++ b/android/app/src/test/java/com/galo/android/data/repo/SyncRepositoryTest.kt @@ -12,7 +12,9 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -69,6 +71,18 @@ class SyncRepositoryTest { assertEquals("p1", db.syncDao().getAsset("b1/1")?.serverPhotoId) } + @Test + fun `unlink marks sync disabled and relinking clears it`() = runBlocking { + repo.link("b1", "Camera", "srv1", SyncMode.MOVE) + assertFalse("a fresh link is not disabled", repo.isSyncDisabled("b1")) + + repo.unlink("b1") + assertTrue("unlinking must be remembered", repo.isSyncDisabled("b1")) + + repo.link("b1", "Camera", "srv1", SyncMode.COPY) + assertFalse("enabling sync clears the off marker", repo.isSyncDisabled("b1")) + } + @Test fun `uploaded counts are per album and skip assets without a server photo`() = runBlocking { db.syncDao().upsertAssets( diff --git a/android/app/src/test/java/com/galo/android/data/sync/ApiSyncRemoteTest.kt b/android/app/src/test/java/com/galo/android/data/sync/ApiSyncRemoteTest.kt new file mode 100644 index 0000000..9aa1b87 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/sync/ApiSyncRemoteTest.kt @@ -0,0 +1,43 @@ +package com.galo.android.data.sync + +import com.galo.android.data.network.dto.PhotoDto +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ApiSyncRemoteTest { + + private fun dto( + download: String? = null, + original: String? = null, + preview: String? = null, + medium: String? = null, + thumb: String? = null, + ) = PhotoDto( + id = "p1", + downloadUrl = download, + originalUrl = original, + previewUrl = preview, + mediumUrl = medium, + thumbUrl = thumb, + ) + + @Test + fun `copy mode downloads the original`() { + assertEquals("/d", pickVariant(dto(download = "/d", original = "/o"), full = true)) + assertEquals("/o", pickVariant(dto(original = "/o"), full = true)) + } + + @Test + fun `move mode downloads the lightest preview`() { + assertEquals("/p", pickVariant(dto(preview = "/p", medium = "/m", thumb = "/t"), full = false)) + assertEquals("/m", pickVariant(dto(medium = "/m", thumb = "/t"), full = false)) + assertEquals("/t", pickVariant(dto(thumb = "/t"), full = false)) + } + + @Test + fun `nothing to download is null`() { + assertNull(pickVariant(dto(), full = true)) + assertNull(pickVariant(dto(), full = false)) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/sync/ProvenanceStampTest.kt b/android/app/src/test/java/com/galo/android/data/sync/ProvenanceStampTest.kt index 6b8e177..30f6686 100644 --- a/android/app/src/test/java/com/galo/android/data/sync/ProvenanceStampTest.kt +++ b/android/app/src/test/java/com/galo/android/data/sync/ProvenanceStampTest.kt @@ -31,6 +31,30 @@ class ProvenanceStampTest { assertEquals("PREVIEW", ProvenanceStamp.decode(legacy)?.mode) } + @Test + fun `marker round-trips the origin`() { + val marker = Provenance( + serverAlbumId = "srv1", + serverPhotoId = "p1", + originalHash = "h", + deviceAlbumId = "b1", + mode = "MOVE", + assetKey = null, + origin = "MIRROR", + ) + + val decoded = ProvenanceStamp.decode(ProvenanceStamp.encode(marker)) + + assertEquals("MIRROR", decoded?.origin) + } + + @Test + fun `a marker without an origin decodes to null (legacy)`() { + val legacy = "galo|album=srv1|photo=p1|hash=h|device=b1|mode=COPY|asset=b1/1" + + assertNull(ProvenanceStamp.decode(legacy)?.origin) + } + @Test fun `bad markers decode to null`() { assertNull(ProvenanceStamp.decode(null)) diff --git a/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt b/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt index ae99c3e..eb05b66 100644 --- a/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt +++ b/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.room.Room import androidx.test.core.app.ApplicationProvider import com.galo.android.data.db.GaloDatabase +import com.galo.android.data.db.entity.DeletedAssetEntity import com.galo.android.data.db.entity.DeviceAssetEntity import com.galo.android.data.db.entity.SyncedAlbumEntity import com.galo.android.domain.model.LocalAssetState @@ -12,6 +13,7 @@ import com.galo.android.domain.model.SyncMode import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before @@ -34,6 +36,8 @@ class SyncCoordinatorTest { var serverMap: Map = emptyMap() val serverMapByAlbum = mutableMapOf>() var failChecksumsFor: Set = emptySet() + var failDeletesFor: Set = emptySet() + var photoCreatedAt: Long = 0L override suspend fun serverChecksums(serverAlbumId: String): Map { checksumCalls++ @@ -41,11 +45,33 @@ class SyncCoordinatorTest { return serverMapByAlbum[serverAlbumId] ?: serverMap } + override suspend fun serverPhotos(serverAlbumId: String): List { + if (serverAlbumId in failChecksumsFor) throw RuntimeException("album unavailable") + val map = serverMapByAlbum[serverAlbumId] ?: serverMap + return map.map { (checksum, id) -> + ServerPhoto( + id = id, + checksum = checksum, + displayName = "a.jpg", + mimeType = "image/jpeg", + isVideo = false, + takenAtMillis = null, + createdAtMillis = photoCreatedAt, + width = 0, + height = 0, + size = 0, + ) + } + } + + override suspend fun fetchPhoto(photo: ServerPhoto, full: Boolean): ByteArray? = ByteArray(4) + override suspend fun renamePhoto(photoId: String, newName: String) { renames += photoId to newName } override suspend fun deletePhoto(photoId: String) { + if (photoId in failDeletesFor) throw RuntimeException("forbidden") deletes += photoId } @@ -54,6 +80,28 @@ class SyncCoordinatorTest { } } + private class FakeMirror : AssetMirror { + val materialized = mutableListOf() + val removed = mutableListOf() + var result: MirrorResult? = null + var removeResult = true + + override suspend fun materialize( + deviceAlbumId: String, + serverAlbumId: String, + mode: SyncMode, + photo: ServerPhoto, + ): MirrorResult { + materialized += photo.id + return result ?: MirrorResult.Done(mediaStoreId = 1000L + materialized.size, uri = "content://m/${photo.id}") + } + + override suspend fun remove(uri: String): Boolean { + removed += uri + return removeResult + } + } + private class FakeQueue : SyncUploadQueue { val enqueued = mutableListOf>() val pending = mutableSetOf() @@ -97,6 +145,7 @@ class SyncCoordinatorTest { private lateinit var provider: FakeProvider private lateinit var queue: FakeQueue private lateinit var offloader: FakeOffloader + private lateinit var mirror: FakeMirror private lateinit var coordinator: SyncCoordinator @Before @@ -109,7 +158,8 @@ class SyncCoordinatorTest { provider = FakeProvider() queue = FakeQueue() offloader = FakeOffloader() - coordinator = SyncCoordinator(db.syncDao(), provider, remote, queue, offloader) + mirror = FakeMirror() + coordinator = SyncCoordinator(db.syncDao(), provider, remote, queue, offloader, mirror) } @After @@ -548,4 +598,244 @@ class SyncCoordinatorTest { assertEquals(listOf("b1"), db.syncDao().indexedAlbumIds()) } + + // --- Two-way materialization ------------------------------------------ + + private suspend fun linkFrom(mode: SyncMode, from: Long) = db.syncDao().upsertSyncedAlbum( + SyncedAlbumEntity("b1", "Camera", "srv1", mode.name, 1L, mirrorFromCreatedAt = from), + ) + + @Test + fun `a new server photo is materialized in copy mode`() = runBlocking { + link(SyncMode.COPY) + remote.serverMap = mapOf("c1" to "p1") + + coordinator.reconcile("b1") + + assertEquals(listOf("p1"), mirror.materialized) + val row = db.syncDao().getAssets("b1").single() + assertEquals("p1", row.serverPhotoId) + assertEquals("MIRROR", row.origin) + assertEquals(LocalAssetState.UPLOADED.name, row.state) + } + + @Test + fun `a new server photo is materialized as a preview in move mode`() = runBlocking { + link(SyncMode.MOVE) + remote.serverMap = mapOf("c1" to "p1") + + coordinator.reconcile("b1") + + assertEquals(listOf("p1"), mirror.materialized) + assertEquals(LocalAssetState.PREVIEW.name, db.syncDao().getAssets("b1").single().state) + } + + @Test + fun `a materialized photo already on the device is not downloaded`() = runBlocking { + link(SyncMode.COPY) + remote.serverMap = mapOf("h" to "p1") + provider.items = listOf(asset("b1/9", hash = "h")) + + coordinator.reconcile("b1") + + assertTrue("content already on the device must not be duplicated", mirror.materialized.isEmpty()) + } + + @Test + fun `a server deletion removes the local mirror`() = runBlocking { + link(SyncMode.COPY) + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "content://media/external/images/media/1", "a.jpg", + "image/jpeg", 1L, 1L, "c1", "p1", null, LocalAssetState.UPLOADED.name, + origin = "MIRROR", + ), + ), + ) + + coordinator.reconcile("b1") + + assertEquals(listOf("content://media/external/images/media/1"), mirror.removed) + assertNull(db.syncDao().getAsset("b1/1")) + } + + @Test + fun `only-new baseline skips photos older than the link`() = runBlocking { + linkFrom(SyncMode.COPY, from = 1000L) + remote.photoCreatedAt = 500L + remote.serverMap = mapOf("c1" to "p1") + + coordinator.reconcile("b1") + + assertTrue("photos predating the baseline must not be downloaded", mirror.materialized.isEmpty()) + } + + @Test + fun `only-new baseline downloads photos after the link`() = runBlocking { + linkFrom(SyncMode.COPY, from = 1000L) + remote.photoCreatedAt = 2000L + remote.serverMap = mapOf("c1" to "p1") + + coordinator.reconcile("b1") + + assertEquals(listOf("p1"), mirror.materialized) + } + + @Test + fun `an explicitly deleted mirror is removed on the server`() = runBlocking { + link(SyncMode.COPY) + remote.serverMap = mapOf("c1" to "p1") + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "content://media/external/images/media/1", "a.jpg", + "image/jpeg", 1L, 1L, "c1", "p1", null, LocalAssetState.UPLOADED.name, + origin = "MIRROR", + ), + ), + ) + db.syncDao().upsertDeletedAsset( + DeletedAssetEntity("p1", "b1", DeletedAssetEntity.PENDING, 1L), + ) + + coordinator.reconcile("b1") + + assertEquals("an explicit in-app deletion reaches the server", listOf("p1"), remote.deletes) + assertNull(db.syncDao().getAsset("b1/1")) + } + + @Test + fun `a mirror whose local copy is missing is recreated, not deleted on the server`() = runBlocking { + link(SyncMode.COPY) + remote.serverMap = mapOf("c1" to "p1") + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "content://media/external/images/media/1", "a.jpg", + "image/jpeg", 1L, 1L, "c1", "p1", null, LocalAssetState.UPLOADED.name, + origin = "MIRROR", + ), + ), + ) + mirror.materialized.clear() + + coordinator.reconcile("b1") + + assertTrue("a server-origin photo must not be deleted for a lost mirror", remote.deletes.isEmpty()) + assertNull("the stale row is dropped so the photo is re-materialized", db.syncDao().getAsset("b1/1")) + assertEquals(listOf("p1"), mirror.materialized) + } + + @Test + fun `a confirmed server deletion clears the tombstone`() = runBlocking { + link(SyncMode.COPY) + remote.serverMap = mapOf("h1" to "p1") + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "h1", "p1", null, LocalAssetState.UPLOADED.name, origin = "OWN", + ), + ), + ) + coordinator.reconcile("b1") + assertEquals(1, db.syncDao().getDeletedAssets("b1").size) + + // The server delete landed: the photo is no longer in the album. + remote.serverMap = emptyMap() + coordinator.reconcile("b1") + + assertEquals(0, db.syncDao().getDeletedAssets("b1").size) + } + + @Test + fun `a failed album read does not delete local mirrors`() = runBlocking { + link(SyncMode.COPY) + remote.failChecksumsFor = setOf("srv1") + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "content://media/external/images/media/1", "a.jpg", + "image/jpeg", 1L, 1L, "c1", "p1", null, LocalAssetState.UPLOADED.name, + origin = "MIRROR", + ), + ), + ) + + val contacted = coordinator.reconcile("b1") + + assertEquals(false, contacted) + assertNotNull("a transient read failure must not drop the mirror", db.syncDao().getAsset("b1/1")) + assertTrue("nothing must be removed on a failed read", mirror.removed.isEmpty()) + } + + @Test + fun `unlinking does not remove materialized files`() = runBlocking { + link(SyncMode.COPY) + remote.serverMap = mapOf("c1" to "p1") + coordinator.reconcile("b1") + assertEquals(listOf("p1"), mirror.materialized) + + db.syncDao().deleteSyncedAlbum("b1") + val contacted = coordinator.reconcile("b1") + + assertEquals(false, contacted) + assertTrue("unbinding must not delete local files", mirror.removed.isEmpty()) + } + + @Test + fun `materialization goes into a single bucket when several albums share a target`() = runBlocking { + db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity("b1", "Camera", "srv1", SyncMode.COPY.name, 1L)) + db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity("b2", "Trips", "srv1", SyncMode.COPY.name, 2L)) + remote.serverMap = mapOf("c1" to "p1") + + coordinator.reconcile("b1") + coordinator.reconcile("b2") + + assertEquals("exactly one bucket must receive the photo", listOf("p1"), mirror.materialized) + } + + @Test + fun `a refused server deletion does not abort the pass and is recorded`() = runBlocking { + link(SyncMode.COPY) + remote.serverMap = mapOf("h1" to "p1") + remote.failDeletesFor = setOf("p1") + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "h1", "p1", null, LocalAssetState.UPLOADED.name, origin = "OWN", + ), + ), + ) + + coordinator.reconcile("b1") + + assertEquals( + DeletedAssetEntity.FAILED, + db.syncDao().getDeletedAsset("p1")?.status, + ) + assertNull(db.syncDao().getAsset("b1/1")) + } + + @Test + fun `a restored server photo is materialized again`() = runBlocking { + link(SyncMode.COPY) + remote.serverMap = mapOf("c1" to "p1") + + coordinator.reconcile("b1") + assertEquals(listOf("p1"), mirror.materialized) + + // Deleted on the server: the local mirror goes away (no tombstone). + remote.serverMap = emptyMap() + coordinator.reconcile("b1") + assertNull(db.syncDao().getAssets("b1").firstOrNull { it.serverPhotoId == "p1" }) + + // Restored from the server trash: it comes back. + remote.serverMap = mapOf("c1" to "p1") + coordinator.reconcile("b1") + + assertEquals(2, mirror.materialized.count { it == "p1" }) + } } diff --git a/android/app/src/test/java/com/galo/android/data/sync/SyncPlannerTest.kt b/android/app/src/test/java/com/galo/android/data/sync/SyncPlannerTest.kt index 419e4d4..03d020a 100644 --- a/android/app/src/test/java/com/galo/android/data/sync/SyncPlannerTest.kt +++ b/android/app/src/test/java/com/galo/android/data/sync/SyncPlannerTest.kt @@ -184,4 +184,100 @@ class SyncPlannerTest { ) assertEquals(listOf(SyncAction.Link(asset, "p9")), actions) } + + @Test + fun `a missing local copy of a server photo is not deleted on the server`() { + val actions = SyncPlanner.plan( + local = emptyList(), + index = listOf( + IndexedAsset("b1/1", "p1", "c1", "a.jpg", origin = "MIRROR"), + ), + serverChecksums = mapOf("c1" to "p1"), + hashesPresentElsewhere = emptySet(), + ) + assertTrue("a server-origin photo must never be deleted for a lost mirror", actions.isEmpty()) + } + + @Test + fun `an explicitly deleted mirror is deleted on the server`() { + val actions = SyncPlanner.plan( + local = emptyList(), + index = listOf(IndexedAsset("b1/1", "p1", "c1", "a.jpg", origin = "MIRROR")), + serverChecksums = mapOf("c1" to "p1"), + hashesPresentElsewhere = emptySet(), + explicitlyDeleted = setOf("p1"), + ) + assertEquals(listOf(SyncAction.DeleteOnServer("p1", "b1/1")), actions) + } + + @Test + fun `a stale own upload whose content left the device is deleted`() { + val actions = SyncPlanner.plan( + local = emptyList(), + index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg", origin = "OWN")), + serverChecksums = mapOf("h1" to "p1"), + hashesPresentElsewhere = emptySet(), + ) + assertEquals(listOf(SyncAction.DeleteOnServer("p1", "b1/1")), actions) + } + + // --- Two-way materialization ------------------------------------------ + + private fun serverPhoto(id: String, checksum: String? = "c$id") = ServerPhoto( + id = id, + checksum = checksum, + displayName = "$id.jpg", + mimeType = "image/jpeg", + isVideo = false, + takenAtMillis = null, + createdAtMillis = 0L, + width = 0, + height = 0, + size = 0, + ) + + @Test + fun `an unrepresented server photo is downloaded`() { + val photo = serverPhoto("p1") + val plan = SyncPlanner.mirrorPlan( + serverPhotos = listOf(photo), + representedPhotoIds = emptySet(), + deviceHashes = emptySet(), + tombstonedPhotoIds = emptySet(), + ) + assertEquals(listOf(photo), plan) + } + + @Test + fun `a represented server photo is not downloaded again`() { + val plan = SyncPlanner.mirrorPlan( + serverPhotos = listOf(serverPhoto("p1")), + representedPhotoIds = setOf("p1"), + deviceHashes = emptySet(), + tombstonedPhotoIds = emptySet(), + ) + assertTrue(plan.isEmpty()) + } + + @Test + fun `a tombstoned server photo is not materialized again`() { + val plan = SyncPlanner.mirrorPlan( + serverPhotos = listOf(serverPhoto("p1")), + representedPhotoIds = emptySet(), + deviceHashes = emptySet(), + tombstonedPhotoIds = setOf("p1"), + ) + assertTrue(plan.isEmpty()) + } + + @Test + fun `content already on the device is not downloaded twice`() { + val plan = SyncPlanner.mirrorPlan( + serverPhotos = listOf(serverPhoto("p1", checksum = "h1")), + representedPhotoIds = emptySet(), + deviceHashes = setOf("h1"), + tombstonedPhotoIds = emptySet(), + ) + assertTrue("the same content must not be materialized again", plan.isEmpty()) + } } diff --git a/android/app/src/test/java/com/galo/android/data/sync/SyncRecoveryTest.kt b/android/app/src/test/java/com/galo/android/data/sync/SyncRecoveryTest.kt new file mode 100644 index 0000000..2d560d1 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/sync/SyncRecoveryTest.kt @@ -0,0 +1,58 @@ +package com.galo.android.data.sync + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.galo.android.data.db.GaloDatabase +import com.galo.android.data.db.entity.DeviceAlbumMetaEntity +import com.galo.android.data.media.MediaStoreDeviceSource +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class SyncRecoveryTest { + + private lateinit var db: GaloDatabase + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, GaloDatabase::class.java) + .allowMainThreadQueries() + .build() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun `recovery is skipped for an album the user turned off`() = runBlocking { + // The album was explicitly unlinked; its previews may still carry markers. + db.syncDao().upsertAlbumMeta( + DeviceAlbumMetaEntity( + deviceAlbumId = "b1", + displayName = null, + coverUri = null, + updatedAt = 1L, + syncDisabledAt = 2L, + ), + ) + + val recovered = SyncRecovery(context, db.syncDao(), MediaStoreDeviceSource(context)) + .recover("b1") + + assertEquals(0, recovered) + assertNull("a disabled album must not be relinked", db.syncDao().getSyncedAlbum("b1")) + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumOriginalsTest.kt b/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumOriginalsTest.kt index 93a61ca..6b59607 100644 --- a/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumOriginalsTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumOriginalsTest.kt @@ -64,4 +64,20 @@ class DeviceAlbumOriginalsTest { ) assertEquals(setOf(1L, 3L), ids) } + + @Test + fun `a materialized preview uses the server original`() { + val map = serverOriginalsByMediaId( + listOf( + DeviceAssetEntity( + assetKey = "b/5", deviceAlbumId = "b", mediaStoreId = 5L, + uri = "content://media/external/images/media/5", displayName = "x.jpg", + mimeType = "image/jpeg", size = 0L, dateModified = 0L, contentHash = "h", + serverPhotoId = "p5", uploadedAt = null, + state = LocalAssetState.PREVIEW.name, origin = "MIRROR", + ), + ), + ) + assertEquals("p5", map[5L]) + } } diff --git a/android/app/src/test/java/com/galo/android/ui/devicealbum/MirrorScopeDialogTest.kt b/android/app/src/test/java/com/galo/android/ui/devicealbum/MirrorScopeDialogTest.kt new file mode 100644 index 0000000..b743ab3 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/devicealbum/MirrorScopeDialogTest.kt @@ -0,0 +1,54 @@ +package com.galo.android.ui.devicealbum + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import com.galo.android.ui.theme.GaloTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class MirrorScopeDialogTest { + + @get:Rule + val compose = createComposeRule() + + @Test + fun `choosing only new reports it`() { + var all: Boolean? = null + compose.setContent { + GaloTheme { + MirrorScopeDialog( + onOnlyNew = { all = false }, + onDownloadAll = { all = true }, + onDismiss = {}, + ) + } + } + compose.onNodeWithTag(TAG_MIRROR_SCOPE).assertIsDisplayed() + compose.onNodeWithTag(TAG_MIRROR_SCOPE_ONLY_NEW).performClick() + assertEquals(false, all) + } + + @Test + fun `choosing download all reports it`() { + var all: Boolean? = null + compose.setContent { + GaloTheme { + MirrorScopeDialog( + onOnlyNew = { all = false }, + onDownloadAll = { all = true }, + onDismiss = {}, + ) + } + } + compose.onNodeWithTag(TAG_MIRROR_SCOPE_ALL).performClick() + assertEquals(true, all) + } +}