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:
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().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L))
|
||||||
db.syncDao().upsertAssets(listOf(asset))
|
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)
|
assertEquals(OffloadResult.Done, result)
|
||||||
// The file is still there but now a capped preview.
|
// 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().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L))
|
||||||
db.syncDao().upsertAssets(listOf(asset))
|
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)
|
assertEquals(OffloadResult.Done, result)
|
||||||
assertTrue("video row should be gone", !exists(uri))
|
assertTrue("video row should be gone", !exists(uri))
|
||||||
@@ -185,7 +185,7 @@ class OffloadInstrumentedTest {
|
|||||||
val asset = assetOf(uri, bucket, "orig.jpg", "image/jpeg")
|
val asset = assetOf(uri, bucket, "orig.jpg", "image/jpeg")
|
||||||
val key = asset.assetKey
|
val key = asset.assetKey
|
||||||
db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L))
|
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.
|
// Simulate a reinstall: the app's index is gone.
|
||||||
db.syncDao().clearAlbum(bucket)
|
db.syncDao().clearAlbum(bucket)
|
||||||
@@ -222,7 +222,7 @@ class OffloadInstrumentedTest {
|
|||||||
db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L))
|
db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L))
|
||||||
db.syncDao().upsertAssets(listOf(asset))
|
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))
|
assertEquals(1600 to 1200, dimensionsOf(uri))
|
||||||
|
|
||||||
val original = Bitmap.createBitmap(2400, 1800, Bitmap.Config.ARGB_8888)
|
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.Database
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
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.AlbumDao
|
||||||
import com.galo.android.data.db.dao.PhotoDao
|
import com.galo.android.data.db.dao.PhotoDao
|
||||||
import com.galo.android.data.db.dao.SyncDao
|
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.AlbumEntity
|
||||||
import com.galo.android.data.db.entity.DeviceAlbumMetaEntity
|
import com.galo.android.data.db.entity.DeviceAlbumMetaEntity
|
||||||
import com.galo.android.data.db.entity.DeviceAssetEntity
|
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.PhotoEntity
|
||||||
import com.galo.android.data.db.entity.SyncedAlbumEntity
|
import com.galo.android.data.db.entity.SyncedAlbumEntity
|
||||||
import com.galo.android.data.db.entity.UploadJobEntity
|
import com.galo.android.data.db.entity.UploadJobEntity
|
||||||
@@ -23,8 +26,9 @@ import com.galo.android.data.db.entity.UploadJobEntity
|
|||||||
SyncedAlbumEntity::class,
|
SyncedAlbumEntity::class,
|
||||||
DeviceAssetEntity::class,
|
DeviceAssetEntity::class,
|
||||||
DeviceAlbumMetaEntity::class,
|
DeviceAlbumMetaEntity::class,
|
||||||
|
DeletedAssetEntity::class,
|
||||||
],
|
],
|
||||||
version = 6,
|
version = 8,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)abstract class GaloDatabase : RoomDatabase() {
|
)abstract class GaloDatabase : RoomDatabase() {
|
||||||
|
|
||||||
@@ -41,7 +45,43 @@ import com.galo.android.data.db.entity.UploadJobEntity
|
|||||||
|
|
||||||
fun create(context: Context): GaloDatabase =
|
fun create(context: Context): GaloDatabase =
|
||||||
Room.databaseBuilder(context.applicationContext, GaloDatabase::class.java, NAME)
|
Room.databaseBuilder(context.applicationContext, GaloDatabase::class.java, NAME)
|
||||||
|
.addMigrations(MIGRATION_6_7, MIGRATION_7_8)
|
||||||
.fallbackToDestructiveMigration()
|
.fallbackToDestructiveMigration()
|
||||||
.build()
|
.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 androidx.room.Upsert
|
||||||
import com.galo.android.data.db.entity.DeviceAlbumMetaEntity
|
import com.galo.android.data.db.entity.DeviceAlbumMetaEntity
|
||||||
import com.galo.android.data.db.entity.DeviceAssetEntity
|
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.db.entity.SyncedAlbumEntity
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@@ -97,4 +98,24 @@ interface SyncDao {
|
|||||||
|
|
||||||
@Query("DELETE FROM device_assets WHERE deviceAlbumId = :deviceAlbumId")
|
@Query("DELETE FROM device_assets WHERE deviceAlbumId = :deviceAlbumId")
|
||||||
suspend fun clearAlbum(deviceAlbumId: String)
|
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
|
package com.galo.android.data.db.entity
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
@@ -12,6 +13,11 @@ data class SyncedAlbumEntity(
|
|||||||
val serverAlbumId: String,
|
val serverAlbumId: String,
|
||||||
val mode: String,
|
val mode: String,
|
||||||
val updatedAt: Long,
|
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 displayName: String?,
|
||||||
val coverUri: String?,
|
val coverUri: String?,
|
||||||
val updatedAt: Long,
|
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 serverPhotoId: String?,
|
||||||
val uploadedAt: Long?,
|
val uploadedAt: Long?,
|
||||||
val state: String,
|
val state: String,
|
||||||
|
/** Uploaded by this client (`OWN`) or materialized from the server (`MIRROR`). */
|
||||||
|
@ColumnInfo(defaultValue = "OWN") val origin: String = "OWN",
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun key(deviceAlbumId: String, mediaStoreId: Long): String = "$deviceAlbumId/$mediaStoreId"
|
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.dao.SyncDao
|
||||||
import com.galo.android.data.db.entity.DeviceAlbumMetaEntity
|
import com.galo.android.data.db.entity.DeviceAlbumMetaEntity
|
||||||
import com.galo.android.data.db.entity.DeviceAssetEntity
|
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.db.entity.SyncedAlbumEntity
|
||||||
import com.galo.android.data.media.MediaStoreDeviceSource
|
import com.galo.android.data.media.MediaStoreDeviceSource
|
||||||
import com.galo.android.domain.model.DeviceAlbum
|
import com.galo.android.domain.model.DeviceAlbum
|
||||||
@@ -57,6 +58,7 @@ class SyncRepository(
|
|||||||
deviceAlbumName: String,
|
deviceAlbumName: String,
|
||||||
serverAlbumId: String,
|
serverAlbumId: String,
|
||||||
mode: SyncMode,
|
mode: SyncMode,
|
||||||
|
mirrorFromCreatedAt: Long? = null,
|
||||||
) = dao.upsertSyncedAlbum(
|
) = dao.upsertSyncedAlbum(
|
||||||
SyncedAlbumEntity(
|
SyncedAlbumEntity(
|
||||||
deviceAlbumId = deviceAlbumId,
|
deviceAlbumId = deviceAlbumId,
|
||||||
@@ -64,10 +66,71 @@ class SyncRepository(
|
|||||||
serverAlbumId = serverAlbumId,
|
serverAlbumId = serverAlbumId,
|
||||||
mode = mode.name,
|
mode = mode.name,
|
||||||
updatedAt = System.currentTimeMillis(),
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun unlink(deviceAlbumId: String) = dao.deleteSyncedAlbum(deviceAlbumId)
|
/**
|
||||||
|
* 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) ------------------------
|
// --- Local album metadata (display name, cover) ------------------------
|
||||||
|
|
||||||
@@ -91,6 +154,7 @@ class SyncRepository(
|
|||||||
displayName = name?.trim()?.takeIf { it.isNotEmpty() },
|
displayName = name?.trim()?.takeIf { it.isNotEmpty() },
|
||||||
coverUri = current?.coverUri,
|
coverUri = current?.coverUri,
|
||||||
updatedAt = System.currentTimeMillis(),
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
syncDisabledAt = current?.syncDisabledAt,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -104,6 +168,7 @@ class SyncRepository(
|
|||||||
displayName = current?.displayName,
|
displayName = current?.displayName,
|
||||||
coverUri = coverUri,
|
coverUri = coverUri,
|
||||||
updatedAt = System.currentTimeMillis(),
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
syncDisabledAt = current?.syncDisabledAt,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -114,6 +179,7 @@ class SyncRepository(
|
|||||||
*/
|
*/
|
||||||
suspend fun forgetAlbum(deviceAlbumId: String) {
|
suspend fun forgetAlbum(deviceAlbumId: String) {
|
||||||
dao.clearAlbum(deviceAlbumId)
|
dao.clearAlbum(deviceAlbumId)
|
||||||
|
dao.clearDeletedAssets(deviceAlbumId)
|
||||||
dao.deleteAlbumMeta(deviceAlbumId)
|
dao.deleteAlbumMeta(deviceAlbumId)
|
||||||
dao.deleteSyncedAlbum(deviceAlbumId)
|
dao.deleteSyncedAlbum(deviceAlbumId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,75 @@
|
|||||||
package com.galo.android.data.sync
|
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.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. */
|
/** [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> =
|
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) {
|
override suspend fun renamePhoto(photoId: String, newName: String) {
|
||||||
api.renamePhoto(photoId, newName)
|
api.renamePhoto(photoId, newName)
|
||||||
@@ -19,4 +82,24 @@ class ApiSyncRemote(private val api: GaloApi) : SyncRemote {
|
|||||||
override suspend fun movePhotos(photoIds: List<String>, serverAlbumId: String) {
|
override suspend fun movePhotos(photoIds: List<String>, serverAlbumId: String) {
|
||||||
api.movePhotos(photoIds, serverAlbumId)
|
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
|
* Watches MediaStore while the app process is alive and notifies on changes so
|
||||||
* synchronization can run promptly in the foreground. Background changes are
|
* 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(
|
class DeviceMediaObserver(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val onChanged: () -> Unit,
|
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) {
|
override fun onChange(selfChange: Boolean) {
|
||||||
onChanged()
|
schedule()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
||||||
onChanged()
|
schedule()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun schedule() {
|
||||||
|
handler.removeCallbacks(notify)
|
||||||
|
handler.postDelayed(notify, debounceMs)
|
||||||
|
}
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
runCatching {
|
runCatching {
|
||||||
context.contentResolver.registerContentObserver(
|
context.contentResolver.registerContentObserver(
|
||||||
@@ -44,5 +55,6 @@ class DeviceMediaObserver(
|
|||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
runCatching { context.contentResolver.unregisterContentObserver(observer) }
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
/** Supplies the device's current content to the synchronization engine. */
|
/** Supplies the device's current content to the synchronization engine. */
|
||||||
interface LocalAssetProvider {
|
interface LocalAssetProvider {
|
||||||
@@ -34,7 +35,7 @@ class DeviceLocalAssetProvider(
|
|||||||
displayName = media.displayName,
|
displayName = media.displayName,
|
||||||
mimeType = media.mimeType,
|
mimeType = media.mimeType,
|
||||||
kind = media.kind,
|
kind = media.kind,
|
||||||
contentHash = hash(media.uri),
|
contentHash = hashCached(media.uri, media.size, media.dateModified),
|
||||||
uri = media.uri,
|
uri = media.uri,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -46,6 +47,23 @@ class DeviceLocalAssetProvider(
|
|||||||
override fun openStream(uri: String): InputStream? =
|
override fun openStream(uri: String): InputStream? =
|
||||||
runCatching { context.contentResolver.openInputStream(Uri.parse(uri)) }.getOrNull()
|
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? =
|
private fun hash(uri: String): String? =
|
||||||
runCatching { openStream(uri)?.use { ContentHash.sha256(it) } }.getOrNull()
|
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.
|
// this back to rebuild the link after a reinstall.
|
||||||
mode = syncMode ?: SyncMode.MOVE.name,
|
mode = syncMode ?: SyncMode.MOVE.name,
|
||||||
assetKey = asset.assetKey,
|
assetKey = asset.assetKey,
|
||||||
|
origin = asset.origin,
|
||||||
)
|
)
|
||||||
|
|
||||||
private suspend fun markPreview(asset: DeviceAssetEntity) {
|
private suspend fun markPreview(asset: DeviceAssetEntity) {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ data class Provenance(
|
|||||||
val deviceAlbumId: String?,
|
val deviceAlbumId: String?,
|
||||||
val mode: String?,
|
val mode: String?,
|
||||||
val assetKey: 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("device=").append(p.deviceAlbumId ?: "")
|
||||||
append('|').append("mode=").append(p.mode ?: "")
|
append('|').append("mode=").append(p.mode ?: "")
|
||||||
append('|').append("asset=").append(p.assetKey ?: "")
|
append('|').append("asset=").append(p.assetKey ?: "")
|
||||||
|
append('|').append("origin=").append(p.origin ?: "")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun decode(raw: String?): Provenance? {
|
fun decode(raw: String?): Provenance? {
|
||||||
@@ -66,6 +69,7 @@ object ProvenanceStamp {
|
|||||||
deviceAlbumId = map["device"]?.ifBlank { null },
|
deviceAlbumId = map["device"]?.ifBlank { null },
|
||||||
mode = map["mode"]?.ifBlank { null },
|
mode = map["mode"]?.ifBlank { null },
|
||||||
assetKey = map["asset"]?.ifBlank { null },
|
assetKey = map["asset"]?.ifBlank { null },
|
||||||
|
origin = map["origin"]?.ifBlank { null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package com.galo.android.data.sync
|
package com.galo.android.data.sync
|
||||||
|
|
||||||
import com.galo.android.data.db.dao.SyncDao
|
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.DeviceAssetEntity
|
||||||
import com.galo.android.data.db.entity.SyncedAlbumEntity
|
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.LocalAssetState
|
||||||
import com.galo.android.domain.model.SyncMode
|
import com.galo.android.domain.model.SyncMode
|
||||||
import com.galo.android.domain.sync.SyncPolicy
|
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
|
* Reconciles a device album with its server album as a two-way mirror. Mode A
|
||||||
* only and performs no server requests; the other modes upload missing content,
|
* (DISABLED) is local only and performs no server requests; the other modes
|
||||||
* dedup by checksum, propagate renames and update the local index.
|
* 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(
|
class SyncCoordinator(
|
||||||
private val dao: SyncDao,
|
private val dao: SyncDao,
|
||||||
@@ -29,6 +33,7 @@ class SyncCoordinator(
|
|||||||
private val remote: SyncRemote,
|
private val remote: SyncRemote,
|
||||||
private val uploadQueue: SyncUploadQueue,
|
private val uploadQueue: SyncUploadQueue,
|
||||||
private val offloader: AssetOffloader,
|
private val offloader: AssetOffloader,
|
||||||
|
private val mirror: AssetMirror,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** Serializes reconciles so rapid triggers never double-upload or double-offload. */
|
/** 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.
|
// A mode can be chosen before a server album; nothing to sync until then.
|
||||||
if (link.serverAlbumId.isBlank()) return false
|
if (link.serverAlbumId.isBlank()) return false
|
||||||
|
|
||||||
val serverChecksums = remote.serverChecksums(link.serverAlbumId)
|
// If the album could not be read, do NOT treat it as empty: an empty list
|
||||||
val livePhotoIds = serverChecksums.values.toSet()
|
// 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 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
|
// Reflect lost/returned server copies before planning, so an offloaded
|
||||||
// preview whose photo is gone is seen as ORPHANED and handled as such.
|
// preview whose photo is gone is seen as ORPHANED and handled as such.
|
||||||
val index = reflectLostCopies(dao.getAssets(deviceAlbumId), livePhotoIds).map {
|
val reflected = reflectLostCopies(dao.getAssets(deviceAlbumId), livePhotoIds)
|
||||||
IndexedAsset(it.assetKey, it.serverPhotoId, it.contentHash, it.displayName, it.state)
|
// 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 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)
|
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)
|
// Mode C: assets already on the server (e.g. after switching from copies)
|
||||||
// are replaced with their offloaded versions, right away, but only while
|
// are replaced with their offloaded versions, right away, but only while
|
||||||
// their copy is confirmed in the target album.
|
// their copy is confirmed in the target album.
|
||||||
@@ -79,7 +107,8 @@ class SyncCoordinator(
|
|||||||
newServerAlbumId: String,
|
newServerAlbumId: String,
|
||||||
mode: SyncMode,
|
mode: SyncMode,
|
||||||
): RetargetResult = mutex.withLock {
|
): RetargetResult = mutex.withLock {
|
||||||
val previous = dao.getSyncedAlbum(deviceAlbumId)
|
val prior = dao.getSyncedAlbum(deviceAlbumId)
|
||||||
|
val previous = prior
|
||||||
?.serverAlbumId
|
?.serverAlbumId
|
||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
val recordedPhotoIds = dao.getAssets(deviceAlbumId)
|
val recordedPhotoIds = dao.getAssets(deviceAlbumId)
|
||||||
@@ -121,6 +150,7 @@ class SyncCoordinator(
|
|||||||
serverAlbumId = newServerAlbumId,
|
serverAlbumId = newServerAlbumId,
|
||||||
mode = mode.name,
|
mode = mode.name,
|
||||||
updatedAt = System.currentTimeMillis(),
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
mirrorFromCreatedAt = prior?.mirrorFromCreatedAt ?: System.currentTimeMillis(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
reconcileLocked(deviceAlbumId)
|
reconcileLocked(deviceAlbumId)
|
||||||
@@ -143,12 +173,14 @@ class SyncCoordinator(
|
|||||||
/**
|
/**
|
||||||
* Reflects the loss or return of the server copy behind an offloaded preview:
|
* 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
|
* 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(
|
private suspend fun reflectLostCopies(
|
||||||
entities: List<DeviceAssetEntity>,
|
entities: List<DeviceAssetEntity>,
|
||||||
livePhotoIds: Set<String>,
|
livePhotoIds: Set<String>,
|
||||||
): List<DeviceAssetEntity> = entities.map { entity ->
|
): List<DeviceAssetEntity> = entities.map { entity ->
|
||||||
|
if (entity.origin == AssetOrigin.MIRROR.name) return@map entity
|
||||||
val photoId = entity.serverPhotoId ?: return@map entity
|
val photoId = entity.serverPhotoId ?: return@map entity
|
||||||
val live = photoId in livePhotoIds
|
val live = photoId in livePhotoIds
|
||||||
when {
|
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
|
* 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,
|
* locally (mode C). Retries assets left as "awaiting free" from a previous pass,
|
||||||
@@ -224,14 +378,27 @@ class SyncCoordinator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is SyncAction.RenameOnServer -> {
|
is SyncAction.RenameOnServer -> {
|
||||||
remote.renamePhoto(action.photoId, action.newName)
|
runCatching { remote.renamePhoto(action.photoId, action.newName) }
|
||||||
dao.updateDisplayNameByServerPhoto(action.photoId, action.newName)
|
dao.updateDisplayNameByServerPhoto(action.photoId, action.newName)
|
||||||
}
|
}
|
||||||
|
|
||||||
is SyncAction.DeleteOnServer -> {
|
is SyncAction.DeleteOnServer -> {
|
||||||
// A photo already gone from the server is only reflected locally:
|
// A photo already gone from the server is only reflected locally:
|
||||||
// the device original is never touched and nothing is re-deleted.
|
// 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)
|
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
|
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.LocalAssetState
|
||||||
import com.galo.android.domain.model.MediaKind
|
import com.galo.android.domain.model.MediaKind
|
||||||
|
|
||||||
@@ -21,6 +22,8 @@ data class IndexedAsset(
|
|||||||
val contentHash: String?,
|
val contentHash: String?,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
val state: 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. */
|
/** A change to apply to the server album. */
|
||||||
@@ -54,6 +57,7 @@ object SyncPlanner {
|
|||||||
index: List<IndexedAsset>,
|
index: List<IndexedAsset>,
|
||||||
serverChecksums: Map<String, String>,
|
serverChecksums: Map<String, String>,
|
||||||
hashesPresentElsewhere: Set<String>,
|
hashesPresentElsewhere: Set<String>,
|
||||||
|
explicitlyDeleted: Set<String> = emptySet(),
|
||||||
): List<SyncAction> {
|
): List<SyncAction> {
|
||||||
val actions = mutableListOf<SyncAction>()
|
val actions = mutableListOf<SyncAction>()
|
||||||
val livePhotoIds = serverChecksums.values.toSet()
|
val livePhotoIds = serverChecksums.values.toSet()
|
||||||
@@ -103,6 +107,10 @@ object SyncPlanner {
|
|||||||
for (indexed in index) {
|
for (indexed in index) {
|
||||||
val photoId = indexed.serverPhotoId ?: continue
|
val photoId = indexed.serverPhotoId ?: continue
|
||||||
if (indexed.assetKey in localKeys) 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
|
val hash = indexed.contentHash
|
||||||
if (hash != null && hash in hashesPresentElsewhere) continue
|
if (hash != null && hash in hashesPresentElsewhere) continue
|
||||||
// The remote delete only happens while this photo is in the target
|
// The remote delete only happens while this photo is in the target
|
||||||
@@ -112,4 +120,20 @@ object SyncPlanner {
|
|||||||
|
|
||||||
return actions
|
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.DeviceAssetEntity
|
||||||
import com.galo.android.data.db.entity.SyncedAlbumEntity
|
import com.galo.android.data.db.entity.SyncedAlbumEntity
|
||||||
import com.galo.android.data.media.MediaStoreDeviceSource
|
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.LocalAssetState
|
||||||
import com.galo.android.domain.model.SyncMode
|
import com.galo.android.domain.model.SyncMode
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -25,6 +26,10 @@ class SyncRecovery(
|
|||||||
|
|
||||||
/** @return how many assets were recovered from their markers. */
|
/** @return how many assets were recovered from their markers. */
|
||||||
suspend fun recover(deviceAlbumId: String): Int = withContext(Dispatchers.IO) {
|
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
|
var recovered = 0
|
||||||
for (media in deviceSource.items(deviceAlbumId)) {
|
for (media in deviceSource.items(deviceAlbumId)) {
|
||||||
val marker = ProvenanceStamp.read(context, Uri.parse(media.uri)) ?: continue
|
val marker = ProvenanceStamp.read(context, Uri.parse(media.uri)) ?: continue
|
||||||
@@ -45,6 +50,7 @@ class SyncRecovery(
|
|||||||
serverPhotoId = photoId,
|
serverPhotoId = photoId,
|
||||||
uploadedAt = null,
|
uploadedAt = null,
|
||||||
state = LocalAssetState.PREVIEW.name,
|
state = LocalAssetState.PREVIEW.name,
|
||||||
|
origin = AssetOrigin.from(marker.origin).name,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,39 @@
|
|||||||
package com.galo.android.data.sync
|
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. */
|
/** Server-side operations the synchronization engine needs. */
|
||||||
interface SyncRemote {
|
interface SyncRemote {
|
||||||
/** Control sums of the server photos in an album, keyed by checksum. */
|
/** Control sums of the server photos in an album, keyed by checksum. */
|
||||||
suspend fun serverChecksums(serverAlbumId: String): Map<String, String>
|
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 renamePhoto(photoId: String, newName: String)
|
||||||
|
|
||||||
suspend fun deletePhoto(photoId: String)
|
suspend fun deletePhoto(photoId: String)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import androidx.work.ExistingWorkPolicy
|
|||||||
import androidx.work.NetworkType
|
import androidx.work.NetworkType
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.workDataOf
|
|
||||||
import com.galo.android.data.db.dao.UploadDao
|
import com.galo.android.data.db.dao.UploadDao
|
||||||
import com.galo.android.data.db.entity.UploadJobEntity
|
import com.galo.android.data.db.entity.UploadJobEntity
|
||||||
import com.galo.android.domain.model.SyncUpload
|
import com.galo.android.domain.model.SyncUpload
|
||||||
@@ -71,9 +70,9 @@ class UploadRepository(
|
|||||||
convertedPath = null,
|
convertedPath = null,
|
||||||
)
|
)
|
||||||
dao.upsert(job)
|
dao.upsert(job)
|
||||||
UploadScheduler.enqueue(context, job.id, wifiOnly)
|
|
||||||
queued++
|
queued++
|
||||||
}
|
}
|
||||||
|
if (queued > 0) UploadScheduler.enqueueDrain(context)
|
||||||
return queued
|
return queued
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,17 +111,22 @@ class UploadRepository(
|
|||||||
syncMode = mode,
|
syncMode = mode,
|
||||||
)
|
)
|
||||||
dao.upsert(job)
|
dao.upsert(job)
|
||||||
UploadScheduler.enqueue(context, job.id, wifiOnly)
|
UploadScheduler.enqueueDrain(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-schedules unfinished work after a restart and clears stale RUNNING status. */
|
/** Re-schedules unfinished work after a restart and clears stale RUNNING status. */
|
||||||
suspend fun requeuePending() {
|
suspend fun requeuePending() {
|
||||||
dao.resetRunning()
|
dao.resetRunning()
|
||||||
dao.getPending().forEach { job ->
|
if (dao.getPending().isNotEmpty()) UploadScheduler.enqueueDrain(context)
|
||||||
UploadScheduler.enqueue(context, job.id, job.wifiOnly)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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)
|
suspend fun getById(id: String): UploadJobEntity? = dao.getById(id)
|
||||||
|
|
||||||
/** True when a sync upload for this asset is already queued or running. */
|
/** True when a sync upload for this asset is already queued or running. */
|
||||||
@@ -161,22 +165,20 @@ class UploadRepository(
|
|||||||
|
|
||||||
object UploadScheduler {
|
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()
|
val constraints = Constraints.Builder()
|
||||||
.setRequiredNetworkType(
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED,
|
|
||||||
)
|
|
||||||
.build()
|
.build()
|
||||||
val request = OneTimeWorkRequestBuilder<UploadWorker>()
|
val request = OneTimeWorkRequestBuilder<UploadWorker>()
|
||||||
.setInputData(workDataOf(KEY_JOB_ID to jobId))
|
|
||||||
.setConstraints(constraints)
|
.setConstraints(constraints)
|
||||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.SECONDS)
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.SECONDS)
|
||||||
.addTag(TAG_UPLOAD)
|
.addTag(TAG_UPLOAD)
|
||||||
.build()
|
.build()
|
||||||
WorkManager.getInstance(context)
|
WorkManager.getInstance(context)
|
||||||
.enqueueUniqueWork(jobId, ExistingWorkPolicy.KEEP, request)
|
.enqueueUniqueWork(UNIQUE_DRAIN, ExistingWorkPolicy.KEEP, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
const val TAG_UPLOAD = "galo-upload"
|
const val TAG_UPLOAD = "galo-upload"
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.galo.android.data.upload
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
@@ -23,58 +25,89 @@ import java.io.File
|
|||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uploads a single queue entry as one multipart POST. Using a foreground
|
* Drains the persistent upload queue: one WorkManager job processes every pending
|
||||||
* notification while running lets large/video uploads continue when the app is
|
* entry sequentially (one file per HTTP request, per the server's fail-fast,
|
||||||
* backgrounded; transient failures retry with WorkManager's exponential backoff
|
* size-capped uploads). A single draining job avoids the JobScheduler limits and
|
||||||
* up to [MAX_ATTEMPTS].
|
* foreground-service churn of one job per file, so a large album uploads reliably.
|
||||||
*/
|
*/
|
||||||
class UploadWorker(
|
class UploadWorker(
|
||||||
appContext: Context,
|
appContext: Context,
|
||||||
params: WorkerParameters,
|
params: WorkerParameters,
|
||||||
) : CoroutineWorker(appContext, params) {
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
|
||||||
|
private enum class Outcome { DONE, RETRY }
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
val app = applicationContext as? GaloApplication ?: return Result.failure()
|
val app = applicationContext as? GaloApplication ?: return Result.failure()
|
||||||
val container = app.container
|
val container = app.container
|
||||||
val repository = container.uploadRepository
|
val repository = container.uploadRepository
|
||||||
|
|
||||||
val jobId = inputData.getString(UploadScheduler.KEY_JOB_ID) ?: return Result.failure()
|
// A previous run may have been killed mid-file; those entries are re-queued.
|
||||||
val job = repository.getById(jobId) ?: return Result.success()
|
repository.resetRunning()
|
||||||
if (job.status == UploadStatus.SUCCESS.name) return Result.success()
|
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)
|
repository.updateState(jobId, UploadStatus.RUNNING, job.progress, null, attempt, job.convertedPath)
|
||||||
|
|
||||||
var tempPath = job.convertedPath
|
var tempPath = job.convertedPath
|
||||||
|
var lastPercent = -1
|
||||||
return try {
|
return try {
|
||||||
setForeground(foregroundInfo(jobId, job.filename, 0))
|
setForegroundBestEffort(job.filename, 0)
|
||||||
val uri = Uri.parse(job.uri)
|
val uri = Uri.parse(job.uri)
|
||||||
|
|
||||||
val (filename, body) = prepareBody(uri, job.filename, job.mimeType, job.size) { percent ->
|
val prepared = prepareBody(uri, job.filename, job.mimeType, job.size) { percent ->
|
||||||
onProgress(repository, jobId, job.filename, percent, attempt, tempPath)
|
if (percent != lastPercent) {
|
||||||
}.also { if (it.tempPath != null) tempPath = it.tempPath }
|
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)
|
container.photoRepository.upsert(photo)
|
||||||
recordProvenance(container, job, photo.id)
|
recordProvenance(container, job, photo.id)
|
||||||
repository.updateState(jobId, UploadStatus.SUCCESS, 100, null, attempt, tempPath)
|
repository.updateState(jobId, UploadStatus.SUCCESS, 100, null, attempt, tempPath)
|
||||||
tempPath?.let { runCatching { File(it).delete() } }
|
tempPath?.let { runCatching { File(it).delete() } }
|
||||||
Result.success()
|
Outcome.DONE
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
val message = describe(e)
|
val message = describe(e)
|
||||||
if (attempt + 1 >= MAX_ATTEMPTS) {
|
if (attempt + 1 >= MAX_ATTEMPTS) {
|
||||||
repository.updateState(jobId, UploadStatus.ERROR, job.progress, message, attempt, tempPath)
|
repository.updateState(jobId, UploadStatus.ERROR, job.progress, message, attempt, tempPath)
|
||||||
Result.failure()
|
Outcome.DONE
|
||||||
} else {
|
} else {
|
||||||
repository.updateState(jobId, UploadStatus.QUEUED, job.progress, message, attempt, tempPath)
|
repository.updateState(jobId, UploadStatus.QUEUED, job.progress, message, attempt + 1, tempPath)
|
||||||
Result.retry()
|
Outcome.RETRY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For a job created by the device-album sync, records the created server photo
|
* Records the created server photo as provenance of the source asset (sync
|
||||||
* as the provenance of the source asset so later passes can dedup and delete.
|
* jobs) and, in mode C, offloads the original right after the upload.
|
||||||
*/
|
*/
|
||||||
private suspend fun recordProvenance(container: AppContainer, job: UploadJobEntity, photoId: String) {
|
private suspend fun recordProvenance(container: AppContainer, job: UploadJobEntity, photoId: String) {
|
||||||
val deviceAlbumId = job.deviceAlbumId ?: return
|
val deviceAlbumId = job.deviceAlbumId ?: return
|
||||||
@@ -92,12 +125,8 @@ class UploadWorker(
|
|||||||
serverPhotoId = photoId,
|
serverPhotoId = photoId,
|
||||||
state = state,
|
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) {
|
if (mode == SyncMode.MOVE) {
|
||||||
container.syncRepository.getAsset(assetKey)?.let { asset ->
|
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)) }
|
runCatching { container.offloadManager.offload(asset, setOf(photoId)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,23 +191,16 @@ class UploadWorker(
|
|||||||
return -1L
|
return -1L
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onProgress(
|
/**
|
||||||
repository: UploadRepository,
|
* Foreground is best-effort: the OS may refuse to start a foreground service
|
||||||
jobId: String,
|
* from the background. The upload still runs; it just may not show a persistent
|
||||||
filename: String,
|
* notification in that case.
|
||||||
percent: Int,
|
*/
|
||||||
attempt: Int,
|
private suspend fun setForegroundBestEffort(filename: String, percent: Int) {
|
||||||
tempPath: String?,
|
runCatching { setForeground(foregroundInfo(filename, percent)) }
|
||||||
) {
|
|
||||||
if (percent == lastPercent) return
|
|
||||||
lastPercent = percent
|
|
||||||
runBlocking(Dispatchers.IO) {
|
|
||||||
repository.updateState(jobId, UploadStatus.RUNNING, percent, null, attempt, tempPath)
|
|
||||||
setForeground(foregroundInfo(jobId, 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)
|
val notification = NotificationCompat.Builder(applicationContext, GaloApplication.UPLOAD_CHANNEL_ID)
|
||||||
.setSmallIcon(android.R.drawable.stat_sys_upload)
|
.setSmallIcon(android.R.drawable.stat_sys_upload)
|
||||||
.setContentTitle("Загрузка в galo")
|
.setContentTitle("Загрузка в galo")
|
||||||
@@ -188,12 +210,19 @@ class UploadWorker(
|
|||||||
.setProgress(100, percent, percent == 0)
|
.setProgress(100, percent, percent == 0)
|
||||||
.build()
|
.build()
|
||||||
return ForegroundInfo(
|
return ForegroundInfo(
|
||||||
jobId.hashCode(),
|
NOTIFICATION_ID,
|
||||||
notification,
|
notification,
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
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) {
|
private fun describe(e: Exception): String = when (e) {
|
||||||
is ApiException -> ErrorMapper.userMessage(e.error)
|
is ApiException -> ErrorMapper.userMessage(e.error)
|
||||||
else -> e.message ?: "Ошибка загрузки"
|
else -> e.message ?: "Ошибка загрузки"
|
||||||
@@ -202,10 +231,8 @@ class UploadWorker(
|
|||||||
private fun percent(sent: Long, total: Long): Int =
|
private fun percent(sent: Long, total: Long): Int =
|
||||||
if (total > 0) ((sent * 100) / total).toInt().coerceIn(0, 100) else 0
|
if (total > 0) ((sent * 100) / total).toInt().coerceIn(0, 100) else 0
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var lastPercent = -1
|
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val MAX_ATTEMPTS = 3
|
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.ApiSyncRemote
|
||||||
import com.galo.android.data.sync.DeviceLocalAssetProvider
|
import com.galo.android.data.sync.DeviceLocalAssetProvider
|
||||||
import com.galo.android.data.sync.DeviceMediaObserver
|
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.OffloadManager
|
||||||
import com.galo.android.data.sync.RepositorySyncUploadQueue
|
import com.galo.android.data.sync.RepositorySyncUploadQueue
|
||||||
import com.galo.android.data.sync.RestoreCoordinator
|
import com.galo.android.data.sync.RestoreCoordinator
|
||||||
@@ -95,10 +96,12 @@ class AppContainer(context: Context) {
|
|||||||
|
|
||||||
val syncRepository = SyncRepository(database.syncDao(), deviceSource)
|
val syncRepository = SyncRepository(database.syncDao(), deviceSource)
|
||||||
|
|
||||||
val syncRemote: SyncRemote = ApiSyncRemote(api)
|
val syncRemote: SyncRemote = ApiSyncRemote(api, apiClient)
|
||||||
|
|
||||||
val offloadManager = OffloadManager(appContext, database.syncDao())
|
val offloadManager = OffloadManager(appContext, database.syncDao())
|
||||||
|
|
||||||
|
val mirrorManager = MirrorManager(appContext, syncRemote, deviceSource)
|
||||||
|
|
||||||
val syncCoordinator = SyncCoordinator(
|
val syncCoordinator = SyncCoordinator(
|
||||||
dao = database.syncDao(),
|
dao = database.syncDao(),
|
||||||
local = DeviceLocalAssetProvider(appContext, database.syncDao(), deviceSource),
|
local = DeviceLocalAssetProvider(appContext, database.syncDao(), deviceSource),
|
||||||
@@ -108,6 +111,7 @@ class AppContainer(context: Context) {
|
|||||||
wifiOnly = { settingsState?.wifiOnlyUploads == true },
|
wifiOnly = { settingsState?.wifiOnlyUploads == true },
|
||||||
),
|
),
|
||||||
offloader = offloadManager,
|
offloader = offloadManager,
|
||||||
|
mirror = mirrorManager,
|
||||||
)
|
)
|
||||||
|
|
||||||
val restoreManager = RestoreManager(
|
val restoreManager = RestoreManager(
|
||||||
@@ -124,6 +128,16 @@ class AppContainer(context: Context) {
|
|||||||
/** Unified per-gallery check and fix (startup + album open). */
|
/** Unified per-gallery check and fix (startup + album open). */
|
||||||
val syncCheck = SyncCheck(syncRepository, syncRecovery, restoreCoordinator, syncCoordinator)
|
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() {
|
fun start() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
settingsRepository.flow.collect { settings ->
|
settingsRepository.flow.collect { settings ->
|
||||||
@@ -140,7 +154,10 @@ class AppContainer(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
runCatching {
|
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. */
|
/** Where a device asset currently lives relative to the server. */
|
||||||
enum class LocalAssetState {
|
enum class LocalAssetState {
|
||||||
/** Original present locally, nothing uploaded yet. */
|
/** Original present locally, nothing uploaded yet. */
|
||||||
@@ -71,4 +90,6 @@ data class DeviceAsset(
|
|||||||
val serverPhotoId: String?,
|
val serverPhotoId: String?,
|
||||||
val uploadedAt: Long?,
|
val uploadedAt: Long?,
|
||||||
val state: LocalAssetState,
|
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_FREE_BANNER_BUTTON = "device_free_banner_button"
|
||||||
const val TAG_NEW_ALBUM_NAME = "device_new_album_name"
|
const val TAG_NEW_ALBUM_NAME = "device_new_album_name"
|
||||||
const val TAG_CREATE_TARGET = "device_create_target"
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -136,6 +139,7 @@ fun DeviceAlbumScreen(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val role by viewModel.role.collectAsStateWithLifecycle()
|
val role by viewModel.role.collectAsStateWithLifecycle()
|
||||||
val notice by viewModel.notice.collectAsStateWithLifecycle()
|
val notice by viewModel.notice.collectAsStateWithLifecycle()
|
||||||
|
val mirrorChoice by viewModel.mirrorChoice.collectAsStateWithLifecycle()
|
||||||
val prefs by viewModel.preferences.collectAsStateWithLifecycle()
|
val prefs by viewModel.preferences.collectAsStateWithLifecycle()
|
||||||
val meta by viewModel.albumMeta.collectAsStateWithLifecycle()
|
val meta by viewModel.albumMeta.collectAsStateWithLifecycle()
|
||||||
var viewerIndex by remember { mutableStateOf<Int?>(null) }
|
var viewerIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
@@ -281,6 +285,14 @@ fun DeviceAlbumScreen(
|
|||||||
onDismiss = { deletePhotoId = null },
|
onDismiss = { deletePhotoId = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mirrorChoice) {
|
||||||
|
MirrorScopeDialog(
|
||||||
|
onOnlyNew = { viewModel.chooseMirrorScope(downloadAll = false) },
|
||||||
|
onDownloadAll = { viewModel.chooseMirrorScope(downloadAll = true) },
|
||||||
|
onDismiss = viewModel::dismissMirrorChoice,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@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. */
|
/** Confirms a mode change and explains what the mode does. */
|
||||||
@Composable
|
@Composable
|
||||||
fun ModeConfirmDialog(
|
fun ModeConfirmDialog( mode: SyncMode,
|
||||||
mode: SyncMode,
|
|
||||||
onConfirm: () -> Unit,
|
onConfirm: () -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -72,6 +72,32 @@ class DeviceAlbumViewModel(
|
|||||||
/** A one-off message for the user (for example a refused target change). */
|
/** A one-off message for the user (for example a refused target change). */
|
||||||
val notice: StateFlow<String?> = _notice.asStateFlow()
|
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() {
|
fun dismissNotice() {
|
||||||
_notice.value = null
|
_notice.value = null
|
||||||
}
|
}
|
||||||
@@ -112,6 +138,17 @@ class DeviceAlbumViewModel(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
load()
|
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() {
|
fun load() {
|
||||||
@@ -218,8 +255,12 @@ class DeviceAlbumViewModel(
|
|||||||
|
|
||||||
fun deleteServerPhoto(photoId: String) {
|
fun deleteServerPhoto(photoId: String) {
|
||||||
viewModelScope.launch {
|
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) }
|
runCatching { container.api.deletePhoto(photoId) }
|
||||||
.onSuccess { container.photoRepository.remove(photoId) }
|
.onSuccess { container.photoRepository.remove(photoId) }
|
||||||
|
reconcileNow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +335,8 @@ class DeviceAlbumViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Persist the chosen mode even before a server album is picked, so the
|
// Persist the chosen mode even before a server album is picked, so the
|
||||||
// choice survives leaving and reopening the album.
|
// choice survives leaving and reopening the album.
|
||||||
|
val hadTarget = container.syncRepository.currentLink(deviceAlbumId)
|
||||||
|
?.serverAlbumId?.isNotBlank() == true
|
||||||
val target = state.value.serverAlbumId
|
val target = state.value.serverAlbumId
|
||||||
?: container.syncRepository.currentLink(deviceAlbumId)?.serverAlbumId
|
?: container.syncRepository.currentLink(deviceAlbumId)?.serverAlbumId
|
||||||
?: ""
|
?: ""
|
||||||
@@ -305,7 +348,10 @@ class DeviceAlbumViewModel(
|
|||||||
)
|
)
|
||||||
refreshRole()
|
refreshRole()
|
||||||
// Nothing to sync until a server album is chosen.
|
// 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) {
|
if (result.strandedCopies > 0) {
|
||||||
_notice.value = "Фото без серверной копии: ${result.strandedCopies}."
|
_notice.value = "Фото без серверной копии: ${result.strandedCopies}."
|
||||||
}
|
}
|
||||||
|
_mirrorChoice.value = mode != SyncMode.DISABLED
|
||||||
}
|
}
|
||||||
|
|
||||||
is RetargetResult.Refused -> _notice.value = result.reason
|
is RetargetResult.Refused -> _notice.value = result.reason
|
||||||
@@ -348,7 +395,10 @@ class DeviceAlbumViewModel(
|
|||||||
mode = mode,
|
mode = mode,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
is RetargetResult.Linked -> refreshRole()
|
is RetargetResult.Linked -> {
|
||||||
|
refreshRole()
|
||||||
|
_mirrorChoice.value = mode != SyncMode.DISABLED
|
||||||
|
}
|
||||||
is RetargetResult.Refused -> _notice.value = result.reason
|
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 androidx.test.core.app.ApplicationProvider
|
||||||
import com.galo.android.data.db.dao.SyncDao
|
import com.galo.android.data.db.dao.SyncDao
|
||||||
import com.galo.android.data.db.entity.DeviceAssetEntity
|
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.db.entity.SyncedAlbumEntity
|
||||||
import com.galo.android.domain.model.LocalAssetState
|
import com.galo.android.domain.model.LocalAssetState
|
||||||
import com.galo.android.domain.model.SyncMode
|
import com.galo.android.domain.model.SyncMode
|
||||||
@@ -91,4 +92,44 @@ class SyncDaoTest {
|
|||||||
dao.deleteAsset(key)
|
dao.deleteAsset(key)
|
||||||
assertNull(dao.getAsset(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 kotlinx.coroutines.runBlocking
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -69,6 +71,18 @@ class SyncRepositoryTest {
|
|||||||
assertEquals("p1", db.syncDao().getAsset("b1/1")?.serverPhotoId)
|
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
|
@Test
|
||||||
fun `uploaded counts are per album and skip assets without a server photo`() = runBlocking {
|
fun `uploaded counts are per album and skip assets without a server photo`() = runBlocking {
|
||||||
db.syncDao().upsertAssets(
|
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)
|
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
|
@Test
|
||||||
fun `bad markers decode to null`() {
|
fun `bad markers decode to null`() {
|
||||||
assertNull(ProvenanceStamp.decode(null))
|
assertNull(ProvenanceStamp.decode(null))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import com.galo.android.data.db.GaloDatabase
|
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.DeviceAssetEntity
|
||||||
import com.galo.android.data.db.entity.SyncedAlbumEntity
|
import com.galo.android.data.db.entity.SyncedAlbumEntity
|
||||||
import com.galo.android.domain.model.LocalAssetState
|
import com.galo.android.domain.model.LocalAssetState
|
||||||
@@ -12,6 +13,7 @@ import com.galo.android.domain.model.SyncMode
|
|||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
@@ -34,6 +36,8 @@ class SyncCoordinatorTest {
|
|||||||
var serverMap: Map<String, String> = emptyMap()
|
var serverMap: Map<String, String> = emptyMap()
|
||||||
val serverMapByAlbum = mutableMapOf<String, Map<String, String>>()
|
val serverMapByAlbum = mutableMapOf<String, Map<String, String>>()
|
||||||
var failChecksumsFor: Set<String> = emptySet()
|
var failChecksumsFor: Set<String> = emptySet()
|
||||||
|
var failDeletesFor: Set<String> = emptySet()
|
||||||
|
var photoCreatedAt: Long = 0L
|
||||||
|
|
||||||
override suspend fun serverChecksums(serverAlbumId: String): Map<String, String> {
|
override suspend fun serverChecksums(serverAlbumId: String): Map<String, String> {
|
||||||
checksumCalls++
|
checksumCalls++
|
||||||
@@ -41,11 +45,33 @@ class SyncCoordinatorTest {
|
|||||||
return serverMapByAlbum[serverAlbumId] ?: serverMap
|
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) {
|
override suspend fun renamePhoto(photoId: String, newName: String) {
|
||||||
renames += photoId to newName
|
renames += photoId to newName
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deletePhoto(photoId: String) {
|
override suspend fun deletePhoto(photoId: String) {
|
||||||
|
if (photoId in failDeletesFor) throw RuntimeException("forbidden")
|
||||||
deletes += photoId
|
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 {
|
private class FakeQueue : SyncUploadQueue {
|
||||||
val enqueued = mutableListOf<Triple<String, String, LocalAsset>>()
|
val enqueued = mutableListOf<Triple<String, String, LocalAsset>>()
|
||||||
val pending = mutableSetOf<String>()
|
val pending = mutableSetOf<String>()
|
||||||
@@ -97,6 +145,7 @@ class SyncCoordinatorTest {
|
|||||||
private lateinit var provider: FakeProvider
|
private lateinit var provider: FakeProvider
|
||||||
private lateinit var queue: FakeQueue
|
private lateinit var queue: FakeQueue
|
||||||
private lateinit var offloader: FakeOffloader
|
private lateinit var offloader: FakeOffloader
|
||||||
|
private lateinit var mirror: FakeMirror
|
||||||
private lateinit var coordinator: SyncCoordinator
|
private lateinit var coordinator: SyncCoordinator
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
@@ -109,7 +158,8 @@ class SyncCoordinatorTest {
|
|||||||
provider = FakeProvider()
|
provider = FakeProvider()
|
||||||
queue = FakeQueue()
|
queue = FakeQueue()
|
||||||
offloader = FakeOffloader()
|
offloader = FakeOffloader()
|
||||||
coordinator = SyncCoordinator(db.syncDao(), provider, remote, queue, offloader)
|
mirror = FakeMirror()
|
||||||
|
coordinator = SyncCoordinator(db.syncDao(), provider, remote, queue, offloader, mirror)
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
@@ -548,4 +598,244 @@ class SyncCoordinatorTest {
|
|||||||
|
|
||||||
assertEquals(listOf("b1"), db.syncDao().indexedAlbumIds())
|
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)
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user