feat(android): album/photo attribute-actions and device gallery sync fixes
Album panel and photo sheet with attributes/actions for every album (server and device), local album rename/cover/delete, EXIF attribute settings, and a unified device-gallery sync check: - add repository "Атрибуты и действия" panel (swipe left) on server and device albums: cover, info, navigation, rename/delete; album settings screen; photo sheet (swipe up) with info, tags and actions; EXIF attribute settings backed by profile preferences - local albums use local metadata and actions: app-only display name and cover (new device_album_meta table), delete removes photos from the phone via the system delete consent - unified mode x state policy (GallerySyncPolicy/SyncCheck) runs on start and on album open: upload/offload in move, restore originals in copy/off - fix move->copy restore, offload of any linked-but-local original, provenance recovery that no longer overwrites the album mode, serialized reconcile and duplicate-upload suppression; prompt for "all files" access on start - album card caption: name (with linked server name when different) and storage icons (memory card / cloud) with item count Sync delta specs into android-client and archive the change add-mobile-album-photo-actions. Verified: unit/Robolectric suite green, :app:assembleDebug, :app:lint, and the DeviceSyncE2ETest instrumented suite on the emulator (7/7).
This commit is contained in:
@@ -211,6 +211,101 @@ class DeviceSyncE2ETest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun switchingFromMoveToCopyRestoresTheOriginal() = runBlocking {
|
||||
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
|
||||
val container = app.container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val serverAlbum = E2E.createAlbum(token, "MoveToCopy ${System.currentTimeMillis()}")
|
||||
|
||||
val (uri, bucket) = insertImage()
|
||||
val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri))
|
||||
container.syncRepository.link(bucket, "MC", 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))
|
||||
|
||||
// Switch to copies and run the check: the original comes back.
|
||||
container.syncRepository.link(bucket, "MC", serverAlbum, SyncMode.COPY)
|
||||
container.syncCheck.checkGallery(bucket)
|
||||
assertTrue(
|
||||
"original should be restored on move→copy",
|
||||
waitForState(container, bucket, key, LocalAssetState.LOCAL),
|
||||
)
|
||||
|
||||
// The restored original must not be re-uploaded as a duplicate.
|
||||
container.syncCoordinator.reconcile(bucket)
|
||||
delay(2_000)
|
||||
assertEquals(1, E2E.listPhotos(token, serverAlbum).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unlinkedAlbumWithPreviewsRestoresOriginals() = runBlocking {
|
||||
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
|
||||
val container = app.container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val serverAlbum = E2E.createAlbum(token, "UnlinkRestore ${System.currentTimeMillis()}")
|
||||
|
||||
val (uri, bucket) = insertImage()
|
||||
val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri))
|
||||
container.syncRepository.link(bucket, "U", 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))
|
||||
|
||||
// Unlink (mode off): previews must still be restored, without touching the server.
|
||||
container.syncRepository.unlink(bucket)
|
||||
container.syncCheck.checkGallery(bucket)
|
||||
assertTrue(
|
||||
"originals should be restored when no longer moving",
|
||||
waitForState(container, bucket, key, LocalAssetState.LOCAL),
|
||||
)
|
||||
assertEquals(1, E2E.listPhotos(token, serverAlbum).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkRebuildsLostIndexFromMarker() = runBlocking {
|
||||
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
|
||||
val container = app.container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val serverAlbum = E2E.createAlbum(token, "LostIndex ${System.currentTimeMillis()}")
|
||||
|
||||
val (uri, bucket) = insertImage()
|
||||
val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri))
|
||||
container.syncRepository.link(bucket, "L", 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))
|
||||
|
||||
// Simulate a lost index/link: the marker in the preview file is the only trail.
|
||||
container.syncRepository.forgetAlbum(bucket)
|
||||
container.syncCheck.checkGallery(bucket, recoverIndex = true)
|
||||
|
||||
val link = waitForLink(container, bucket)
|
||||
assertTrue("link should be rebuilt from the marker", link != null)
|
||||
assertEquals(SyncMode.MOVE, SyncMode.from(link?.mode))
|
||||
assertTrue(
|
||||
"an offloaded asset stays offloaded in move mode",
|
||||
waitForState(container, bucket, key, LocalAssetState.PREVIEW),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun waitForLink(
|
||||
container: com.galo.android.di.AppContainer,
|
||||
bucket: String,
|
||||
): com.galo.android.data.db.entity.SyncedAlbumEntity? {
|
||||
val deadline = System.currentTimeMillis() + 10_000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val link = container.syncRepository.currentLink(bucket)
|
||||
if (link != null && link.serverAlbumId.isNotBlank()) return link
|
||||
delay(200)
|
||||
}
|
||||
return container.syncRepository.currentLink(bucket)
|
||||
}
|
||||
|
||||
private suspend fun waitForState(
|
||||
container: com.galo.android.di.AppContainer,
|
||||
bucket: String,
|
||||
|
||||
Reference in New Issue
Block a user