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:
iu
2026-10-13 21:49:12 +03:00
parent d1bb964685
commit fa210e6c7f
75 changed files with 6094 additions and 167 deletions
@@ -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,