feat(android): двусторонняя синхронизация альбома устройства с сервером

Связанная пара «альбом устройства → серверный альбом» становится миррором:
серверные фото материализуются на устройстве (оригинал в режиме копий, превью/
постер в переносе), состав меняется в обе стороны.

- Признак происхождения (OWN/MIRROR) в индексе и метке провенанса; БД v8.
- Материализация серверных фото (MirrorManager) с датой съёмки и меткой.
- Дедуп, материализация ровно в один бакет при n:1, baseline «только новое»/«всё».
- Двустороннее удаление с учётом прав; для зеркал намерением считается только
  явное удаление в приложении (надгробие), пропавшая локальная копия
  материализуется заново — иначе терялись серверные оригиналы.
- Неудачное чтение серверного списка не считается пустым альбомом.
- Устойчивое выключение синхронизации (syncDisabledAt): восстановление
  происхождения не возвращает связь.
- Очередь загрузки — одно дренирующее задание вместо задания на файл
  (JobScheduler переставал запускать остаток на больших альбомах).
- Дебаунс наблюдателя MediaStore и кэш sha256 по (size, dateModified).

Покрыто unit-тестами и instrumented-тестами (режимы A/B/C и переключения,
двусторонний миррор, объём 100 фото + 20 видео на тест).
This commit is contained in:
iu
2026-10-16 23:19:02 +03:00
parent 19309e2ac0
commit cd326c0ae6
56 changed files with 2766 additions and 88 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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<Uri>()
@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<Uri, String> {
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<GaloApplication>()
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
}
}
@@ -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)
@@ -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<Uri>()
@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<GaloApplication>().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<GaloApplication>().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<GaloApplication>().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<GaloApplication>().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<GaloApplication>().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<GaloApplication>().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<GaloApplication>().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<GaloApplication>().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<GaloApplication>().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))
}
}
@@ -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<Uri>()
@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<GaloApplication>().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<GaloApplication>().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)
}
}
@@ -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<Uri>, val videos: List<Uri>) {
val uris: List<Uri> 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")
}
}
}
@@ -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<Uri>()
@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<GaloApplication>().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<GaloApplication>().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<GaloApplication>().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<GaloApplication>().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<GaloApplication>().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 }
}
}
@@ -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")
}
}
}
}
@@ -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<DeletedAssetEntity>
@Query("SELECT * FROM deleted_assets")
suspend fun getAllDeletedAssets(): List<DeletedAssetEntity>
@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)
}
@@ -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"
}
}
@@ -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)
}
@@ -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<String, String> =
api.albumChecksums(serverAlbumId)
serverPhotos(serverAlbumId).mapNotNull { photo ->
photo.checksum?.let { it to photo.id }
}.toMap()
override suspend fun serverPhotos(serverAlbumId: String): List<ServerPhoto> {
val out = ArrayList<ServerPhoto>()
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<String>, 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)
}
}
@@ -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)
}
}
@@ -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<String, CachedHash>()
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()
}
@@ -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"
}
}
@@ -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) {
@@ -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 },
)
}
@@ -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<DeviceAssetEntity>,
livePhotoIds: Set<String>,
): List<DeviceAssetEntity> = 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<DeviceAssetEntity>,
livePhotoIds: Set<String>,
localKeys: Set<String>,
tombstones: Set<String>,
) {
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<String>) {
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<ServerPhoto>,
assets: List<LocalAsset>,
indexEntities: List<DeviceAssetEntity>,
) {
// 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<String>()
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"
}
}
@@ -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<IndexedAsset>,
serverChecksums: Map<String, String>,
hashesPresentElsewhere: Set<String>,
explicitlyDeleted: Set<String> = emptySet(),
): List<SyncAction> {
val actions = mutableListOf<SyncAction>()
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<ServerPhoto>,
representedPhotoIds: Set<String>,
deviceHashes: Set<String>,
tombstonedPhotoIds: Set<String>,
): List<ServerPhoto> = serverPhotos.filter { photo ->
photo.id !in representedPhotoIds &&
photo.id !in tombstonedPhotoIds &&
(photo.checksum == null || photo.checksum !in deviceHashes)
}
}
@@ -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,
),
),
)
@@ -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<String, String>
/** The album's photos with the metadata needed to materialize them. */
suspend fun serverPhotos(serverAlbumId: String): List<ServerPhoto>
/**
* 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)
@@ -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<UploadJobEntity> = 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<UploadWorker>()
.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"
@@ -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
}
}
@@ -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()
}
}
@@ -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,
)
@@ -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<Int?>(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,
) {
@@ -72,6 +72,32 @@ class DeviceAlbumViewModel(
/** A one-off message for the user (for example a refused target change). */
val notice: StateFlow<String?> = _notice.asStateFlow()
private val _mirrorChoice = MutableStateFlow(false)
/** True while the first-pass choice ("only new" vs "download all") is shown. */
val mirrorChoice: StateFlow<Boolean> = _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
}
}
@@ -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<Context>()
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<Context>()
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()
}
}
}
@@ -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)
}
}
@@ -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(
@@ -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))
}
}
@@ -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))
@@ -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<String, String> = emptyMap()
val serverMapByAlbum = mutableMapOf<String, Map<String, String>>()
var failChecksumsFor: Set<String> = emptySet()
var failDeletesFor: Set<String> = emptySet()
var photoCreatedAt: Long = 0L
override suspend fun serverChecksums(serverAlbumId: String): Map<String, String> {
checksumCalls++
@@ -41,11 +45,33 @@ class SyncCoordinatorTest {
return serverMapByAlbum[serverAlbumId] ?: serverMap
}
override suspend fun serverPhotos(serverAlbumId: String): List<ServerPhoto> {
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<String>()
val removed = mutableListOf<String>()
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<Triple<String, String, LocalAsset>>()
val pending = mutableSetOf<String>()
@@ -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" })
}
}
@@ -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())
}
}
@@ -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"))
}
}
@@ -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])
}
}
@@ -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)
}
}