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().upsertAssets(listOf(asset))
|
||||
|
||||
val result = OffloadManager(context, db.syncDao()).offload(asset)
|
||||
val result = OffloadManager(context, db.syncDao()).offload(asset, setOf("photo1"))
|
||||
|
||||
assertEquals(OffloadResult.Done, result)
|
||||
// The file is still there but now a capped preview.
|
||||
@@ -156,7 +156,7 @@ class OffloadInstrumentedTest {
|
||||
db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L))
|
||||
db.syncDao().upsertAssets(listOf(asset))
|
||||
|
||||
val result = OffloadManager(context, db.syncDao()).offload(asset)
|
||||
val result = OffloadManager(context, db.syncDao()).offload(asset, setOf("photo1"))
|
||||
|
||||
assertEquals(OffloadResult.Done, result)
|
||||
assertTrue("video row should be gone", !exists(uri))
|
||||
@@ -185,7 +185,7 @@ class OffloadInstrumentedTest {
|
||||
val asset = assetOf(uri, bucket, "orig.jpg", "image/jpeg")
|
||||
val key = asset.assetKey
|
||||
db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L))
|
||||
assertEquals(OffloadResult.Done, OffloadManager(context, db.syncDao()).offload(asset))
|
||||
assertEquals(OffloadResult.Done, OffloadManager(context, db.syncDao()).offload(asset, setOf("photo1")))
|
||||
|
||||
// Simulate a reinstall: the app's index is gone.
|
||||
db.syncDao().clearAlbum(bucket)
|
||||
@@ -222,7 +222,7 @@ class OffloadInstrumentedTest {
|
||||
db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L))
|
||||
db.syncDao().upsertAssets(listOf(asset))
|
||||
|
||||
assertEquals(OffloadResult.Done, OffloadManager(context, db.syncDao()).offload(asset))
|
||||
assertEquals(OffloadResult.Done, OffloadManager(context, db.syncDao()).offload(asset, setOf("photo1")))
|
||||
assertEquals(1600 to 1200, dimensionsOf(uri))
|
||||
|
||||
val original = Bitmap.createBitmap(2400, 1800, Bitmap.Config.ARGB_8888)
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
package com.galo.android
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.galo.android.data.db.entity.DeviceAssetEntity
|
||||
import com.galo.android.data.sync.ContentHash
|
||||
import com.galo.android.di.AppContainer
|
||||
import com.galo.android.domain.media.MediaPermissions
|
||||
import com.galo.android.domain.model.LocalAssetState
|
||||
import com.galo.android.domain.model.SyncMode
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.RuleChain
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.Description
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.model.Statement
|
||||
|
||||
/**
|
||||
* Every synchronization mode (A/B/C) and the switches between them. Small, fast
|
||||
* and functional; the volume coverage lives in [SyncVolumeE2ETest]. Against a
|
||||
* running galo instance at 10.0.2.2:8088.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SyncModesE2ETest {
|
||||
|
||||
private val mediaPermissionRule: TestRule = object : TestRule {
|
||||
override fun apply(base: Statement, description: Description): Statement = object : Statement() {
|
||||
override fun evaluate() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val pkg = instrumentation.targetContext.packageName
|
||||
MediaPermissions.required(Build.VERSION.SDK_INT).forEach { permission ->
|
||||
runCatching { instrumentation.uiAutomation.grantRuntimePermission(pkg, permission) }
|
||||
}
|
||||
base.evaluate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val rules: TestRule = RuleChain.outerRule(mediaPermissionRule)
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var resolver: ContentResolver
|
||||
private val created = mutableListOf<Uri>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
resolver = context.contentResolver
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
created.forEach { runCatching { resolver.delete(it, null, null) } }
|
||||
}
|
||||
|
||||
private data class One(val uri: Uri, val bucket: String, val key: String, val bytes: ByteArray)
|
||||
|
||||
private fun insertImage(folder: String): One {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Images.Media.DISPLAY_NAME, "mode-${System.nanoTime()}.jpg")
|
||||
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||
put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/$folder-${System.nanoTime()}/")
|
||||
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||
}
|
||||
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!!
|
||||
val bitmap = Bitmap.createBitmap(320, 240, Bitmap.Config.ARGB_8888)
|
||||
bitmap.eraseColor(android.graphics.Color.rgb(30, 90, 200))
|
||||
resolver.openOutputStream(uri)!!.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
|
||||
bitmap.recycle()
|
||||
values.clear()
|
||||
values.put(MediaStore.Images.Media.IS_PENDING, 0)
|
||||
resolver.update(uri, values, null, null)
|
||||
created += uri
|
||||
val bucket = resolver.query(uri, arrayOf(MediaStore.Images.Media.BUCKET_ID), null, null, null)!!
|
||||
.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getString(0)
|
||||
}
|
||||
val bytes = resolver.openInputStream(uri)!!.use { it.readBytes() }
|
||||
return One(uri, bucket, DeviceAssetEntity.key(bucket, ContentUris.parseId(uri)), bytes)
|
||||
}
|
||||
|
||||
private suspend fun awaitState(
|
||||
container: AppContainer,
|
||||
bucket: String,
|
||||
key: String,
|
||||
state: LocalAssetState,
|
||||
timeoutMs: Long = 90_000,
|
||||
): Boolean {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val now = container.syncRepository.assets(bucket).firstOrNull { it.assetKey == key }?.state
|
||||
if (now == state.name) return true
|
||||
delay(500)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun awaitPhotos(container: AppContainer, album: String, minimum: Int): Int {
|
||||
val deadline = System.currentTimeMillis() + 90_000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val count = runCatching { container.syncRemote.serverPhotos(album).size }.getOrDefault(0)
|
||||
if (count >= minimum) return count
|
||||
delay(500)
|
||||
}
|
||||
return runCatching { container.syncRemote.serverPhotos(album).size }.getOrDefault(0)
|
||||
}
|
||||
|
||||
private fun localHash(uri: Uri): String = TestMedia.hash(resolver, uri)
|
||||
|
||||
// --- Mode A ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun disabledModeIsLocalOnly() = runBlocking {
|
||||
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
|
||||
E2E.configure(container)
|
||||
E2E.registerAuthenticated(container)
|
||||
|
||||
val item = insertImage("GaloModesA")
|
||||
assertNull("a disabled album has no link", container.syncRepository.currentLink(item.bucket))
|
||||
assertFalse("mode A must not contact the server", container.syncCoordinator.reconcile(item.bucket))
|
||||
assertEquals("the local original is untouched", localHash(item.uri), ContentHash.sha256(item.bytes.inputStream()))
|
||||
}
|
||||
|
||||
// --- Mode B ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun copyModeUploadsAndKeepsTheOriginal() = runBlocking {
|
||||
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val album = E2E.createAlbum(token, "B ${System.currentTimeMillis()}")
|
||||
|
||||
val item = insertImage("GaloModesB")
|
||||
container.syncRepository.link(item.bucket, "B", album, SyncMode.COPY)
|
||||
assertTrue(container.syncCoordinator.reconcile(item.bucket))
|
||||
|
||||
assertTrue("photo should reach the server", awaitPhotos(container, album, 1) >= 1)
|
||||
assertTrue("asset stays uploaded", awaitState(container, item.bucket, item.key, LocalAssetState.UPLOADED))
|
||||
assertEquals("the original stays on the device", localHash(item.uri), ContentHash.sha256(item.bytes.inputStream()))
|
||||
}
|
||||
|
||||
// --- Mode C ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun moveModeUploadsAndOffloads() = runBlocking {
|
||||
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val album = E2E.createAlbum(token, "C ${System.currentTimeMillis()}")
|
||||
|
||||
val item = insertImage("GaloModesC")
|
||||
container.syncRepository.link(item.bucket, "C", album, SyncMode.MOVE)
|
||||
assertTrue(container.syncCoordinator.reconcile(item.bucket))
|
||||
|
||||
assertTrue("photo should reach the server", awaitPhotos(container, album, 1) >= 1)
|
||||
assertTrue("the original is replaced by a preview", awaitState(container, item.bucket, item.key, LocalAssetState.PREVIEW))
|
||||
}
|
||||
|
||||
// --- Switch B -> C --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun copyToMoveOffloadsExistingUploads() = runBlocking {
|
||||
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val album = E2E.createAlbum(token, "B2C ${System.currentTimeMillis()}")
|
||||
|
||||
val item = insertImage("GaloModesB2C")
|
||||
container.syncRepository.link(item.bucket, "B", album, SyncMode.COPY)
|
||||
container.syncCoordinator.reconcile(item.bucket)
|
||||
assertTrue(awaitPhotos(container, album, 1) >= 1)
|
||||
assertTrue(awaitState(container, item.bucket, item.key, LocalAssetState.UPLOADED))
|
||||
|
||||
container.syncRepository.link(item.bucket, "B", album, SyncMode.MOVE)
|
||||
container.syncCheck.checkGallery(item.bucket)
|
||||
|
||||
assertTrue("switching to move offloads the upload", awaitState(container, item.bucket, item.key, LocalAssetState.PREVIEW))
|
||||
}
|
||||
|
||||
// --- Switch C -> B --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun moveToCopyRestoresTheOriginal() = runBlocking {
|
||||
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val album = E2E.createAlbum(token, "C2B ${System.currentTimeMillis()}")
|
||||
|
||||
val item = insertImage("GaloModesC2B")
|
||||
container.syncRepository.link(item.bucket, "C", album, SyncMode.MOVE)
|
||||
container.syncCoordinator.reconcile(item.bucket)
|
||||
assertTrue(awaitPhotos(container, album, 1) >= 1)
|
||||
assertTrue(awaitState(container, item.bucket, item.key, LocalAssetState.PREVIEW))
|
||||
|
||||
container.syncRepository.link(item.bucket, "C", album, SyncMode.COPY)
|
||||
container.syncCheck.checkGallery(item.bucket)
|
||||
|
||||
assertTrue("the original is restored locally", awaitState(container, item.bucket, item.key, LocalAssetState.LOCAL))
|
||||
assertEquals("the restored file is the original bytes", localHash(item.uri), ContentHash.sha256(item.bytes.inputStream()))
|
||||
}
|
||||
|
||||
// --- Switch to A (off), regression ---------------------------------------
|
||||
|
||||
@Test
|
||||
fun moveToOffStaysOffAfterRecovery() = runBlocking {
|
||||
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val album = E2E.createAlbum(token, "C2A ${System.currentTimeMillis()}")
|
||||
|
||||
val item = insertImage("GaloModesC2A")
|
||||
container.syncRepository.link(item.bucket, "C", album, SyncMode.MOVE)
|
||||
container.syncCoordinator.reconcile(item.bucket)
|
||||
assertTrue(awaitPhotos(container, album, 1) >= 1)
|
||||
assertTrue(awaitState(container, item.bucket, item.key, LocalAssetState.PREVIEW))
|
||||
|
||||
container.syncRepository.unlink(item.bucket)
|
||||
assertNull(container.syncRepository.currentLink(item.bucket))
|
||||
|
||||
container.syncCheck.checkGallery(item.bucket, recoverIndex = true)
|
||||
assertNull("turning off must be sticky", container.syncRepository.currentLink(item.bucket))
|
||||
assertTrue(container.syncRepository.isSyncDisabled(item.bucket))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun copyToOffStaysOffAfterRecovery() = runBlocking {
|
||||
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val album = E2E.createAlbum(token, "B2A ${System.currentTimeMillis()}")
|
||||
|
||||
val item = insertImage("GaloModesB2A")
|
||||
container.syncRepository.link(item.bucket, "B", album, SyncMode.COPY)
|
||||
container.syncCoordinator.reconcile(item.bucket)
|
||||
assertTrue(awaitPhotos(container, album, 1) >= 1)
|
||||
|
||||
container.syncRepository.unlink(item.bucket)
|
||||
container.syncCheck.checkGallery(item.bucket, recoverIndex = true)
|
||||
|
||||
assertNull(container.syncRepository.currentLink(item.bucket))
|
||||
assertTrue(container.syncRepository.isSyncDisabled(item.bucket))
|
||||
}
|
||||
|
||||
// --- Switch off -> B (re-enable) -----------------------------------------
|
||||
|
||||
@Test
|
||||
fun offToCopyUploadsAgain() = runBlocking {
|
||||
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val album = E2E.createAlbum(token, "A2B ${System.currentTimeMillis()}")
|
||||
|
||||
val item = insertImage("GaloModesA2B")
|
||||
container.syncRepository.unlink(item.bucket)
|
||||
assertTrue(container.syncRepository.isSyncDisabled(item.bucket))
|
||||
|
||||
container.syncRepository.link(item.bucket, "B", album, SyncMode.COPY)
|
||||
assertFalse(container.syncRepository.isSyncDisabled(item.bucket))
|
||||
container.syncCoordinator.reconcile(item.bucket)
|
||||
|
||||
assertTrue("re-enabled sync uploads again", awaitPhotos(container, album, 1) >= 1)
|
||||
}
|
||||
|
||||
// --- Reinstall-style recovery for a genuine setup -------------------------
|
||||
|
||||
@Test
|
||||
fun recoveryRebuildsLinkWhenSyncWasNotTurnedOff() = runBlocking {
|
||||
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val album = E2E.createAlbum(token, "Recover ${System.currentTimeMillis()}")
|
||||
|
||||
val item = insertImage("GaloModesRecover")
|
||||
container.syncRepository.link(item.bucket, "C", album, SyncMode.MOVE)
|
||||
container.syncCoordinator.reconcile(item.bucket)
|
||||
assertTrue(awaitPhotos(container, album, 1) >= 1)
|
||||
assertTrue(awaitState(container, item.bucket, item.key, LocalAssetState.PREVIEW))
|
||||
|
||||
container.syncRepository.forgetAlbum(item.bucket)
|
||||
assertNull(container.syncRepository.currentLink(item.bucket))
|
||||
|
||||
val recovered = container.syncRecovery.recover(item.bucket)
|
||||
assertTrue("markers must rebuild the link", recovered >= 1)
|
||||
assertNotNull(container.syncRepository.currentLink(item.bucket))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package com.galo.android
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.galo.android.data.db.entity.DeviceAssetEntity
|
||||
import com.galo.android.data.sync.ContentHash
|
||||
import com.galo.android.data.sync.ProvenanceStamp
|
||||
import com.galo.android.di.AppContainer
|
||||
import com.galo.android.domain.media.MediaPermissions
|
||||
import com.galo.android.domain.model.AssetOrigin
|
||||
import com.galo.android.domain.model.LocalAssetState
|
||||
import com.galo.android.domain.model.SyncMode
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.RuleChain
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.Description
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.model.Statement
|
||||
|
||||
/**
|
||||
* Dedicated volume coverage: 100 photos and 20 videos per test, both directions.
|
||||
*
|
||||
* - upload: the whole device album reaches the server through the drain queue;
|
||||
* - materialize: a server album of the same size lands on the device (previews in
|
||||
* move mode; server videos become posters).
|
||||
*
|
||||
* This suite is slow (every file is one HTTP request) — it intentionally lives
|
||||
* apart from the fast functional tests. Against a running galo at 10.0.2.2:8088.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SyncVolumeE2ETest {
|
||||
|
||||
private val mediaPermissionRule: TestRule = object : TestRule {
|
||||
override fun apply(base: Statement, description: Description): Statement = object : Statement() {
|
||||
override fun evaluate() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val pkg = instrumentation.targetContext.packageName
|
||||
MediaPermissions.required(Build.VERSION.SDK_INT).forEach { permission ->
|
||||
runCatching { instrumentation.uiAutomation.grantRuntimePermission(pkg, permission) }
|
||||
}
|
||||
base.evaluate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val rules: TestRule = RuleChain.outerRule(mediaPermissionRule)
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var resolver: ContentResolver
|
||||
private val created = mutableListOf<Uri>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
resolver = context.contentResolver
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
created.forEach { runCatching { resolver.delete(it, null, null) } }
|
||||
}
|
||||
|
||||
private val total = TestMedia.PHOTO_COUNT + TestMedia.VIDEO_COUNT
|
||||
|
||||
private fun anchor(folder: String): String {
|
||||
val values = android.content.ContentValues().apply {
|
||||
put(MediaStore.Images.Media.DISPLAY_NAME, "anchor-${System.nanoTime()}.jpg")
|
||||
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||
put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/$folder-${System.nanoTime()}/")
|
||||
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||
}
|
||||
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!!
|
||||
android.graphics.Bitmap.createBitmap(96, 96, android.graphics.Bitmap.Config.ARGB_8888).let {
|
||||
// A colour nothing else uses, so the anchor never dedups with a server photo.
|
||||
it.eraseColor(android.graphics.Color.rgb(7, 200, 13))
|
||||
resolver.openOutputStream(uri)!!.use { out -> it.compress(android.graphics.Bitmap.CompressFormat.JPEG, 85, out) }
|
||||
it.recycle()
|
||||
}
|
||||
values.clear()
|
||||
values.put(MediaStore.Images.Media.IS_PENDING, 0)
|
||||
resolver.update(uri, values, null, null)
|
||||
created += uri
|
||||
return resolver.query(uri, arrayOf(MediaStore.Images.Media.BUCKET_ID), null, null, null)!!
|
||||
.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getString(0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun countLocal(bucket: String, collection: Uri, prefix: String): Int =
|
||||
resolver.query(
|
||||
collection,
|
||||
arrayOf(MediaStore.MediaColumns._ID),
|
||||
"${MediaStore.MediaColumns.BUCKET_ID} = ? AND ${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ?",
|
||||
arrayOf(bucket, "$prefix%"),
|
||||
null,
|
||||
)?.use { it.count } ?: 0
|
||||
|
||||
private fun localUri(bucket: String, collection: Uri, name: String): Uri? =
|
||||
resolver.query(
|
||||
collection,
|
||||
arrayOf(MediaStore.MediaColumns._ID),
|
||||
"${MediaStore.MediaColumns.BUCKET_ID} = ? AND ${MediaStore.MediaColumns.DISPLAY_NAME} = ?",
|
||||
arrayOf(bucket, name),
|
||||
null,
|
||||
)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
android.content.ContentUris.withAppendedId(collection, cursor.getLong(0))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitServerCount(container: AppContainer, album: String, minimum: Int): Int {
|
||||
val deadline = System.currentTimeMillis() + 900_000
|
||||
var last = -1
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
last = runCatching { container.syncRemote.serverPhotos(album).size }.getOrDefault(-1)
|
||||
if (last >= minimum) return last
|
||||
delay(1_000)
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
private suspend fun awaitState(
|
||||
container: AppContainer,
|
||||
bucket: String,
|
||||
key: String,
|
||||
state: LocalAssetState,
|
||||
): Boolean {
|
||||
val deadline = System.currentTimeMillis() + 600_000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (container.syncRepository.assets(bucket).firstOrNull { it.assetKey == key }?.state == state.name) return true
|
||||
delay(1_000)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun materializeUntil(
|
||||
container: AppContainer,
|
||||
bucket: String,
|
||||
timeoutMs: Long = 480_000,
|
||||
done: () -> Boolean,
|
||||
) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
container.syncCoordinator.reconcile(bucket)
|
||||
if (done()) return
|
||||
delay(2_000)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun copyUploadsTheWholeDeviceAlbum() = runBlocking {
|
||||
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val album = E2E.createAlbum(token, "VolUp ${System.currentTimeMillis()}")
|
||||
|
||||
val seeded = TestMedia.seed(resolver, "GaloVolUp")
|
||||
created += seeded.uris
|
||||
val bucket = seeded.bucket(resolver)
|
||||
val first = seeded.photos.first()
|
||||
val key = DeviceAssetEntity.key(bucket, android.content.ContentUris.parseId(first))
|
||||
val bytes = resolver.openInputStream(first)!!.use { it.readBytes() }
|
||||
|
||||
container.syncRepository.link(bucket, "VolUp", album, SyncMode.COPY)
|
||||
assertTrue(container.syncCoordinator.reconcile(bucket))
|
||||
|
||||
assertEquals("all media must reach the server", total, awaitServerCount(container, album, total))
|
||||
assertTrue("assets stay uploaded", awaitState(container, bucket, key, LocalAssetState.UPLOADED))
|
||||
assertEquals(
|
||||
"the original stays on the device",
|
||||
ContentHash.sha256(bytes.inputStream()),
|
||||
TestMedia.hash(resolver, first),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun copyMaterializesServerVolume() = runBlocking {
|
||||
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val album = E2E.createAlbum(token, "VolDown ${System.currentTimeMillis()}")
|
||||
val bucket = anchor("GaloVolDown")
|
||||
TestMedia.uploadServerMedia(token, album)
|
||||
|
||||
container.syncRepository.link(bucket, "VolDown", album, SyncMode.COPY, mirrorFromCreatedAt = 0L)
|
||||
val images = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
val videos = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
val deadline = System.currentTimeMillis() + 480_000
|
||||
var pass = 0
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
container.syncCoordinator.reconcile(bucket)
|
||||
val p = countLocal(bucket, images, "srv-photo-")
|
||||
val v = countLocal(bucket, videos, "srv-video-")
|
||||
android.util.Log.i(
|
||||
"VolTest",
|
||||
"pass=${pass++} photos=$p videos=$v server=${runCatching { container.syncRemote.serverPhotos(album).size }.getOrDefault(-1)}",
|
||||
)
|
||||
if (p >= TestMedia.PHOTO_COUNT && v >= TestMedia.VIDEO_COUNT) break
|
||||
delay(2_000)
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
"all server photos must be materialized",
|
||||
countLocal(bucket, images, "srv-photo-") >= TestMedia.PHOTO_COUNT,
|
||||
)
|
||||
assertTrue(
|
||||
"all server videos must be materialized",
|
||||
countLocal(bucket, videos, "srv-video-") >= TestMedia.VIDEO_COUNT,
|
||||
)
|
||||
|
||||
val sample = localUri(bucket, images, "srv-photo-000.jpg")
|
||||
assertTrue("a sample server photo must exist locally", sample != null)
|
||||
assertTrue("materialized sample must be a mirror", ProvenanceStamp.read(context, sample!!)?.origin == AssetOrigin.MIRROR.name)
|
||||
|
||||
val mirrored = container.syncRepository.assets(bucket)
|
||||
.count { it.origin == AssetOrigin.MIRROR.name }
|
||||
assertTrue("materialized items must be recorded as mirrors", mirrored >= TestMedia.PHOTO_COUNT)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.galo.android
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import com.galo.android.data.sync.ContentHash
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* Shared media seeding for the dedicated volume test. Content is made unique per
|
||||
* index so the checksum-based deduplication does not collapse it.
|
||||
*/
|
||||
object TestMedia {
|
||||
|
||||
const val PHOTO_COUNT = 100
|
||||
const val VIDEO_COUNT = 20
|
||||
|
||||
/** A unique JPEG for [index] (distinct pixel colour, so distinct checksum). */
|
||||
fun jpeg(index: Int, size: Int = 96): ByteArray {
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val r = (index * 7) % 256
|
||||
val g = (index * 13) % 256
|
||||
val b = (index * 29) % 256
|
||||
bitmap.eraseColor(Color.rgb(r, g, b))
|
||||
val out = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, out)
|
||||
bitmap.recycle()
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
/** A unique video for [index] (bundled asset, distinct bytes each). */
|
||||
fun video(index: Int): ByteArray {
|
||||
val name = "video-%02d.mp4".format(index.coerceIn(1, VIDEO_COUNT))
|
||||
return androidx.test.platform.app.InstrumentationRegistry.getInstrumentation()
|
||||
.context.assets.open(name).use { input -> input.readBytes() }
|
||||
}
|
||||
|
||||
data class Seeded(val photos: List<Uri>, val videos: List<Uri>) {
|
||||
val uris: List<Uri> get() = photos + videos
|
||||
fun bucket(resolver: ContentResolver): String =
|
||||
resolver.query(photos.first(), arrayOf(MediaStore.Images.Media.BUCKET_ID), null, null, null)!!
|
||||
.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getString(0)
|
||||
}
|
||||
}
|
||||
|
||||
/** Inserts [PHOTO_COUNT] photos and [VIDEO_COUNT] videos into DCIM/[folder]. */
|
||||
fun seed(resolver: ContentResolver, folder: String): Seeded {
|
||||
// A unique bucket per call: leftovers from interrupted runs never leak in.
|
||||
val path = "DCIM/$folder-${System.nanoTime()}/"
|
||||
val photos = (0 until PHOTO_COUNT).map { index ->
|
||||
insertImage(resolver, path, "photo-%03d.jpg".format(index), jpeg(index))
|
||||
}
|
||||
val videos = (1..VIDEO_COUNT).map { index ->
|
||||
insertVideo(resolver, path, "video-%02d.mp4".format(index), video(index))
|
||||
}
|
||||
return Seeded(photos, videos)
|
||||
}
|
||||
|
||||
private fun insertImage(
|
||||
resolver: ContentResolver,
|
||||
relativePath: String,
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
): Uri {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Images.Media.DISPLAY_NAME, name)
|
||||
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||
put(MediaStore.Images.Media.RELATIVE_PATH, relativePath)
|
||||
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||
}
|
||||
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!!
|
||||
resolver.openOutputStream(uri)!!.use { it.write(bytes) }
|
||||
values.clear()
|
||||
values.put(MediaStore.Images.Media.IS_PENDING, 0)
|
||||
resolver.update(uri, values, null, null)
|
||||
return uri
|
||||
}
|
||||
|
||||
private fun insertVideo(
|
||||
resolver: ContentResolver,
|
||||
relativePath: String,
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
): Uri {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Video.Media.DISPLAY_NAME, name)
|
||||
put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
|
||||
put(MediaStore.Video.Media.RELATIVE_PATH, relativePath)
|
||||
put(MediaStore.Video.Media.IS_PENDING, 1)
|
||||
}
|
||||
val uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)!!
|
||||
resolver.openOutputStream(uri)!!.use { it.write(bytes) }
|
||||
values.clear()
|
||||
values.put(MediaStore.Video.Media.IS_PENDING, 0)
|
||||
resolver.update(uri, values, null, null)
|
||||
return uri
|
||||
}
|
||||
|
||||
fun hash(resolver: ContentResolver, uri: Uri): String =
|
||||
resolver.openInputStream(uri)!!.use { ContentHash.sha256(it) }
|
||||
|
||||
/** Uploads the full volume (photos + videos) straight to [album] on the server. */
|
||||
fun uploadServerMedia(token: String, album: String) {
|
||||
for (i in 0 until PHOTO_COUNT) {
|
||||
E2E.upload(token, album, "srv-photo-%03d.jpg".format(i), jpeg(i), "image/jpeg")
|
||||
}
|
||||
for (i in 1..VIDEO_COUNT) {
|
||||
E2E.upload(token, album, "srv-video-%02d.mp4".format(i), video(i), "video/mp4")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package com.galo.android
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.galo.android.data.sync.ProvenanceStamp
|
||||
import com.galo.android.domain.media.MediaPermissions
|
||||
import com.galo.android.domain.model.SyncMode
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.RuleChain
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.Description
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.model.Statement
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* End-to-end two-way synchronization: a photo added to the target server album
|
||||
* (as the web UI would) is materialized into the device album's MediaStore bucket
|
||||
* with provenance, and deletions propagate both ways. Small and fast; volume
|
||||
* coverage lives in [SyncVolumeE2ETest]. Against a running galo at 10.0.2.2:8088.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TwoWayMirrorE2ETest {
|
||||
|
||||
private val mediaPermissionRule: TestRule = object : TestRule {
|
||||
override fun apply(base: Statement, description: Description): Statement = object : Statement() {
|
||||
override fun evaluate() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val pkg = instrumentation.targetContext.packageName
|
||||
MediaPermissions.required(Build.VERSION.SDK_INT).forEach { permission ->
|
||||
runCatching { instrumentation.uiAutomation.grantRuntimePermission(pkg, permission) }
|
||||
}
|
||||
base.evaluate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val rules: TestRule = RuleChain.outerRule(mediaPermissionRule)
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var resolver: ContentResolver
|
||||
private val created = mutableListOf<Uri>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
resolver = context.contentResolver
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
created.forEach { runCatching { resolver.delete(it, null, null) } }
|
||||
}
|
||||
|
||||
/** Anchors the album bucket with one image and returns its bucket id. */
|
||||
private fun insertAnchor(): String {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Images.Media.DISPLAY_NAME, "anchor-${System.nanoTime()}.jpg")
|
||||
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||
put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/GaloTwoWay-${System.nanoTime()}/")
|
||||
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||
}
|
||||
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!!
|
||||
val bitmap = Bitmap.createBitmap(160, 120, Bitmap.Config.ARGB_8888)
|
||||
bitmap.eraseColor(android.graphics.Color.rgb(7, 200, 13))
|
||||
resolver.openOutputStream(uri)!!.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
|
||||
bitmap.recycle()
|
||||
values.clear()
|
||||
values.put(MediaStore.Images.Media.IS_PENDING, 0)
|
||||
resolver.update(uri, values, null, null)
|
||||
created += uri
|
||||
return resolver.query(uri, arrayOf(MediaStore.Images.Media.BUCKET_ID), null, null, null)!!
|
||||
.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getString(0)
|
||||
}
|
||||
}
|
||||
|
||||
/** JPEG with a capture date, so the server reports `taken_at`. */
|
||||
private fun datedJpeg(): ByteArray {
|
||||
val file = File(context.cacheDir, "web-${System.nanoTime()}.jpg")
|
||||
file.writeBytes(E2E.jpegBytes(128))
|
||||
ExifInterface(file).apply {
|
||||
setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, "2021:05:05 10:00:00")
|
||||
saveAttributes()
|
||||
}
|
||||
val bytes = file.readBytes()
|
||||
file.delete()
|
||||
return bytes
|
||||
}
|
||||
|
||||
private fun awaitLocalFile(bucket: String, name: String, timeoutMs: Long = 60_000): Uri? {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val uri = queryLocal(bucket, name)
|
||||
if (uri != null) {
|
||||
if (uri !in created) created += uri
|
||||
return uri
|
||||
}
|
||||
Thread.sleep(300)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun queryLocal(bucket: String, name: String): Uri? =
|
||||
resolver.query(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
arrayOf(MediaStore.Images.Media._ID),
|
||||
"${MediaStore.Images.Media.BUCKET_ID} = ? AND ${MediaStore.Images.Media.DISPLAY_NAME} = ?",
|
||||
arrayOf(bucket, name),
|
||||
null,
|
||||
)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
ContentUris.withAppendedId(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
cursor.getLong(0),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun dateTakenOf(uri: Uri): Long =
|
||||
resolver.query(uri, arrayOf(MediaStore.Images.Media.DATE_TAKEN), null, null, null)!!
|
||||
.use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else 0L }
|
||||
|
||||
@Test
|
||||
fun webPhotoIsMaterializedWithProvenanceAndDate() = runBlocking {
|
||||
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val album = E2E.createAlbum(token, "TwoWay ${System.currentTimeMillis()}")
|
||||
val bucket = insertAnchor()
|
||||
|
||||
E2E.upload(token, album, "web.jpg", datedJpeg(), "image/jpeg")
|
||||
val serverPhoto = E2E.listPhotos(token, album).first { it.originalFilename == "web.jpg" }
|
||||
|
||||
container.syncRepository.link(bucket, "TwoWay", album, SyncMode.COPY, mirrorFromCreatedAt = 0L)
|
||||
container.syncCoordinator.reconcile(bucket)
|
||||
|
||||
val local = awaitLocalFile(bucket, "web.jpg")
|
||||
assertNotNull("the server photo must be materialized into the album bucket", local)
|
||||
|
||||
val marker = ProvenanceStamp.read(context, local!!)
|
||||
assertNotNull("materialized file must carry a provenance marker", marker)
|
||||
assertTrue("origin must be MIRROR", marker!!.origin == "MIRROR")
|
||||
assertTrue("provenance must point at the server photo", marker.serverPhotoId == serverPhoto.id)
|
||||
|
||||
val exifDate = runCatching {
|
||||
resolver.openInputStream(local).use {
|
||||
if (it == null) null else ExifInterface(it).getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)
|
||||
}
|
||||
}.getOrNull()
|
||||
if (!serverPhoto.takenAt.isNullOrBlank()) {
|
||||
assertTrue(
|
||||
"capture date must be preserved on the device",
|
||||
dateTakenOf(local) > 0L || !exifDate.isNullOrBlank(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingTheLocalCopyKeepsTheServerPhotoAndRecreates() = runBlocking {
|
||||
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val album = E2E.createAlbum(token, "TwoWay del ${System.currentTimeMillis()}")
|
||||
val bucket = insertAnchor()
|
||||
|
||||
E2E.upload(token, album, "web.jpg", E2E.jpegBytes(128), "image/jpeg")
|
||||
val serverPhoto = E2E.listPhotos(token, album).first { it.originalFilename == "web.jpg" }
|
||||
container.syncRepository.link(bucket, "TwoWay", album, SyncMode.COPY, mirrorFromCreatedAt = 0L)
|
||||
container.syncCoordinator.reconcile(bucket)
|
||||
|
||||
val local = awaitLocalFile(bucket, "web.jpg")
|
||||
assertNotNull(local)
|
||||
|
||||
// The user deletes the downloaded copy from the gallery. The server photo
|
||||
// is not this client's upload, so it must survive; the local copy returns.
|
||||
resolver.delete(local!!, null, null)
|
||||
container.syncCoordinator.reconcile(bucket)
|
||||
|
||||
assertTrue(
|
||||
"a lost local mirror must not delete a server photo",
|
||||
runCatching { container.syncRemote.serverPhotos(album) }.getOrDefault(emptyList())
|
||||
.any { it.id == serverPhoto.id },
|
||||
)
|
||||
assertNotNull("the materialized copy must be recreated", awaitLocalFile(bucket, "web.jpg"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingOnTheServerRemovesTheLocalCopy() = runBlocking {
|
||||
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val album = E2E.createAlbum(token, "TwoWay srv ${System.currentTimeMillis()}")
|
||||
val bucket = insertAnchor()
|
||||
|
||||
E2E.upload(token, album, "web.jpg", E2E.jpegBytes(128), "image/jpeg")
|
||||
val serverPhoto = E2E.listPhotos(token, album).first { it.originalFilename == "web.jpg" }
|
||||
container.syncRepository.link(bucket, "TwoWay", album, SyncMode.COPY, mirrorFromCreatedAt = 0L)
|
||||
container.syncCoordinator.reconcile(bucket)
|
||||
|
||||
val local = awaitLocalFile(bucket, "web.jpg")
|
||||
assertNotNull(local)
|
||||
|
||||
E2E.deletePhoto(token, serverPhoto.id)
|
||||
|
||||
var remaining: Uri? = queryLocal(bucket, "web.jpg")
|
||||
val deadline = System.currentTimeMillis() + 30_000
|
||||
while (System.currentTimeMillis() < deadline && remaining != null) {
|
||||
container.syncCoordinator.reconcile(bucket)
|
||||
remaining = queryLocal(bucket, "web.jpg")
|
||||
if (remaining != null) delay(300)
|
||||
}
|
||||
assertNull("a server deletion must remove the local materialized file", remaining)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun moveMaterializesVideoAsPoster() = runBlocking {
|
||||
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val album = E2E.createAlbum(token, "TwoWay vid ${System.currentTimeMillis()}")
|
||||
val bucket = insertAnchor()
|
||||
|
||||
E2E.upload(token, album, "vid.mp4", TestMedia.video(1), "video/mp4")
|
||||
container.syncRepository.link(bucket, "TwoWay", album, SyncMode.MOVE, mirrorFromCreatedAt = 0L)
|
||||
|
||||
var local = queryLocal(bucket, "vid-preview.jpg")
|
||||
val deadline = System.currentTimeMillis() + 60_000
|
||||
while (System.currentTimeMillis() < deadline && local == null) {
|
||||
container.syncCoordinator.reconcile(bucket)
|
||||
local = queryLocal(bucket, "vid-preview.jpg")
|
||||
if (local != null) created += local
|
||||
delay(500)
|
||||
}
|
||||
assertNotNull("a server video must become a poster in move mode", local)
|
||||
|
||||
val mirrored = container.syncRepository.assets(bucket)
|
||||
.any { it.origin == "MIRROR" && it.state == "PREVIEW" }
|
||||
assertTrue("the poster must be recorded as a mirror preview", mirrored)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitDeleteOfAMirrorRemovesTheServerPhoto() = runBlocking {
|
||||
val container = ApplicationProvider.getApplicationContext<GaloApplication>().container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val album = E2E.createAlbum(token, "TwoWay exp ${System.currentTimeMillis()}")
|
||||
val bucket = insertAnchor()
|
||||
|
||||
E2E.upload(token, album, "web.jpg", E2E.jpegBytes(128), "image/jpeg")
|
||||
val serverPhoto = E2E.listPhotos(token, album).first { it.originalFilename == "web.jpg" }
|
||||
container.syncRepository.link(bucket, "TwoWay", album, SyncMode.COPY, mirrorFromCreatedAt = 0L)
|
||||
container.syncCoordinator.reconcile(bucket)
|
||||
|
||||
val local = awaitLocalFile(bucket, "web.jpg")
|
||||
assertNotNull(local)
|
||||
|
||||
// An explicit in-app deletion: mark the intent and remove the local copy.
|
||||
container.syncRepository.markExplicitDelete(bucket, serverPhoto.id)
|
||||
resolver.delete(local!!, null, null)
|
||||
container.syncCoordinator.reconcile(bucket)
|
||||
|
||||
assertTrue(
|
||||
"an explicit in-app deletion must remove the server photo",
|
||||
awaitServerPhotoGone(container, album, serverPhoto.id),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun awaitServerPhotoGone(
|
||||
container: com.galo.android.di.AppContainer,
|
||||
album: String,
|
||||
photoId: String,
|
||||
): Boolean {
|
||||
val deadline = System.currentTimeMillis() + 20_000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val alive = runCatching { container.syncRemote.serverPhotos(album) }.getOrDefault(emptyList())
|
||||
.any { it.id == photoId }
|
||||
if (!alive) return true
|
||||
delay(300)
|
||||
}
|
||||
return runCatching { container.syncRemote.serverPhotos(album) }.getOrDefault(emptyList())
|
||||
.none { it.id == photoId }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user