feat(android): sync device albums with a server album

Add phone album sync to the Android client: MediaStore source (device
albums), a Room index with upload provenance, on-device previews, and a
mixed server + device album list where a linked pair collapses into a
single device card with a link indicator and mode label.

Three modes over one pipeline: A (off), B (upload copies, originals stay
local), C (upload originals and offload them locally to previews). The
sync engine tracks device changes (add/remove/rename/move/content), matches
assets to server photos by sha256 content hash, deletes only this client's
uploads, and reflects server changes without touching local originals.
Offload is automatic; the zoomed viewer pulls the server original.

Backend: expose the original content checksum in photo data (Postgres and
SQLite), additive with no migration.

Also anchor the `.gitignore` `data/` rule to `/data/` so the Android
`com.galo.android.data` package is tracked, and add the halo launcher icon.
This commit is contained in:
iu
2026-10-13 17:10:47 +03:00
parent b7316f9e00
commit 38b5b03f59
102 changed files with 7199 additions and 153 deletions
@@ -0,0 +1,232 @@
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.provider.MediaStore
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import android.util.Log
import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.domain.model.LocalAssetState
import com.galo.android.domain.model.SyncMode
import com.galo.android.ui.devicealbum.DeviceAlbumViewModel
import com.galo.android.ui.devicealbum.OriginalSource
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* End-to-end device-album synchronization against a running galo instance at
* 10.0.2.2:8088: a device album is linked to a server album, mode B uploads its
* content through the real queue, and a second pass does not duplicate it.
*/
@RunWith(AndroidJUnit4::class)
class DeviceSyncE2ETest {
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(): Pair<Uri, String> {
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "sync-${System.nanoTime()}.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/GaloSync/")
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)
resolver.openOutputStream(uri)!!.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
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 copyModeUploadsDeviceAlbumAndDeduplicates() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
Log.i(TAG_LOG, "configure")
E2E.configure(container)
Log.i(TAG_LOG, "register")
val token = E2E.registerAuthenticated(container)
Log.i(TAG_LOG, "createAlbum")
val serverAlbum = E2E.createAlbum(token, "Device sync ${System.currentTimeMillis()}")
Log.i(TAG_LOG, "insert")
val (_, bucket) = insertImage()
Log.i(TAG_LOG, "link")
container.syncRepository.link(bucket, "Sync", serverAlbum, SyncMode.COPY)
Log.i(TAG_LOG, "reconcile")
val contacted = withTimeout(30_000) { container.syncCoordinator.reconcile(bucket) }
Log.i(TAG_LOG, "reconcile=$contacted")
assertTrue("reconcile should contact the server", contacted)
Log.i(TAG_LOG, "await photos")
val uploaded = awaitPhotos(token, serverAlbum, minimum = 1)
Log.i(TAG_LOG, "uploaded=$uploaded")
assertTrue("device photo should reach the server", uploaded >= 1)
// A second pass must not duplicate content already on the server.
Log.i(TAG_LOG, "second reconcile")
container.syncCoordinator.reconcile(bucket)
delay(2_000)
val afterSecond = E2E.listPhotos(token, serverAlbum).size
Log.i(TAG_LOG, "afterSecond=$afterSecond")
assertEquals(uploaded, afterSecond)
Log.i(TAG_LOG, "done")
Unit
}
private suspend fun awaitPhotos(token: String, albumId: String, minimum: Int): Int {
val deadline = System.currentTimeMillis() + 60_000
while (System.currentTimeMillis() < deadline) {
val count = E2E.listPhotos(token, albumId).size
if (count >= minimum) return count
delay(500)
}
return E2E.listPhotos(token, albumId).size
}
@Test
fun moveModeUploadsThenOffloadsTheOriginal() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val serverAlbum = E2E.createAlbum(token, "Move ${System.currentTimeMillis()}")
val (uri, bucket) = insertImage()
val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri))
container.syncRepository.link(bucket, "Move", serverAlbum, SyncMode.MOVE)
assertTrue("reconcile should contact the server", container.syncCoordinator.reconcile(bucket))
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
// The original is offloaded automatically right after the upload.
val offloaded = waitForState(container, bucket, key, LocalAssetState.PREVIEW)
assertTrue("asset should be offloaded automatically", offloaded)
}
@Test
fun switchingFromCopiesToMoveOffloadsExistingUploads() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val serverAlbum = E2E.createAlbum(token, "CopyThenMove ${System.currentTimeMillis()}")
val (uri, bucket) = insertImage()
val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri))
// First, copies: upload while keeping the original.
container.syncRepository.link(bucket, "C", serverAlbum, SyncMode.COPY)
assertTrue(container.syncCoordinator.reconcile(bucket))
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
assertTrue("upload should be recorded", waitForState(container, bucket, key, LocalAssetState.UPLOADED))
// Switch to move: the already-uploaded asset is offloaded.
container.syncRepository.link(bucket, "C", serverAlbum, SyncMode.MOVE)
assertTrue(container.syncCoordinator.reconcile(bucket))
assertTrue(
"existing upload should be offloaded on switching to move",
waitForState(container, bucket, key, LocalAssetState.PREVIEW),
)
}
@Test
fun offloadedAssetExposesTheServerOriginalForZoom() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val serverAlbum = E2E.createAlbum(token, "Zoom ${System.currentTimeMillis()}")
val (uri, bucket) = insertImage()
val mediaId = ContentUris.parseId(uri)
val key = DeviceAssetEntity.key(bucket, mediaId)
container.syncRepository.link(bucket, "Z", serverAlbum, SyncMode.MOVE)
assertTrue(container.syncCoordinator.reconcile(bucket))
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW))
val viewModel = DeviceAlbumViewModel(container, bucket)
val collector = launch { viewModel.state.collect { } }
try {
var source: OriginalSource? = null
val deadline = System.currentTimeMillis() + 8_000
while (System.currentTimeMillis() < deadline) {
source = viewModel.originalSource(mediaId)
if (source != null) break
delay(200)
}
assertNotNull("an offloaded asset must expose the server original", source)
assertTrue("the natural size must be known", source!!.width > 0 && source!!.height > 0)
// The URL must actually be reachable and return the original bytes.
val bytes = okhttp3.OkHttpClient().newCall(
okhttp3.Request.Builder().url(source!!.url).build(),
).execute().use { response ->
assertTrue("original should be fetched, HTTP ${response.code}", response.isSuccessful)
response.body?.bytes()?.size ?: 0
}
assertTrue("original should not be empty", bytes > 0)
} finally {
collector.cancel()
}
}
private suspend fun waitForState(
container: com.galo.android.di.AppContainer,
bucket: String,
key: String,
state: LocalAssetState,
): Boolean {
val deadline = System.currentTimeMillis() + 30_000
while (System.currentTimeMillis() < deadline) {
val asset = container.syncRepository.assets(bucket).firstOrNull { it.assetKey == key }
if (asset?.state == state.name) return true
delay(300)
}
return false
}
private companion object {
const val TAG_LOG = "GaloSyncE2E"
}
}
@@ -66,8 +66,7 @@ object E2E {
}
}
fun createAlbum(token: String, title: String): String {
val payload = """{"title":"$title"}""".toRequestBody("application/json".toMediaType())
fun createAlbum(token: String, title: String): String { val payload = """{"title":"$title"}""".toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$BASE_URL/api/albums")
.header("Authorization", "Bearer $token")
@@ -79,8 +78,7 @@ object E2E {
}
}
fun upload(token: String, albumId: String, filename: String, bytes: ByteArray, mime: String) {
val multipart = MultipartBody.Builder()
fun upload(token: String, albumId: String, filename: String, bytes: ByteArray, mime: String) { val multipart = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("files", filename, bytes.toRequestBody(mime.toMediaType()))
.build()
@@ -95,6 +93,17 @@ object E2E {
}
}
fun listPhotos(token: String, albumId: String): List<PhotoDto> {
val request = Request.Builder()
.url("$BASE_URL/api/albums/$albumId/photos?limit=50")
.header("Authorization", "Bearer $token")
.build()
client.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
return GaloJson.decodeFromString<com.galo.android.data.network.dto.PhotoListDto>(body).photos
}
}
fun awaitPhotoWithPlayback(token: String, albumId: String, timeoutMs: Long): PhotoDto {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
@@ -22,6 +22,7 @@ import com.galo.android.ui.album.TAG_PHOTO_PREFIX
import com.galo.android.ui.albums.TAG_ALBUM_CARD_PREFIX
import com.galo.android.ui.albums.TAG_ALBUMS_GRID
import com.galo.android.ui.auth.TAG_AUTH_EMAIL
import com.galo.android.ui.auth.TAG_AUTH_IDENTIFIER
import com.galo.android.ui.auth.TAG_AUTH_NAME
import com.galo.android.ui.auth.TAG_AUTH_PASSWORD
import com.galo.android.ui.auth.TAG_AUTH_SUBMIT
@@ -70,7 +71,7 @@ class E2EFlowTest {
// Registration.
compose.waitUntil(TIMEOUT) {
compose.onAllNodesWithTag(TAG_AUTH_EMAIL).fetchSemanticsNodes().isNotEmpty()
compose.onAllNodesWithTag(TAG_AUTH_IDENTIFIER).fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithText("Создать аккаунт").performClick()
compose.waitUntil(TIMEOUT) {
@@ -0,0 +1,69 @@
package com.galo.android
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.galo.android.domain.model.SyncMode
import com.galo.android.ui.devicealbum.DeviceAlbumViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* Choosing a synchronization mode must persist even before a server album is
* picked, so leaving and reopening the album keeps the choice.
*/
@RunWith(AndroidJUnit4::class)
class ModePersistenceTest {
@Test
fun turningSyncOffClearsTheLinkedAlbum() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
val albumId = "off-${System.currentTimeMillis()}"
val viewModel = DeviceAlbumViewModel(container, albumId)
try {
container.syncRepository.link(albumId, "Camera", "srv1", SyncMode.COPY)
viewModel.setMode(SyncMode.DISABLED)
var cleared = false
val deadline = System.currentTimeMillis() + 5_000
while (System.currentTimeMillis() < deadline) {
if (container.syncRepository.currentLink(albumId) == null) {
cleared = true
break
}
delay(100)
}
assertTrue("switching sync off should clear the linked album", cleared)
} finally {
container.syncRepository.unlink(albumId)
}
}
@Test
fun modePersistsBeforeTargetChosen() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
val albumId = "persist-${System.currentTimeMillis()}"
val viewModel = DeviceAlbumViewModel(container, albumId)
try {
viewModel.setMode(SyncMode.COPY)
var stored: SyncMode? = null
val deadline = System.currentTimeMillis() + 5_000
while (System.currentTimeMillis() < deadline) {
stored = container.syncRepository.currentLink(albumId)?.let { SyncMode.from(it.mode) }
if (stored == SyncMode.COPY) break
delay(100)
}
assertEquals(SyncMode.COPY, stored)
} finally {
container.syncRepository.unlink(albumId)
}
}
}
@@ -0,0 +1,226 @@
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.graphics.BitmapFactory
import android.net.Uri
import android.provider.MediaStore
import androidx.room.Room
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.GaloDatabase
import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.data.db.entity.SyncedAlbumEntity
import com.galo.android.data.media.MediaStoreDeviceSource
import com.galo.android.data.sync.OffloadManager
import com.galo.android.data.sync.OffloadResult
import com.galo.android.data.sync.OriginalFetcher
import com.galo.android.data.sync.ProvenanceStamp
import com.galo.android.data.sync.RestoreManager
import com.galo.android.data.sync.SyncRecovery
import com.galo.android.domain.model.LocalAssetState
import com.galo.android.domain.model.SyncMode
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.ByteArrayOutputStream
@RunWith(AndroidJUnit4::class)
class OffloadInstrumentedTest {
private lateinit var context: Context
private lateinit var resolver: ContentResolver
private lateinit var db: GaloDatabase
private val created = mutableListOf<Uri>()
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
resolver = context.contentResolver
db = Room.inMemoryDatabaseBuilder(context, GaloDatabase::class.java)
.allowMainThreadQueries()
.build()
}
@After
fun tearDown() {
created.forEach { runCatching { resolver.delete(it, null, null) } }
db.close()
}
private fun bucketOf(uri: Uri): String =
resolver.query(uri, arrayOf(MediaStore.MediaColumns.BUCKET_ID), null, null, null)!!.use { cursor ->
cursor.moveToFirst()
cursor.getString(0)
}
private fun sizeOf(uri: Uri): Long =
resolver.openInputStream(uri)!!.use { it.readBytes().size.toLong() }
private fun dimensionsOf(uri: Uri): Pair<Int, Int> =
resolver.openInputStream(uri)!!.use { stream ->
val bitmap = BitmapFactory.decodeStream(stream)
bitmap.width to bitmap.height
}
private fun exists(uri: Uri): Boolean =
resolver.query(uri, arrayOf(MediaStore.MediaColumns._ID), null, null, null)!!.use { it.count > 0 }
private fun insertImage(width: Int, height: Int): Pair<Uri, String> {
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "orig-${System.nanoTime()}.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/GaloTest/")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!!
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
resolver.openOutputStream(uri)!!.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
values.clear()
values.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(uri, values, null, null)
created += uri
return uri to bucketOf(uri)
}
private fun insertVideo(): Pair<Uri, String> {
val values = ContentValues().apply {
put(MediaStore.Video.Media.DISPLAY_NAME, "clip-${System.nanoTime()}.mp4")
put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
put(MediaStore.Video.Media.RELATIVE_PATH, "DCIM/GaloTest/")
put(MediaStore.Video.Media.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)!!
val bytes = InstrumentationRegistry.getInstrumentation().context.assets
.open("test.mp4").use { it.readBytes() }
resolver.openOutputStream(uri)!!.use { it.write(bytes) }
values.clear()
values.put(MediaStore.Video.Media.IS_PENDING, 0)
resolver.update(uri, values, null, null)
created += uri
return uri to bucketOf(uri)
}
private fun assetOf(uri: Uri, bucket: String, name: String, mime: String): DeviceAssetEntity {
val id = ContentUris.parseId(uri)
return DeviceAssetEntity(
assetKey = DeviceAssetEntity.key(bucket, id),
deviceAlbumId = bucket,
mediaStoreId = id,
uri = uri.toString(),
displayName = name,
mimeType = mime,
size = sizeOf(uri),
dateModified = 0L,
contentHash = "hash",
serverPhotoId = "photo1",
uploadedAt = null,
state = LocalAssetState.AWAITING_FREE.name,
)
}
@Test
fun offloadReplacesOriginalWithPreviewAndStampsProvenance() = runBlocking {
val (uri, bucket) = insertImage(2000, 1500)
val asset = assetOf(uri, bucket, "orig.jpg", "image/jpeg")
db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L))
db.syncDao().upsertAssets(listOf(asset))
val result = OffloadManager(context, db.syncDao()).offload(asset)
assertEquals(OffloadResult.Done, result)
// The file is still there but now a capped preview.
assertEquals(1600 to 1200, dimensionsOf(uri))
// Provenance travels with the file.
val marker = ProvenanceStamp.read(context, uri)
assertNotNull(marker)
assertEquals("photo1", marker?.serverPhotoId)
assertEquals("srvX", marker?.serverAlbumId)
assertEquals(LocalAssetState.PREVIEW.name, db.syncDao().getAsset(asset.assetKey)?.state)
}
@Test
fun offloadVideoReplacesItWithAPosterInTheSameFolder() = runBlocking {
val (uri, bucket) = insertVideo()
val asset = assetOf(uri, bucket, "clip.mp4", "video/mp4")
db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L))
db.syncDao().upsertAssets(listOf(asset))
val result = OffloadManager(context, db.syncDao()).offload(asset)
assertEquals(OffloadResult.Done, result)
assertTrue("video row should be gone", !exists(uri))
val posters = mutableListOf<String>()
resolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME),
"${MediaStore.Images.Media.RELATIVE_PATH} LIKE ?",
arrayOf("DCIM/GaloTest/%"),
null,
)!!.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
posters += cursor.getString(1)
created += ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
}
}
assertTrue("expected a poster image", posters.any { it.endsWith("-preview.jpg") })
assertEquals(LocalAssetState.PREVIEW.name, db.syncDao().getAsset(asset.assetKey)?.state)
}
@Test
fun recoveryRebuildsLinkAndProvenanceFromMarkers() = runBlocking {
val (uri, bucket) = insertImage(1000, 800)
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))
// Simulate a reinstall: the app's index is gone.
db.syncDao().clearAlbum(bucket)
db.syncDao().deleteSyncedAlbum(bucket)
val recovered = SyncRecovery(context, db.syncDao(), MediaStoreDeviceSource(context))
.recover(bucket)
assertTrue("expected at least one recovered asset", recovered >= 1)
assertEquals("photo1", db.syncDao().getAsset(key)?.serverPhotoId)
assertEquals("srvX", db.syncDao().getSyncedAlbum(bucket)?.serverAlbumId)
}
@Test
fun restoreWritesTheOriginalBackOverThePreview() = runBlocking {
val (uri, bucket) = insertImage(2400, 1800)
val asset = assetOf(uri, bucket, "orig.jpg", "image/jpeg")
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(1600 to 1200, dimensionsOf(uri))
val original = Bitmap.createBitmap(2400, 1800, Bitmap.Config.ARGB_8888)
val bytes = ByteArrayOutputStream().use { out ->
original.compress(Bitmap.CompressFormat.JPEG, 90, out)
out.toByteArray()
}
val fetcher = object : OriginalFetcher {
override suspend fun fetch(photoId: String): ByteArray = bytes
}
val report = RestoreManager(context, db.syncDao(), fetcher).restore(listOf(asset))
assertEquals(1, report.restored)
assertEquals(2400 to 1800, dimensionsOf(uri))
assertEquals(LocalAssetState.LOCAL.name, db.syncDao().getAsset(asset.assetKey)?.state)
}
}
@@ -0,0 +1,97 @@
package com.galo.android
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.galo.android.domain.model.SyncMode
import com.galo.android.ui.devicealbum.DeviceAlbumViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* Choosing "copies on the server" must offer the server albums as targets, and
* picking one must persist the link (mode + target).
*/
@RunWith(AndroidJUnit4::class)
class TargetSelectionTest {
@Test
fun choosingCopyThenTargetPersistsTheLink() = runBlocking { val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val serverAlbum = E2E.createAlbum(token, "Target ${System.currentTimeMillis()}")
val bucket = "target-${System.currentTimeMillis()}"
val viewModel = DeviceAlbumViewModel(container, bucket)
var targetsLoaded = false
val collector = launch {
viewModel.state.collect { state ->
if (state.serverAlbums.any { it.id == serverAlbum }) targetsLoaded = true
}
}
try {
waitFor("server albums offered") { targetsLoaded }
viewModel.setMode(SyncMode.COPY)
waitFor("mode persisted") {
container.syncRepository.currentLink(bucket)?.let { SyncMode.from(it.mode) } == SyncMode.COPY
}
viewModel.setTarget(serverAlbum)
waitFor("target persisted") {
container.syncRepository.currentLink(bucket)?.serverAlbumId == serverAlbum
}
val link = container.syncRepository.currentLink(bucket)
assertEquals(SyncMode.COPY, link?.let { SyncMode.from(it.mode) })
assertEquals(serverAlbum, link?.serverAlbumId)
assertTrue(targetsLoaded)
} finally {
collector.cancel()
container.syncRepository.unlink(bucket)
}
}
@Test
fun creatingANewAlbumLinksAndSyncs() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
E2E.registerAuthenticated(container)
val bucket = "create-${System.currentTimeMillis()}"
val viewModel = DeviceAlbumViewModel(container, bucket)
val collector = launch { viewModel.state.collect { } }
try {
viewModel.setMode(SyncMode.COPY)
val title = "Созданный ${System.currentTimeMillis()}"
viewModel.createAlbum(title)
waitFor("link created") {
container.syncRepository.currentLink(bucket)?.serverAlbumId?.isNotBlank() == true
}
val link = container.syncRepository.currentLink(bucket)
assertEquals(SyncMode.COPY, link?.let { SyncMode.from(it.mode) })
assertTrue("created album should exist on the server", container.api.albums().any { it.title == title })
} finally {
collector.cancel()
container.syncRepository.unlink(bucket)
}
}
private suspend fun waitFor(what: String, condition: suspend () -> Boolean) { val deadline = System.currentTimeMillis() + 8_000
while (System.currentTimeMillis() < deadline) {
if (condition()) return
delay(100)
}
throw AssertionError("timed out waiting for: $what")
}
}