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

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

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

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