diff --git a/android/README.md b/android/README.md index 6dbda6e..4d2cdeb 100644 --- a/android/README.md +++ b/android/README.md @@ -100,6 +100,36 @@ While an album screen is active the client holds `GET /api/sse/albums/:id?token=` and backfills missed changes via `GET /api/albums/:id/photos?since=` after a reconnect. +## Attributes and actions + +The mobile client mirrors the web workspace's attribute/action management. + +- **Album panel.** Swiping right-to-left on any album screen (server or device) + opens the «Атрибуты и действия» panel with the sections «Обложка», «Информация», + «Навигация» and «Действия» (rename, delete). Cover and navigation are limited to + owner/admin. For a device album linked to a server album, the panel shows the + linked server album's real attributes; an unlinked device album shows local + values (cover, name, item count) without the navigation section. +- **Photo sheet.** Swiping up in full-screen viewing (server or device) opens the + «Атрибуты и действия» sheet with «Информация», «Метки» and «Действия» (download, + move, copy, set as cover, delete) plus rename by tapping the file name. The sheet + does not open while an image is zoomed, a panorama rotates, or video controls are + in use. For device media linked to a server photo, the sheet shows that photo's + server attributes and actions; otherwise it shows local properties. +- **Attribute settings.** The «Информация» section reaches a screen listing the EXIF + attributes with visibility checkboxes and label fields («отметить все»/«снять + все»); the choice is stored in the profile's `photo_info` preferences, using the + same shape as web, so the two clients stay in sync. + +API operations backing these panels (all already served by the backend): + +- `PATCH /api/albums/:id` (`title`, `description`, `cover_photo_id` / `clear_cover`), + `DELETE /api/albums/:id`, `GET /api/albums/:id/members`, + `GET|POST|DELETE /api/albums/:id/share` +- `POST /api/photo-batch/copy`, `POST|DELETE /api/photos/:id/tags`, `GET /api/tags?q=` +- `PATCH /api/auth/me/preferences`; account payload `GET /api/auth/me` and photo + payloads expose `preferences`, `exif`, `tags`, `uploader` and `download_url`. + ## Device albums and synchronization The album list mixes the server's albums with the device's own albums (MediaStore @@ -152,7 +182,9 @@ Unit tests cover URL resolution, error mapping, token refresh, the offline cache/repository fallback, page merging, the object cache key, SSE event parsing/reconnect, HEIC detection, and the panorama camera math. Robolectric Compose tests cover the albums grid/offline banner, the viewer swipe navigation, -and upload statuses. +and upload statuses. Request-level tests cover the album/tag/preferences +operations behind the attribute/action panels, and DTO tests cover the rich photo +and account payloads (`exif`, `tags`, `uploader`, `preferences`). Device-only checks (real server E2E, Wi-Fi-only gating, background continuation, HEIC conversion of a real asset) require a device or emulator. diff --git a/android/app/src/androidTest/java/com/galo/android/DeviceSyncE2ETest.kt b/android/app/src/androidTest/java/com/galo/android/DeviceSyncE2ETest.kt index 3080b5c..953d734 100644 --- a/android/app/src/androidTest/java/com/galo/android/DeviceSyncE2ETest.kt +++ b/android/app/src/androidTest/java/com/galo/android/DeviceSyncE2ETest.kt @@ -211,6 +211,101 @@ class DeviceSyncE2ETest { } } + @Test + fun switchingFromMoveToCopyRestoresTheOriginal() = runBlocking { + val app = ApplicationProvider.getApplicationContext() + 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() + 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() + 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, diff --git a/android/app/src/main/java/com/galo/android/data/db/GaloDatabase.kt b/android/app/src/main/java/com/galo/android/data/db/GaloDatabase.kt index 5b4f2e4..91f6e7c 100644 --- a/android/app/src/main/java/com/galo/android/data/db/GaloDatabase.kt +++ b/android/app/src/main/java/com/galo/android/data/db/GaloDatabase.kt @@ -9,6 +9,7 @@ import com.galo.android.data.db.dao.PhotoDao import com.galo.android.data.db.dao.SyncDao import com.galo.android.data.db.dao.UploadDao import com.galo.android.data.db.entity.AlbumEntity +import com.galo.android.data.db.entity.DeviceAlbumMetaEntity import com.galo.android.data.db.entity.DeviceAssetEntity import com.galo.android.data.db.entity.PhotoEntity import com.galo.android.data.db.entity.SyncedAlbumEntity @@ -21,11 +22,11 @@ import com.galo.android.data.db.entity.UploadJobEntity UploadJobEntity::class, SyncedAlbumEntity::class, DeviceAssetEntity::class, + DeviceAlbumMetaEntity::class, ], - version = 4, + version = 6, exportSchema = false, -) -abstract class GaloDatabase : RoomDatabase() { +)abstract class GaloDatabase : RoomDatabase() { abstract fun albumDao(): AlbumDao diff --git a/android/app/src/main/java/com/galo/android/data/db/dao/SyncDao.kt b/android/app/src/main/java/com/galo/android/data/db/dao/SyncDao.kt index 75b90f5..5306f97 100644 --- a/android/app/src/main/java/com/galo/android/data/db/dao/SyncDao.kt +++ b/android/app/src/main/java/com/galo/android/data/db/dao/SyncDao.kt @@ -3,6 +3,7 @@ package com.galo.android.data.db.dao import androidx.room.Dao import androidx.room.Query import androidx.room.Upsert +import com.galo.android.data.db.entity.DeviceAlbumMetaEntity import com.galo.android.data.db.entity.DeviceAssetEntity import com.galo.android.data.db.entity.SyncedAlbumEntity import kotlinx.coroutines.flow.Flow @@ -27,8 +28,25 @@ interface SyncDao { @Query("DELETE FROM synced_albums WHERE deviceAlbumId = :deviceAlbumId") suspend fun deleteSyncedAlbum(deviceAlbumId: String) + // --- Local album metadata (display name, cover) ------------------------ + + @Query("SELECT * FROM device_album_meta") + fun observeAlbumMeta(): Flow> + + @Query("SELECT * FROM device_album_meta WHERE deviceAlbumId = :deviceAlbumId") + suspend fun getAlbumMeta(deviceAlbumId: String): DeviceAlbumMetaEntity? + + @Upsert + suspend fun upsertAlbumMeta(meta: DeviceAlbumMetaEntity) + + @Query("DELETE FROM device_album_meta WHERE deviceAlbumId = :deviceAlbumId") + suspend fun deleteAlbumMeta(deviceAlbumId: String) + // --- Device asset index and provenance --------------------------------- + @Query("SELECT DISTINCT deviceAlbumId FROM device_assets") + suspend fun indexedAlbumIds(): List + @Query("SELECT * FROM device_assets WHERE deviceAlbumId = :deviceAlbumId") fun observeAssets(deviceAlbumId: String): Flow> diff --git a/android/app/src/main/java/com/galo/android/data/db/entity/PhotoEntity.kt b/android/app/src/main/java/com/galo/android/data/db/entity/PhotoEntity.kt index 3a560ab..b733d63 100644 --- a/android/app/src/main/java/com/galo/android/data/db/entity/PhotoEntity.kt +++ b/android/app/src/main/java/com/galo/android/data/db/entity/PhotoEntity.kt @@ -20,6 +20,8 @@ data class PhotoEntity( val size: Long, val takenAt: String?, val caption: String, + val latitude: Double? = null, + val longitude: Double? = null, val isPanorama: Boolean, val thumbUrl: String?, val mediumUrl: String?, @@ -28,4 +30,8 @@ data class PhotoEntity( val originalUrl: String?, val downloadUrl: String?, val createdAt: String?, + val tagsJson: String = "[]", + val exifJson: String = "{}", + val uploaderName: String? = null, + val uploaderAvatarUrl: String? = null, ) diff --git a/android/app/src/main/java/com/galo/android/data/db/entity/SyncEntities.kt b/android/app/src/main/java/com/galo/android/data/db/entity/SyncEntities.kt index 29033f5..a467e06 100644 --- a/android/app/src/main/java/com/galo/android/data/db/entity/SyncEntities.kt +++ b/android/app/src/main/java/com/galo/android/data/db/entity/SyncEntities.kt @@ -14,6 +14,18 @@ data class SyncedAlbumEntity( val updatedAt: Long, ) +/** + * Local, app-only metadata for a device album: a display name and a chosen + * cover. Stored regardless of whether the album is linked to a server album. + */ +@Entity(tableName = "device_album_meta") +data class DeviceAlbumMetaEntity( + @PrimaryKey val deviceAlbumId: String, + val displayName: String?, + val coverUri: String?, + val updatedAt: Long, +) + /** * Local index of a device asset plus provenance: the server photo it was * uploaded as (when known) and the content hash used to match it. diff --git a/android/app/src/main/java/com/galo/android/data/mapper/Mappers.kt b/android/app/src/main/java/com/galo/android/data/mapper/Mappers.kt index abaf89a..1c96f28 100644 --- a/android/app/src/main/java/com/galo/android/data/mapper/Mappers.kt +++ b/android/app/src/main/java/com/galo/android/data/mapper/Mappers.kt @@ -2,12 +2,18 @@ package com.galo.android.data.mapper import com.galo.android.data.db.entity.AlbumEntity import com.galo.android.data.db.entity.PhotoEntity +import com.galo.android.data.network.GaloJson import com.galo.android.data.network.ObjectUrlResolver import com.galo.android.data.network.dto.AlbumDto import com.galo.android.data.network.dto.PhotoDto +import com.galo.android.data.network.dto.TagDto import com.galo.android.domain.model.Album import com.galo.android.domain.model.MediaKind import com.galo.android.domain.model.Photo +import com.galo.android.domain.model.Tag +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive // Remote DTO -> persisted entity (URLs kept raw so the cache survives a base-URL change). fun AlbumDto.toEntity(): AlbumEntity = AlbumEntity( @@ -35,6 +41,8 @@ fun PhotoDto.toEntity(): PhotoEntity = PhotoEntity( size = size, takenAt = takenAt, caption = caption, + latitude = latitude, + longitude = longitude, isPanorama = tags.any { MediaKind.isPanoTag(it.name) }, thumbUrl = thumbUrl, mediumUrl = mediumUrl, @@ -43,6 +51,10 @@ fun PhotoDto.toEntity(): PhotoEntity = PhotoEntity( originalUrl = originalUrl, downloadUrl = downloadUrl, createdAt = createdAt, + tagsJson = encodeTags(tags), + exifJson = encodeExif(exif), + uploaderName = uploader?.name, + uploaderAvatarUrl = uploader?.avatarUrl, ) // Persisted entity -> domain (URLs resolved against the current base URL). @@ -71,6 +83,8 @@ fun PhotoEntity.toDomain(resolver: ObjectUrlResolver): Photo = Photo( size = size, takenAt = takenAt, caption = caption, + latitude = latitude, + longitude = longitude, isPanorama = isPanorama, thumbUrl = resolver.resolve(thumbUrl), mediumUrl = resolver.resolve(mediumUrl), @@ -79,6 +93,10 @@ fun PhotoEntity.toDomain(resolver: ObjectUrlResolver): Photo = Photo( originalUrl = resolver.resolve(originalUrl), downloadUrl = resolver.resolve(downloadUrl), createdAt = createdAt, + tags = decodeTags(tagsJson), + exif = decodeExif(exifJson), + uploaderName = uploaderName, + uploaderAvatarUrl = resolver.resolve(uploaderAvatarUrl), ) fun PhotoDto.toDomain(resolver: ObjectUrlResolver): Photo = Photo( @@ -93,6 +111,8 @@ fun PhotoDto.toDomain(resolver: ObjectUrlResolver): Photo = Photo( size = size, takenAt = takenAt, caption = caption, + latitude = latitude, + longitude = longitude, isPanorama = tags.any { MediaKind.isPanoTag(it.name) }, thumbUrl = resolver.resolve(thumbUrl), mediumUrl = resolver.resolve(mediumUrl), @@ -101,4 +121,31 @@ fun PhotoDto.toDomain(resolver: ObjectUrlResolver): Photo = Photo( originalUrl = resolver.resolve(originalUrl), downloadUrl = resolver.resolve(downloadUrl), createdAt = createdAt, + tags = tags.map { Tag(it.id, it.name) }, + exif = exif.mapValues { it.value.asExifString() }, + uploaderName = uploader?.name, + uploaderAvatarUrl = resolver.resolve(uploader?.avatarUrl), ) + +private fun encodeTags(tags: List): String = + runCatching { GaloJson.encodeToString(tags) }.getOrDefault("[]") + +private fun decodeTags(json: String): List = + runCatching { + GaloJson.decodeFromString>(json).map { Tag(it.id, it.name) } + }.getOrDefault(emptyList()) + +private fun encodeExif(exif: Map): String = + runCatching { + GaloJson.encodeToString(exif.mapValues { it.value.asExifString() }) + }.getOrDefault("{}") + +private fun decodeExif(json: String): Map = + runCatching { GaloJson.decodeFromString>(json) } + .getOrDefault(emptyMap()) + +private fun JsonElement.asExifString(): String = + when (this) { + is JsonPrimitive -> content + else -> toString() + } diff --git a/android/app/src/main/java/com/galo/android/data/media/MediaStoreDeviceSource.kt b/android/app/src/main/java/com/galo/android/data/media/MediaStoreDeviceSource.kt index b69fa7a..0e1261e 100644 --- a/android/app/src/main/java/com/galo/android/data/media/MediaStoreDeviceSource.kt +++ b/android/app/src/main/java/com/galo/android/data/media/MediaStoreDeviceSource.kt @@ -95,6 +95,19 @@ class MediaStoreDeviceSource(private val context: Context) { fun items(albumId: String): List = allMedia().filter { it.albumId == albumId } + /** + * System consent to delete media the app does not own. Launching the returned + * sender (after user confirmation) removes the items from MediaStore. + */ + fun deleteConsent(uris: List): android.content.IntentSender? = + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R && uris.isNotEmpty()) { + runCatching { + MediaStore.createDeleteRequest(context.contentResolver, uris).intentSender + }.getOrNull() + } else { + null + } + private fun allMedia(): List = query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaKind.IMAGE) + query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, MediaKind.VIDEO) diff --git a/android/app/src/main/java/com/galo/android/data/network/GaloApi.kt b/android/app/src/main/java/com/galo/android/data/network/GaloApi.kt index 081fd42..22fd94e 100644 --- a/android/app/src/main/java/com/galo/android/data/network/GaloApi.kt +++ b/android/app/src/main/java/com/galo/android/data/network/GaloApi.kt @@ -1,6 +1,7 @@ package com.galo.android.data.network import com.galo.android.data.network.dto.AddIdentifierRequest +import com.galo.android.data.network.dto.AddTagRequest import com.galo.android.data.network.dto.AlbumDto import com.galo.android.data.network.dto.AlbumEnvelopeDto import com.galo.android.data.network.dto.AlbumListDto @@ -12,12 +13,23 @@ import com.galo.android.data.network.dto.DeleteAccountRequest import com.galo.android.data.network.dto.HealthDto import com.galo.android.data.network.dto.LoginRequest import com.galo.android.data.network.dto.LogoutRequest +import com.galo.android.data.network.dto.MemberDto +import com.galo.android.data.network.dto.MemberListDto import com.galo.android.data.network.dto.PhotoDto import com.galo.android.data.network.dto.PhotoEnvelopeDto import com.galo.android.data.network.dto.PhotoListDto +import com.galo.android.data.network.dto.PreferencesDto +import com.galo.android.data.network.dto.PreferencesEnvelopeDto import com.galo.android.data.network.dto.RegisterRequest import com.galo.android.data.network.dto.RenamePhotoRequest +import com.galo.android.data.network.dto.ShareLinkDto +import com.galo.android.data.network.dto.ShareLinkEnvelopeDto +import com.galo.android.data.network.dto.ShareLinkListDto +import com.galo.android.data.network.dto.ShareLinkRequest +import com.galo.android.data.network.dto.TagDto +import com.galo.android.data.network.dto.TagListDto import com.galo.android.data.network.dto.TokenEnvelopeDto +import com.galo.android.data.network.dto.UpdateAlbumRequest import com.galo.android.data.network.dto.UpdateProfileRequest import com.galo.android.data.network.dto.UploadResponseDto import com.galo.android.data.network.dto.UserDto @@ -76,6 +88,15 @@ class GaloApi( override suspend fun me(): UserDto = get("api/auth/me").decode().user + /** Full account payload including saved preferences and their `photo_info`. */ + suspend fun meEnvelope(): UserEnvelopeDto = get("api/auth/me").decode() + + /** Persists preferences (e.g. which EXIF attributes are visible and their labels). */ + suspend fun savePreferences(preferences: PreferencesDto): PreferencesDto = + patchJson("api/auth/me/preferences", json.encodeToString(preferences)) + .decode() + .preferences + override suspend fun updateProfile(name: String): UserDto = patchJson("api/auth/me", json.encodeToString(UpdateProfileRequest(name))) .decode() @@ -127,6 +148,10 @@ class GaloApi( suspend fun album(albumId: String): AlbumDto = get("api/albums/$albumId").decode().album + /** Album plus the caller's role on it. */ + suspend fun albumEnvelope(albumId: String): AlbumEnvelopeDto = + get("api/albums/$albumId").decode() + /** Creates a server album and returns it. */ suspend fun createAlbum(title: String): AlbumDto = postJson("api/albums", json.encodeToString(CreateAlbumRequest(title))) @@ -183,6 +208,70 @@ class GaloApi( postJson("api/photo-batch/move", json.encodeToString(BatchRequest(photoIds, albumId))) } + /** Copies photos to another album in one batch. */ + suspend fun copyPhotos(photoIds: List, albumId: String) { + postJson("api/photo-batch/copy", json.encodeToString(BatchRequest(photoIds, albumId))) + } + + /** Updates album attributes (title, description, cover). Omitted fields are left unchanged. */ + suspend fun updateAlbum( + albumId: String, + title: String? = null, + description: String? = null, + coverPhotoId: String? = null, + clearCover: Boolean = false, + ): AlbumDto { + val body = UpdateAlbumRequest( + title = title, + description = description, + coverPhotoId = coverPhotoId, + clearCover = if (clearCover) true else null, + ) + return patchJson("api/albums/$albumId", json.encodeToString(body)) + .decode() + .album + } + + /** Deletes an album; its photos are moved to the server trash. */ + suspend fun deleteAlbum(albumId: String) { + execute(Request.Builder().url(api.endpoint("api/albums/$albumId")).delete().build()) + } + + suspend fun members(albumId: String): List = + get("api/albums/$albumId/members").decode().members + + suspend fun shareLinks(albumId: String): List = + get("api/albums/$albumId/share").decode().shareLinks + + suspend fun createShareLink(albumId: String, expiresIn: String = "168h"): ShareLinkDto = + postJson("api/albums/$albumId/share", json.encodeToString(ShareLinkRequest(expiresIn))) + .decode() + .shareLink + + suspend fun revokeShareLink(albumId: String, linkId: String) { + execute( + Request.Builder().url(api.endpoint("api/albums/$albumId/share/$linkId")).delete().build(), + ) + } + + /** Adds a label to a photo and returns the updated photo. */ + suspend fun addTag(photoId: String, name: String): PhotoDto = + postJson("api/photos/$photoId/tags", json.encodeToString(AddTagRequest(name))) + .decode() + .photo + + /** Removes a label from a photo and returns the updated photo. */ + suspend fun removeTag(photoId: String, tagId: String): PhotoDto = + execute( + Request.Builder().url(api.endpoint("api/photos/$photoId/tags/$tagId")).delete().build(), + ).decode().photo + + /** Lists the user's labels, optionally filtered by a query. */ + suspend fun tags(query: String = ""): List { + val suffix = if (query.isBlank()) "" else "?q=" + urlEncode(query) + return get("api/tags$suffix").decode().tags + } + /** * Control sums of an album's photos keyed by checksum, following pagination. * Used by the device-album sync to avoid re-uploading existing content. diff --git a/android/app/src/main/java/com/galo/android/data/network/dto/Dtos.kt b/android/app/src/main/java/com/galo/android/data/network/dto/Dtos.kt index eda7e82..754a597 100644 --- a/android/app/src/main/java/com/galo/android/data/network/dto/Dtos.kt +++ b/android/app/src/main/java/com/galo/android/data/network/dto/Dtos.kt @@ -2,6 +2,7 @@ package com.galo.android.data.network.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement @Serializable data class HealthDto( @@ -37,6 +38,7 @@ data class UserDto( @Serializable data class UserEnvelopeDto( val user: UserDto = UserDto(), + val preferences: PreferencesDto? = null, ) @Serializable @@ -141,7 +143,11 @@ data class PhotoDto( val size: Long = 0, @SerialName("taken_at") val takenAt: String? = null, val caption: String = "", + val latitude: Double? = null, + val longitude: Double? = null, val tags: List = emptyList(), + val exif: Map = emptyMap(), + val uploader: UserDto? = null, @SerialName("thumb_url") val thumbUrl: String? = null, @SerialName("medium_url") val mediumUrl: String? = null, @SerialName("preview_url") val previewUrl: String? = null, @@ -183,3 +189,74 @@ data class ErrorBodyDto( val code: String = "internal_error", val message: String = "internal server error", ) + +@Serializable +data class UpdateAlbumRequest( + val title: String? = null, + val description: String? = null, + @SerialName("cover_photo_id") val coverPhotoId: String? = null, + @SerialName("clear_cover") val clearCover: Boolean? = null, +) + +@Serializable +data class ShareLinkRequest( + @SerialName("expires_in") val expiresIn: String = "168h", +) + +@Serializable +data class ShareLinkDto( + val id: String = "", + @SerialName("album_id") val albumId: String = "", + val token: String = "", + @SerialName("expires_at") val expiresAt: String? = null, + @SerialName("revoked_at") val revokedAt: String? = null, +) + +@Serializable +data class ShareLinkEnvelopeDto( + @SerialName("share_link") val shareLink: ShareLinkDto = ShareLinkDto(), +) + +@Serializable +data class ShareLinkListDto( + @SerialName("share_links") val shareLinks: List = emptyList(), +) + +@Serializable +data class MemberDto( + @SerialName("user_id") val userId: String = "", + val role: String = "", + val user: UserDto = UserDto(), +) + +@Serializable +data class MemberListDto( + val members: List = emptyList(), +) + +@Serializable +data class TagListDto( + val tags: List = emptyList(), +) + +@Serializable +data class AddTagRequest( + val name: String, +) + +@Serializable +data class PhotoInfoPrefsDto( + val visible: List = emptyList(), + val labels: Map = emptyMap(), +) + +@Serializable +data class PreferencesDto( + @SerialName("photo_info") val photoInfo: PhotoInfoPrefsDto? = null, +) + +@Serializable +data class PreferencesEnvelopeDto( + val preferences: PreferencesDto = PreferencesDto(), +) + diff --git a/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt b/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt index df77d92..2d46f9c 100644 --- a/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt +++ b/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt @@ -1,13 +1,16 @@ package com.galo.android.data.repo import com.galo.android.data.db.dao.SyncDao +import com.galo.android.data.db.entity.DeviceAlbumMetaEntity 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.domain.model.DeviceAlbum +import com.galo.android.domain.model.DeviceAlbumMeta import com.galo.android.domain.model.LocalAssetState import com.galo.android.domain.model.SyncMode import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map /** Device albums, their links to server albums and their asset index. */ class SyncRepository( @@ -22,6 +25,9 @@ class SyncRepository( suspend fun assets(deviceAlbumId: String): List = dao.getAssets(deviceAlbumId) + /** Device albums that have a local asset index (possibly partial/unlinked). */ + suspend fun indexedAlbumIds(): List = dao.indexedAlbumIds() + suspend fun getAsset(assetKey: String): DeviceAssetEntity? = dao.getAsset(assetKey) suspend fun currentLink(deviceAlbumId: String): SyncedAlbumEntity? = @@ -48,6 +54,61 @@ class SyncRepository( suspend fun unlink(deviceAlbumId: String) = dao.deleteSyncedAlbum(deviceAlbumId) + // --- Local album metadata (display name, cover) ------------------------ + + /** App-only metadata for device albums, keyed by device album id. */ + fun observeAlbumMeta(): Flow> = + dao.observeAlbumMeta().map { list -> + list.associate { it.deviceAlbumId to DeviceAlbumMeta(it.deviceAlbumId, it.displayName, it.coverUri) } + } + + suspend fun albumMeta(deviceAlbumId: String): DeviceAlbumMeta? = + dao.getAlbumMeta(deviceAlbumId)?.let { + DeviceAlbumMeta(it.deviceAlbumId, it.displayName, it.coverUri) + } + + /** Sets the app-only display name for a device album. */ + suspend fun setAlbumName(deviceAlbumId: String, name: String?) { + val current = dao.getAlbumMeta(deviceAlbumId) + dao.upsertAlbumMeta( + DeviceAlbumMetaEntity( + deviceAlbumId = deviceAlbumId, + displayName = name?.trim()?.takeIf { it.isNotEmpty() }, + coverUri = current?.coverUri, + updatedAt = System.currentTimeMillis(), + ), + ) + } + + /** Sets (or clears, with null) the chosen cover URI for a device album. */ + suspend fun setAlbumCover(deviceAlbumId: String, coverUri: String?) { + val current = dao.getAlbumMeta(deviceAlbumId) + dao.upsertAlbumMeta( + DeviceAlbumMetaEntity( + deviceAlbumId = deviceAlbumId, + displayName = current?.displayName, + coverUri = coverUri, + updatedAt = System.currentTimeMillis(), + ), + ) + } + + /** + * Forgets a device album locally: drops its link, asset index and app-only + * metadata. Does not touch media on the device. + */ + suspend fun forgetAlbum(deviceAlbumId: String) { + dao.clearAlbum(deviceAlbumId) + dao.deleteAlbumMeta(deviceAlbumId) + dao.deleteSyncedAlbum(deviceAlbumId) + } + + /** System consent to delete the given device media (see [MediaStoreDeviceSource.deleteConsent]). */ + fun deleteConsent(uris: List) = deviceSource.deleteConsent(uris) + + fun deviceItemUris(albumId: String): List = + deviceSource.items(albumId).map { android.net.Uri.parse(it.uri) } + /** Records the server photo created for a device asset (called after upload). */ suspend fun recordUploaded( deviceAlbumId: String, diff --git a/android/app/src/main/java/com/galo/android/data/sync/OffloadManager.kt b/android/app/src/main/java/com/galo/android/data/sync/OffloadManager.kt index 5ed4ce5..7ba52eb 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/OffloadManager.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/OffloadManager.kt @@ -12,6 +12,7 @@ import com.galo.android.data.db.entity.DeviceAssetEntity import com.galo.android.data.media.PreviewGenerator import com.galo.android.domain.model.LocalAssetState import com.galo.android.domain.model.MediaKind +import com.galo.android.domain.model.SyncMode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -50,12 +51,18 @@ class OffloadManager( mutex.withLock { runCatching { val uri = Uri.parse(asset.uri) - val serverAlbumId = dao.getSyncedAlbum(asset.deviceAlbumId)?.serverAlbumId + val link = dao.getSyncedAlbum(asset.deviceAlbumId) + val serverAlbumId = link?.serverAlbumId + val syncMode = link?.mode if (kindOf(asset.mimeType) == MediaKind.VIDEO) { - offloadVideo(asset, uri, serverAlbumId) + offloadVideo(asset, uri, serverAlbumId, syncMode) } else { - offloadPhoto(asset, uri, serverAlbumId) + offloadPhoto(asset, uri, serverAlbumId, syncMode) } + }.onSuccess { + android.util.Log.i(TAG, "offload ${asset.assetKey}: $it") + }.onFailure { + android.util.Log.w(TAG, "offload ${asset.assetKey} failed", it) }.getOrDefault(OffloadResult.Failed) } } @@ -76,7 +83,12 @@ class OffloadManager( null } - private suspend fun offloadPhoto(asset: DeviceAssetEntity, uri: Uri, serverAlbumId: String?): OffloadResult { + private suspend fun offloadPhoto( + asset: DeviceAssetEntity, + uri: Uri, + serverAlbumId: String?, + syncMode: String?, + ): OffloadResult { val preview = PreviewGenerator.tempPreviewFile(context, asset.assetKey) val sourceExif = runCatching { context.contentResolver.openInputStream(uri)?.use { ExifInterface(it) } @@ -84,7 +96,7 @@ class OffloadManager( if (!PreviewGenerator.generate(context, uri, MediaKind.IMAGE, preview)) return OffloadResult.Failed ProvenanceStamp.copyDateTaken(sourceExif, preview) - ProvenanceStamp.write(preview, provenanceOf(asset, serverAlbumId)) + ProvenanceStamp.write(preview, provenanceOf(asset, serverAlbumId, syncMode)) return try { writeInPlace(uri, preview) @@ -97,10 +109,15 @@ class OffloadManager( } } - private suspend fun offloadVideo(asset: DeviceAssetEntity, uri: Uri, serverAlbumId: String?): OffloadResult { + private suspend fun offloadVideo( + asset: DeviceAssetEntity, + uri: Uri, + serverAlbumId: String?, + syncMode: String?, + ): OffloadResult { val poster = PreviewGenerator.tempPreviewFile(context, asset.assetKey) if (!PreviewGenerator.generate(context, uri, MediaKind.VIDEO, poster)) return OffloadResult.Failed - ProvenanceStamp.write(poster, provenanceOf(asset, serverAlbumId)) + ProvenanceStamp.write(poster, provenanceOf(asset, serverAlbumId, syncMode)) val relativePath = relativePathOf(uri) return try { @@ -152,12 +169,18 @@ class OffloadManager( ?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } }.getOrNull().orEmpty().ifBlank { "DCIM/" } - private fun provenanceOf(asset: DeviceAssetEntity, serverAlbumId: String?) = Provenance( + private fun provenanceOf( + asset: DeviceAssetEntity, + serverAlbumId: String?, + syncMode: String?, + ) = Provenance( serverAlbumId = serverAlbumId, serverPhotoId = asset.serverPhotoId, originalHash = asset.contentHash, deviceAlbumId = asset.deviceAlbumId, - mode = LocalAssetState.PREVIEW.name, + // The album's synchronization mode (not the asset state) — recovery reads + // this back to rebuild the link after a reinstall. + mode = syncMode ?: SyncMode.MOVE.name, assetKey = asset.assetKey, ) @@ -167,4 +190,8 @@ class OffloadManager( private fun kindOf(mimeType: String?): MediaKind = if (mimeType?.startsWith("video/") == true) MediaKind.VIDEO else MediaKind.IMAGE + + private companion object { + const val TAG = "OffloadManager" + } } diff --git a/android/app/src/main/java/com/galo/android/data/sync/RestoreManager.kt b/android/app/src/main/java/com/galo/android/data/sync/RestoreManager.kt index 6391d11..415d90a 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/RestoreManager.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/RestoreManager.kt @@ -42,6 +42,12 @@ class RestoreManager( /** Restoring must fit in the free space; otherwise it is deferred. */ fun canRestore(requiredBytes: Long, availableBytes: Long): Boolean = availableBytes >= requiredBytes + + /** Assets whose originals live on the server and should be brought back. */ + fun restoreTargets(assets: List): List = + assets.filter { + it.serverPhotoId != null && it.state == LocalAssetState.PREVIEW.name + } } suspend fun restore(assets: List): RestoreReport = withContext(Dispatchers.IO) { @@ -52,6 +58,7 @@ class RestoreManager( for (asset in assets) { val photoId = asset.serverPhotoId ?: continue val data = runCatching { fetcher.fetch(photoId) }.getOrNull() ?: continue + if (data.isEmpty()) continue if (!canRestore(data.size.toLong(), availableBytes())) { return@withContext RestoreReport(restored, bytes, needsConsent, outOfSpace = true) } @@ -62,6 +69,8 @@ class RestoreManager( bytes += data.size } catch (e: SecurityException) { needsConsent++ + } catch (e: Exception) { + // Leave the asset as is and continue with the rest. } } RestoreReport(restored, bytes, needsConsent, outOfSpace = false) diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncCheck.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncCheck.kt new file mode 100644 index 0000000..c65c3bb --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncCheck.kt @@ -0,0 +1,47 @@ +package com.galo.android.data.sync + +import com.galo.android.data.repo.SyncRepository +import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.SyncMode +import com.galo.android.domain.sync.GallerySyncPolicy + +/** + * The unified per-gallery check and fix, run on app start and when a device album + * is opened. It makes the on-device content match the album's mode for every + * combination (see [GallerySyncPolicy]): it rebuilds provenance from markers when + * asked, restores originals for copy/off albums that hold previews, then + * reconciles (upload / link / offload). + */ +class SyncCheck( + private val repository: SyncRepository, + private val recovery: SyncRecovery, + private val restoreManager: RestoreManager, + private val coordinator: SyncCoordinator, +) { + + suspend fun checkGallery(deviceAlbumId: String, recoverIndex: Boolean = false) { + if (recoverIndex) runCatching { recovery.recover(deviceAlbumId) } + + val mode = repository.currentLink(deviceAlbumId) + ?.let { SyncMode.from(it.mode) } ?: SyncMode.DISABLED + val assets = runCatching { repository.assets(deviceAlbumId) }.getOrDefault(emptyList()) + + // Bring originals back where the mode expects them locally (copy/off with + // previews left over from move, possibly with a lost index). + val restore = assets.filter { entity -> + GallerySyncPolicy.decide( + mode = mode, + state = LocalAssetState.from(entity.state), + hasServerPhoto = entity.serverPhotoId != null, + ) == GallerySyncPolicy.LocalFix.RESTORE + } + if (restore.isNotEmpty()) runCatching { restoreManager.restore(restore) } + + // Upload what is missing and offload what is already on the server (move). + runCatching { coordinator.reconcile(deviceAlbumId) } + } + + suspend fun checkAll(deviceAlbumIds: Collection, recoverIndex: Boolean = true) { + deviceAlbumIds.forEach { checkGallery(it, recoverIndex) } + } +} diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt index 511a276..edbc6ab 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt @@ -5,6 +5,8 @@ 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.domain.sync.SyncPolicy +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** * Reconciles a device album with its server album. Mode A (DISABLED) is local @@ -19,13 +21,16 @@ class SyncCoordinator( private val offloader: AssetOffloader, ) { + /** Serializes reconciles so rapid triggers never double-upload or double-offload. */ + private val mutex = Mutex() + /** @return true when the server was contacted, false for a local-only album. */ - suspend fun reconcile(deviceAlbumId: String): Boolean { - val link = dao.getSyncedAlbum(deviceAlbumId) ?: return false + suspend fun reconcile(deviceAlbumId: String): Boolean = mutex.withLock { + val link = dao.getSyncedAlbum(deviceAlbumId) ?: return@withLock false val mode = SyncMode.from(link.mode) - if (!SyncPolicy.contactsServer(mode)) return false + if (!SyncPolicy.contactsServer(mode)) return@withLock false // A mode can be chosen before a server album; nothing to sync until then. - if (link.serverAlbumId.isBlank()) return false + if (link.serverAlbumId.isBlank()) return@withLock false val serverChecksums = remote.serverChecksums(link.serverAlbumId) val livePhotoIds = serverChecksums.values.toSet() @@ -41,20 +46,40 @@ class SyncCoordinator( // Mode C: assets already on the server (e.g. after switching from copies) // are replaced with their offloaded versions, right away. if (mode == SyncMode.MOVE) offloadUploaded(deviceAlbumId, assets) - return true + return@withLock true } /** - * Offloads assets that are already uploaded but still have their original - * locally. When the OS requires consent, the asset is marked as awaiting - * freeing so the album screen can request it. + * Reconciles every given device album, so a startup/background pass covers all + * galleries (including partially synced ones). @return true when any album + * contacted the server. + */ + suspend fun reconcileAll(deviceAlbumIds: Collection): Boolean { + var any = false + for (id in deviceAlbumIds) { + any = reconcile(id) || any + } + return any + } + + /** + * Offloads assets that are already on the server but still have their original + * locally (mode C). Retries assets left as "awaiting free" from a previous pass, + * so freeing completes automatically on a later run without any prompt. */ private suspend fun offloadUploaded(deviceAlbumId: String, localAssets: List) { val localKeys = localAssets.mapTo(HashSet()) { it.assetKey } for (entity in dao.getAssets(deviceAlbumId)) { if (entity.serverPhotoId == null) continue if (entity.assetKey !in localKeys) continue - if (entity.state != LocalAssetState.UPLOADED.name) continue + // Anything linked to a server photo that still has its original on the + // device (uploaded, awaiting free, or otherwise) is offloaded; only the + // already-offloaded preview/server-only states are skipped. + if (entity.state == LocalAssetState.PREVIEW.name || + entity.state == LocalAssetState.SERVER_ONLY.name + ) { + continue + } val result = runCatching { offloader.offload(entity) }.getOrNull() ?: continue when (result) { is OffloadResult.Done, OffloadResult.Failed -> Unit @@ -82,7 +107,11 @@ class SyncCoordinator( record(deviceAlbumId, action.asset, action.photoId, mode) is SyncAction.Upload -> - uploadQueue.enqueue(deviceAlbumId, serverAlbumId, action.asset, mode) + // Skip if an upload for this asset is already queued/running, so + // overlapping passes do not duplicate it. + if (!uploadQueue.isPending(action.asset.assetKey)) { + uploadQueue.enqueue(deviceAlbumId, serverAlbumId, action.asset, mode) + } is SyncAction.RenameOnServer -> { remote.renamePhoto(action.photoId, action.newName) diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncRecovery.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncRecovery.kt index f4de2af..99fb41a 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/SyncRecovery.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncRecovery.kt @@ -49,12 +49,17 @@ class SyncRecovery( ), ) marker.serverAlbumId?.let { serverAlbumId -> + val existing = dao.getSyncedAlbum(deviceAlbumId) + val markerMode = marker.mode?.let { raw -> SyncMode.parseOrNull(raw)?.name } dao.upsertSyncedAlbum( SyncedAlbumEntity( deviceAlbumId = deviceAlbumId, - deviceAlbumName = media.albumName, - serverAlbumId = serverAlbumId, - mode = marker.mode ?: SyncMode.MOVE.name, + deviceAlbumName = existing?.deviceAlbumName?.ifBlank { null } ?: media.albumName, + serverAlbumId = existing?.serverAlbumId?.takeIf { it.isNotBlank() } ?: serverAlbumId, + // Keep an existing link's mode; only fall back to the marker + // when creating a link from scratch. The marker must carry a + // real sync mode, never an asset state. + mode = existing?.mode ?: markerMode ?: SyncMode.MOVE.name, updatedAt = System.currentTimeMillis(), ), ) diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncUploadQueue.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncUploadQueue.kt index 1f48cbf..320d2e3 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/SyncUploadQueue.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncUploadQueue.kt @@ -11,6 +11,9 @@ interface SyncUploadQueue { asset: LocalAsset, mode: SyncMode, ) + + /** True when this asset already has a queued/running upload. */ + suspend fun isPending(assetKey: String): Boolean } /** [SyncUploadQueue] backed by the existing [UploadRepository]/WorkManager queue. */ @@ -37,4 +40,6 @@ class RepositorySyncUploadQueue( wifiOnly = wifiOnly(), ) } + + override suspend fun isPending(assetKey: String): Boolean = uploads.isPending(assetKey) } diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncWorker.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncWorker.kt index 8113eeb..243c470 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/SyncWorker.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncWorker.kt @@ -26,14 +26,27 @@ class SyncWorker( override suspend fun doWork(): Result { val app = applicationContext as? GaloApplication ?: return Result.failure() val container = app.container - val links = runCatching { container.syncRepository.observeLinks().first() } + val sync = container.syncRepository + val links = runCatching { sync.observeLinks().first() } .getOrElse { return Result.retry() } + val linkedIds = links.map { it.deviceAlbumId } + val indexedIds = runCatching { sync.indexedAlbumIds() }.getOrDefault(emptyList()) + + // Check and fix every gallery we know about (linked now or with a local + // index). recoverIndex rebuilds provenance from markers, covering a lost + // index and partial syncs. + val toCheck = (linkedIds + indexedIds).toSet() var failed = false - for (link in links) { - runCatching { container.syncCoordinator.reconcile(link.deviceAlbumId) } + for (id in toCheck) { + runCatching { container.syncCheck.checkGallery(id, recoverIndex = true) } .onFailure { failed = true } } + + // Resume unfinished uploads so partially transferred galleries continue. + runCatching { container.uploadRepository.requeuePending() } + .onFailure { failed = true } + return if (failed) Result.retry() else Result.success() } } diff --git a/android/app/src/main/java/com/galo/android/data/upload/UploadRepository.kt b/android/app/src/main/java/com/galo/android/data/upload/UploadRepository.kt index a2860a5..898cab2 100644 --- a/android/app/src/main/java/com/galo/android/data/upload/UploadRepository.kt +++ b/android/app/src/main/java/com/galo/android/data/upload/UploadRepository.kt @@ -106,6 +106,10 @@ class UploadRepository( suspend fun getById(id: String): UploadJobEntity? = dao.getById(id) + /** True when a sync upload for this asset is already queued or running. */ + suspend fun isPending(assetKey: String): Boolean = + dao.getPending().any { it.assetKey == assetKey } + suspend fun updateState( id: String, status: UploadStatus, diff --git a/android/app/src/main/java/com/galo/android/di/AppContainer.kt b/android/app/src/main/java/com/galo/android/di/AppContainer.kt index ac1f61e..4406027 100644 --- a/android/app/src/main/java/com/galo/android/di/AppContainer.kt +++ b/android/app/src/main/java/com/galo/android/di/AppContainer.kt @@ -27,6 +27,7 @@ import com.galo.android.data.sync.DeviceMediaObserver import com.galo.android.data.sync.OffloadManager import com.galo.android.data.sync.RepositorySyncUploadQueue import com.galo.android.data.sync.RestoreManager +import com.galo.android.data.sync.SyncCheck import com.galo.android.data.sync.SyncCoordinator import com.galo.android.data.sync.SyncRecovery import com.galo.android.data.sync.SyncRemote @@ -107,17 +108,24 @@ class AppContainer(context: Context) { val syncRecovery = SyncRecovery(appContext, database.syncDao(), deviceSource) + /** Unified per-gallery check and fix (startup + album open). */ + val syncCheck = SyncCheck(syncRepository, syncRecovery, restoreManager, syncCoordinator) + fun start() { scope.launch { settingsRepository.flow.collect { settings -> + val first = settingsState == null settingsState = settings runCatching { SyncScheduler.ensurePeriodic(appContext, settings.wifiOnlyUploads) } + // Run the full synchronization check once the base URL is known, so + // the startup pass can actually reach the server (all galleries, + // including partially synced ones). + if (first) { + runCatching { uploadRepository.requeuePending() } + runCatching { SyncScheduler.runNow(appContext) } + } } } - scope.launch { - runCatching { uploadRepository.requeuePending() } - } - runCatching { SyncScheduler.runNow(appContext) } runCatching { DeviceMediaObserver(appContext) { runCatching { SyncScheduler.runNow(appContext) } }.start() } diff --git a/android/app/src/main/java/com/galo/android/domain/attributes/AlbumAttributes.kt b/android/app/src/main/java/com/galo/android/domain/attributes/AlbumAttributes.kt new file mode 100644 index 0000000..7e4596c --- /dev/null +++ b/android/app/src/main/java/com/galo/android/domain/attributes/AlbumAttributes.kt @@ -0,0 +1,97 @@ +package com.galo.android.domain.attributes + +import com.galo.android.domain.model.Album +import com.galo.android.domain.model.DeviceAlbum + +/** + * Resolved attributes shown by the album "Атрибуты и действия" panel. + * + * A server album is backed by the server; a device album is local (its metadata + * and actions are local), optionally linked to a server album for the + * members/share navigation. + */ +data class AlbumAttributes( + val title: String, + val description: String, + val coverUrl: String?, + val photosCount: Int, + val membersCount: Int, + /** Server album id when linked, else null. */ + val serverAlbumId: String?, + /** Caller's role on the linked/server album (owner/admin/member/viewer), or null. */ + val role: String?, + /** True for device albums. */ + val isLocal: Boolean, + /** Navigation rows: enabled only when a server album backs them. */ + val membersEnabled: Boolean, + val shareEnabled: Boolean, + val settingsEnabled: Boolean, + val canRename: Boolean, + val canDelete: Boolean, +) { + val canEdit: Boolean get() = role == "owner" || role == "admin" +} + +/** Where the album panel's attributes come from. */ +sealed interface AlbumAttributeSource { + /** A server album, opened directly. */ + data class Server(val album: Album, val role: String) : AlbumAttributeSource + + /** + * A device album. [linkedServer] is the server album it syncs with, when any; + * [coverUrl] is the resolved cover (chosen local cover or first item). + */ + data class Local( + val device: DeviceAlbum, + val title: String, + val coverUrl: String?, + val linkedServer: Album?, + val role: String?, + ) : AlbumAttributeSource +} + +object AlbumAttributesResolver { + + fun resolve(source: AlbumAttributeSource): AlbumAttributes = when (source) { + is AlbumAttributeSource.Server -> fromServer(source.album, source.role) + + is AlbumAttributeSource.Local -> { + val linked = source.linkedServer + val canEditLinked = source.role == "owner" || source.role == "admin" + AlbumAttributes( + title = source.title, + description = "", + coverUrl = source.coverUrl, + photosCount = source.device.itemCount, + membersCount = linked?.membersCount ?: 0, + serverAlbumId = linked?.id, + role = source.role, + isLocal = true, + membersEnabled = linked != null, + shareEnabled = linked != null && canEditLinked, + settingsEnabled = true, + canRename = true, + canDelete = true, + ) + } + } + + private fun fromServer(album: Album, role: String): AlbumAttributes { + val canEdit = role == "owner" || role == "admin" + return AlbumAttributes( + title = album.title, + description = album.description, + coverUrl = album.coverMediumUrl ?: album.coverThumbUrl, + photosCount = album.photosCount, + membersCount = album.membersCount, + serverAlbumId = album.id, + role = role, + isLocal = false, + membersEnabled = true, + shareEnabled = canEdit, + settingsEnabled = canEdit, + canRename = canEdit, + canDelete = canEdit, + ) + } +} diff --git a/android/app/src/main/java/com/galo/android/domain/attributes/PhotoAttributeSource.kt b/android/app/src/main/java/com/galo/android/domain/attributes/PhotoAttributeSource.kt new file mode 100644 index 0000000..03ec848 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/domain/attributes/PhotoAttributeSource.kt @@ -0,0 +1,28 @@ +package com.galo.android.domain.attributes + +import com.galo.android.domain.model.DeviceMedia + +/** + * Where the photo "Атрибуты и действия" sheet gets its data. + * + * A device media item linked to a server photo is server-backed (rich attributes + * and actions); otherwise it is local (device properties and local actions). + */ +sealed interface PhotoAttributeSource { + /** Server photo id backing the sheet. */ + data class Server(val photoId: String) : PhotoAttributeSource + + /** A device media item with no matching server photo. */ + data class Local(val media: DeviceMedia) : PhotoAttributeSource +} + +object PhotoAttributeResolver { + + /** [serverPhotoId] is the photo this device media maps to, when known. */ + fun resolve(media: DeviceMedia, serverPhotoId: String?): PhotoAttributeSource = + if (serverPhotoId.isNullOrBlank()) { + PhotoAttributeSource.Local(media) + } else { + PhotoAttributeSource.Server(serverPhotoId) + } +} diff --git a/android/app/src/main/java/com/galo/android/domain/exif/ExifCatalog.kt b/android/app/src/main/java/com/galo/android/domain/exif/ExifCatalog.kt new file mode 100644 index 0000000..0a10ce3 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/domain/exif/ExifCatalog.kt @@ -0,0 +1,164 @@ +package com.galo.android.domain.exif + +import java.text.Collator +import java.util.Locale + +/** EXIF attribute labels, mirroring the web catalog (`frontend/src/lib/exif.ts`). */ +val EXIF_LABELS: Map = mapOf( + "Make" to "Производитель", + "Model" to "Модель", + "LensMake" to "Производитель объектива", + "LensModel" to "Объектив", + "ExposureTime" to "Выдержка", + "ShutterSpeedValue" to "Выдержка (APEX)", + "FNumber" to "Диафрагма", + "ApertureValue" to "Диафрагма (APEX)", + "MaxApertureValue" to "Макс. диафрагма", + "FocalLength" to "Фокусное расстояние", + "FocalLengthIn35mmFilm" to "Фокусное (35 мм)", + "ISOSpeedRatings" to "ISO", + "PhotographicSensitivity" to "ISO", + "ExposureProgram" to "Программа съёмки", + "ExposureMode" to "Режим экспозиции", + "ExposureBiasValue" to "Экспокоррекция", + "BrightnessValue" to "Яркость (APEX)", + "MeteringMode" to "Режим замера", + "LightSource" to "Источник света", + "Flash" to "Вспышка", + "WhiteBalance" to "Баланс белого", + "Orientation" to "Ориентация", + "Software" to "ПО", + "ColorSpace" to "Цветовое пространство", + "SceneCaptureType" to "Тип сцены", + "Contrast" to "Контраст", + "Saturation" to "Насыщенность", + "Sharpness" to "Резкость", + "CustomRendered" to "Обработка", + "GainControl" to "Усиление", + "SensingMethod" to "Метод съёмки", + "SubjectDistanceRange" to "Дистанция", + "DigitalZoomRatio" to "Цифровой зум", + "CompressedBitsPerPixel" to "Сжатие (бит/пиксель)", + "ImageDescription" to "Описание", +) + +/** Attributes excluded from the derived list (already shown elsewhere or noise). */ +val EXIF_SKIP: Set = setOf( + "GPSLatitude", "GPSLongitude", "GPSLatitudeRef", "GPSLongitudeRef", + "GPSAltitude", "GPSAltitudeRef", "GPSVersionID", "GPSDateStamp", + "GPSTimeStamp", "GPSProcessingMethod", "GPSAreaInformation", "GPSImgDirection", + "GPSImgDirectionRef", "GPSDestBearing", "GPSDestBearingRef", "GPSSpeed", + "GPSSpeedRef", "GPSTrack", "GPSTrackRef", "GPSMapDatum", "GPSDifferential", + "DateTime", "DateTimeDigitized", "DateTimeOriginal", "SubSecTime", + "SubSecTimeDigitized", "SubSecTimeOriginal", "ExifVersion", "FlashpixVersion", + "InteroperabilityVersion", "InteroperabilityIndex", "ExifTag", "GPSTag", + "InteroperabilityTag", "ExifIFDPointer", "GPSInfoIFDPointer", + "ComponentsConfiguration", "MakerNote", "UserComment", "FileSource", + "SceneType", "PhotometricInterpretation", "YCbCrPositioning", "ResolutionUnit", + "BitsPerSample", "XResolution", "YResolution", "ImageWidth", "ImageLength", + "PixelXDimension", "PixelYDimension", +) + +/** Value dictionaries for enum-like attributes, mirroring web `EXIF_ENUMS`. */ +val EXIF_ENUMS: Map> = mapOf( + "Orientation" to mapOf( + 1 to "Горизонтальная", 2 to "Зеркальная (гориз.)", 3 to "Повёрнута на 180°", + 4 to "Зеркальная (верт.)", 5 to "Зеркальная + 90° CW", 6 to "Повёрнута на 90° CW", + 7 to "Зеркальная + 90° CCW", 8 to "Повёрнута на 90° CCW", + ), + "ExposureProgram" to mapOf( + 0 to "Не задано", 1 to "Ручная", 2 to "Программная", 3 to "Приоритет диафрагмы", + 4 to "Приоритет выдержки", 5 to "Творческая", 6 to "Спортивная", 7 to "Портрет", + 8 to "Пейзаж", + ), + "ExposureMode" to mapOf(0 to "Авто", 1 to "Ручной", 2 to "Авто-брекетинг"), + "MeteringMode" to mapOf( + 0 to "Неизвестно", 1 to "Средневзвешенный", 2 to "Центрально-взвешенный", + 3 to "Точечный", 4 to "Многозонный", 5 to "Матричный", 6 to "Частичный", + 255 to "Другое", + ), + "LightSource" to mapOf( + 0 to "Неизвестно", 1 to "Дневной свет", 2 to "Люминесцентный", + 3 to "Лампа накаливания", 4 to "Вспышка", 9 to "Хорошая погода", 10 to "Облачно", + 11 to "Тень", + ), + "Flash" to mapOf( + 0 to "Не сработала", 1 to "Сработала", 9 to "Сработала (принуд.)", + 16 to "Отключена", 24 to "Не сработала (авто)", 25 to "Сработала (авто)", + ), + "WhiteBalance" to mapOf(0 to "Авто", 1 to "Ручной"), + "SceneCaptureType" to mapOf( + 0 to "Стандартный", 1 to "Пейзаж", 2 to "Портрет", 3 to "Ночной", + ), + "Contrast" to mapOf(0 to "Обычный", 1 to "Мягкий", 2 to "Жёсткий"), + "Saturation" to mapOf(0 to "Обычная", 1 to "Низкая", 2 to "Высокая"), + "Sharpness" to mapOf(0 to "Обычная", 1 to "Мягкая", 2 to "Жёсткая"), + "CustomRendered" to mapOf(0 to "Обычная", 1 to "Особая"), + "GainControl" to mapOf( + 0 to "Нет", 1 to "Малое усиление", 2 to "Большое усиление", 3 to "Низкое усиление", + 4 to "Высокое усиление", + ), + "SensingMethod" to mapOf( + 1 to "Не задано", 2 to "Однокристальный", 3 to "Двухчиповый", 4 to "Трёхчиповый", + 5 to "Цветной последовательный", 7 to "Трёхлинейный", 8 to "Цветной последовательный", + ), + "SubjectDistanceRange" to mapOf(0 to "Неизвестно", 1 to "Макро", 2 to "Близко", 3 to "Далеко"), + "ColorSpace" to mapOf(1 to "sRGB", 65535 to "Некалиброванное"), +) + +/** Base attribute set shown before the user saves their own selection. */ +val EXIF_BASE: List = listOf( + "Make", + "Model", + "LensModel", + "ExposureTime", + "FNumber", + "ISOSpeedRatings", + "FocalLength", + "FocalLengthIn35mmFilm", +) + +/** All known attribute keys, mirroring web `EXIF_CATALOG`. */ +val EXIF_CATALOG: List = EXIF_LABELS.keys.toList() + +data class ExifRow(val key: String, val label: String, val value: String) + +private val ruCollator: Collator = Collator.getInstance(Locale("ru")) + +/** Formats a raw EXIF value for display, mirroring web `formatExifValue`. */ +fun formatExifValue(key: String, value: String?): String { + if (value == null) return "" + val n = value.toDoubleOrNull() + if (key == "ExposureTime" && n != null && n > 0) { + return if (n >= 1) "${jsNumber(n)} с" else "1/${Math.round(1 / n)}" + } + if ((key == "FNumber" || key == "ApertureValue" || key == "MaxApertureValue") && n != null) { + return "f/${jsNumber(n)}" + } + if ((key == "FocalLength" || key == "FocalLengthIn35mmFilm") && n != null) { + return "${jsNumber(n)} мм" + } + if (key == "ExposureBiasValue" && n != null) { + return "${if (n > 0) "+" else ""}${jsNumber(n)} EV" + } + val enums = EXIF_ENUMS[key] + if (enums != null && n != null) { + val label = enums[n.toInt()] + if (label != null) return label + } + return value +} + +/** Renders a number like JavaScript's `${n}`: integral values lose the `.0`. */ +private fun jsNumber(n: Double): String = + if (n.isFinite() && n == Math.floor(n)) n.toLong().toString() else n.toString() + +/** Builds the visible attribute rows for a photo, mirroring web `buildExifRows`. */ +fun buildExifRows( + exif: Map, + labels: Map = emptyMap(), +): List = + exif.entries + .filter { (k, v) -> k !in EXIF_SKIP && v.isNotBlank() } + .map { (k, v) -> ExifRow(k, labels[k] ?: EXIF_LABELS[k] ?: k, formatExifValue(k, v)) } + .sortedWith { a, b -> ruCollator.compare(a.label, b.label) } diff --git a/android/app/src/main/java/com/galo/android/domain/model/DeviceAlbums.kt b/android/app/src/main/java/com/galo/android/domain/model/DeviceAlbums.kt index 592aca4..59c6ec2 100644 --- a/android/app/src/main/java/com/galo/android/domain/model/DeviceAlbums.kt +++ b/android/app/src/main/java/com/galo/android/domain/model/DeviceAlbums.kt @@ -1,6 +1,6 @@ package com.galo.android.domain.model -/** An album of the device (a MediaStore bucket). */ +/** A device album of the device (a MediaStore bucket). */ data class DeviceAlbum( val id: String, val name: String, @@ -9,6 +9,13 @@ data class DeviceAlbum( val relativePath: String, ) +/** App-only metadata for a device album: display name override and chosen cover. */ +data class DeviceAlbumMeta( + val deviceAlbumId: String, + val displayName: String?, + val coverUri: String?, +) + /** A single media item of the device found in a [DeviceAlbum]. */ data class DeviceMedia( val id: Long, diff --git a/android/app/src/main/java/com/galo/android/domain/model/Models.kt b/android/app/src/main/java/com/galo/android/domain/model/Models.kt index 8e623ec..3f3feb6 100644 --- a/android/app/src/main/java/com/galo/android/domain/model/Models.kt +++ b/android/app/src/main/java/com/galo/android/domain/model/Models.kt @@ -63,6 +63,8 @@ data class Photo( val size: Long, val takenAt: String?, val caption: String, + val latitude: Double? = null, + val longitude: Double? = null, val isPanorama: Boolean, val thumbUrl: String?, val mediumUrl: String?, @@ -71,9 +73,19 @@ data class Photo( val originalUrl: String?, val downloadUrl: String?, val createdAt: String?, + val tags: List = emptyList(), + val exif: Map = emptyMap(), + val uploaderName: String? = null, + val uploaderAvatarUrl: String? = null, ) data class Page( val items: List, val nextCursor: String?, ) + +/** A user-scoped label attached to a photo. */ +data class Tag( + val id: String, + val name: String, +) diff --git a/android/app/src/main/java/com/galo/android/domain/model/Sync.kt b/android/app/src/main/java/com/galo/android/domain/model/Sync.kt index 61dadf2..7f836b2 100644 --- a/android/app/src/main/java/com/galo/android/domain/model/Sync.kt +++ b/android/app/src/main/java/com/galo/android/domain/model/Sync.kt @@ -15,6 +15,10 @@ enum class SyncMode { companion object { fun from(raw: String?): SyncMode = entries.firstOrNull { it.name == raw } ?: DISABLED + + /** Parses a stored mode, returning null for anything that is not a mode. */ + fun parseOrNull(raw: String?): SyncMode? = + entries.firstOrNull { it.name == raw } } } diff --git a/android/app/src/main/java/com/galo/android/domain/sync/GallerySyncPolicy.kt b/android/app/src/main/java/com/galo/android/domain/sync/GallerySyncPolicy.kt new file mode 100644 index 0000000..289f08b --- /dev/null +++ b/android/app/src/main/java/com/galo/android/domain/sync/GallerySyncPolicy.kt @@ -0,0 +1,41 @@ +package com.galo.android.domain.sync + +import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.SyncMode + +/** + * What the synchronization check should do with one device asset, given its mode + * and recorded state. Makes the whole matrix explicit so no combination (for + * example copy mode holding offloaded previews) is left unhandled. + */ +object GallerySyncPolicy { + + /** The local fix to apply to an asset (upload/link is done by the reconciler). */ + enum class LocalFix { UPLOAD, OFFLOAD, RESTORE, NONE } + + fun decide(mode: SyncMode, state: LocalAssetState, hasServerPhoto: Boolean): LocalFix = when (state) { + // Known on the server but never on this device: nothing local to fix. + LocalAssetState.SERVER_ONLY -> LocalFix.NONE + + // Only a preview locally: restore in copy/off, keep as-is in move. + LocalAssetState.PREVIEW -> + if (mode == SyncMode.MOVE) LocalFix.NONE else LocalFix.RESTORE + + // Original still local, already on the server: offload in move, keep otherwise. + LocalAssetState.UPLOADED -> offloadIfMove(mode, hasServerPhoto) + + // Uploaded and waiting to free: same as uploaded for fixing purposes. + LocalAssetState.AWAITING_FREE -> offloadIfMove(mode, hasServerPhoto) + + // Original local, not yet on the server. + LocalAssetState.LOCAL -> when { + hasServerPhoto && mode == SyncMode.MOVE -> LocalFix.OFFLOAD + hasServerPhoto -> LocalFix.NONE + mode == SyncMode.DISABLED -> LocalFix.NONE + else -> LocalFix.UPLOAD + } + } + + private fun offloadIfMove(mode: SyncMode, hasServerPhoto: Boolean): LocalFix = + if (mode == SyncMode.MOVE && hasServerPhoto) LocalFix.OFFLOAD else LocalFix.NONE +} diff --git a/android/app/src/main/java/com/galo/android/ui/GaloNavHost.kt b/android/app/src/main/java/com/galo/android/ui/GaloNavHost.kt index 16a8aba..7ea2368 100644 --- a/android/app/src/main/java/com/galo/android/ui/GaloNavHost.kt +++ b/android/app/src/main/java/com/galo/android/ui/GaloNavHost.kt @@ -2,11 +2,22 @@ package com.galo.android.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -15,10 +26,13 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.galo.android.GaloApplication import com.galo.android.data.prefs.SettingsState +import com.galo.android.domain.media.AllFilesAccess import com.galo.android.ui.album.AlbumScreen +import com.galo.android.ui.album.AlbumSettingsScreen import com.galo.android.ui.albums.AlbumsScreen import com.galo.android.ui.auth.AuthScreen import com.galo.android.ui.devicealbum.DeviceAlbumScreen +import com.galo.android.ui.photo.AttributeSettingsScreen import com.galo.android.ui.profile.ProfileScreen import com.galo.android.ui.setup.SetupScreen import com.galo.android.ui.upload.UploadScreen @@ -31,11 +45,15 @@ object Routes { const val UPLOADS = "uploads" const val PROFILE = "profile" const val ALBUM = "album/{albumId}" + const val ALBUM_SETTINGS = "album/{albumId}/settings" const val DEVICE_ALBUM = "deviceAlbum/{deviceAlbumId}" const val VIEWER = "album/{albumId}/viewer?index={index}" + const val ATTRIBUTE_SETTINGS = "attributeSettings" fun album(albumId: String) = "album/$albumId" + fun albumSettings(albumId: String) = "album/$albumId/settings" + fun deviceAlbum(deviceAlbumId: String) = "deviceAlbum/$deviceAlbumId" fun viewer(albumId: String, index: Int) = "album/$albumId/viewer?index=$index" @@ -57,6 +75,22 @@ fun GaloNavHost(app: GaloApplication) { } val navController = rememberNavController() + + // On launch (once the app is operational) ask for the "all files" access that + // makes moving originals to the server work without per-item prompts. + val context = LocalContext.current + var allFilesPrompt by remember { mutableStateOf(false) } + val allFilesLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { + allFilesPrompt = !AllFilesAccess.isGranted() + } + val operational = current.baseUrl.isNotBlank() && + !container.tokenStore.accessToken().isNullOrBlank() + LaunchedEffect(operational) { + if (operational && !AllFilesAccess.isGranted()) allFilesPrompt = true + } + val start = when { current.baseUrl.isBlank() -> Routes.SETUP container.tokenStore.accessToken().isNullOrBlank() -> Routes.AUTH @@ -114,6 +148,12 @@ fun GaloNavHost(app: GaloApplication) { onBack = { navController.popBackStack() }, ) } + composable(Routes.ATTRIBUTE_SETTINGS) { + AttributeSettingsScreen( + app = app, + onBack = { navController.popBackStack() }, + ) + } composable( route = Routes.ALBUM, arguments = listOf(navArgument("albumId") { type = NavType.StringType }), @@ -125,6 +165,18 @@ fun GaloNavHost(app: GaloApplication) { onOpenViewer = { index -> navController.navigate(Routes.viewer(albumId, index)) }, + onOpenSettings = { navController.navigate(Routes.albumSettings(albumId)) }, + onBack = { navController.popBackStack() }, + ) + } + composable( + route = Routes.ALBUM_SETTINGS, + arguments = listOf(navArgument("albumId") { type = NavType.StringType }), + ) { entry -> + val albumId = entry.arguments?.getString("albumId").orEmpty() + AlbumSettingsScreen( + app = app, + albumId = albumId, onBack = { navController.popBackStack() }, ) } @@ -136,6 +188,7 @@ fun GaloNavHost(app: GaloApplication) { DeviceAlbumScreen( app = app, deviceAlbumId = deviceAlbumId, + onOpenServerSettings = { id -> navController.navigate(Routes.albumSettings(id)) }, onBack = { navController.popBackStack() }, ) } @@ -156,7 +209,43 @@ fun GaloNavHost(app: GaloApplication) { albumId = albumId, initialIndex = index, onBack = { navController.popBackStack() }, + onOpenAttributeSettings = { navController.navigate(Routes.ATTRIBUTE_SETTINGS) }, ) } } + + if (allFilesPrompt) { + AllFilesAccessDialog( + onEnable = { + runCatching { allFilesLauncher.launch(AllFilesAccess.settingsIntent(context)) } + }, + onDismiss = { allFilesPrompt = false }, + ) + } +} + +const val TAG_ALL_FILES_PROMPT_DIALOG = "all_files_prompt_dialog" +const val TAG_ALL_FILES_PROMPT_ENABLE = "all_files_prompt_enable" + +/** Startup prompt to grant the "all files" access needed to free space on move. */ +@Composable +fun AllFilesAccessDialog(onEnable: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TAG_ALL_FILES_PROMPT_DIALOG), + title = { Text("Доступ к файлам") }, + text = { + Text( + "Чтобы переносить фото на сервер и освобождать место на телефоне, " + + "разрешите galo доступ «Все файлы».", + ) + }, + confirmButton = { + TextButton( + onClick = onEnable, + modifier = Modifier.testTag(TAG_ALL_FILES_PROMPT_ENABLE), + ) { Text("Включить") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Позже") } }, + ) } diff --git a/android/app/src/main/java/com/galo/android/ui/album/AlbumScreen.kt b/android/app/src/main/java/com/galo/android/ui/album/AlbumScreen.kt index 3e961da..108d6a5 100644 --- a/android/app/src/main/java/com/galo/android/ui/album/AlbumScreen.kt +++ b/android/app/src/main/java/com/galo/android/ui/album/AlbumScreen.kt @@ -8,28 +8,36 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -40,13 +48,24 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.galo.android.GaloApplication +import com.galo.android.domain.attributes.AlbumAttributeSource +import com.galo.android.domain.attributes.AlbumAttributesResolver import com.galo.android.domain.model.MediaKind import com.galo.android.domain.model.Photo import com.galo.android.ui.common.GaloAsyncImage +import com.galo.android.ui.panel.AlbumAttributesPanel +import com.galo.android.ui.panel.EndDrawerScaffold const val TAG_ALBUM_GRID = "album_grid" const val TAG_ALBUM_OFFLINE = "album_offline" const val TAG_PHOTO_PREFIX = "photo_" +const val TAG_ALBUM_RENAME_DIALOG = "album_rename_dialog" +const val TAG_ALBUM_RENAME_FIELD = "album_rename_field" +const val TAG_ALBUM_RENAME_CONFIRM = "album_rename_confirm" +const val TAG_ALBUM_DELETE_DIALOG = "album_delete_dialog" +const val TAG_ALBUM_DELETE_CONFIRM = "album_delete_confirm" +const val TAG_COVER_PICKER = "cover_picker" +const val TAG_COVER_OPTION_PREFIX = "cover_option_" @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -54,6 +73,7 @@ fun AlbumScreen( app: GaloApplication, albumId: String, onOpenViewer: (Int) -> Unit, + onOpenSettings: () -> Unit, onBack: () -> Unit, ) { val factory = viewModelFactory { @@ -62,6 +82,15 @@ fun AlbumScreen( val viewModel: AlbumViewModel = viewModel(factory = factory) val state by viewModel.state.collectAsStateWithLifecycle() + var panelOpen by remember { mutableStateOf(false) } + var renameOpen by remember { mutableStateOf(false) } + var deleteOpen by remember { mutableStateOf(false) } + var coverPickOpen by remember { mutableStateOf(false) } + + val attributes = state.album?.let { + AlbumAttributesResolver.resolve(AlbumAttributeSource.Server(it, state.role)) + } + Scaffold( topBar = { TopAppBar( @@ -79,47 +108,67 @@ fun AlbumScreen( ) }, ) { padding -> - Column(modifier = Modifier.fillMaxSize().padding(padding)) { - if (state.offline) { - Surface( - color = MaterialTheme.colorScheme.errorContainer, - modifier = Modifier.fillMaxWidth().testTag(TAG_ALBUM_OFFLINE), - ) { - Text( - text = state.error ?: "Офлайн-режим", - color = MaterialTheme.colorScheme.onErrorContainer, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + EndDrawerScaffold( + open = panelOpen, + onOpenChange = { panelOpen = it }, + modifier = Modifier.padding(padding), + drawerContent = { + if (attributes != null) { + AlbumAttributesPanel( + attributes = attributes, + onPickCover = { coverPickOpen = true }, + onResetCover = viewModel::resetCover, + onOpenMembers = onOpenSettings, + onOpenSettings = onOpenSettings, + onOpenShare = onOpenSettings, + onRename = { renameOpen = true }, + onDelete = { deleteOpen = true }, ) } - } - when { - state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - - state.photos.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("В альбоме пока нет медиа") - } - - else -> LazyVerticalGrid( - columns = GridCells.Fixed(3), - contentPadding = PaddingValues(2.dp), - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - modifier = Modifier.fillMaxSize().testTag(TAG_ALBUM_GRID), - ) { - itemsIndexed(state.photos, key = { _, p -> p.id }) { index, photo -> - PhotoCell(photo = photo, onClick = { onOpenViewer(index) }) + }, + ) { + Column(modifier = Modifier.fillMaxSize()) { + if (state.offline) { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth().testTag(TAG_ALBUM_OFFLINE), + ) { + Text( + text = state.error ?: "Офлайн-режим", + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) } - if (!state.endReached) { - item(span = { GridItemSpan(maxLineSpan) }) { - LaunchedEffect(state.photos.size) { viewModel.loadNextPage() } - Box( - Modifier.fillMaxWidth().padding(16.dp), - contentAlignment = Alignment.Center, - ) { - if (state.loadingMore) CircularProgressIndicator(Modifier.size(24.dp)) + } + when { + state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + + state.photos.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("В альбоме пока нет медиа") + } + + else -> LazyVerticalGrid( + columns = GridCells.Fixed(3), + contentPadding = PaddingValues(2.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.fillMaxSize().testTag(TAG_ALBUM_GRID), + ) { + itemsIndexed(state.photos, key = { _, p -> p.id }) { index, photo -> + PhotoCell(photo = photo, onClick = { onOpenViewer(index) }) + } + if (!state.endReached) { + item(span = { GridItemSpan(maxLineSpan) }) { + LaunchedEffect(state.photos.size) { viewModel.loadNextPage() } + Box( + Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center, + ) { + if (state.loadingMore) CircularProgressIndicator(Modifier.size(24.dp)) + } } } } @@ -127,6 +176,129 @@ fun AlbumScreen( } } } + + if (renameOpen) { + RenameAlbumDialog( + initial = state.album?.title.orEmpty(), + onConfirm = { + viewModel.rename(it) + renameOpen = false + }, + onDismiss = { renameOpen = false }, + ) + } + + if (deleteOpen) { + DeleteAlbumDialog( + title = state.album?.title.orEmpty(), + onConfirm = { + deleteOpen = false + viewModel.delete(onDeleted = onBack) + }, + onDismiss = { deleteOpen = false }, + ) + } + + if (coverPickOpen) { + CoverPickerDialog( + photos = state.photos, + onPick = { + viewModel.setCover(it) + coverPickOpen = false + }, + onDismiss = { coverPickOpen = false }, + ) + } +} + +@Composable +fun RenameAlbumDialog( + initial: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var text by remember { mutableStateOf(initial) } + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TAG_ALBUM_RENAME_DIALOG), + title = { Text("Переименовать альбом") }, + text = { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + singleLine = true, + label = { Text("Название") }, + modifier = Modifier.fillMaxWidth().testTag(TAG_ALBUM_RENAME_FIELD), + ) + }, + confirmButton = { + TextButton( + onClick = { onConfirm(text) }, + enabled = text.isNotBlank(), + modifier = Modifier.testTag(TAG_ALBUM_RENAME_CONFIRM), + ) { Text("Сохранить") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } }, + ) +} + +@Composable +fun DeleteAlbumDialog( + title: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TAG_ALBUM_DELETE_DIALOG), + title = { Text("Удалить альбом?") }, + text = { Text("«$title» и его фото будут перемещены в корзину.") }, + confirmButton = { + TextButton( + onClick = onConfirm, + modifier = Modifier.testTag(TAG_ALBUM_DELETE_CONFIRM), + ) { Text("Удалить") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } }, + ) +} + +@Composable +fun CoverPickerDialog( + photos: List, + onPick: (String) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TAG_COVER_PICKER), + title = { Text("Выбрать обложку") }, + text = { + if (photos.isEmpty()) { + Text("В альбоме нет фото") + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.heightIn(max = 360.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + items(photos, key = { it.id }) { photo -> + GaloAsyncImage( + url = photo.thumbUrl ?: photo.mediumUrl ?: photo.previewUrl, + contentDescription = photo.originalFilename, + modifier = Modifier + .aspectRatio(1f) + .testTag(TAG_COVER_OPTION_PREFIX + photo.id) + .clickable { onPick(photo.id) }, + ) + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } }, + ) } @Composable diff --git a/android/app/src/main/java/com/galo/android/ui/album/AlbumSettingsScreen.kt b/android/app/src/main/java/com/galo/android/ui/album/AlbumSettingsScreen.kt new file mode 100644 index 0000000..03e1a37 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/album/AlbumSettingsScreen.kt @@ -0,0 +1,178 @@ +package com.galo.android.ui.album + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AddLink +import androidx.compose.material.icons.filled.LinkOff +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.galo.android.GaloApplication + +const val TAG_SETTINGS_TITLE_FIELD = "settings_title_field" +const val TAG_SETTINGS_DESC_FIELD = "settings_desc_field" +const val TAG_SETTINGS_SAVE = "settings_save" +const val TAG_SETTINGS_SAVED = "settings_saved" +const val TAG_SETTINGS_MEMBER_PREFIX = "settings_member_" +const val TAG_SETTINGS_LINK_CREATE = "settings_link_create" +const val TAG_SETTINGS_LINK_PREFIX = "settings_link_" +const val TAG_SETTINGS_LINK_REVOKE_PREFIX = "settings_link_revoke_" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AlbumSettingsScreen( + app: GaloApplication, + albumId: String, + onBack: () -> Unit, +) { + val factory = viewModelFactory { + initializer { AlbumSettingsViewModel(app.container, albumId) } + } + val viewModel: AlbumSettingsViewModel = viewModel(factory = factory) + val state by viewModel.state.collectAsStateWithLifecycle() + AlbumSettingsContent( + state = state, + onSave = viewModel::save, + onCreateLink = viewModel::createLink, + onRevokeLink = viewModel::revokeLink, + onBack = onBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AlbumSettingsContent( + state: AlbumSettingsViewModel.UiState, + onSave: (String, String) -> Unit, + onCreateLink: (String) -> Unit, + onRevokeLink: (String) -> Unit, + onBack: () -> Unit, +) { + var title by remember(state.title) { mutableStateOf(state.title) } + var description by remember(state.description) { mutableStateOf(state.description) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Настройки") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + + HorizontalDivider() + Text("Альбом", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("Название") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag(TAG_SETTINGS_TITLE_FIELD), + ) + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Описание") }, + modifier = Modifier.fillMaxWidth().testTag(TAG_SETTINGS_DESC_FIELD), + ) + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton( + onClick = { onSave(title, description) }, + modifier = Modifier.testTag(TAG_SETTINGS_SAVE), + ) { + Icon(Icons.Filled.Save, contentDescription = null) + Text(" Сохранить") + } + if (state.saved) { + Text( + "Сохранено", + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.testTag(TAG_SETTINGS_SAVED), + ) + } + } + + HorizontalDivider() + Text("Участники", style = MaterialTheme.typography.titleMedium) + state.members.forEach { member -> + Text( + text = "${member.user.name} · ${member.role}", + modifier = Modifier + .fillMaxWidth() + .testTag(TAG_SETTINGS_MEMBER_PREFIX + member.userId), + ) + } + + HorizontalDivider() + Text("Ссылки для просмотра", style = MaterialTheme.typography.titleMedium) + TextButton( + onClick = { onCreateLink("168h") }, + modifier = Modifier.testTag(TAG_SETTINGS_LINK_CREATE), + ) { + Icon(Icons.Filled.AddLink, contentDescription = null) + Text(" Создать ссылку") + } + state.shareLinks.forEach { link -> + Row( + modifier = Modifier.fillMaxWidth().testTag(TAG_SETTINGS_LINK_PREFIX + link.id), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "/share/${link.token}", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + ) + TextButton( + onClick = { onRevokeLink(link.id) }, + modifier = Modifier.testTag(TAG_SETTINGS_LINK_REVOKE_PREFIX + link.id), + ) { + Icon(Icons.Filled.LinkOff, contentDescription = null) + Text(" Отозвать") + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/album/AlbumSettingsViewModel.kt b/android/app/src/main/java/com/galo/android/ui/album/AlbumSettingsViewModel.kt new file mode 100644 index 0000000..2b5c7bf --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/album/AlbumSettingsViewModel.kt @@ -0,0 +1,93 @@ +package com.galo.android.ui.album + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.galo.android.data.network.dto.MemberDto +import com.galo.android.data.network.dto.ShareLinkDto +import com.galo.android.di.AppContainer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class AlbumSettingsViewModel( + private val container: AppContainer, + private val albumId: String, +) : ViewModel() { + + data class UiState( + val title: String = "", + val description: String = "", + val members: List = emptyList(), + val shareLinks: List = emptyList(), + val loading: Boolean = true, + val saved: Boolean = false, + val error: String? = null, + ) + + private val _state = MutableStateFlow(UiState()) + val state: StateFlow = _state.asStateFlow() + + init { + load() + } + + fun load() { + viewModelScope.launch { + runCatching { + val album = container.api.album(albumId) + val members = container.api.members(albumId) + val links = container.api.shareLinks(albumId) + Triple(album, members, links) + }.onSuccess { (album, members, links) -> + _state.update { + it.copy( + title = album.title, + description = album.description, + members = members, + shareLinks = links, + loading = false, + error = null, + ) + } + }.onFailure { e -> + _state.update { it.copy(loading = false, error = e.message) } + } + } + } + + fun save(title: String, description: String) { + viewModelScope.launch { + runCatching { + container.api.updateAlbum(albumId, title = title.trim(), description = description) + }.onSuccess { album -> + _state.update { + it.copy(title = album.title, description = album.description, saved = true, error = null) + } + }.onFailure { e -> + _state.update { it.copy(error = e.message) } + } + } + } + + fun createLink(expiresIn: String) { + viewModelScope.launch { + runCatching { container.api.createShareLink(albumId, expiresIn) } + .onSuccess { link -> + _state.update { it.copy(shareLinks = it.shareLinks + link, error = null) } + } + .onFailure { e -> _state.update { it.copy(error = e.message) } } + } + } + + fun revokeLink(id: String) { + viewModelScope.launch { + runCatching { container.api.revokeShareLink(albumId, id) } + .onSuccess { + _state.update { s -> s.copy(shareLinks = s.shareLinks.filterNot { it.id == id }) } + } + .onFailure { e -> _state.update { it.copy(error = e.message) } } + } + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/album/AlbumViewModel.kt b/android/app/src/main/java/com/galo/android/ui/album/AlbumViewModel.kt index 3641c79..4c3cd37 100644 --- a/android/app/src/main/java/com/galo/android/ui/album/AlbumViewModel.kt +++ b/android/app/src/main/java/com/galo/android/ui/album/AlbumViewModel.kt @@ -22,6 +22,7 @@ class AlbumViewModel( data class UiState( val album: Album? = null, + val role: String = "", val photos: List = emptyList(), val loading: Boolean = true, val loadingMore: Boolean = false, @@ -40,6 +41,7 @@ class AlbumViewModel( ) private val status = MutableStateFlow(Status()) + private val role = MutableStateFlow("") /** Watermark for `?since=` backfill; advanced as events are applied. */ private var lastSync: String = Instant.now().toString() @@ -48,9 +50,11 @@ class AlbumViewModel( container.albumRepository.observeAlbum(albumId), container.photoRepository.observePhotos(albumId), status, - ) { album, photos, s -> + role, + ) { album, photos, s, r -> UiState( album = album, + role = r, photos = photos, loading = !s.loaded, loadingMore = s.loadingMore, @@ -61,11 +65,63 @@ class AlbumViewModel( }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState()) init { - viewModelScope.launch { container.albumRepository.loadAlbum(albumId) } + viewModelScope.launch { loadAlbum() } loadNextPage() observeEvents() } + private suspend fun loadAlbum() { + container.albumRepository.loadAlbum(albumId) + runCatching { container.api.albumEnvelope(albumId).role } + .getOrNull() + ?.let { role.value = it } + } + + /** Renames the album (title). */ + fun rename(title: String) { + val trimmed = title.trim() + if (trimmed.isEmpty()) return + viewModelScope.launch { + runCatching { + container.api.updateAlbum(albumId, title = trimmed) + container.albumRepository.loadAlbum(albumId) + }.onFailure { e -> + status.update { it.copy(error = (e as? Exception)?.message ?: "Ошибка переименования") } + } + } + } + + /** Sets a photo as the album cover. */ + fun setCover(photoId: String) { + viewModelScope.launch { + runCatching { + container.api.updateAlbum(albumId, coverPhotoId = photoId) + container.albumRepository.loadAlbum(albumId) + } + } + } + + /** Clears the album cover. */ + fun resetCover() { + viewModelScope.launch { + runCatching { + container.api.updateAlbum(albumId, clearCover = true) + container.albumRepository.loadAlbum(albumId) + } + } + } + + /** Deletes the album; [onDeleted] runs after the server confirms. */ + fun delete(onDeleted: () -> Unit) { + viewModelScope.launch { + runCatching { container.api.deleteAlbum(albumId) } + .onSuccess { onDeleted() } + .onFailure { e -> + status.update { it.copy(error = (e as? Exception)?.message ?: "Ошибка удаления") } + } + } + } + /** Live updates while the screen is alive; reconnect backfills missed changes. */ private fun observeEvents() { viewModelScope.launch { @@ -120,7 +176,7 @@ class AlbumViewModel( fun refresh() { status.update { it.copy(loaded = false, endReached = false, nextCursor = null) } - viewModelScope.launch { container.albumRepository.loadAlbum(albumId) } + viewModelScope.launch { loadAlbum() } loadNextPage() } } diff --git a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt index 6c65e65..7b08129 100644 --- a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt +++ b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt @@ -10,13 +10,16 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.CloudUpload import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.SdCard import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -50,9 +53,9 @@ const val TAG_ALBUMS_OFFLINE = "albums_offline" const val TAG_ALBUMS_AVATAR = "albums_avatar" const val TAG_ALBUM_CARD_PREFIX = "album_card_" const val TAG_DEVICE_ALBUM_CARD_PREFIX = "device_album_card_" -const val TAG_ALBUM_SOURCE_PREFIX = "album_source_" -const val TAG_ALBUM_LINK_PREFIX = "album_link_" -const val TAG_ALBUM_MODE_PREFIX = "album_mode_" +const val TAG_ALBUM_STORAGE_DEVICE_PREFIX = "album_storage_device_" +const val TAG_ALBUM_STORAGE_CLOUD_PREFIX = "album_storage_cloud_" +const val TAG_ALBUM_COUNT_PREFIX = "album_count_" @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -166,68 +169,71 @@ fun AlbumsContent( } } +/** Where a device album's media live. */ +private enum class StoragePlacement { DEVICE, DEVICE_AND_CLOUD, CLOUD } + @Composable private fun AlbumCardBody( + id: String, title: String, - counter: String, - source: String, - link: String?, - key: String, - mode: String? = null, + placement: StoragePlacement, + count: String, + arrowTo: String?, ) { Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = counter, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) Row(verticalAlignment = Alignment.CenterVertically) { - Surface( - color = MaterialTheme.colorScheme.secondaryContainer, - shape = MaterialTheme.shapes.small, - modifier = Modifier.testTag(TAG_ALBUM_SOURCE_PREFIX + key), - ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + if (arrowTo != null) { Text( - text = source, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - ) - } - if (mode != null) { - Text( - text = mode, - style = MaterialTheme.typography.labelSmall, + text = "→ $arrowTo", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .padding(start = 6.dp) - .testTag(TAG_ALBUM_MODE_PREFIX + key), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 6.dp), ) } } - if (link != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (placement != StoragePlacement.CLOUD) { + Icon( + imageVector = Icons.Filled.SdCard, + contentDescription = "Карта памяти", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp).testTag(TAG_ALBUM_STORAGE_DEVICE_PREFIX + id), + ) + } + if (placement != StoragePlacement.DEVICE) { + Icon( + imageVector = Icons.Filled.Cloud, + contentDescription = "Облако", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(start = if (placement == StoragePlacement.CLOUD) 0.dp else 4.dp) + .size(16.dp) + .testTag(TAG_ALBUM_STORAGE_CLOUD_PREFIX + id), + ) + } Text( - text = link, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, + text = count, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(top = 2.dp) - .testTag(TAG_ALBUM_LINK_PREFIX + key), + modifier = Modifier.padding(start = 6.dp).testTag(TAG_ALBUM_COUNT_PREFIX + id), ) } } } @Composable -private fun ServerAlbumCard(item: AlbumListItem.ServerAlbum, onClick: () -> Unit) { val album = item.album +private fun ServerAlbumCard(item: AlbumListItem.ServerAlbum, onClick: () -> Unit) { + val album = item.album val key = TAG_ALBUM_CARD_PREFIX + album.id Card( modifier = Modifier @@ -245,12 +251,11 @@ private fun ServerAlbumCard(item: AlbumListItem.ServerAlbum, onClick: () -> Unit .clip(MaterialTheme.shapes.medium), ) AlbumCardBody( + id = album.id, title = album.title, - counter = "${album.photosCount} фото · ${album.membersCount} участн.", - source = "Сервер", - link = item.linkedDeviceNames.takeIf { it.isNotEmpty() } - ?.joinToString(prefix = "источник: "), - key = key, + placement = StoragePlacement.CLOUD, + count = "${album.photosCount} фото", + arrowTo = null, ) } } @@ -260,6 +265,7 @@ private fun ServerAlbumCard(item: AlbumListItem.ServerAlbum, onClick: () -> Unit private fun DeviceAlbumCard(item: AlbumListItem.DeviceAlbumItem, onClick: () -> Unit) { val album: DeviceAlbum = item.album val key = TAG_DEVICE_ALBUM_CARD_PREFIX + album.id + val arrowTo = item.linkedServerTitle?.takeIf { it != album.name } Card( modifier = Modifier .fillMaxWidth() @@ -276,19 +282,19 @@ private fun DeviceAlbumCard(item: AlbumListItem.DeviceAlbumItem, onClick: () -> .clip(MaterialTheme.shapes.medium), ) AlbumCardBody( + id = album.id, title = album.name, - counter = "${album.itemCount} эл.", - source = "Телефон", - link = item.linkedServerTitle?.let { "→ $it" }, - key = key, - mode = albumModeLabel(item.mode), + placement = placementOf(item.mode), + count = "${album.itemCount} эл.", + arrowTo = arrowTo, ) } } } -private fun albumModeLabel(mode: SyncMode): String = when (mode) { - SyncMode.DISABLED -> "синхр. выкл." - SyncMode.COPY -> "копии" - SyncMode.MOVE -> "перенос" +/** Storage icon placement for a device album: where its media live. */ +private fun placementOf(mode: SyncMode): StoragePlacement = when (mode) { + SyncMode.DISABLED -> StoragePlacement.DEVICE + SyncMode.COPY -> StoragePlacement.DEVICE_AND_CLOUD + SyncMode.MOVE -> StoragePlacement.CLOUD } diff --git a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt index 3ce0808..f4b35ab 100644 --- a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt +++ b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt @@ -41,21 +41,31 @@ class AlbumsViewModel(private val container: AppContainer) : ViewModel() { val state: StateFlow = combine( container.albumRepository.observeAlbums(), container.syncRepository.observeLinks(), - combine(deviceAlbums, deviceAccess) { devices, access -> devices to access }, + combine(deviceAlbums, deviceAccess, container.syncRepository.observeAlbumMeta()) { devices, access, meta -> + Triple(devices, access, meta) + }, status, avatar, ) { albums, links, device, s, a -> + val withMeta = device.first.map { d -> + val m = device.third[d.id] + d.copy( + name = m?.displayName?.takeIf { it.isNotBlank() } ?: d.name, + coverUri = m?.coverUri ?: d.coverUri, + ) + } val items = AlbumListBuilder.build( albums = albums, links = links.map { link -> AlbumLink( deviceAlbumId = link.deviceAlbumId, - deviceAlbumName = link.deviceAlbumName, + deviceAlbumName = device.third[link.deviceAlbumId]?.displayName + ?.takeIf { it.isNotBlank() } ?: link.deviceAlbumName, serverAlbumId = link.serverAlbumId, mode = SyncMode.from(link.mode), ) }, - devices = device.first, + devices = withMeta, ) UiState( diff --git a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt index 125b9a2..ab7425e 100644 --- a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt +++ b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt @@ -1,5 +1,7 @@ package com.galo.android.ui.devicealbum +import android.content.Intent +import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.IntentSenderRequest import androidx.activity.result.contract.ActivityResultContracts @@ -64,12 +66,26 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.galo.android.GaloApplication +import com.galo.android.domain.attributes.AlbumAttributeSource +import com.galo.android.domain.attributes.AlbumAttributesResolver +import com.galo.android.domain.attributes.PhotoAttributeResolver +import com.galo.android.domain.attributes.PhotoAttributeSource +import com.galo.android.domain.exif.EXIF_BASE import com.galo.android.domain.media.AllFilesAccess import com.galo.android.domain.model.Album +import com.galo.android.domain.model.DeviceAlbum import com.galo.android.domain.model.DeviceMedia import com.galo.android.domain.model.MediaKind +import com.galo.android.domain.model.Photo import com.galo.android.domain.model.SyncMode +import com.galo.android.ui.album.RenameAlbumDialog import com.galo.android.ui.common.GaloAsyncImage +import com.galo.android.ui.panel.AlbumAttributesPanel +import com.galo.android.ui.panel.EndDrawerScaffold +import com.galo.android.ui.photo.DeletePhotoDialog +import com.galo.android.ui.photo.PhotoAttributesSheet +import com.galo.android.ui.photo.localPhotoSheetModel +import com.galo.android.ui.photo.serverPhotoSheetModel import kotlinx.coroutines.launch const val TAG_DEVICE_GRID = "device_album_grid" @@ -103,6 +119,7 @@ const val TAG_CREATE_TARGET = "device_create_target" fun DeviceAlbumScreen( app: GaloApplication, deviceAlbumId: String, + onOpenServerSettings: (String) -> Unit = {}, onBack: () -> Unit, ) { val factory = viewModelFactory { @@ -110,7 +127,14 @@ fun DeviceAlbumScreen( } val viewModel: DeviceAlbumViewModel = viewModel(factory = factory) val state by viewModel.state.collectAsStateWithLifecycle() + val role by viewModel.role.collectAsStateWithLifecycle() + val prefs by viewModel.preferences.collectAsStateWithLifecycle() + val meta by viewModel.albumMeta.collectAsStateWithLifecycle() var viewerIndex by remember { mutableStateOf(null) } + var deletePhotoId by remember { mutableStateOf(null) } + + val localName = meta?.displayName?.takeIf { it.isNotBlank() } ?: state.name + val localCoverUrl = meta?.coverUri val context = LocalContext.current var allFilesGranted by remember { mutableStateOf(AllFilesAccess.isGranted()) } @@ -119,6 +143,11 @@ fun DeviceAlbumScreen( ) { allFilesGranted = AllFilesAccess.isGranted() } + val deleteLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartIntentSenderForResult(), + ) { + viewModel.refreshAfterDelete() + } LifecycleResumeEffect(Unit) { allFilesGranted = AllFilesAccess.isGranted() onPauseOrDispose { } @@ -130,23 +159,97 @@ fun DeviceAlbumScreen( items = state.items, initialIndex = openIndex, onBack = { viewerIndex = null }, + serverPhotoByMediaId = state.serverPhotoByMediaId, + sheet = { media -> + when (val source = PhotoAttributeResolver.resolve(media, state.serverPhotoByMediaId[media.id])) { + is PhotoAttributeSource.Server -> { + var photo by remember(source.photoId) { mutableStateOf(null) } + LaunchedEffect(source.photoId) { photo = viewModel.serverPhoto(source.photoId) } + photo?.let { p -> + var showAll by remember(p.id) { mutableStateOf(false) } + var tagQuery by remember(p.id) { mutableStateOf("") } + var suggestions by remember(p.id) { mutableStateOf>(emptyList()) } + LaunchedEffect(tagQuery) { + if (tagQuery.isNotBlank()) suggestions = viewModel.tagSuggestions(tagQuery) + } + val model = serverPhotoSheetModel( + photo = p, + role = role, + visible = prefs?.visible?.toSet() ?: EXIF_BASE.toSet(), + labels = prefs?.labels.orEmpty(), + showAll = showAll, + ) + PhotoAttributesSheet( + model = model, + onDownload = { + p.downloadUrl?.let { url -> + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } + } + }, + onSetCover = { if (state.serverAlbumId != null) viewModel.setServerCover(p.id) }, + onDelete = { deletePhotoId = p.id }, + onAddTag = { viewModel.addServerTag(p.id, it) }, + onRemoveTag = { viewModel.removeServerTag(p.id, it) }, + tagSuggestions = suggestions.filter { s -> model.tags.none { it.equals(s, true) } }, + onShowAll = { showAll = true }, + ) + } + } + + is PhotoAttributeSource.Local -> { + PhotoAttributesSheet( + model = localPhotoSheetModel(media), + onShare = { + val share = Intent(Intent.ACTION_SEND).apply { + type = media.mimeType ?: "image/*" + putExtra(Intent.EXTRA_STREAM, Uri.parse(media.uri)) + } + runCatching { context.startActivity(Intent.createChooser(share, null)) } + }, + ) + } + } + }, originalSourceProvider = { mediaId -> viewModel.originalSource(mediaId) }, ) } else { DeviceAlbumContent( state = state, + role = role, + localName = localName, + localCoverUrl = localCoverUrl, onBack = onBack, onMode = viewModel::setMode, onTarget = viewModel::setTarget, onRestoreOriginals = viewModel::restoreOriginals, onOpen = { index -> viewerIndex = index }, onCreateAlbum = viewModel::createAlbum, + onRenameAlbum = viewModel::renameAlbum, + onSetCover = viewModel::setCover, + onResetCover = { viewModel.setCover(null) }, + onDeleteAlbum = { + viewModel.deleteAlbum { sender -> + deleteLauncher.launch(IntentSenderRequest.Builder(sender).build()) + } + }, + onOpenServerSettings = { state.serverAlbumId?.let(onOpenServerSettings) }, allFilesAccessGranted = allFilesGranted, onRequestAllFilesAccess = { runCatching { allFilesLauncher.launch(AllFilesAccess.settingsIntent(context)) } }, ) } + + deletePhotoId?.let { id -> + DeletePhotoDialog( + title = state.items.firstOrNull { state.serverPhotoByMediaId[it.id] == id }?.displayName ?: "", + onConfirm = { + viewModel.deleteServerPhoto(id) + deletePhotoId = null + }, + onDismiss = { deletePhotoId = null }, + ) + } } @OptIn(ExperimentalMaterial3Api::class) @@ -159,6 +262,14 @@ fun DeviceAlbumContent( onRestoreOriginals: () -> Unit, onOpen: (Int) -> Unit, onCreateAlbum: (String) -> Unit, + role: String = "", + localName: String? = null, + localCoverUrl: String? = null, + onRenameAlbum: (String) -> Unit = {}, + onSetCover: (String?) -> Unit = {}, + onResetCover: () -> Unit = {}, + onDeleteAlbum: () -> Unit = {}, + onOpenServerSettings: () -> Unit = {}, allFilesAccessGranted: Boolean = true, onRequestAllFilesAccess: () -> Unit = {}, ) { @@ -245,6 +356,28 @@ fun DeviceAlbumContent( ) } + val device = DeviceAlbum( + id = state.name, + name = state.name.ifBlank { "Альбом телефона" }, + itemCount = state.items.size, + coverUri = state.items.firstOrNull()?.uri, + relativePath = "", + ) + val linkedServer = state.serverAlbums.firstOrNull { it.id == state.serverAlbumId } + val attributes = AlbumAttributesResolver.resolve( + AlbumAttributeSource.Local( + device = device, + title = localName?.takeIf { it.isNotBlank() } ?: device.name, + coverUrl = localCoverUrl ?: device.coverUri, + linkedServer = linkedServer, + role = role, + ), + ) + var panelOpen by remember { mutableStateOf(false) } + var renameOpen by remember { mutableStateOf(false) } + var deleteOpen by remember { mutableStateOf(false) } + var coverPickOpen by remember { mutableStateOf(false) } + ModalNavigationDrawer( drawerState = drawerState, drawerContent = { @@ -273,7 +406,24 @@ fun DeviceAlbumContent( ) }, ) { padding -> - Column(modifier = Modifier.fillMaxSize().padding(padding)) { + EndDrawerScaffold( + open = panelOpen, + onOpenChange = { panelOpen = it }, + modifier = Modifier.padding(padding), + drawerContent = { + AlbumAttributesPanel( + attributes = attributes, + onPickCover = { coverPickOpen = true }, + onResetCover = onResetCover, + onOpenMembers = onOpenServerSettings, + onOpenSettings = { scope.launch { drawerState.open() } }, + onOpenShare = onOpenServerSettings, + onRename = { renameOpen = true }, + onDelete = { deleteOpen = true }, + ) + }, + ) { + Column(modifier = Modifier.fillMaxSize()) { if (state.items.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("В альбоме нет медиа") @@ -296,8 +446,112 @@ fun DeviceAlbumContent( } } } + } } } + + if (renameOpen) { + RenameAlbumDialog( + initial = attributes.title, + onConfirm = { + onRenameAlbum(it) + renameOpen = false + }, + onDismiss = { renameOpen = false }, + ) + } + + if (deleteOpen) { + DeleteLocalAlbumDialog( + title = attributes.title, + itemCount = state.items.size, + onConfirm = { + deleteOpen = false + onDeleteAlbum() + }, + onDismiss = { deleteOpen = false }, + ) + } + + if (coverPickOpen) { + LocalCoverPickerDialog( + items = state.items, + onPick = { + onSetCover(it) + coverPickOpen = false + }, + onDismiss = { coverPickOpen = false }, + ) + } +} + +const val TAG_LOCAL_DELETE_DIALOG = "local_delete_dialog" +const val TAG_LOCAL_DELETE_CONFIRM = "local_delete_confirm" +const val TAG_LOCAL_COVER_PICKER = "local_cover_picker" +const val TAG_LOCAL_COVER_OPTION_PREFIX = "local_cover_option_" + +/** Confirms deleting a device album's photos from the phone itself. */ +@Composable +fun DeleteLocalAlbumDialog( + title: String, + itemCount: Int, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TAG_LOCAL_DELETE_DIALOG), + title = { Text("Удалить альбом?") }, + text = { + Text("«$title» и $itemCount фото будут удалены с телефона. Действие необратимо.") + }, + confirmButton = { + TextButton( + onClick = onConfirm, + modifier = Modifier.testTag(TAG_LOCAL_DELETE_CONFIRM), + ) { Text("Удалить с телефона", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } }, + ) +} + +/** Picks a cover from the device album's own media. */ +@Composable +fun LocalCoverPickerDialog( + items: List, + onPick: (String) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TAG_LOCAL_COVER_PICKER), + title = { Text("Выбрать обложку") }, + text = { + if (items.isEmpty()) { + Text("В альбоме нет медиа") + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.heightIn(max = 360.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + items(items, key = { it.id }) { media -> + GaloAsyncImage( + url = media.uri, + contentDescription = media.displayName, + modifier = Modifier + .aspectRatio(1f) + .testTag(TAG_LOCAL_COVER_OPTION_PREFIX + media.id) + .clickable { onPick(media.uri) }, + ) + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } }, + ) } @Composable diff --git a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt index 2b1e467..5e728df 100644 --- a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt +++ b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt @@ -6,15 +6,23 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.galo.android.di.AppContainer import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.mapper.toDomain +import com.galo.android.data.network.dto.PhotoInfoPrefsDto +import com.galo.android.data.sync.RestoreManager import com.galo.android.domain.model.Album +import com.galo.android.domain.model.DeviceAlbumMeta import com.galo.android.domain.model.DeviceMedia import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.Photo import com.galo.android.domain.model.SyncMode import com.galo.android.data.sync.OffloadResult import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -44,6 +52,21 @@ class DeviceAlbumViewModel( private val pendingMode = MutableStateFlow(null) + private val _role = MutableStateFlow("") + + /** Caller's role on the linked server album (empty when unlinked). */ + val role: StateFlow = _role.asStateFlow() + + private val _preferences = MutableStateFlow(null) + + /** Saved photo-attribute preferences (visibility + labels). */ + val preferences: StateFlow = _preferences.asStateFlow() + + /** App-only metadata (display name, chosen cover) for this device album. */ + val albumMeta: StateFlow = container.syncRepository.observeAlbumMeta() + .map { it[deviceAlbumId] } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + val state: StateFlow = combine( container.albumRepository.observeAlbums(), container.syncRepository.observeLinks(), @@ -88,23 +111,179 @@ class DeviceAlbumViewModel( viewModelScope.launch { runCatching { container.albumRepository.refresh() } } + viewModelScope.launch { refreshRole() } + viewModelScope.launch { + runCatching { container.api.meEnvelope().preferences?.photoInfo } + .getOrNull() + ?.let { _preferences.value = it } + } + // Check and fix this gallery on open (see [SyncCheck]): upload/offload for + // move, restore originals for copy/off when previews are left over. + viewModelScope.launch { container.syncCheck.checkGallery(deviceAlbumId) } + } + + /** Loads the caller's role on the linked server album, for panel gating. */ + private suspend fun refreshRole() { + val id = state.value.serverAlbumId + _role.value = if (id.isNullOrBlank()) { + "" + } else { + runCatching { container.api.albumEnvelope(id).role }.getOrDefault("") ?: "" + } + } + + /** Renames the linked server album. */ + fun renameServerAlbum(title: String) { val id = state.value.serverAlbumId ?: return + val trimmed = title.trim() + if (trimmed.isEmpty()) return + viewModelScope.launch { + runCatching { + container.api.updateAlbum(id, title = trimmed) + container.albumRepository.loadAlbum(id) + } + } + } + + /** Sets a linked-server-album photo as its cover. */ + fun setServerCover(photoId: String) { + val id = state.value.serverAlbumId ?: return + viewModelScope.launch { + runCatching { + container.api.updateAlbum(id, coverPhotoId = photoId) + container.albumRepository.loadAlbum(id) + } + } + } + + /** Clears the linked server album's cover. */ + fun resetServerCover() { + val id = state.value.serverAlbumId ?: return + viewModelScope.launch { + runCatching { + container.api.updateAlbum(id, clearCover = true) + container.albumRepository.loadAlbum(id) + } + } + } + + /** Deletes the linked server album; [onDeleted] runs after the server confirms. */ + fun deleteServerAlbum(onDeleted: () -> Unit) { + val id = state.value.serverAlbumId ?: return + viewModelScope.launch { + runCatching { container.api.deleteAlbum(id) } + .onSuccess { + unlink() + onDeleted() + } + } + } + + /** Snapshot of the linked server album's photos, for the cover picker. */ + suspend fun serverAlbumPhotos(): List { + val id = state.value.serverAlbumId ?: return emptyList() + return runCatching { container.photoRepository.observePhotos(id).first() } + .getOrDefault(emptyList()) + } + + /** Fetches a server photo by id (for the sheet of a mapped device item). */ + suspend fun serverPhoto(photoId: String): Photo? { + val dto = runCatching { container.api.photo(photoId) }.getOrNull() ?: return null + return dto.toDomain(container.apiClient.objectUrls) + } + + suspend fun tagSuggestions(query: String): List = + runCatching { container.api.tags(query).map { it.name } }.getOrDefault(emptyList()) + + fun deleteServerPhoto(photoId: String) { + viewModelScope.launch { + runCatching { container.api.deletePhoto(photoId) } + .onSuccess { container.photoRepository.remove(photoId) } + } + } + + fun addServerTag(photoId: String, name: String) { + val trimmed = name.trim() + if (trimmed.isEmpty()) return + viewModelScope.launch { + runCatching { container.api.addTag(photoId, trimmed) } + .onSuccess { container.photoRepository.upsert(it) } + } + } + + fun removeServerTag(photoId: String, tagName: String) { + viewModelScope.launch { + val tagId = runCatching { container.api.photo(photoId).tags } + .getOrDefault(emptyList()) + .firstOrNull { it.name.equals(tagName, ignoreCase = true) } + ?.id ?: return@launch + runCatching { container.api.removeTag(photoId, tagId) } + .onSuccess { container.photoRepository.upsert(it) } + } + } + + // --- Local (device) album attributes and actions ----------------------- + + /** Sets the app-only display name for this device album. */ + fun renameAlbum(name: String) { + viewModelScope.launch { container.syncRepository.setAlbumName(deviceAlbumId, name) } + } + + /** Sets (or clears, with null) the chosen cover for this device album. */ + fun setCover(uri: String?) { + viewModelScope.launch { container.syncRepository.setAlbumCover(deviceAlbumId, uri) } + } + + /** + * Deletes every photo of this device album. Media the app does not own needs + * the system delete consent, surfaced through [onConsent]. + */ + fun deleteAlbum(onConsent: (IntentSender) -> Unit) { + viewModelScope.launch { + val uris = container.syncRepository.deviceItemUris(deviceAlbumId) + if (uris.isEmpty()) { + finishDeleteAlbum() + return@launch + } + val sender = container.syncRepository.deleteConsent(uris) + if (sender != null) onConsent(sender) else finishDeleteAlbum() + } + } + + /** Re-reads the album after a delete consent and forgets it if now empty. */ + fun refreshAfterDelete() { + viewModelScope.launch { + load() + if (local.value.items.isEmpty()) container.syncRepository.forgetAlbum(deviceAlbumId) + } + } + + private suspend fun finishDeleteAlbum() { + container.syncRepository.forgetAlbum(deviceAlbumId) + load() } fun setMode(mode: SyncMode) { pendingMode.value = mode + if (mode == SyncMode.DISABLED) { + // Turning sync off clears the link to the server album. + viewModelScope.launch { container.syncRepository.unlink(deviceAlbumId) } + return + } viewModelScope.launch { - if (mode == SyncMode.DISABLED) { - // Turning sync off clears the link to the server album. - container.syncRepository.unlink(deviceAlbumId) - return@launch - } + // Persist the chosen mode even before a server album is picked, so the + // choice survives leaving and reopening the album. + val target = state.value.serverAlbumId + ?: container.syncRepository.currentLink(deviceAlbumId)?.serverAlbumId + ?: "" container.syncRepository.link( deviceAlbumId = deviceAlbumId, deviceAlbumName = local.value.name, - serverAlbumId = state.value.serverAlbumId ?: "", + serverAlbumId = target, mode = mode, ) - if (state.value.serverAlbumId != null) reconcileNow() + refreshRole() + // Nothing to sync until a server album is chosen. + if (target.isNotBlank()) reconcileNow() } } @@ -116,6 +295,7 @@ class DeviceAlbumViewModel( serverAlbumId = serverAlbumId, mode = pendingMode.value ?: state.value.mode, ) + refreshRole() reconcileNow() } } @@ -133,6 +313,7 @@ class DeviceAlbumViewModel( serverAlbumId = album.id, mode = pendingMode.value ?: state.value.mode, ) + refreshRole() reconcileNow() } } @@ -170,9 +351,8 @@ class DeviceAlbumViewModel( /** Returns originals to the device for every offloaded (preview-only) asset. */ fun restoreOriginals() { viewModelScope.launch { - val assets = container.syncRepository.assets(deviceAlbumId) - .filter { it.state == LocalAssetState.PREVIEW.name } - container.restoreManager.restore(assets) + val targets = RestoreManager.restoreTargets(container.syncRepository.assets(deviceAlbumId)) + if (targets.isNotEmpty()) container.restoreManager.restore(targets) } } diff --git a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceViewerScreen.kt b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceViewerScreen.kt index f775358..a8446ae 100644 --- a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceViewerScreen.kt +++ b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceViewerScreen.kt @@ -1,5 +1,7 @@ package com.galo.android.ui.devicealbum +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager @@ -10,18 +12,22 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.testTag import com.galo.android.domain.model.DeviceMedia import com.galo.android.domain.model.MediaKind @@ -37,6 +43,8 @@ fun DeviceViewerScreen( items: List, initialIndex: Int, onBack: () -> Unit, + serverPhotoByMediaId: Map = emptyMap(), + sheet: (@Composable (DeviceMedia) -> Unit)? = null, originalSourceProvider: suspend (Long) -> OriginalSource? = { null }, ) { if (items.isEmpty()) { @@ -49,6 +57,20 @@ fun DeviceViewerScreen( val current = items.getOrNull(pagerState.currentPage) var zoomed by remember(current?.id) { mutableStateOf(false) } + var sheetOpen by remember(current?.id) { mutableStateOf(false) } + var dragUp by remember(current?.id) { mutableFloatStateOf(0f) } + val sheetGesture = if (sheet != null) { + Modifier.pointerInput(current?.id, zoomed) { + val canOpen = current != null && !zoomed && current.kind != MediaKind.VIDEO + detectVerticalDragGestures( + onDragStart = { dragUp = 0f }, + onDragEnd = { if (canOpen && dragUp < -80f) sheetOpen = true }, + ) { _, dragAmount -> dragUp += dragAmount } + } + } else { + Modifier + } + Scaffold( topBar = { TopAppBar( @@ -64,6 +86,7 @@ fun DeviceViewerScreen( }, ) { padding -> Surface(color = Color.Black, modifier = Modifier.fillMaxSize().padding(padding)) { + Box(Modifier.fillMaxSize().then(sheetGesture)) { HorizontalPager( state = pagerState, userScrollEnabled = !zoomed, @@ -94,6 +117,16 @@ fun DeviceViewerScreen( ) } } + } + } + } + + if (sheetOpen && sheet != null && current != null) { + ModalBottomSheet( + onDismissRequest = { sheetOpen = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + sheet(current) } } } diff --git a/android/app/src/main/java/com/galo/android/ui/panel/AlbumAttributesPanel.kt b/android/app/src/main/java/com/galo/android/ui/panel/AlbumAttributesPanel.kt new file mode 100644 index 0000000..c634c29 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/panel/AlbumAttributesPanel.kt @@ -0,0 +1,176 @@ +package com.galo.android.ui.panel + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddLink +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Wallpaper +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.galo.android.domain.attributes.AlbumAttributes +import com.galo.android.ui.common.GaloAsyncImage + +const val TAG_ALBUM_PANEL = "album_panel" +const val TAG_PANEL_COVER_PICK = "panel_cover_pick" +const val TAG_PANEL_COVER_RESET = "panel_cover_reset" +const val TAG_PANEL_RENAME = "panel_rename" +const val TAG_PANEL_DELETE = "panel_delete" +const val TAG_PANEL_MEMBERS = "panel_members" +const val TAG_PANEL_SETTINGS = "panel_settings" +const val TAG_PANEL_SHARE = "panel_share" + +/** + * The "Атрибуты и действия" album panel, mirroring the web album panel. It is + * shown for every album; navigation rows appear always but are enabled only when + * a server album backs them, and cover/actions depend on local vs server. + */ +@Composable +fun AlbumAttributesPanel( + attributes: AlbumAttributes, + onPickCover: () -> Unit, + onResetCover: () -> Unit, + onOpenMembers: () -> Unit, + onOpenSettings: () -> Unit, + onOpenShare: () -> Unit, + onRename: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .testTag(TAG_ALBUM_PANEL) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Атрибуты и действия", style = MaterialTheme.typography.titleMedium) + + PanelSection("Обложка") { + GaloAsyncImage( + url = attributes.coverUrl, + contentDescription = attributes.title, + modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), + ) + if (attributes.isLocal || attributes.canEdit) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = onPickCover, + modifier = Modifier.testTag(TAG_PANEL_COVER_PICK), + ) { + Icon(Icons.Filled.Wallpaper, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(" Выбрать обложку") + } + if (attributes.coverUrl != null) { + TextButton( + onClick = onResetCover, + modifier = Modifier.testTag(TAG_PANEL_COVER_RESET), + ) { + Text("Сбросить") + } + } + } + } + } + + PanelSection("Информация") { + Text(attributes.title, style = MaterialTheme.typography.titleMedium) + if (attributes.description.isNotBlank()) { + Text(attributes.description, style = MaterialTheme.typography.bodyMedium) + } + Text( + text = if (attributes.isLocal && attributes.serverAlbumId == null) { + "${attributes.photosCount} элементов" + } else { + "${attributes.photosCount} фото · ${attributes.membersCount} участников" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + PanelSection("Навигация") { + PanelNavRow( + Icons.Filled.Group, "Участники", TAG_PANEL_MEMBERS, + onClick = onOpenMembers, enabled = attributes.membersEnabled, + ) + PanelNavRow( + Icons.Filled.Settings, "Настройки", TAG_PANEL_SETTINGS, + onClick = onOpenSettings, enabled = attributes.settingsEnabled, + ) + PanelNavRow( + Icons.Filled.AddLink, "Ссылки для просмотра", TAG_PANEL_SHARE, + onClick = onOpenShare, enabled = attributes.shareEnabled, + ) + } + + if (attributes.canRename || attributes.canDelete) { + PanelSection("Действия") { + if (attributes.canRename) { + PanelNavRow(Icons.Filled.Edit, "Переименовать", TAG_PANEL_RENAME, onRename) + } + if (attributes.canDelete) { + PanelNavRow(Icons.Filled.Delete, "Удалить", TAG_PANEL_DELETE, onDelete, danger = true) + } + } + } + } +} + +@Composable +private fun PanelSection(title: String, content: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + HorizontalDivider() + Text(title, style = MaterialTheme.typography.titleSmall) + content() + } +} + +@Composable +private fun PanelNavRow( + icon: ImageVector, + label: String, + tag: String, + onClick: () -> Unit, + enabled: Boolean = true, + danger: Boolean = false, +) { + TextButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.fillMaxWidth().testTag(tag), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp)) + Text( + label, + color = if (danger) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/panel/EndDrawerScaffold.kt b/android/app/src/main/java/com/galo/android/ui/panel/EndDrawerScaffold.kt new file mode 100644 index 0000000..9265f70 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/panel/EndDrawerScaffold.kt @@ -0,0 +1,113 @@ +package com.galo.android.ui.panel + +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +const val TAG_END_DRAWER_SCRIM = "end_drawer_scrim" + +/** + * A right-anchored ("end") drawer. Material3 only ships a start-anchored + * `ModalNavigationDrawer`, so this draws the panel on the right and opens it on a + * right-to-left swipe over the content. Rightward drags are left unconsumed so a + * start-anchored drawer (e.g. the device sync settings) keeps working. + */ +@Composable +fun EndDrawerScaffold( + open: Boolean, + onOpenChange: (Boolean) -> Unit, + drawerWidth: Dp = 320.dp, + modifier: Modifier = Modifier, + drawerContent: @Composable () -> Unit, + content: @Composable () -> Unit, +) { + val density = LocalDensity.current + val widthPx = with(density) { drawerWidth.toPx() } + val offset = remember { Animatable(widthPx) } + val scope = rememberCoroutineScope() + + LaunchedEffect(open, widthPx) { + offset.animateTo(if (open) 0f else widthPx) + } + + // While the panel is open the scrim and panel sit above the content, so a + // rightward swipe must be caught here and close the panel. Consuming it also + // stops a start-anchored drawer behind from opening (e.g. sync settings). + val closeDrag = Modifier.pointerInput(widthPx) { + var total = 0f + detectHorizontalDragGestures( + onDragStart = { total = 0f }, + onDragEnd = { if (total > 40f) onOpenChange(false) }, + ) { change, dragAmount -> + total += dragAmount + change.consume() + } + } + + Box(modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxSize() + .pointerInput(widthPx) { + detectHorizontalDragGestures( + onDragEnd = { onOpenChange(offset.value < widthPx / 2f) }, + ) { change, dragAmount -> + val next = (offset.value + dragAmount).coerceIn(0f, widthPx) + // Follow leftward drags (revealing the panel) or any drag while open; + // leave rightward drags to a start-anchored drawer. + if (next < offset.value || offset.value < widthPx) { + change.consume() + scope.launch { offset.snapTo(next) } + } + } + }, + ) { + content() + } + + if (open) { + Box( + Modifier + .fillMaxSize() + .testTag(TAG_END_DRAWER_SCRIM) + .background(Color.Black.copy(alpha = 0.4f)) + .then(closeDrag) + .clickable { onOpenChange(false) }, + ) + Surface( + tonalElevation = 3.dp, + shadowElevation = 8.dp, + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .width(drawerWidth) + .offset { IntOffset(offset.value.roundToInt(), 0) } + .then(closeDrag), + ) { + drawerContent() + } + } + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/photo/AttributeSettingsScreen.kt b/android/app/src/main/java/com/galo/android/ui/photo/AttributeSettingsScreen.kt new file mode 100644 index 0000000..9605e42 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/photo/AttributeSettingsScreen.kt @@ -0,0 +1,144 @@ +package com.galo.android.ui.photo + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.galo.android.GaloApplication +import com.galo.android.domain.exif.EXIF_CATALOG +import com.galo.android.domain.exif.EXIF_LABELS + +const val TAG_ATTR_ROW_PREFIX = "attr_row_" +const val TAG_ATTR_CHECK_PREFIX = "attr_check_" +const val TAG_ATTR_LABEL_PREFIX = "attr_label_" +const val TAG_ATTR_ALL = "attr_all" +const val TAG_ATTR_NONE = "attr_none" +const val TAG_ATTR_SAVE = "attr_save" +const val TAG_ATTR_RESET = "attr_reset" +const val TAG_ATTR_SAVED = "attr_saved" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AttributeSettingsScreen( + app: GaloApplication, + onBack: () -> Unit, +) { + val factory = viewModelFactory { + initializer { AttributeSettingsViewModel(app.container) } + } + val viewModel: AttributeSettingsViewModel = viewModel(factory = factory) + val state by viewModel.state.collectAsStateWithLifecycle() + AttributeSettingsContent( + state = state, + onToggle = viewModel::toggle, + onSetAll = viewModel::setAll, + onLabel = viewModel::setLabel, + onSave = viewModel::save, + onReset = viewModel::reset, + onBack = onBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AttributeSettingsContent( + state: AttributeSettingsViewModel.UiState, + onToggle: (String) -> Unit, + onSetAll: (Boolean) -> Unit, + onLabel: (String, String) -> Unit, + onSave: () -> Unit, + onReset: () -> Unit, + onBack: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Настройки атрибутов") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад") + } + }, + actions = { + TextButton(onClick = onSave, modifier = Modifier.testTag(TAG_ATTR_SAVE)) { + Text("Сохранить") + } + }, + ) + }, + ) { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { onSetAll(true) }, modifier = Modifier.testTag(TAG_ATTR_ALL)) { + Text("Отметить все") + } + TextButton(onClick = { onSetAll(false) }, modifier = Modifier.testTag(TAG_ATTR_NONE)) { + Text("Снять все") + } + TextButton(onClick = onReset, modifier = Modifier.testTag(TAG_ATTR_RESET)) { + Text("Сбросить") + } + if (state.saved) { + Text( + "Сохранено", + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.testTag(TAG_ATTR_SAVED), + ) + } + } + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(EXIF_CATALOG, key = { it }) { key -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .testTag(TAG_ATTR_ROW_PREFIX + key), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = key in state.visible, + onCheckedChange = { onToggle(key) }, + modifier = Modifier.testTag(TAG_ATTR_CHECK_PREFIX + key), + ) + OutlinedTextField( + value = state.labels[key] ?: "", + onValueChange = { onLabel(key, it) }, + label = { Text(EXIF_LABELS[key] ?: key) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(TAG_ATTR_LABEL_PREFIX + key), + ) + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/photo/AttributeSettingsViewModel.kt b/android/app/src/main/java/com/galo/android/ui/photo/AttributeSettingsViewModel.kt new file mode 100644 index 0000000..dc7857b --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/photo/AttributeSettingsViewModel.kt @@ -0,0 +1,100 @@ +package com.galo.android.ui.photo + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.galo.android.data.network.dto.PhotoInfoPrefsDto +import com.galo.android.data.network.dto.PreferencesDto +import com.galo.android.di.AppContainer +import com.galo.android.domain.exif.EXIF_BASE +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class AttributeSettingsViewModel( + private val container: AppContainer, +) : ViewModel() { + + data class UiState( + val visible: Set = EXIF_BASE.toSet(), + val labels: Map = emptyMap(), + val loading: Boolean = true, + val saved: Boolean = false, + val error: String? = null, + ) + + private val _state = MutableStateFlow(UiState()) + val state: StateFlow = _state.asStateFlow() + + init { + load() + } + + fun load() { + viewModelScope.launch { + runCatching { container.api.meEnvelope().preferences?.photoInfo } + .onSuccess { info -> + _state.update { + it.copy( + visible = info?.visible?.toSet() ?: EXIF_BASE.toSet(), + labels = info?.labels.orEmpty(), + loading = false, + ) + } + } + .onFailure { e -> _state.update { it.copy(loading = false, error = e.message) } } + } + } + + fun toggle(key: String) { + _state.update { s -> + val next = s.visible.toMutableSet() + if (!next.add(key)) next.remove(key) + s.copy(visible = next, saved = false) + } + } + + fun setAll(visible: Boolean) { + _state.update { s -> + s.copy( + visible = if (visible) com.galo.android.domain.exif.EXIF_CATALOG.toSet() else emptySet(), + saved = false, + ) + } + } + + fun setLabel(key: String, value: String) { + _state.update { s -> + val labels = s.labels.toMutableMap() + if (value.isBlank()) labels.remove(key) else labels[key] = value + s.copy(labels = labels, saved = false) + } + } + + fun save() { + persist(PreferencesDto(PhotoInfoPrefsDto(visible = _state.value.visible.toList(), labels = _state.value.labels))) + } + + /** Removes the saved preferences, restoring the base attribute set. */ + fun reset() { + persist(PreferencesDto(photoInfo = null)) + } + + private fun persist(preferences: PreferencesDto) { + viewModelScope.launch { + runCatching { container.api.savePreferences(preferences) } + .onSuccess { saved -> + _state.update { + it.copy( + visible = saved.photoInfo?.visible?.toSet() ?: EXIF_BASE.toSet(), + labels = saved.photoInfo?.labels.orEmpty(), + saved = true, + error = null, + ) + } + } + .onFailure { e -> _state.update { it.copy(error = e.message) } } + } + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/photo/PhotoAttributesSheet.kt b/android/app/src/main/java/com/galo/android/ui/photo/PhotoAttributesSheet.kt new file mode 100644 index 0000000..7687fd9 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/photo/PhotoAttributesSheet.kt @@ -0,0 +1,267 @@ +package com.galo.android.ui.photo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Label +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Wallpaper +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp + +const val TAG_PHOTO_SHEET = "photo_sheet" +const val TAG_SHEET_TITLE = "sheet_title" +const val TAG_SHEET_SETTINGS = "sheet_settings" +const val TAG_SHEET_MORE = "sheet_more" +const val TAG_SHEET_TAG_FIELD = "sheet_tag_field" +const val TAG_SHEET_TAG_SUGGESTION_PREFIX = "sheet_tag_suggestion_" +const val TAG_SHEET_TAG_CHIP_PREFIX = "sheet_tag_chip_" +const val TAG_SHEET_TAG_REMOVE_PREFIX = "sheet_tag_remove_" +const val TAG_SHEET_OPEN = "sheet_open" +const val TAG_SHEET_DOWNLOAD = "sheet_download" +const val TAG_SHEET_MOVE = "sheet_move" +const val TAG_SHEET_COPY = "sheet_copy" +const val TAG_SHEET_COVER = "sheet_cover" +const val TAG_SHEET_DELETE = "sheet_delete" +const val TAG_SHEET_SHARE = "sheet_share" + +/** + * The "Атрибуты и действия" sheet, mirroring the web photo panel. Sections appear + * based on the model; each action row appears only when its callback is provided. + */ +@Composable +fun PhotoAttributesSheet( + model: PhotoSheetModel, + modifier: Modifier = Modifier, + onOpen: (() -> Unit)? = null, + onRename: (() -> Unit)? = null, + onDownload: (() -> Unit)? = null, + onMove: (() -> Unit)? = null, + onCopy: (() -> Unit)? = null, + onSetCover: (() -> Unit)? = null, + onDelete: (() -> Unit)? = null, + onShare: (() -> Unit)? = null, + onOpenAttributeSettings: (() -> Unit)? = null, + onAddTag: ((String) -> Unit)? = null, + onRemoveTag: ((String) -> Unit)? = null, + tagSuggestions: List = emptyList(), + onShowAll: (() -> Unit)? = null, +) { + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .testTag(TAG_PHOTO_SHEET) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("Атрибуты и действия", style = MaterialTheme.typography.titleMedium) + + // Title (rename affordance). + if (onRename != null) { + TextButton(onClick = onRename, modifier = Modifier.testTag(TAG_SHEET_TITLE)) { + Text(model.title, style = MaterialTheme.typography.titleSmall) + } + } else { + Text(model.title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.testTag(TAG_SHEET_TITLE)) + } + + PanelSection("Информация") { + if (model.uploaderName != null) MetaRow("Автор", model.uploaderName) + MetaRow("Файл", formatSize(model.size)) + model.mimeType?.let { MetaRow("Тип", it) } + if (model.width > 0 && model.height > 0) { + MetaRow("Разрешение", "${model.width}×${model.height}") + } + model.exifRows.forEach { row -> MetaRow(row.label, row.value) } + if (model.hiddenAttributeCount > 0 && onShowAll != null) { + TextButton(onClick = onShowAll, modifier = Modifier.testTag(TAG_SHEET_MORE)) { + Text("…") + } + } + model.takenAt?.let { MetaRow("Снято", it) } + if (model.latitude != null && model.longitude != null) { + MetaRow("Координаты", "${model.latitude}, ${model.longitude}") + } + model.createdAt?.let { MetaRow("Загружено", it) } + } + + if (onAddTag != null || onRemoveTag != null) { + PanelSection("Метки") { + if (model.tags.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + model.tags.forEach { name -> + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Label, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(" $name", modifier = Modifier.weight(1f)) + if (onRemoveTag != null) { + IconButtonTag( + icon = Icons.Filled.Close, + description = "Убрать метку $name", + tag = TAG_SHEET_TAG_REMOVE_PREFIX + name, + onClick = { onRemoveTag(name) }, + ) + } + } + } + } + } + if (onAddTag != null) { + var query by remember { mutableStateOf("") } + OutlinedTextField( + value = query, + onValueChange = { query = it }, + placeholder = { Text("Добавить метку…") }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Text), + keyboardActions = KeyboardActions(onDone = { + if (query.isNotBlank()) { + onAddTag(query) + query = "" + } + }), + modifier = Modifier.fillMaxWidth().testTag(TAG_SHEET_TAG_FIELD), + ) + tagSuggestions.forEach { suggestion -> + TextButton( + onClick = { + onAddTag(suggestion) + query = "" + }, + modifier = Modifier.testTag(TAG_SHEET_TAG_SUGGESTION_PREFIX + suggestion), + ) { + Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(" $suggestion") + } + } + } + } + } + + if (onOpen != null || onDownload != null || onMove != null || onCopy != null || + onSetCover != null || onDelete != null || onShare != null + ) { + PanelSection("Действия") { + ActionRow(Icons.Filled.Image, "Открыть (просмотр)", TAG_SHEET_OPEN, onOpen) + ActionRow(Icons.Filled.Download, "Скачать", TAG_SHEET_DOWNLOAD, onDownload) + ActionRow(Icons.Filled.ArrowBack, "Переместить", TAG_SHEET_MOVE, onMove) + ActionRow(Icons.Filled.ContentCopy, "Копировать", TAG_SHEET_COPY, onCopy) + ActionRow(Icons.Filled.Wallpaper, "Установить как обложку", TAG_SHEET_COVER, onSetCover) + ActionRow(Icons.Filled.Share, "Поделиться", TAG_SHEET_SHARE, onShare) + if (onDelete != null) { + TextButton( + onClick = onDelete, + modifier = Modifier.fillMaxWidth().testTag(TAG_SHEET_DELETE), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(20.dp)) + Text("Удалить", color = MaterialTheme.colorScheme.error) + } + } + } + } + } + + if (onOpenAttributeSettings != null) { + ActionRow(Icons.Filled.Settings, "Настройки атрибутов", TAG_SHEET_SETTINGS, onOpenAttributeSettings) + } + } +} + +@Composable +private fun PanelSection(title: String, content: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + HorizontalDivider() + Text(title, style = MaterialTheme.typography.titleSmall) + content() + } +} + +@Composable +private fun MetaRow(label: String, value: String) { + Row(modifier = Modifier.fillMaxWidth()) { + Text( + label, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(value, style = MaterialTheme.typography.bodySmall) + } +} + +@Composable +private fun ActionRow(icon: ImageVector, label: String, tag: String, onClick: (() -> Unit)?) { + if (onClick == null) return + TextButton(onClick = onClick, modifier = Modifier.fillMaxWidth().testTag(tag)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp)) + Text(label) + } + } +} + +@Composable +private fun IconButtonTag( + icon: ImageVector, + description: String, + tag: String, + onClick: () -> Unit, +) { + TextButton(onClick = onClick, modifier = Modifier.testTag(tag)) { + Icon(icon, contentDescription = description, modifier = Modifier.size(16.dp)) + } +} + +private fun formatSize(bytes: Long): String { + if (bytes <= 0) return "—" + val units = listOf("Б", "КБ", "МБ", "ГБ") + var v = bytes.toDouble() + var i = 0 + while (v >= 1024 && i < units.size - 1) { + v /= 1024 + i++ + } + return if (i > 0 && v < 10) "${String.format(java.util.Locale.US, "%.1f", v)} ${units[i]}" else "${v.toLong()} ${units[i]}" +} diff --git a/android/app/src/main/java/com/galo/android/ui/photo/PhotoDialogs.kt b/android/app/src/main/java/com/galo/android/ui/photo/PhotoDialogs.kt new file mode 100644 index 0000000..179efc4 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/photo/PhotoDialogs.kt @@ -0,0 +1,117 @@ +package com.galo.android.ui.photo + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.galo.android.domain.model.Album + +const val TAG_PHOTO_RENAME_DIALOG = "photo_rename_dialog" +const val TAG_PHOTO_RENAME_FIELD = "photo_rename_field" +const val TAG_PHOTO_RENAME_CONFIRM = "photo_rename_confirm" +const val TAG_PHOTO_DELETE_DIALOG = "photo_delete_dialog" +const val TAG_PHOTO_DELETE_CONFIRM = "photo_delete_confirm" +const val TAG_ALBUM_PICKER = "album_picker" +const val TAG_ALBUM_PICKER_OPTION_PREFIX = "album_picker_option_" + +@Composable +fun RenamePhotoDialog( + initial: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var text by remember { mutableStateOf(initial) } + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TAG_PHOTO_RENAME_DIALOG), + title = { Text("Переименовать фото") }, + text = { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + singleLine = true, + label = { Text("Имя файла") }, + modifier = Modifier.fillMaxWidth().testTag(TAG_PHOTO_RENAME_FIELD), + ) + }, + confirmButton = { + TextButton( + onClick = { onConfirm(text) }, + enabled = text.isNotBlank(), + modifier = Modifier.testTag(TAG_PHOTO_RENAME_CONFIRM), + ) { Text("Сохранить") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } }, + ) +} + +@Composable +fun DeletePhotoDialog( + title: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TAG_PHOTO_DELETE_DIALOG), + title = { Text("Удалить фото?") }, + text = { Text("«$title» будет перемещено в корзину.") }, + confirmButton = { + TextButton(onClick = onConfirm, modifier = Modifier.testTag(TAG_PHOTO_DELETE_CONFIRM)) { + Text("Удалить") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } }, + ) +} + +/** Picks a destination album for a move or copy. */ +@Composable +fun AlbumPickerDialog( + title: String, + albums: List, + onPick: (String) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(TAG_ALBUM_PICKER), + title = { Text(title) }, + text = { + Column( + modifier = Modifier + .heightIn(max = 320.dp) + .verticalScroll(rememberScrollState()), + ) { + if (albums.isEmpty()) { + Text("Нет других альбомов") + } + albums.forEach { album -> + TextButton( + onClick = { onPick(album.id) }, + modifier = Modifier + .fillMaxWidth() + .testTag(TAG_ALBUM_PICKER_OPTION_PREFIX + album.id), + ) { + Text(album.title) + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } }, + ) +} diff --git a/android/app/src/main/java/com/galo/android/ui/photo/PhotoSheetModel.kt b/android/app/src/main/java/com/galo/android/ui/photo/PhotoSheetModel.kt new file mode 100644 index 0000000..3af5c82 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/photo/PhotoSheetModel.kt @@ -0,0 +1,82 @@ +package com.galo.android.ui.photo + +import com.galo.android.domain.exif.ExifRow +import com.galo.android.domain.exif.buildExifRows +import com.galo.android.domain.model.DeviceMedia +import com.galo.android.domain.model.Photo + +/** + * Everything the photo "Атрибуты и действия" sheet renders, already resolved + * (server photo or local device media). Actions are optional callbacks on the + * sheet composable; a null callback hides its row. + */ +data class PhotoSheetModel( + val title: String, + val uploaderName: String? = null, + val size: Long = 0, + val mimeType: String? = null, + val width: Int = 0, + val height: Int = 0, + val takenAt: String? = null, + val latitude: Double? = null, + val longitude: Double? = null, + val createdAt: String? = null, + val exifRows: List = emptyList(), + val hiddenAttributeCount: Int = 0, + val tags: List = emptyList(), + val isServer: Boolean = true, + val canRename: Boolean = false, + val canSetCover: Boolean = false, + val canDelete: Boolean = false, +) + +/** Rows for the current photo; when [showAll] the hidden attributes are included too. */ +fun serverPhotoSheetModel( + photo: Photo, + role: String, + visible: Set, + labels: Map, + showAll: Boolean, +): PhotoSheetModel { + val allRows = buildExifRows(photo.exif, labels) + val shown = if (showAll) allRows else allRows.filter { it.key in visible } + val canEdit = role == "owner" || role == "admin" + return PhotoSheetModel( + title = photo.originalFilename, + uploaderName = photo.uploaderName, + size = photo.size, + mimeType = photo.mimeType, + width = photo.width, + height = photo.height, + takenAt = photo.takenAt, + latitude = photo.latitude, + longitude = photo.longitude, + createdAt = photo.createdAt, + exifRows = shown, + hiddenAttributeCount = allRows.size - shown.size, + tags = photo.tags.map { it.name }, + isServer = true, + canRename = true, + canSetCover = canEdit, + canDelete = canEdit, + ) +} + +/** Local device media that has no matching server photo. */ +fun localPhotoSheetModel(media: DeviceMedia): PhotoSheetModel = PhotoSheetModel( + title = media.displayName, + uploaderName = null, + size = media.size, + mimeType = media.mimeType, + width = media.width, + height = media.height, + takenAt = media.dateTaken?.toString(), + createdAt = media.dateModified.toString(), + exifRows = emptyList(), + hiddenAttributeCount = 0, + tags = emptyList(), + isServer = false, + canRename = false, + canSetCover = false, + canDelete = false, +) diff --git a/android/app/src/main/java/com/galo/android/ui/viewer/ViewerScreen.kt b/android/app/src/main/java/com/galo/android/ui/viewer/ViewerScreen.kt index 98f0f8c..d931136 100644 --- a/android/app/src/main/java/com/galo/android/ui/viewer/ViewerScreen.kt +++ b/android/app/src/main/java/com/galo/android/ui/viewer/ViewerScreen.kt @@ -1,5 +1,8 @@ package com.galo.android.ui.viewer +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -12,26 +15,38 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.galo.android.GaloApplication +import com.galo.android.domain.exif.EXIF_BASE import com.galo.android.domain.model.MediaKind import com.galo.android.domain.model.Photo +import com.galo.android.ui.photo.AlbumPickerDialog +import com.galo.android.ui.photo.DeletePhotoDialog +import com.galo.android.ui.photo.PhotoAttributesSheet +import com.galo.android.ui.photo.RenamePhotoDialog +import com.galo.android.ui.photo.serverPhotoSheetModel const val TAG_VIEWER_PAGER = "viewer_pager" @@ -42,13 +57,92 @@ fun ViewerScreen( albumId: String, initialIndex: Int, onBack: () -> Unit, + onOpenAttributeSettings: () -> Unit = {}, ) { val factory = viewModelFactory { initializer { ViewerViewModel(app.container, albumId) } } val viewModel: ViewerViewModel = viewModel(factory = factory) val photos by viewModel.photos.collectAsStateWithLifecycle() - ViewerContent(photos = photos, initialIndex = initialIndex, onBack = onBack) + val role by viewModel.role.collectAsStateWithLifecycle() + val prefs by viewModel.preferences.collectAsStateWithLifecycle() + val albums by viewModel.albums.collectAsStateWithLifecycle() + + val context = LocalContext.current + var renameFor by remember { mutableStateOf(null) } + var deleteFor by remember { mutableStateOf(null) } + var transferFor by remember { mutableStateOf?>(null) } + + ViewerContent( + photos = photos, + initialIndex = initialIndex, + onBack = onBack, + sheet = { photo -> + val visible = prefs?.visible?.toSet() ?: EXIF_BASE.toSet() + val labels = prefs?.labels.orEmpty() + var showAll by remember(photo.id) { mutableStateOf(false) } + var tagQuery by remember(photo.id) { mutableStateOf("") } + var suggestions by remember(photo.id) { mutableStateOf>(emptyList()) } + LaunchedEffect(tagQuery) { + if (tagQuery.isNotBlank()) suggestions = viewModel.tagSuggestions(tagQuery) + } + val model = serverPhotoSheetModel(photo, role, visible, labels, showAll) + PhotoAttributesSheet( + model = model, + onRename = { renameFor = photo }, + onDownload = { + photo.downloadUrl?.let { url -> + runCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } + } + }, + onMove = { transferFor = photo to false }, + onCopy = { transferFor = photo to true }, + onSetCover = { viewModel.setCover(albumId, photo.id) }, + onDelete = { deleteFor = photo }, + onAddTag = { viewModel.addTag(photo.id, it) }, + onRemoveTag = { viewModel.removeTag(photo.id, it) }, + tagSuggestions = suggestions.filter { s -> model.tags.none { it.equals(s, true) } }, + onOpenAttributeSettings = onOpenAttributeSettings, + onShowAll = { showAll = true }, + ) + }, + ) + + renameFor?.let { photo -> + RenamePhotoDialog( + initial = photo.originalFilename, + onConfirm = { + viewModel.rename(photo.id, it) + renameFor = null + }, + onDismiss = { renameFor = null }, + ) + } + + deleteFor?.let { photo -> + DeletePhotoDialog( + title = photo.originalFilename, + onConfirm = { + viewModel.delete(photo.id) + deleteFor = null + }, + onDismiss = { deleteFor = null }, + ) + } + + transferFor?.let { (photo, isCopy) -> + AlbumPickerDialog( + title = if (isCopy) "Копировать в альбом" else "Переместить в альбом", + albums = albums.filter { it.id != albumId }, + onPick = { target -> + if (isCopy) viewModel.copy(photo.id, target) else viewModel.move(photo.id, target) + transferFor = null + }, + onDismiss = { transferFor = null }, + ) + } } @OptIn(ExperimentalMaterial3Api::class) @@ -57,6 +151,7 @@ fun ViewerContent( photos: List, initialIndex: Int, onBack: () -> Unit, + sheet: (@Composable (Photo) -> Unit)? = null, ) { if (photos.isEmpty()) { Scaffold( @@ -90,6 +185,23 @@ fun ViewerContent( var zoomed by remember(current?.id) { mutableStateOf(false) } val pagerScrollEnabled = current?.isPanorama != true && !zoomed + // Swipe up opens the attributes/actions sheet, except while zoomed, on a + // panorama, or on video (where the gesture belongs to the media). + var sheetOpen by remember(current?.id) { mutableStateOf(false) } + var dragUp by remember(current?.id) { mutableFloatStateOf(0f) } + val sheetGesture = if (sheet != null) { + Modifier.pointerInput(current?.id, zoomed) { + val canOpen = current != null && !zoomed && !current.isPanorama && + current.kind != MediaKind.VIDEO + detectVerticalDragGestures( + onDragStart = { dragUp = 0f }, + onDragEnd = { if (canOpen && dragUp < -80f) sheetOpen = true }, + ) { _, dragAmount -> dragUp += dragAmount } + } + } else { + Modifier + } + Scaffold( topBar = { TopAppBar( @@ -111,21 +223,32 @@ fun ViewerContent( color = Color.Black, modifier = Modifier.fillMaxSize().padding(padding), ) { - HorizontalPager( - state = pagerState, - userScrollEnabled = pagerScrollEnabled, - modifier = Modifier.fillMaxSize().testTag(TAG_VIEWER_PAGER), - key = { index -> photos.getOrNull(index)?.id ?: index }, - ) { page -> - val photo = photos.getOrNull(page) ?: return@HorizontalPager - MediaPage( - photo = photo, - active = pagerState.currentPage == page, - onZoomChanged = { zoomed = it }, - ) + Box(Modifier.fillMaxSize().then(sheetGesture)) { + HorizontalPager( + state = pagerState, + userScrollEnabled = pagerScrollEnabled, + modifier = Modifier.fillMaxSize().testTag(TAG_VIEWER_PAGER), + key = { index -> photos.getOrNull(index)?.id ?: index }, + ) { page -> + val photo = photos.getOrNull(page) ?: return@HorizontalPager + MediaPage( + photo = photo, + active = pagerState.currentPage == page, + onZoomChanged = { zoomed = it }, + ) + } } } } + + if (sheetOpen && sheet != null && current != null) { + ModalBottomSheet( + onDismissRequest = { sheetOpen = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + sheet(current) + } + } } @Composable diff --git a/android/app/src/main/java/com/galo/android/ui/viewer/ViewerViewModel.kt b/android/app/src/main/java/com/galo/android/ui/viewer/ViewerViewModel.kt index f17bffe..40fbc65 100644 --- a/android/app/src/main/java/com/galo/android/ui/viewer/ViewerViewModel.kt +++ b/android/app/src/main/java/com/galo/android/ui/viewer/ViewerViewModel.kt @@ -2,10 +2,15 @@ package com.galo.android.ui.viewer import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.galo.android.data.network.dto.PhotoInfoPrefsDto import com.galo.android.di.AppContainer +import com.galo.android.domain.model.Album import com.galo.android.domain.model.Photo +import com.galo.android.domain.model.Tag +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -17,6 +22,15 @@ class ViewerViewModel( val photos: StateFlow> = container.photoRepository.observePhotos(albumId) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + private val _role = MutableStateFlow("") + val role: StateFlow = _role.asStateFlow() + + private val _preferences = MutableStateFlow(null) + val preferences: StateFlow = _preferences.asStateFlow() + + val albums: StateFlow> = container.albumRepository.observeAlbums() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + init { // Cold start (e.g. restored route): make sure the cache is populated. viewModelScope.launch { @@ -24,5 +38,77 @@ class ViewerViewModel( container.photoRepository.loadNextPage(albumId, cursor = null) } } + viewModelScope.launch { + runCatching { container.api.albumEnvelope(albumId).role } + .getOrNull() + ?.let { _role.value = it } + } + viewModelScope.launch { + runCatching { container.api.meEnvelope().preferences?.photoInfo } + .getOrNull() + ?.let { _preferences.value = it } + } + } + + suspend fun tagSuggestions(query: String): List = + runCatching { container.api.tags(query).map { it.name } }.getOrDefault(emptyList()) + + fun rename(photoId: String, name: String) { + val trimmed = name.trim() + if (trimmed.isEmpty()) return + viewModelScope.launch { + runCatching { container.api.renamePhoto(photoId, trimmed) } + .onSuccess { container.photoRepository.upsert(it) } + } + } + + fun delete(photoId: String) { + viewModelScope.launch { + runCatching { container.api.deletePhoto(photoId) } + .onSuccess { container.photoRepository.remove(photoId) } + } + } + + fun move(photoId: String, targetAlbumId: String) { + viewModelScope.launch { + runCatching { container.api.movePhotos(listOf(photoId), targetAlbumId) } + .onSuccess { container.photoRepository.remove(photoId) } + } + } + + fun copy(photoId: String, targetAlbumId: String) { + viewModelScope.launch { + runCatching { container.api.copyPhotos(listOf(photoId), targetAlbumId) } + } + } + + fun setCover(targetAlbumId: String, photoId: String) { + viewModelScope.launch { + runCatching { container.api.updateAlbum(targetAlbumId, coverPhotoId = photoId) } + } + } + + fun addTag(photoId: String, name: String) { + val trimmed = name.trim() + if (trimmed.isEmpty()) return + viewModelScope.launch { + runCatching { container.api.addTag(photoId, trimmed) } + .onSuccess { container.photoRepository.upsert(it) } + } + } + + fun removeTag(photoId: String, tagName: String) { + viewModelScope.launch { + val tag = photos.value.firstOrNull { it.id == photoId } + ?.tags?.firstOrNull { it.name.equals(tagName, ignoreCase = true) } + ?: Tag(id = "", name = tagName) + if (tag.id.isBlank()) return@launch + runCatching { container.api.removeTag(photoId, tag.id) } + .onSuccess { container.photoRepository.upsert(it) } + } + } + + fun download(photo: Photo, onOpen: (String) -> Unit) { + photo.downloadUrl?.let(onOpen) } } diff --git a/android/app/src/test/java/com/galo/android/data/network/GaloApiTest.kt b/android/app/src/test/java/com/galo/android/data/network/GaloApiTest.kt new file mode 100644 index 0000000..a8ce1ff --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/network/GaloApiTest.kt @@ -0,0 +1,168 @@ +package com.galo.android.data.network + +import com.galo.android.data.auth.InMemoryTokenStore +import com.galo.android.data.network.dto.PhotoInfoPrefsDto +import com.galo.android.data.network.dto.PreferencesDto +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** Request-level coverage for the album/tag/preferences operations added for the panels. */ +class GaloApiTest { + + private lateinit var server: MockWebServer + private lateinit var api: GaloApi + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val client = ApiClient( + tokenStore = InMemoryTokenStore(), + baseUrlProvider = { server.url("/").toString() }, + insecureAllowedProvider = { true }, + ) + api = GaloApi(client) + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `meEnvelope reads saved photo_info preferences`() = runTest { + server.enqueue( + MockResponse().setBody( + """{"user":{"id":"u1","name":"Ann"},"preferences":{"photo_info":{"visible":["ISO"]}}}""", + ), + ) + + val envelope = api.meEnvelope() + + assertEquals(listOf("ISO"), envelope.preferences?.photoInfo?.visible) + assertEquals("/api/auth/me", server.takeRequest().path) + } + + @Test + fun `savePreferences patches preferences`() = runTest { + server.enqueue(MockResponse().setBody("""{"preferences":{"photo_info":{"visible":["Make"]}}}""")) + + val saved = api.savePreferences( + PreferencesDto(photoInfo = PhotoInfoPrefsDto(visible = listOf("Make"))), + ) + + assertEquals(listOf("Make"), saved.photoInfo?.visible) + val request = server.takeRequest() + assertEquals("PATCH", request.method) + assertEquals("/api/auth/me/preferences", request.path) + assertTrue(request.body.readUtf8().contains("\"visible\":[\"Make\"]")) + } + + @Test + fun `updateAlbum sends title and cover, omitting clear_cover`() = runTest { + server.enqueue(MockResponse().setBody("""{"album":{"id":"a1","title":"New"}}""")) + + val album = api.updateAlbum("a1", title = "New", description = "d", coverPhotoId = "p1") + + assertEquals("New", album.title) + val request = server.takeRequest() + assertEquals("PATCH", request.method) + assertEquals("/api/albums/a1", request.path) + val body = request.body.readUtf8() + assertTrue(body.contains("\"title\":\"New\"")) + assertTrue(body.contains("\"cover_photo_id\":\"p1\"")) + assertFalse(body.contains("clear_cover")) + } + + @Test + fun `updateAlbum can clear the cover`() = runTest { + server.enqueue(MockResponse().setBody("""{"album":{"id":"a1"}}""")) + + api.updateAlbum("a1", clearCover = true) + + val body = server.takeRequest().body.readUtf8() + assertTrue(body.contains("\"clear_cover\":true")) + } + + @Test + fun `deleteAlbum issues DELETE`() = runTest { + server.enqueue(MockResponse().setBody("")) + + api.deleteAlbum("a1") + + val request = server.takeRequest() + assertEquals("DELETE", request.method) + assertEquals("/api/albums/a1", request.path) + } + + @Test + fun `members lists album participants`() = runTest { + server.enqueue( + MockResponse().setBody( + """{"members":[{"user_id":"u1","role":"owner","user":{"id":"u1","name":"Ann"}}]}""", + ), + ) + + val members = api.members("a1") + + assertEquals("owner", members.single().role) + assertEquals("Ann", members.single().user.name) + assertEquals("/api/albums/a1/members", server.takeRequest().path) + } + + @Test + fun `share links are listed created and revoked`() = runTest { + server.enqueue(MockResponse().setBody("""{"share_links":[{"id":"l1","token":"tok"}]}""")) + assertEquals("l1", api.shareLinks("a1").single().id) + assertEquals("/api/albums/a1/share", server.takeRequest().path) + + server.enqueue( + MockResponse().setResponseCode(201).setBody("""{"share_link":{"id":"l2","token":"tok2"}}"""), + ) + assertEquals("l2", api.createShareLink("a1", expiresIn = "24h").id) + val create = server.takeRequest() + assertEquals("POST", create.method) + assertTrue(create.body.readUtf8().contains("\"expires_in\":\"24h\"")) + + server.enqueue(MockResponse().setBody("")) + api.revokeShareLink("a1", "l2") + assertEquals("DELETE", server.takeRequest().method) + } + + @Test + fun `copyPhotos posts a copy batch`() = runTest { + server.enqueue(MockResponse().setBody("""{"photos":[]}""")) + + api.copyPhotos(listOf("p1", "p2"), "a2") + + val request = server.takeRequest() + assertEquals("/api/photo-batch/copy", request.path) + val body = request.body.readUtf8() + assertTrue(body.contains("\"ids\":[\"p1\",\"p2\"]")) + assertTrue(body.contains("\"album_id\":\"a2\"")) + } + + @Test + fun `addTag removeTag and tags use the tag endpoints`() = runTest { + server.enqueue( + MockResponse().setBody("""{"photo":{"id":"p1","tags":[{"id":"t1","name":"sun"}]}}"""), + ) + assertEquals("sun", api.addTag("p1", "sun").tags.single().name) + assertEquals("/api/photos/p1/tags", server.takeRequest().path) + + server.enqueue(MockResponse().setBody("""{"photo":{"id":"p1","tags":[]}}""")) + assertTrue(api.removeTag("p1", "t1").tags.isEmpty()) + assertEquals("/api/photos/p1/tags/t1", server.takeRequest().path) + + server.enqueue(MockResponse().setBody("""{"tags":[{"id":"t2","name":"sunset"}]}""")) + assertEquals("sunset", api.tags("sun").single().name) + assertEquals("/api/tags?q=sun", server.takeRequest().path) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/network/dto/PhotoDtoTest.kt b/android/app/src/test/java/com/galo/android/data/network/dto/PhotoDtoTest.kt new file mode 100644 index 0000000..20d8b6a --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/network/dto/PhotoDtoTest.kt @@ -0,0 +1,69 @@ +package com.galo.android.data.network.dto + +import com.galo.android.data.mapper.toDomain +import com.galo.android.data.network.GaloJson +import com.galo.android.data.network.ObjectUrlResolver +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Deserialization of the rich photo payload used by the attributes/actions sheet. */ +class PhotoDtoTest { + + private val resolver = ObjectUrlResolver { "https://photos.example.com" } + + private val json = """ + { + "id": "p1", + "album_id": "a1", + "original_filename": "IMG_0001.jpg", + "kind": "image", + "mime_type": "image/jpeg", + "width": 4000, + "height": 3000, + "size": 1234567, + "taken_at": "2024-05-01T10:00:00Z", + "caption": "", + "tags": [{"id": "t1", "name": "pano360"}], + "exif": {"Make": "Canon", "FNumber": 2.8, "ExposureTime": "1/250"}, + "uploader": {"id": "u1", "name": "Ann", "avatar_url": "/api/avatars/u1/v2"}, + "download_url": "/api/photos/p1/download" + } + """.trimIndent() + + @Test + fun `decodes exif tags uploader and download url`() { + val dto = GaloJson.decodeFromString(json) + + assertEquals("Canon", dto.exif["Make"]!!.toString().trim('"')) + assertEquals("pano360", dto.tags.single().name) + assertEquals("Ann", dto.uploader?.name) + assertEquals("/api/photos/p1/download", dto.downloadUrl) + } + + @Test + fun `maps rich fields into the domain photo`() { + val photo = GaloJson.decodeFromString(json).toDomain(resolver) + + assertEquals("Canon", photo.exif["Make"]) + assertEquals("2.8", photo.exif["FNumber"]) + assertEquals("1/250", photo.exif["ExposureTime"]) + assertEquals(listOf("pano360"), photo.tags.map { it.name }) + assertTrue(photo.isPanorama) + assertEquals("Ann", photo.uploaderName) + assertEquals( + "https://photos.example.com/api/avatars/u1/v2", + photo.uploaderAvatarUrl, + ) + } + + @Test + fun `photo without exif decodes to empty attributes`() { + val minimal = """{"id":"p2","album_id":"a1","original_filename":"x.jpg","kind":"image"}""" + val photo = GaloJson.decodeFromString(minimal).toDomain(resolver) + + assertTrue(photo.exif.isEmpty()) + assertTrue(photo.tags.isEmpty()) + assertEquals(null, photo.uploaderName) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/network/dto/UserPreferencesDtoTest.kt b/android/app/src/test/java/com/galo/android/data/network/dto/UserPreferencesDtoTest.kt new file mode 100644 index 0000000..f57c429 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/network/dto/UserPreferencesDtoTest.kt @@ -0,0 +1,49 @@ +package com.galo.android.data.network.dto + +import com.galo.android.data.network.GaloJson +import org.junit.Assert.assertEquals +import org.junit.Test + +/** The account payload carries saved preferences, including photo attribute settings. */ +class UserPreferencesDtoTest { + + @Test + fun `decodes photo_info preferences from the account payload`() { + val json = """ + { + "user": {"id": "u1", "name": "Ann"}, + "preferences": { + "photo_info": { + "visible": ["Make", "Model"], + "labels": {"Make": "Камера", "Model": "Модель"} + } + } + } + """.trimIndent() + + val envelope = GaloJson.decodeFromString(json) + + assertEquals(listOf("Make", "Model"), envelope.preferences?.photoInfo?.visible) + assertEquals("Камера", envelope.preferences?.photoInfo?.labels?.get("Make")) + } + + @Test + fun `account payload without preferences decodes to null`() { + val envelope = GaloJson.decodeFromString("""{"user":{"id":"u1","name":"Ann"}}""") + + assertEquals(null, envelope.preferences) + } + + @Test + fun `preferences round-trip keeps photo_info shape`() { + val dto = PreferencesDto( + photoInfo = PhotoInfoPrefsDto(visible = listOf("ISO"), labels = mapOf("ISO" to "ISO")), + ) + val encoded = GaloJson.encodeToString(PreferencesDto.serializer(), dto) + + assertEquals( + dto, + GaloJson.decodeFromString(encoded), + ) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/sync/ProvenanceStampTest.kt b/android/app/src/test/java/com/galo/android/data/sync/ProvenanceStampTest.kt new file mode 100644 index 0000000..6b8e177 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/sync/ProvenanceStampTest.kt @@ -0,0 +1,39 @@ +package com.galo.android.data.sync + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ProvenanceStampTest { + + @Test + fun `marker round-trips the sync mode`() { + val marker = Provenance( + serverAlbumId = "srv1", + serverPhotoId = "p1", + originalHash = "h", + deviceAlbumId = "b1", + mode = "MOVE", + assetKey = "b1/1", + ) + + val decoded = ProvenanceStamp.decode(ProvenanceStamp.encode(marker)) + + assertEquals(marker, decoded) + } + + @Test + fun `a legacy marker may carry a non-mode value`() { + // Older builds wrote the asset state here; decoding must still work and + // the caller decides it is not a valid mode. + val legacy = "galo|album=srv1|photo=p1|hash=h|device=b1|mode=PREVIEW|asset=b1/1" + + assertEquals("PREVIEW", ProvenanceStamp.decode(legacy)?.mode) + } + + @Test + fun `bad markers decode to null`() { + assertNull(ProvenanceStamp.decode(null)) + assertNull(ProvenanceStamp.decode("not-galo")) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/sync/RestoreManagerTest.kt b/android/app/src/test/java/com/galo/android/data/sync/RestoreManagerTest.kt index 234ea4d..3854dc6 100644 --- a/android/app/src/test/java/com/galo/android/data/sync/RestoreManagerTest.kt +++ b/android/app/src/test/java/com/galo/android/data/sync/RestoreManagerTest.kt @@ -1,11 +1,33 @@ package com.galo.android.data.sync +import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.domain.model.LocalAssetState +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test class RestoreManagerTest { + private fun asset( + key: String, + serverPhotoId: String? = "p1", + state: LocalAssetState = LocalAssetState.PREVIEW, + ) = DeviceAssetEntity( + assetKey = key, + deviceAlbumId = "b1", + mediaStoreId = 1L, + uri = "content://media/external/images/media/1", + displayName = "a.jpg", + mimeType = "image/jpeg", + size = 1L, + dateModified = 1L, + contentHash = "h", + serverPhotoId = serverPhotoId, + uploadedAt = 1L, + state = state.name, + ) + @Test fun `restoring fits when there is enough space`() { assertTrue(RestoreManager.canRestore(requiredBytes = 100, availableBytes = 100)) @@ -16,4 +38,16 @@ class RestoreManagerTest { fun `restoring is deferred when space is short`() { assertFalse(RestoreManager.canRestore(requiredBytes = 500, availableBytes = 100)) } + + @Test + fun `restore targets are previews that have a server photo`() { + val preview = asset("b1/1") + val local = asset("b1/2", state = LocalAssetState.LOCAL) + val noServer = asset("b1/3", serverPhotoId = null) + val awaiting = asset("b1/4", state = LocalAssetState.AWAITING_FREE) + + val targets = RestoreManager.restoreTargets(listOf(preview, local, noServer, awaiting)) + + assertEquals(listOf("b1/1"), targets.map { it.assetKey }) + } } diff --git a/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt b/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt index ad29482..c1a4081 100644 --- a/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt +++ b/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt @@ -53,6 +53,7 @@ class SyncCoordinatorTest { private class FakeQueue : SyncUploadQueue { val enqueued = mutableListOf>() + val pending = mutableSetOf() override suspend fun enqueue( deviceAlbumId: String, serverAlbumId: String, @@ -61,6 +62,8 @@ class SyncCoordinatorTest { ) { enqueued += Triple(deviceAlbumId, serverAlbumId, asset) } + + override suspend fun isPending(assetKey: String): Boolean = assetKey in pending } private class FakeProvider : LocalAssetProvider { @@ -267,9 +270,75 @@ class SyncCoordinatorTest { assertEquals(LocalAssetState.AWAITING_FREE.name, db.syncDao().getAsset("b1/1")?.state) } + @Test + fun `awaiting-free assets are retried on a later pass without a prompt`() = runBlocking { + link(SyncMode.MOVE) + remote.serverMap = mapOf("h" to "p1") + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "h", "p1", 1L, LocalAssetState.AWAITING_FREE.name, + ), + ), + ) + provider.items = listOf(asset("b1/1", hash = "h")) + offloader.result = OffloadResult.Done + + coordinator.reconcile("b1") + + assertEquals(listOf("b1/1"), offloader.offloaded) + } + + @Test + fun `a linked asset still on the device is offloaded in move mode`() = runBlocking { + link(SyncMode.MOVE) + remote.serverMap = mapOf("h" to "p1") + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "h", "p1", 1L, LocalAssetState.LOCAL.name, + ), + ), + ) + provider.items = listOf(asset("b1/1", hash = "h")) + offloader.result = OffloadResult.Done + + coordinator.reconcile("b1") + + assertEquals(listOf("b1/1"), offloader.offloaded) + } + @Test fun `move delegates to the server`() = runBlocking { coordinator.moveToServerAlbum(listOf("p1", "p2"), "srv2") assertEquals(listOf(listOf("p1", "p2") to "srv2"), remote.moves) } + + @Test + fun `reconcileAll reconciles every listed gallery including partial ones`() = runBlocking { + db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity("b1", "Camera", "srv1", SyncMode.COPY.name, 1L)) + db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity("b2", "Trips", "srv2", SyncMode.COPY.name, 1L)) + provider.items = listOf(asset("x/1", hash = "h2")) + + val contacted = coordinator.reconcileAll(listOf("b1", "b2", "missing")) + + assertTrue(contacted) + assertEquals(setOf("b1", "b2"), queue.enqueued.map { it.first }.toSet()) + } + + @Test + fun `indexed album ids list galleries with local provenance`() = runBlocking { + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "h", "p1", 1L, LocalAssetState.UPLOADED.name, + ), + ), + ) + + assertEquals(listOf("b1"), db.syncDao().indexedAlbumIds()) + } } diff --git a/android/app/src/test/java/com/galo/android/domain/attributes/AlbumAttributesResolverTest.kt b/android/app/src/test/java/com/galo/android/domain/attributes/AlbumAttributesResolverTest.kt new file mode 100644 index 0000000..eaf0fab --- /dev/null +++ b/android/app/src/test/java/com/galo/android/domain/attributes/AlbumAttributesResolverTest.kt @@ -0,0 +1,113 @@ +package com.galo.android.domain.attributes + +import com.galo.android.domain.model.Album +import com.galo.android.domain.model.DeviceAlbum +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AlbumAttributesResolverTest { + + private fun serverAlbum(id: String, title: String) = Album( + id = id, + title = title, + description = "server description", + ownerId = "u1", + coverPhotoId = "p1", + coverThumbUrl = "https://host/thumb", + coverMediumUrl = null, + photosCount = 7, + membersCount = 3, + updatedAt = null, + ) + + private fun deviceAlbum(id: String, name: String) = DeviceAlbum( + id = id, + name = name, + itemCount = 12, + coverUri = "content://media/$id/1", + relativePath = "DCIM/$name/", + ) + + @Test + fun `server album uses server attributes and enables everything by role`() { + val attributes = AlbumAttributesResolver.resolve( + AlbumAttributeSource.Server(serverAlbum("a1", "Summer"), role = "owner"), + ) + + assertEquals("Summer", attributes.title) + assertEquals("server description", attributes.description) + assertEquals("https://host/thumb", attributes.coverUrl) + assertEquals(7, attributes.photosCount) + assertEquals(3, attributes.membersCount) + assertEquals("a1", attributes.serverAlbumId) + assertTrue(attributes.canEdit) + assertFalse(attributes.isLocal) + assertTrue(attributes.membersEnabled) + assertTrue(attributes.shareEnabled) + assertTrue(attributes.canRename) + assertTrue(attributes.canDelete) + } + + @Test + fun `server viewer cannot rename, delete or manage links`() { + val attributes = AlbumAttributesResolver.resolve( + AlbumAttributeSource.Server(serverAlbum("a1", "Summer"), role = "viewer"), + ) + + assertFalse(attributes.canEdit) + assertTrue(attributes.membersEnabled) + assertFalse(attributes.shareEnabled) + assertFalse(attributes.canRename) + assertFalse(attributes.canDelete) + } + + @Test + fun `linked local album uses local metadata and enables actions plus linked navigation`() { + val attributes = AlbumAttributesResolver.resolve( + AlbumAttributeSource.Local( + device = deviceAlbum("d1", "Camera"), + title = "Моё имя", + coverUrl = "content://media/d1/9", + linkedServer = serverAlbum("a9", "Trips"), + role = "admin", + ), + ) + + assertEquals("Моё имя", attributes.title) + assertEquals("content://media/d1/9", attributes.coverUrl) + assertEquals(12, attributes.photosCount) + assertEquals("a9", attributes.serverAlbumId) + assertTrue(attributes.isLocal) + assertTrue(attributes.membersEnabled) + assertTrue(attributes.shareEnabled) + assertTrue(attributes.canRename) + assertTrue(attributes.canDelete) + } + + @Test + fun `unlinked local album shows disabled navigation and local actions`() { + val attributes = AlbumAttributesResolver.resolve( + AlbumAttributeSource.Local( + device = deviceAlbum("d1", "Camera"), + title = "Camera", + coverUrl = null, + linkedServer = null, + role = null, + ), + ) + + assertEquals("Camera", attributes.title) + assertEquals("", attributes.description) + assertNull(attributes.serverAlbumId) + assertEquals(12, attributes.photosCount) + assertTrue(attributes.isLocal) + assertFalse(attributes.membersEnabled) + assertFalse(attributes.shareEnabled) + assertTrue(attributes.settingsEnabled) + assertTrue(attributes.canRename) + assertTrue(attributes.canDelete) + } +} diff --git a/android/app/src/test/java/com/galo/android/domain/attributes/PhotoAttributeResolverTest.kt b/android/app/src/test/java/com/galo/android/domain/attributes/PhotoAttributeResolverTest.kt new file mode 100644 index 0000000..fc6f1e5 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/domain/attributes/PhotoAttributeResolverTest.kt @@ -0,0 +1,49 @@ +package com.galo.android.domain.attributes + +import com.galo.android.domain.model.DeviceMedia +import com.galo.android.domain.model.MediaKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PhotoAttributeResolverTest { + + private fun media(id: Long) = DeviceMedia( + id = id, + uri = "content://media/external/images/media/$id", + albumId = "b1", + albumName = "Camera", + displayName = "IMG_$id.jpg", + mimeType = "image/jpeg", + size = 100L, + width = 4000, + height = 3000, + dateModified = 5L, + dateTaken = null, + relativePath = "DCIM/Camera/", + kind = MediaKind.IMAGE, + ) + + @Test + fun `mapped media resolves to the server photo`() { + val source = PhotoAttributeResolver.resolve(media(1L), serverPhotoId = "p42") + + assertTrue(source is PhotoAttributeSource.Server) + assertEquals("p42", (source as PhotoAttributeSource.Server).photoId) + } + + @Test + fun `unmapped media resolves to the local item`() { + val source = PhotoAttributeResolver.resolve(media(2L), serverPhotoId = null) + + assertTrue(source is PhotoAttributeSource.Local) + assertEquals(2L, (source as PhotoAttributeSource.Local).media.id) + } + + @Test + fun `blank server id is treated as unmapped`() { + val source = PhotoAttributeResolver.resolve(media(3L), serverPhotoId = " ") + + assertTrue(source is PhotoAttributeSource.Local) + } +} diff --git a/android/app/src/test/java/com/galo/android/domain/exif/ExifCatalogTest.kt b/android/app/src/test/java/com/galo/android/domain/exif/ExifCatalogTest.kt new file mode 100644 index 0000000..4aa6050 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/domain/exif/ExifCatalogTest.kt @@ -0,0 +1,67 @@ +package com.galo.android.domain.exif + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The catalog must stay in sync with the web catalog (`frontend/src/lib/exif.ts`). */ +class ExifCatalogTest { + + @Test + fun `base set matches the web default`() { + assertEquals( + listOf( + "Make", "Model", "LensModel", "ExposureTime", "FNumber", + "ISOSpeedRatings", "FocalLength", "FocalLengthIn35mmFilm", + ), + EXIF_BASE, + ) + } + + @Test + fun `labels match the web catalog`() { + assertEquals("Производитель", EXIF_LABELS["Make"]) + assertEquals("Модель", EXIF_LABELS["Model"]) + assertEquals("Объектив", EXIF_LABELS["LensModel"]) + assertEquals("Выдержка", EXIF_LABELS["ExposureTime"]) + assertEquals("Диафрагма", EXIF_LABELS["FNumber"]) + assertEquals("ISO", EXIF_LABELS["ISOSpeedRatings"]) + assertEquals("Фокусное расстояние", EXIF_LABELS["FocalLength"]) + assertEquals(35, EXIF_CATALOG.size) + } + + @Test + fun `formats exposure aperture focal and enums`() { + assertEquals("1/250", formatExifValue("ExposureTime", "0.004")) + assertEquals("2 с", formatExifValue("ExposureTime", "2")) + assertEquals("f/2.8", formatExifValue("FNumber", "2.8")) + assertEquals("35 мм", formatExifValue("FocalLength", "35")) + assertEquals("+1 EV", formatExifValue("ExposureBiasValue", "1.0")) + assertEquals("Авто", formatExifValue("WhiteBalance", "0")) + assertEquals("canon", formatExifValue("Make", "canon")) + } + + @Test + fun `builds rows skipping gps and technical tags and sorting by label`() { + val rows = buildExifRows( + mapOf( + "Make" to "Canon", + "GPSLatitude" to "55.7", + "ImageWidth" to "4000", + "Model" to "R5", + ), + ) + + assertEquals(listOf("Model", "Make"), rows.map { it.key }) + assertFalse(rows.any { it.key == "GPSLatitude" }) + assertTrue(rows.all { it.value.isNotBlank() || it.key == "Make" }) + } + + @Test + fun `custom labels override the catalog`() { + val rows = buildExifRows(mapOf("Make" to "Canon"), labels = mapOf("Make" to "Камера")) + + assertEquals("Камера", rows.single().label) + } +} diff --git a/android/app/src/test/java/com/galo/android/domain/model/SyncModeTest.kt b/android/app/src/test/java/com/galo/android/domain/model/SyncModeTest.kt new file mode 100644 index 0000000..83e332f --- /dev/null +++ b/android/app/src/test/java/com/galo/android/domain/model/SyncModeTest.kt @@ -0,0 +1,24 @@ +package com.galo.android.domain.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SyncModeTest { + + @Test + fun `from returns disabled for unknown values`() { + assertEquals(SyncMode.DISABLED, SyncMode.from("PREVIEW")) + assertEquals(SyncMode.DISABLED, SyncMode.from(null)) + assertEquals(SyncMode.COPY, SyncMode.from("COPY")) + } + + @Test + fun `parseOrNull keeps only real modes`() { + assertEquals(SyncMode.MOVE, SyncMode.parseOrNull("MOVE")) + assertEquals(SyncMode.COPY, SyncMode.parseOrNull("COPY")) + assertEquals(SyncMode.DISABLED, SyncMode.parseOrNull("DISABLED")) + assertNull(SyncMode.parseOrNull("PREVIEW")) + assertNull(SyncMode.parseOrNull(null)) + } +} diff --git a/android/app/src/test/java/com/galo/android/domain/sync/GallerySyncPolicyTest.kt b/android/app/src/test/java/com/galo/android/domain/sync/GallerySyncPolicyTest.kt new file mode 100644 index 0000000..6af0b49 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/domain/sync/GallerySyncPolicyTest.kt @@ -0,0 +1,73 @@ +package com.galo.android.domain.sync + +import com.galo.android.domain.model.LocalAssetState +import com.galo.android.domain.model.SyncMode +import com.galo.android.domain.sync.GallerySyncPolicy.LocalFix +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Full mode × state × linked matrix — every combination must be handled. */ +class GallerySyncPolicyTest { + + private fun decide(mode: SyncMode, state: LocalAssetState, linked: Boolean) = + GallerySyncPolicy.decide(mode, state, hasServerPhoto = linked) + + @Test + fun `server-only is never fixed locally`() { + for (mode in SyncMode.entries) { + for (linked in listOf(true, false)) { + assertEquals(LocalFix.NONE, decide(mode, LocalAssetState.SERVER_ONLY, linked)) + } + } + } + + @Test + fun `preview is restored unless in move mode`() { + assertEquals(LocalFix.RESTORE, decide(SyncMode.COPY, LocalAssetState.PREVIEW, true)) + assertEquals(LocalFix.RESTORE, decide(SyncMode.DISABLED, LocalAssetState.PREVIEW, true)) + assertEquals(LocalFix.RESTORE, decide(SyncMode.COPY, LocalAssetState.PREVIEW, false)) + assertEquals(LocalFix.NONE, decide(SyncMode.MOVE, LocalAssetState.PREVIEW, true)) + } + + @Test + fun `uploaded and awaiting-free are offloaded only in move mode`() { + for (state in listOf(LocalAssetState.UPLOADED, LocalAssetState.AWAITING_FREE)) { + assertEquals(LocalFix.OFFLOAD, decide(SyncMode.MOVE, state, true)) + assertEquals(LocalFix.NONE, decide(SyncMode.MOVE, state, false)) + assertEquals(LocalFix.NONE, decide(SyncMode.COPY, state, true)) + assertEquals(LocalFix.NONE, decide(SyncMode.DISABLED, state, true)) + } + } + + @Test + fun `local uploads when a server mode is chosen and not yet linked`() { + assertEquals(LocalFix.UPLOAD, decide(SyncMode.COPY, LocalAssetState.LOCAL, false)) + assertEquals(LocalFix.UPLOAD, decide(SyncMode.MOVE, LocalAssetState.LOCAL, false)) + assertEquals(LocalFix.NONE, decide(SyncMode.DISABLED, LocalAssetState.LOCAL, false)) + } + + @Test + fun `local already linked is offloaded in move and kept otherwise`() { + assertEquals(LocalFix.OFFLOAD, decide(SyncMode.MOVE, LocalAssetState.LOCAL, true)) + assertEquals(LocalFix.NONE, decide(SyncMode.COPY, LocalAssetState.LOCAL, true)) + assertEquals(LocalFix.NONE, decide(SyncMode.DISABLED, LocalAssetState.LOCAL, true)) + } + + @Test + fun `every combination yields a defined action`() { + for (mode in SyncMode.entries) { + for (state in LocalAssetState.entries) { + for (linked in listOf(true, false)) { + val fix = decide(mode, state, linked) + // Sanity: restore never happens in move, offload never outside move. + if (mode == SyncMode.MOVE) { + assert(fix != LocalFix.RESTORE) + } + if (mode != SyncMode.MOVE) { + assert(fix != LocalFix.OFFLOAD) + } + } + } + } + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/AllFilesAccessDialogTest.kt b/android/app/src/test/java/com/galo/android/ui/AllFilesAccessDialogTest.kt new file mode 100644 index 0000000..6ae6f89 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/AllFilesAccessDialogTest.kt @@ -0,0 +1,51 @@ +package com.galo.android.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.galo.android.ui.theme.GaloTheme +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class AllFilesAccessDialogTest { + + @get:Rule + val compose = createComposeRule() + + @Test + fun `enable launches the settings intent`() { + var enabled = false + compose.setContent { + GaloTheme(dynamicColor = false) { + AllFilesAccessDialog(onEnable = { enabled = true }, onDismiss = {}) + } + } + + compose.onNodeWithTag(TAG_ALL_FILES_PROMPT_DIALOG).assertIsDisplayed() + compose.onNodeWithTag(TAG_ALL_FILES_PROMPT_ENABLE).performClick() + + assertTrue(enabled) + } + + @Test + fun `later dismisses the prompt`() { + var dismissed = false + compose.setContent { + GaloTheme(dynamicColor = false) { + AllFilesAccessDialog(onEnable = {}, onDismiss = { dismissed = true }) + } + } + + compose.onNodeWithText("Позже").performClick() + + assertTrue(dismissed) + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/album/AlbumDialogsTest.kt b/android/app/src/test/java/com/galo/android/ui/album/AlbumDialogsTest.kt new file mode 100644 index 0000000..7fe8c65 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/album/AlbumDialogsTest.kt @@ -0,0 +1,66 @@ +package com.galo.android.ui.album + +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import com.galo.android.ui.theme.GaloTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class AlbumDialogsTest { + + @get:Rule + val compose = createComposeRule() + + @Test + fun `rename dialog confirms with the edited title`() { + var confirmed: String? = null + compose.setContent { + GaloTheme(dynamicColor = false) { + RenameAlbumDialog(initial = "Старое", onConfirm = { confirmed = it }, onDismiss = {}) + } + } + + compose.onNodeWithTag(TAG_ALBUM_RENAME_FIELD).performTextClearance() + compose.onNodeWithTag(TAG_ALBUM_RENAME_FIELD).performTextInput("Новое") + compose.onNodeWithTag(TAG_ALBUM_RENAME_CONFIRM).performClick() + compose.waitForIdle() + + assertEquals("Новое", confirmed) + } + + @Test + fun `rename is disabled for a blank title`() { + compose.setContent { + GaloTheme(dynamicColor = false) { + RenameAlbumDialog(initial = " ", onConfirm = {}, onDismiss = {}) + } + } + + compose.onNodeWithTag(TAG_ALBUM_RENAME_CONFIRM).assertIsNotEnabled() + } + + @Test + fun `delete dialog confirms deletion`() { + var confirmed = false + compose.setContent { + GaloTheme(dynamicColor = false) { + DeleteAlbumDialog(title = "Отпуск", onConfirm = { confirmed = true }, onDismiss = {}) + } + } + + compose.onNodeWithTag(TAG_ALBUM_DELETE_CONFIRM).performClick() + + assertTrue(confirmed) + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/album/AlbumSettingsContentTest.kt b/android/app/src/test/java/com/galo/android/ui/album/AlbumSettingsContentTest.kt new file mode 100644 index 0000000..4635e26 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/album/AlbumSettingsContentTest.kt @@ -0,0 +1,101 @@ +package com.galo.android.ui.album + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import com.galo.android.data.network.dto.MemberDto +import com.galo.android.data.network.dto.ShareLinkDto +import com.galo.android.data.network.dto.UserDto +import com.galo.android.ui.theme.GaloTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class AlbumSettingsContentTest { + + @get:Rule + val compose = createComposeRule() + + private fun render( + state: AlbumSettingsViewModel.UiState, + onSave: (String, String) -> Unit = { _, _ -> }, + onCreateLink: (String) -> Unit = {}, + onRevokeLink: (String) -> Unit = {}, + ) { + compose.setContent { + GaloTheme(dynamicColor = false) { + AlbumSettingsContent( + state = state, + onSave = onSave, + onCreateLink = onCreateLink, + onRevokeLink = onRevokeLink, + onBack = {}, + ) + } + } + } + + @Test + fun `saving passes the edited title and description`() { + var saved: Pair? = null + render( + AlbumSettingsViewModel.UiState(title = "Старое", description = "desc"), + onSave = { t, d -> saved = t to d }, + ) + + compose.onNodeWithTag(TAG_SETTINGS_TITLE_FIELD).performTextClearance() + compose.onNodeWithTag(TAG_SETTINGS_TITLE_FIELD).performTextInput("Новое") + compose.onNodeWithTag(TAG_SETTINGS_SAVE).performClick() + compose.waitForIdle() + + assertEquals("Новое" to "desc", saved) + } + + @Test + fun `saved indicator appears after a save`() { + render(AlbumSettingsViewModel.UiState(title = "A", description = "", saved = true)) + + compose.onNodeWithTag(TAG_SETTINGS_SAVED).assertIsDisplayed() + } + + @Test + fun `members are listed`() { + render( + AlbumSettingsViewModel.UiState( + members = listOf(MemberDto(userId = "u1", role = "owner", user = UserDto(id = "u1", name = "Ann"))), + ), + ) + + compose.onNodeWithTag(TAG_SETTINGS_MEMBER_PREFIX + "u1").assertIsDisplayed() + } + + @Test + fun `links can be created and revoked`() { + var created = false + var revoked: String? = null + render( + AlbumSettingsViewModel.UiState( + shareLinks = listOf(ShareLinkDto(id = "l1", token = "tok")), + ), + onCreateLink = { created = true }, + onRevokeLink = { revoked = it }, + ) + + compose.onNodeWithTag(TAG_SETTINGS_LINK_CREATE).performScrollTo().performClick() + compose.onNodeWithTag(TAG_SETTINGS_LINK_REVOKE_PREFIX + "l1").performScrollTo().performClick() + compose.waitForIdle() + + assertTrue(created) + assertEquals("l1", revoked) + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt index 9dce557..d8cd99e 100644 --- a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt @@ -72,11 +72,13 @@ class AlbumsContentTest { compose.onNodeWithTag(TAG_ALBUMS_GRID).assertIsDisplayed() compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + "a1").assertIsDisplayed() compose.onNodeWithText("Summer").assertIsDisplayed() - compose.onNodeWithText("4 фото · 2 участн.").assertIsDisplayed() + compose.onNodeWithText("4 фото").assertIsDisplayed() + compose.onNodeWithTag(TAG_ALBUM_STORAGE_CLOUD_PREFIX + "a1", useUnmergedTree = true).assertExists() + compose.onNodeWithTag(TAG_ALBUM_STORAGE_DEVICE_PREFIX + "a1", useUnmergedTree = true).assertDoesNotExist() } @Test - fun `mixed list shows device album with source and link`() { + fun `mixed list shows device album with storage and link`() { val items = AlbumListBuilder.build( albums = listOf(album("a1", "Отпуск")), links = listOf(AlbumLink("b1", "Camera", "a1", SyncMode.COPY)), @@ -88,10 +90,27 @@ class AlbumsContentTest { compose.onNodeWithTag(TAG_DEVICE_ALBUM_CARD_PREFIX + "b1").assertIsDisplayed() // The linked server album is collapsed into the device card, not shown twice. compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + "a1").assertDoesNotExist() - compose.onNodeWithText("Телефон").assertIsDisplayed() - compose.onNodeWithText("9 эл.").assertIsDisplayed() + compose.onNodeWithText("Camera").assertIsDisplayed() compose.onNodeWithText("→ Отпуск").assertIsDisplayed() - compose.onNodeWithText("копии").assertIsDisplayed() + compose.onNodeWithText("9 эл.").assertIsDisplayed() + compose.onNodeWithTag(TAG_ALBUM_STORAGE_DEVICE_PREFIX + "b1", useUnmergedTree = true).assertExists() + compose.onNodeWithTag(TAG_ALBUM_STORAGE_CLOUD_PREFIX + "b1", useUnmergedTree = true).assertExists() + } + + @Test + fun `device album caption uses storage icons and hides matching server name`() { + val items = AlbumListBuilder.build( + albums = listOf(album("a1", "Отпуск")), + links = listOf(AlbumLink("b1", "Отпуск", "a1", SyncMode.DISABLED)), + devices = listOf(device("b1", "Отпуск", 5)), + ) + compose.setContent { + screen(AlbumsViewModel.UiState(items = items, deviceAccess = true)) + } + compose.onNodeWithText("5 эл.").assertIsDisplayed() + compose.onNodeWithTag(TAG_ALBUM_STORAGE_DEVICE_PREFIX + "b1", useUnmergedTree = true).assertExists() + compose.onNodeWithTag(TAG_ALBUM_STORAGE_CLOUD_PREFIX + "b1", useUnmergedTree = true).assertDoesNotExist() + compose.onNodeWithText("→ Отпуск").assertDoesNotExist() } @Test diff --git a/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumContentTest.kt b/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumContentTest.kt index 2f4a939..d836f6a 100644 --- a/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumContentTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumContentTest.kt @@ -2,6 +2,7 @@ package com.galo.android.ui.devicealbum import androidx.compose.runtime.Composable import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.junit4.createComposeRule @@ -10,10 +11,21 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.test.swipeRight import com.galo.android.domain.model.Album import com.galo.android.domain.model.DeviceMedia import com.galo.android.domain.model.MediaKind import com.galo.android.domain.model.SyncMode +import com.galo.android.ui.album.TAG_ALBUM_RENAME_CONFIRM +import com.galo.android.ui.album.TAG_ALBUM_RENAME_FIELD +import com.galo.android.ui.panel.TAG_ALBUM_PANEL +import com.galo.android.ui.panel.TAG_PANEL_DELETE +import com.galo.android.ui.panel.TAG_PANEL_RENAME import com.galo.android.ui.theme.GaloTheme import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -78,6 +90,12 @@ class DeviceAlbumContentTest { onMode: (SyncMode) -> Unit = {}, onTarget: (String) -> Unit = {}, onCreateAlbum: (String) -> Unit = {}, + role: String = "", + localName: String? = null, + localCoverUrl: String? = null, + onDeleteAlbum: () -> Unit = {}, + onRenameAlbum: (String) -> Unit = {}, + onSetCover: (String?) -> Unit = {}, ) { GaloTheme(dynamicColor = false) { DeviceAlbumContent( @@ -88,6 +106,12 @@ class DeviceAlbumContentTest { onRestoreOriginals = {}, onOpen = {}, onCreateAlbum = onCreateAlbum, + role = role, + localName = localName, + localCoverUrl = localCoverUrl, + onDeleteAlbum = onDeleteAlbum, + onRenameAlbum = onRenameAlbum, + onSetCover = onSetCover, ) } } @@ -168,6 +192,45 @@ class DeviceAlbumContentTest { fun `mode action for the same mode does nothing`() { assertEquals(null, modeAction(SyncMode.COPY, hasTarget = true, target = SyncMode.COPY)) } + + @Test + fun `all mode transition variants are routed correctly`() { + // Off → copy/move without a target: pick one first. + assertEquals( + ModeAction.PickTarget(SyncMode.COPY), + modeAction(SyncMode.DISABLED, hasTarget = false, target = SyncMode.COPY), + ) + assertEquals( + ModeAction.PickTarget(SyncMode.MOVE), + modeAction(SyncMode.DISABLED, hasTarget = false, target = SyncMode.MOVE), + ) + // Off → copy with a target: confirm. + assertEquals( + ModeAction.Confirm(SyncMode.COPY), + modeAction(SyncMode.DISABLED, hasTarget = true, target = SyncMode.COPY), + ) + // Copy → move: confirm. + assertEquals( + ModeAction.Confirm(SyncMode.MOVE), + modeAction(SyncMode.COPY, hasTarget = true, target = SyncMode.MOVE), + ) + // Move → copy/off: warn and offer to restore the originals. + assertEquals( + ModeAction.LeaveC(SyncMode.COPY), + modeAction(SyncMode.MOVE, hasTarget = true, target = SyncMode.COPY), + ) + assertEquals( + ModeAction.LeaveC(SyncMode.DISABLED), + modeAction(SyncMode.MOVE, hasTarget = true, target = SyncMode.DISABLED), + ) + // Copy → off: applies directly. + assertEquals( + ModeAction.Apply(SyncMode.DISABLED), + modeAction(SyncMode.COPY, hasTarget = true, target = SyncMode.DISABLED), + ) + // Same mode: nothing. + assertEquals(null, modeAction(SyncMode.MOVE, hasTarget = true, target = SyncMode.MOVE)) + } @Test fun `target picker lists albums and picks one`() { var picked: String? = null @@ -275,4 +338,107 @@ class DeviceAlbumContentTest { compose.onNodeWithTag(TAG_MODE_PREFIX + SyncMode.MOVE.name).performClick() assertEquals(SyncMode.MOVE, chosen) } + + @Test + fun `swipe left opens the attributes panel`() { + compose.setContent { + content( + DeviceAlbumViewModel.UiState( + name = "Camera", + items = listOf(media(1), media(2)), + serverAlbumId = "s1", + serverAlbums = listOf(album("s1", "Отпуск")), + ), + role = "owner", + ) + } + + compose.onNodeWithTag(TAG_ALBUM_PANEL).assertDoesNotExist() + compose.onNodeWithTag(TAG_DEVICE_GRID).performTouchInput { swipeLeft() } + compose.waitForIdle() + compose.onNodeWithTag(TAG_ALBUM_PANEL).assertIsDisplayed() + } + + @Test + fun `sync settings drawer opens independently of the panel`() { + compose.setContent { + content( + DeviceAlbumViewModel.UiState( + name = "Camera", + items = listOf(media(1)), + serverAlbumId = "s1", + serverAlbums = listOf(album("s1", "Отпуск")), + ), + role = "owner", + ) + } + + openSettings() + + compose.onNodeWithTag(TAG_DEVICE_SETTINGS).assertIsDisplayed() + compose.onNodeWithTag(TAG_ALBUM_PANEL).assertDoesNotExist() + } + + @Test + fun `swiping right on the open panel closes it instead of opening sync settings`() { + compose.setContent { + content( + DeviceAlbumViewModel.UiState( + name = "Camera", + items = listOf(media(1), media(2)), + serverAlbumId = "s1", + serverAlbums = listOf(album("s1", "Отпуск")), + ), + role = "owner", + ) + } + + compose.onNodeWithTag(TAG_DEVICE_GRID).performTouchInput { swipeLeft() } + compose.waitForIdle() + compose.onNodeWithTag(TAG_ALBUM_PANEL).assertIsDisplayed() + + compose.onNodeWithTag(TAG_ALBUM_PANEL).performTouchInput { swipeRight() } + compose.waitForIdle() + + compose.onNodeWithTag(TAG_ALBUM_PANEL).assertDoesNotExist() + compose.onNodeWithTag(TAG_DEVICE_SETTINGS).assertIsNotDisplayed() + } + + @Test + fun `local panel rename reports the new name`() { + var name: String? = null + compose.setContent { + content( + DeviceAlbumViewModel.UiState(name = "Camera", items = listOf(media(1))), + onRenameAlbum = { name = it }, + ) + } + + compose.onNodeWithTag(TAG_DEVICE_GRID).performTouchInput { swipeLeft() } + compose.waitForIdle() + compose.onNodeWithTag(TAG_PANEL_RENAME).performScrollTo().performClick() + compose.onNodeWithTag(TAG_ALBUM_RENAME_FIELD).performTextClearance() + compose.onNodeWithTag(TAG_ALBUM_RENAME_FIELD).performTextInput("Поездка") + compose.onNodeWithTag(TAG_ALBUM_RENAME_CONFIRM).performClick() + + assertEquals("Поездка", name) + } + + @Test + fun `local panel delete asks confirmation then deletes locally`() { + var deleted = false + compose.setContent { + content( + DeviceAlbumViewModel.UiState(name = "Camera", items = listOf(media(1), media(2))), + onDeleteAlbum = { deleted = true }, + ) + } + + compose.onNodeWithTag(TAG_DEVICE_GRID).performTouchInput { swipeLeft() } + compose.waitForIdle() + compose.onNodeWithTag(TAG_PANEL_DELETE).performScrollTo().performClick() + compose.onNodeWithTag(TAG_LOCAL_DELETE_CONFIRM).performClick() + + assertTrue(deleted) + } } diff --git a/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceViewerSheetTest.kt b/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceViewerSheetTest.kt new file mode 100644 index 0000000..4b9598f --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceViewerSheetTest.kt @@ -0,0 +1,83 @@ +package com.galo.android.ui.devicealbum + +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeUp +import com.galo.android.domain.model.DeviceMedia +import com.galo.android.domain.model.MediaKind +import com.galo.android.ui.theme.GaloTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class DeviceViewerSheetTest { + + private val sheetTag = "device_sheet_content" + + @get:Rule + val compose = createComposeRule() + + private fun media(id: Long, kind: MediaKind = MediaKind.IMAGE) = DeviceMedia( + id = id, + uri = "content://media/external/images/media/$id", + albumId = "b1", + albumName = "Camera", + displayName = "IMG_$id.jpg", + mimeType = "image/jpeg", + size = 100L, + width = 4000, + height = 3000, + dateModified = 5L, + dateTaken = null, + relativePath = "DCIM/Camera/", + kind = kind, + ) + + @Test + fun `swipe up opens the sheet in the device viewer`() { + compose.setContent { + GaloTheme(dynamicColor = false) { + DeviceViewerScreen( + items = listOf(media(1)), + initialIndex = 0, + onBack = {}, + sheet = { Text("SHEET", modifier = Modifier.testTag(sheetTag)) }, + ) + } + } + + compose.onNodeWithTag(sheetTag).assertDoesNotExist() + compose.onNodeWithTag(TAG_DEVICE_VIEWER_PAGER).performTouchInput { swipeUp() } + compose.waitForIdle() + + compose.onNodeWithTag(sheetTag).assertIsDisplayed() + } + + @Test + fun `swipe up does not open the sheet on a video`() { + compose.setContent { + GaloTheme(dynamicColor = false) { + DeviceViewerScreen( + items = listOf(media(2, MediaKind.VIDEO)), + initialIndex = 0, + onBack = {}, + sheet = { Text("SHEET", modifier = Modifier.testTag(sheetTag)) }, + ) + } + } + + compose.onNodeWithTag(TAG_DEVICE_VIEWER_PAGER).performTouchInput { swipeUp() } + compose.waitForIdle() + + compose.onNodeWithTag(sheetTag).assertDoesNotExist() + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/panel/AlbumAttributesPanelTest.kt b/android/app/src/test/java/com/galo/android/ui/panel/AlbumAttributesPanelTest.kt new file mode 100644 index 0000000..34ab276 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/panel/AlbumAttributesPanelTest.kt @@ -0,0 +1,136 @@ +package com.galo.android.ui.panel + +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import com.galo.android.domain.attributes.AlbumAttributes +import com.galo.android.ui.theme.GaloTheme +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class AlbumAttributesPanelTest { + + @get:Rule + val compose = createComposeRule() + + private fun serverAttributes(role: String) = AlbumAttributes( + title = "Summer", + description = "desc", + coverUrl = "https://host/cover", + photosCount = 4, + membersCount = 2, + serverAlbumId = "a1", + role = role, + isLocal = false, + membersEnabled = true, + shareEnabled = role == "owner" || role == "admin", + settingsEnabled = role == "owner" || role == "admin", + canRename = role == "owner" || role == "admin", + canDelete = role == "owner" || role == "admin", + ) + + private fun localAttributes(linked: Boolean) = AlbumAttributes( + title = "Camera", + description = "", + coverUrl = "content://media/d1/1", + photosCount = 9, + membersCount = if (linked) 2 else 0, + serverAlbumId = if (linked) "s1" else null, + role = if (linked) "owner" else null, + isLocal = true, + membersEnabled = linked, + shareEnabled = linked, + settingsEnabled = true, + canRename = true, + canDelete = true, + ) + + private fun render( + attributes: AlbumAttributes, + onPickCover: () -> Unit = {}, + onRename: () -> Unit = {}, + onDelete: () -> Unit = {}, + ) { + compose.setContent { + GaloTheme(dynamicColor = false) { + AlbumAttributesPanel( + attributes = attributes, + onPickCover = onPickCover, + onResetCover = {}, + onOpenMembers = {}, + onOpenSettings = {}, + onOpenShare = {}, + onRename = onRename, + onDelete = onDelete, + ) + } + } + } + + @Test + fun `server owner sees cover controls navigation and actions`() { + render(serverAttributes("owner")) + + compose.onNodeWithTag(TAG_PANEL_COVER_PICK).assertExists() + compose.onNodeWithTag(TAG_PANEL_COVER_RESET).assertExists() + compose.onNodeWithTag(TAG_PANEL_MEMBERS).assertExists() + compose.onNodeWithTag(TAG_PANEL_SETTINGS).assertExists() + compose.onNodeWithTag(TAG_PANEL_SHARE).assertExists() + compose.onNodeWithTag(TAG_PANEL_RENAME).assertExists() + compose.onNodeWithTag(TAG_PANEL_DELETE).assertExists() + } + + @Test + fun `server viewer has no cover controls and disabled editable navigation`() { + render(serverAttributes("viewer")) + + compose.onNodeWithTag(TAG_PANEL_COVER_PICK).assertDoesNotExist() + compose.onNodeWithTag(TAG_PANEL_MEMBERS).assertExists() + compose.onNodeWithTag(TAG_PANEL_SETTINGS).assertIsNotEnabled() + compose.onNodeWithTag(TAG_PANEL_SHARE).assertIsNotEnabled() + compose.onNodeWithTag(TAG_PANEL_RENAME).assertDoesNotExist() + compose.onNodeWithTag(TAG_PANEL_DELETE).assertDoesNotExist() + } + + @Test + fun `unlinked local album shows cover and actions with disabled navigation`() { + render(localAttributes(linked = false)) + + compose.onNodeWithTag(TAG_PANEL_COVER_PICK).assertExists() + compose.onNodeWithTag(TAG_PANEL_MEMBERS).assertIsNotEnabled() + compose.onNodeWithTag(TAG_PANEL_SHARE).assertIsNotEnabled() + compose.onNodeWithTag(TAG_PANEL_SETTINGS).assertExists() + compose.onNodeWithTag(TAG_PANEL_RENAME).assertExists() + compose.onNodeWithTag(TAG_PANEL_DELETE).assertExists() + } + + @Test + fun `action rows invoke their callbacks`() { + var picked = false + var renamed = false + var deleted = false + render( + localAttributes(linked = false), + onPickCover = { picked = true }, + onRename = { renamed = true }, + onDelete = { deleted = true }, + ) + + compose.onNodeWithTag(TAG_PANEL_COVER_PICK).performScrollTo().performClick() + compose.onNodeWithTag(TAG_PANEL_RENAME).performScrollTo().performClick() + compose.onNodeWithTag(TAG_PANEL_DELETE).performScrollTo().performClick() + compose.waitForIdle() + + assertTrue(picked) + assertTrue(renamed) + assertTrue(deleted) + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/panel/EndDrawerScaffoldTest.kt b/android/app/src/test/java/com/galo/android/ui/panel/EndDrawerScaffoldTest.kt new file mode 100644 index 0000000..5fd7d35 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/panel/EndDrawerScaffoldTest.kt @@ -0,0 +1,84 @@ +package com.galo.android.ui.panel + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.click +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.test.swipeRight +import androidx.compose.ui.unit.dp +import com.galo.android.ui.theme.GaloTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class EndDrawerScaffoldTest { + + private val contentTag = "content" + private val panelTag = "panel" + + @get:Rule + val compose = createComposeRule() + + private fun render(open: MutableState) { + compose.setContent { + GaloTheme(dynamicColor = false) { + EndDrawerScaffold( + open = open.value, + onOpenChange = { open.value = it }, + drawerWidth = 120.dp, + drawerContent = { Text("PANEL", modifier = Modifier.testTag(panelTag)) }, + ) { + Box(Modifier.fillMaxSize().testTag(contentTag)) + } + } + } + } + + @Test + fun `swipe left opens the end drawer`() { + val open = mutableStateOf(false) + render(open) + + compose.onNodeWithTag(panelTag).assertDoesNotExist() + compose.onNodeWithTag(contentTag).performTouchInput { swipeLeft() } + compose.waitForIdle() + + compose.onNodeWithTag(panelTag).assertIsDisplayed() + } + + @Test + fun `tapping the scrim closes the end drawer`() { + render(mutableStateOf(true)) + + compose.onNodeWithTag(panelTag).assertIsDisplayed() + compose.onNodeWithTag(TAG_END_DRAWER_SCRIM).performTouchInput { click(Offset(8f, 8f)) } + compose.waitForIdle() + + compose.onNodeWithTag(panelTag).assertDoesNotExist() + } + + @Test + fun `swipe right on the panel closes it`() { + render(mutableStateOf(true)) + + compose.onNodeWithTag(panelTag).assertIsDisplayed() + compose.onNodeWithTag(panelTag).performTouchInput { swipeRight() } + compose.waitForIdle() + + compose.onNodeWithTag(panelTag).assertDoesNotExist() + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/photo/AttributeSettingsContentTest.kt b/android/app/src/test/java/com/galo/android/ui/photo/AttributeSettingsContentTest.kt new file mode 100644 index 0000000..099ba57 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/photo/AttributeSettingsContentTest.kt @@ -0,0 +1,89 @@ +package com.galo.android.ui.photo + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import com.galo.android.ui.theme.GaloTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class AttributeSettingsContentTest { + + @get:Rule + val compose = createComposeRule() + + private fun render( + state: AttributeSettingsViewModel.UiState, + onToggle: (String) -> Unit = {}, + onSetAll: (Boolean) -> Unit = {}, + onSave: () -> Unit = {}, + onReset: () -> Unit = {}, + ) { + compose.setContent { + GaloTheme(dynamicColor = false) { + AttributeSettingsContent( + state = state, + onToggle = onToggle, + onSetAll = onSetAll, + onLabel = { _, _ -> }, + onSave = onSave, + onReset = onReset, + onBack = {}, + ) + } + } + } + + @Test + fun `all and none report their target state`() { + var all: Boolean? = null + render(AttributeSettingsViewModel.UiState(), onSetAll = { all = it }) + + compose.onNodeWithTag(TAG_ATTR_ALL).performClick() + assertEquals(true, all) + compose.onNodeWithTag(TAG_ATTR_NONE).performClick() + assertEquals(false, all) + } + + @Test + fun `save and reset report`() { + var saved = false + var reset = false + render( + AttributeSettingsViewModel.UiState(), + onSave = { saved = true }, + onReset = { reset = true }, + ) + + compose.onNodeWithTag(TAG_ATTR_SAVE).performClick() + compose.onNodeWithTag(TAG_ATTR_RESET).performClick() + + assertTrue(saved) + assertTrue(reset) + } + + @Test + fun `toggling a row reports its key`() { + var toggled: String? = null + render(AttributeSettingsViewModel.UiState(), onToggle = { toggled = it }) + + compose.onNodeWithTag(TAG_ATTR_CHECK_PREFIX + "Make").performClick() + + assertEquals("Make", toggled) + } + + @Test + fun `saved indicator appears`() { + render(AttributeSettingsViewModel.UiState(saved = true)) + + compose.onNodeWithTag(TAG_ATTR_SAVED).assertIsDisplayed() + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/photo/PhotoAttributesSheetTest.kt b/android/app/src/test/java/com/galo/android/ui/photo/PhotoAttributesSheetTest.kt new file mode 100644 index 0000000..4e8cbcb --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/photo/PhotoAttributesSheetTest.kt @@ -0,0 +1,160 @@ +package com.galo.android.ui.photo + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import com.galo.android.domain.exif.ExifRow +import com.galo.android.ui.theme.GaloTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class PhotoAttributesSheetTest { + + @get:Rule + val compose = createComposeRule() + + private fun model( + tags: List = emptyList(), + hidden: Int = 0, + ) = PhotoSheetModel( + title = "IMG_0001.jpg", + uploaderName = "Ann", + size = 2048, + mimeType = "image/jpeg", + width = 4000, + height = 3000, + takenAt = "2024-05-01", + createdAt = "2024-05-02", + exifRows = listOf(ExifRow("Make", "Производитель", "Canon")), + hiddenAttributeCount = hidden, + tags = tags, + isServer = true, + canRename = true, + canSetCover = true, + canDelete = true, + ) + + @Test + fun `info section shows metadata and exif`() { + compose.setContent { + GaloTheme(dynamicColor = false) { + PhotoAttributesSheet(model = model()) + } + } + + compose.onNodeWithText("Ann").assertIsDisplayed() + compose.onNodeWithText("Canon").assertIsDisplayed() + compose.onNodeWithText("4000×3000").assertIsDisplayed() + } + + @Test + fun `more expands the hidden attributes`() { + var expanded = false + compose.setContent { + GaloTheme(dynamicColor = false) { + PhotoAttributesSheet(model = model(hidden = 2), onShowAll = { expanded = true }) + } + } + + compose.onNodeWithTag(TAG_SHEET_MORE).assertIsDisplayed() + compose.onNodeWithTag(TAG_SHEET_MORE).performClick() + + assertTrue(expanded) + } + + @Test + fun `removing a tag reports its name`() { + var removed: String? = null + compose.setContent { + GaloTheme(dynamicColor = false) { + PhotoAttributesSheet( + model = model(tags = listOf("sun")), + onRemoveTag = { removed = it }, + onAddTag = {}, + ) + } + } + + compose.onNodeWithTag(TAG_SHEET_TAG_REMOVE_PREFIX + "sun").performScrollTo().performClick() + + assertEquals("sun", removed) + } + + @Test + fun `adding a tag from a suggestion reports it`() { + var added: String? = null + compose.setContent { + GaloTheme(dynamicColor = false) { + PhotoAttributesSheet( + model = model(), + onAddTag = { added = it }, + tagSuggestions = listOf("beach"), + ) + } + } + + compose.onNodeWithTag(TAG_SHEET_TAG_SUGGESTION_PREFIX + "beach").performScrollTo().performClick() + + assertEquals("beach", added) + } + + @Test + fun `each action row invokes its callback`() { + var download = false + var move = false + var copy = false + var cover = false + var delete = false + compose.setContent { + GaloTheme(dynamicColor = false) { + PhotoAttributesSheet( + model = model(), + onDownload = { download = true }, + onMove = { move = true }, + onCopy = { copy = true }, + onSetCover = { cover = true }, + onDelete = { delete = true }, + ) + } + } + + compose.onNodeWithTag(TAG_SHEET_DOWNLOAD).performScrollTo().performClick() + compose.onNodeWithTag(TAG_SHEET_MOVE).performScrollTo().performClick() + compose.onNodeWithTag(TAG_SHEET_COPY).performScrollTo().performClick() + compose.onNodeWithTag(TAG_SHEET_COVER).performScrollTo().performClick() + compose.onNodeWithTag(TAG_SHEET_DELETE).performScrollTo().performClick() + compose.waitForIdle() + + assertTrue(download) + assertTrue(move) + assertTrue(copy) + assertTrue(cover) + assertTrue(delete) + } + + @Test + fun `local media offers local actions only`() { + compose.setContent { + GaloTheme(dynamicColor = false) { + PhotoAttributesSheet( + model = PhotoSheetModel(title = "IMG.jpg", isServer = false), + onShare = {}, + ) + } + } + + compose.onNodeWithTag(TAG_SHEET_SHARE).performScrollTo().assertIsDisplayed() + compose.onNodeWithTag(TAG_SHEET_DOWNLOAD).assertDoesNotExist() + compose.onNodeWithTag(TAG_SHEET_DELETE).assertDoesNotExist() + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/photo/PhotoDialogsTest.kt b/android/app/src/test/java/com/galo/android/ui/photo/PhotoDialogsTest.kt new file mode 100644 index 0000000..ce36f8b --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/photo/PhotoDialogsTest.kt @@ -0,0 +1,98 @@ +package com.galo.android.ui.photo + +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import com.galo.android.domain.model.Album +import com.galo.android.ui.theme.GaloTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class PhotoDialogsTest { + + @get:Rule + val compose = createComposeRule() + + private fun album(id: String, title: String) = Album( + id = id, + title = title, + description = "", + ownerId = "u1", + coverPhotoId = null, + coverThumbUrl = null, + coverMediumUrl = null, + photosCount = 0, + membersCount = 0, + updatedAt = null, + ) + + @Test + fun `rename dialog confirms the edited name`() { + var confirmed: String? = null + compose.setContent { + GaloTheme(dynamicColor = false) { + RenamePhotoDialog(initial = "old.jpg", onConfirm = { confirmed = it }, onDismiss = {}) + } + } + + compose.onNodeWithTag(TAG_PHOTO_RENAME_FIELD).performTextClearance() + compose.onNodeWithTag(TAG_PHOTO_RENAME_FIELD).performTextInput("new.jpg") + compose.onNodeWithTag(TAG_PHOTO_RENAME_CONFIRM).performClick() + + assertEquals("new.jpg", confirmed) + } + + @Test + fun `rename is disabled for a blank name`() { + compose.setContent { + GaloTheme(dynamicColor = false) { + RenamePhotoDialog(initial = "", onConfirm = {}, onDismiss = {}) + } + } + + compose.onNodeWithTag(TAG_PHOTO_RENAME_CONFIRM).assertIsNotEnabled() + } + + @Test + fun `delete dialog confirms deletion`() { + var confirmed = false + compose.setContent { + GaloTheme(dynamicColor = false) { + DeletePhotoDialog(title = "IMG.jpg", onConfirm = { confirmed = true }, onDismiss = {}) + } + } + + compose.onNodeWithTag(TAG_PHOTO_DELETE_CONFIRM).performClick() + + assertTrue(confirmed) + } + + @Test + fun `album picker reports the chosen album`() { + var picked: String? = null + compose.setContent { + GaloTheme(dynamicColor = false) { + AlbumPickerDialog( + title = "Переместить в альбом", + albums = listOf(album("a2", "Работа")), + onPick = { picked = it }, + onDismiss = {}, + ) + } + } + + compose.onNodeWithTag(TAG_ALBUM_PICKER_OPTION_PREFIX + "a2").performClick() + + assertEquals("a2", picked) + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/viewer/ViewerSheetTest.kt b/android/app/src/test/java/com/galo/android/ui/viewer/ViewerSheetTest.kt new file mode 100644 index 0000000..1e6930e --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/viewer/ViewerSheetTest.kt @@ -0,0 +1,113 @@ +package com.galo.android.ui.viewer + +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.pinch +import androidx.compose.ui.test.swipeUp +import com.galo.android.domain.model.MediaKind +import com.galo.android.domain.model.Photo +import com.galo.android.ui.theme.GaloTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class ViewerSheetTest { + + private val sheetTag = "sheet_content" + + @get:Rule + val compose = createComposeRule() + + private fun photo( + id: String, + name: String, + kind: MediaKind = MediaKind.IMAGE, + isPanorama: Boolean = false, + ) = Photo( + id = id, + albumId = "a1", + originalFilename = name, + kind = kind, + mimeType = null, + width = 100, + height = 100, + durationMs = 0, + size = 0, + takenAt = null, + caption = "", + isPanorama = isPanorama, + thumbUrl = null, + mediumUrl = null, + previewUrl = null, + playbackUrl = null, + originalUrl = null, + downloadUrl = null, + createdAt = null, + ) + + private fun render(photo: Photo) { + compose.setContent { + GaloTheme(dynamicColor = false) { + ViewerContent( + photos = listOf(photo), + initialIndex = 0, + onBack = {}, + sheet = { Text("SHEET", modifier = Modifier.testTag(sheetTag)) }, + ) + } + } + } + + @Test + fun `swipe up opens the attributes sheet`() { + render(photo("p1", "first.jpg")) + + compose.onNodeWithTag(sheetTag).assertDoesNotExist() + compose.onNodeWithTag(TAG_VIEWER_PAGER).performTouchInput { swipeUp() } + compose.waitForIdle() + + compose.onNodeWithTag(sheetTag).assertIsDisplayed() + } + + @Test + fun `swipe up does not open the sheet on a panorama`() { + render(photo("p1", "pano.jpg", isPanorama = true)) + + compose.onNodeWithText("pano.jpg").assertIsDisplayed() + compose.onNodeWithTag(TAG_VIEWER_PAGER).performTouchInput { swipeUp() } + compose.waitForIdle() + + compose.onNodeWithTag(sheetTag).assertDoesNotExist() + } + + @Test + fun `swipe up does not open the sheet when zoomed`() { + render(photo("p1", "first.jpg")) + + compose.onNodeWithTag(TAG_ZOOMABLE_IMAGE).performTouchInput { + pinch( + start0 = center + Offset(-40f, 0f), + end0 = center + Offset(-160f, 0f), + start1 = center + Offset(40f, 0f), + end1 = center + Offset(160f, 0f), + ) + } + compose.waitForIdle() + + compose.onNodeWithTag(TAG_VIEWER_PAGER).performTouchInput { swipeUp() } + compose.waitForIdle() + + compose.onNodeWithTag(sheetTag).assertDoesNotExist() + } +} diff --git a/openspec/changes/archive/2026-10-13-add-mobile-album-photo-actions/.openspec.yaml b/openspec/changes/archive/2026-10-13-add-mobile-album-photo-actions/.openspec.yaml new file mode 100644 index 0000000..5fcc9ec --- /dev/null +++ b/openspec/changes/archive/2026-10-13-add-mobile-album-photo-actions/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-13 diff --git a/openspec/changes/archive/2026-10-13-add-mobile-album-photo-actions/design.md b/openspec/changes/archive/2026-10-13-add-mobile-album-photo-actions/design.md new file mode 100644 index 0000000..9c28cf6 --- /dev/null +++ b/openspec/changes/archive/2026-10-13-add-mobile-album-photo-actions/design.md @@ -0,0 +1,129 @@ +# Design + +## Context + +См. `proposal.md` — зачем. Текущее состояние, влияющее на решение: + +- `GaloApi` умеет только `albums list/get/create`, `photos list/get/upload`, `renamePhoto`, + `deletePhoto`, `movePhotos`. Доступны, но не задействованы: `PATCH/DELETE /albums/:id`, + `/albums/:id/members`, `/albums/:id/share`, `POST /photo-batch/copy`, + `/photos/:id/tags`, `/tags`, `PATCH /auth/me/preferences`. +- Сервер уже отдаёт в JSON фото `exif`, `tags`, `uploader`, `download_url`, а `/auth/me` — + `preferences`; `PhotoDto`/`UserDto` в Android этих полей не читают. +- `DeviceAlbumViewModel` держит `serverPhotoByMediaId` (сопоставление локального медиа + серверному фото), но `DeviceViewerScreen` его не получает. +- `DeviceAlbumScreen` уже использует `ModalNavigationDrawer` (стартовый/левый drawer) под + настройки синхронизации. +- Просмотр (`ViewerScreen`/`DeviceViewerScreen`) занят жестами: горизонтальный пейджер, + pinch-zoom, double-tap, панорама (drag по двум осям), плеер видео. + +## Goals / Non-Goals + +**Goals:** +- Единая панель «Атрибуты и действия» на всех экранах альбомов (свайп справа налево). +- Единый лист «Атрибуты и действия» в обоих просмотрах (свайп вверх). +- Резолвинг источника атрибутов: сервер / связанный сервер / локальные значения. +- Расширение клиентского API/DTO до паритета с web без изменений бэкенда. + +**Non-Goals:** +- Изменения бэкенда, web и схемы spec-ов (кроме этой delta). +- Новые серверные способы работы с медиа (всё уже есть в API). +- Пересмотр существующих жестов просмотра (пейджер, зум, панорама) и drawer'а синхронизации. + +## Decisions + +### 1. Панель альбома = `ModalNavigationDrawer` с end-drawer +Свайп справа налево открывает правый (end) `ModalNavigationDrawer` на `AlbumScreen` и на +`DeviceAlbumScreen`. Так сохраняется симметрия: на device-экране левый drawer — синхронизация, +правый — атрибуты/действия; на серверном — только правый. +_Альтернатива_: bottom sheet на альбоме — не повторяет web-раскладку «правая панель» и хуже +для длинного списка атрибутов. + +### 2. Один компонент панели; атрибуты локального альбома — локальные +Общий composable-контент панели принимает «модель атрибутов альбома», которую строит экран: + +- серверный альбом → из `AlbumDto` (включая `role` из envelope); +- локальный альбом → локальные метаданные (`DeviceAlbum` + app-only метаданные), связанный + серверный альбом используется только как источник для навигации «Участники»/«Ссылки». + +Секции панели показываются всегда; строки навигации, требующие серверной стороны, отображаются +неактивными, когда связи нет. + +_Альтернатива_: три отдельные панели — дублирование и расхождение поведения. + +### 2a. App-only метаданные локального альбома +Имя (отображаемое) и выбранная обложка локального альбома хранятся в новой Room-таблице +`device_album_meta` (ключ — id альбома-устройства). «Переименовать» пишет `displayName`, +«Выбрать обложку»/«Сбросить» — `coverUri`. Список альбомов и панель применяют их поверх +данных MediaStore. + +_Альтернатива_: менять bucket в MediaStore — невозможно без прав и необратимо. + +### 2b. Удаление локального альбома удаляет медиа телефона +«Удалить» собирает все URI медиа альбома и запрашивает системное согласие +(`MediaStore.createDeleteRequest`) через тот же механизм, что и разгрузка в режиме C. После +подтверждения система удаляет файлы; приложение забывает локальный индекс и метаданные +(`forgetAlbum`). + +### 3. Лист фото = `ModalBottomSheet`, открытие по порогу свайпа вверх +`ViewerScreen` и `DeviceViewerScreen` добавляют `detectVerticalDragGestures` под условием: +не открывать при активном зуме, панораме и поверх контролов видео. В счёт: открывать при +`!zoomed && !isPanorama && kind != VIDEO` (для видео — через видимый drag-handle/кнопку). +_Альтернатива_: кнопка вместо жеста — расходится с исходным требованием (свайп вверх). + +### 4. Резолвинг атрибутов/действий device-медиа +Если `serverPhotoByMediaId[mediaId]` есть — лист грузит серверное фото (`GET /photos/:id`) и +показывает серверные атрибуты/действия; иначе — локальную модель из `DeviceMedia`. +Сопоставление передаётся в `DeviceViewerScreen` из `DeviceAlbumViewModel`. + +### 5. Расширение клиентского API/DTO (без бэкенда) +- `PhotoDto` + `Photo`: `exif`, `tags`, `uploader`, `download_url`, `likes_count`, + `comments_count` (используется то, что нужно листу). +- `UserDto`/`me()`: `preferences`. +- `GaloApi`: `updateAlbum` (title/description/cover/clear), `deleteAlbum`, `members`, + share-links, `copyPhotos`, `addTag`/`removeTag`/`tags`, `download` (через существующий + `download_url`), `savePreferences`. +- `ProfileApi` не трогаем; `preferences` добавляется в ответ `me()`. + +### 6. Настройки атрибутов — новый экран, состояние в профиле +Переносим каталог `EXIF_LABELS`/`EXIF_BASE` (web `exif.ts`) в Android-ресурс/объект и храним +`photo_info.visible` + `photo_info.labels` в `preferences` через `PATCH /auth/me/preferences` +(тот же формат, что у web). _Альтернатива_: собственный формат — рассинхрон настроек между +web и мобильным для одного пользователя. + +## Risks / Trade-offs + +- [Конфликт жеста «вверх» с зумом/панорамой/плеером] → открывать лист только вне этих + состояний; для видео — не перехватывать жест, доступ через handle/кнопку. +- [Удаление/переименование локального альбома не имеет смысла и деструктивно] → действия + серверного уровня показываются только при связи и применяются к серверному альбому; без + связи раздел действий пуст. +- [Офлайн] → операции серверного уровня недоступны офлайн; используем уже существующее + офлайн-состояние экрана и показываем ошибку без падения. +- [Дублирование EXIF-каталога между web и Android] → переносим один в один и фиксируем + базовый набор; расхождение контролируется тестом. +- [Права доступа] → гейтим секции по `role` (владелец/админ/автор), как web. + +### 7. Полная проверка синхронизации при запуске +`SyncWorker` реконсилирует все известные галереи (связанные плюс имеющие локальный индекс), +восстанавливает утраченные связи по меткам происхождения (случай частичной синхронизации) и +перевыставляет незавершённые загрузки (`requeuePending`). Запуск воркера происходит после +загрузки настроек (когда известен base URL), а смена режима на серверный сразу вызывает +`reconcile`. Та же проверка выполняется при открытии галереи. Разгрузка в режиме переноса +автоматическая: любой ассет, уже имеющий серверное фото и всё ещё хранящий оригинал на +устройстве, разгружается (включая повторные попытки и ассеты, оставшиеся «ожидающими +освобождения»). Обратный случай (переход из переноса в копии/выключено) возвращает оригиналы с +server и довосстанавливает незавершённый возврат при следующей проверке; восстановление не +обрывается на единичных ошибках. Решение по каждому ассету вынесено в политику +`GallerySyncPolicy` («режим × состояние × связанность»), которую применяет общая проверка +`SyncCheck` — так охвачены все сочетания, включая «копии с разгруженными файлами». + +## Migration Plan + +Изменение аддитивное, бэкенд и данные не мигрируются. Выкатывается в составе обычного релиза +Android-клиента; откат — откат версии приложения. Пользовательские `preferences` не меняют +формат, поэтому web и мобильный остаются совместимы. + +## Open Questions + +Нет — все решения, влияющие на спек и декомпозицию, приняты выше. diff --git a/openspec/changes/archive/2026-10-13-add-mobile-album-photo-actions/proposal.md b/openspec/changes/archive/2026-10-13-add-mobile-album-photo-actions/proposal.md new file mode 100644 index 0000000..f78c1ff --- /dev/null +++ b/openspec/changes/archive/2026-10-13-add-mobile-album-photo-actions/proposal.md @@ -0,0 +1,52 @@ +# Proposal + +## Why + +В web у альбомов и фото есть полноценное управление атрибутами и действиями (панель +обложки/информации/навигации, разделы «Действия», «Метки», «Информация» с настройками +EXIF-атрибутов). В Android-клиенте этого нет: экран альбома — только сетка, просмотр — +только изображение с навигацией, а `GaloApi` умеет лишь переименовать/удалить/переместить. +С телефона нельзя ни посмотреть свойства, ни выполнить действия «как в web». + +## What Changes + +- Новый **экранный жест**: свайп справа налево в любом альбоме (серверном и локальном) + открывает панель **«Атрибуты и действия»** (drawer справа). +- Панель альбома повторяет структуру web (`AlbumPanel` + экран настроек): + - Атрибуты: «Обложка» (превью, выбрать/сбросить), «Информация» (название, описание, + счётчики), «Навигация» (участники, настройки, ссылки для просмотра). + - Действия: «Переименовать», «Удалить». + - Источник данных: серверный альбом — данные сервера; локальный, связанный с серверным — + реальные атрибуты связанного серверного альбома; локальный без связи — локальные + значения (обложка/имя/счётчик), без раздела «Навигация». +- Новый **жест просмотра**: свайп вверх в полноэкранном просмотре (серверном и device) + открывает лист **«Атрибуты и действия»**, повторяющий web `PhotoPanel`: + - Атрибуты: «Информация» (автор, файл, тип, разрешение, EXIF, снято, координаты, + загружено) с настройками списка EXIF-атрибутов; «Метки». + - Действия: «Открыть (просмотр)» (из сетки), «Скачать», «Переместить», «Копировать», + «Установить как обложку альбома», «Удалить»; переименование по тапу на имя. + - Для device-медиа: если элемент сопоставлен серверному фото — реальные серверные + атрибуты и действия; иначе локальные атрибуты и локальные действия. +- Расширение клиентского слоя Android: `GaloApi` + DTO получают операции альбома + (обновление/обложка/удаление/участники/share), копирование, метки, скачивание, `exif` + и `preferences`; новый экран «Настройки атрибутов» (видимость + подписи, в профиль). +- **Бэкенд не меняется**: все нужные endpoints уже есть. + +## Capabilities + +### New Capabilities + + +### Modified Capabilities + +- `android-client`: добавляются требования к панели «Атрибуты и действия» на экранах + альбомов, к листу «Атрибуты и действия» в полноэкранном просмотре, к выбору источника + атрибутов для локальных альбомов/медиа и к поддерживающим операциям API/настройкам + атрибутов в профиле. + +## Impact + +- `android/app/src/main/java/com/galo/android/`: `data/network/GaloApi.kt`, `.../dto/Dtos.kt`, + `domain/model/`, `ui/album/`, `ui/viewer/`, `ui/devicealbum/`, новые экраны настроек. +- Тесты Android (`android/app/src/androidTest`, `test`). +- Бэкенд, web и схема spec-ов не затрагиваются (кроме новой delta для `android-client`). diff --git a/openspec/changes/archive/2026-10-13-add-mobile-album-photo-actions/specs/android-client/spec.md b/openspec/changes/archive/2026-10-13-add-mobile-album-photo-actions/specs/android-client/spec.md new file mode 100644 index 0000000..4ae79b0 --- /dev/null +++ b/openspec/changes/archive/2026-10-13-add-mobile-album-photo-actions/specs/android-client/spec.md @@ -0,0 +1,266 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: Проверка доступа «Все файлы» при запуске +Приложение SHALL при запуске, когда оно уже настроено и авторизовано, проверять наличие доступа +«Все файлы» и при его отсутствии SHALL предлагать его включить, открывая системный экран настроек. + +#### Scenario: Доступ не выдан +- **WHEN** приложение запущено и доступ «Все файлы» не выдан +- **THEN** показывается диалог с предложением включить доступ + +#### Scenario: Включение доступа +- **WHEN** пользователь подтверждает +- **THEN** открывается системный экран выдачи доступа «Все файлы» + +#### Scenario: Доступ уже выдан +- **WHEN** доступ уже выдан +- **THEN** диалог не показывается + +### Requirement: Возврат оригиналов при выходе из переноса +При переключении альбома из режима переноса в «копии» или «выключено» приложение SHALL +возвращать оригиналы с сервера (когда пользователь выбирает возврат), а незавершённый возврат +SHALL довосстанавливаться при следующей проверке (при запуске и при открытии альбома). + +#### Scenario: Перенос → копии +- **WHEN** пользователь переключает альбом из переноса в копии и подтверждает возврат оригиналов +- **THEN** оригиналы скачиваются с сервера и заменяют превью на устройстве + +#### Scenario: Довосстановление +- **WHEN** альбом не в режиме переноса, но содержит превью с серверным фото +- **THEN** при запуске или открытии альбома оригиналы довосстанавливаются + +#### Scenario: Перенос → выключено +- **WHEN** пользователь переключает перенос в выключено +- **THEN** предлагается вернуть оригиналы или оставить превью + +### Requirement: Единая проверка соответствия режиму +При запуске и при открытии альбома приложение SHALL приводить локальное содержимое в +соответствие режиму для всех сочетаний состояния ассета: в «переносе» разгружать уже загруженные +оригиналы, а в «копиях»/«выключено» восстанавливать превью; отсутствующий локальный индекс SHALL +восстанавливаться по метке происхождения. + +#### Scenario: Копии с разгруженными файлами +- **WHEN** альбом в режиме копий, а часть файлов разгружена (превью) +- **THEN** проверка восстанавливает оригиналы + +#### Scenario: Перенос с неразгруженными файлами +- **WHEN** альбом в режиме переноса, файл есть на сервере, но оригинал ещё на телефоне +- **THEN** проверка разгружает оригинал + +#### Scenario: Потерянный индекс +- **WHEN** у разгруженного медиа нет записи в локальном индексе +- **THEN** связь и происхождение восстанавливаются по метке в файле + +#### Scenario: Выключенный режим +- **WHEN** альбом без синхронизации содержит превью +- **THEN** оригиналы восстанавливаются, а сервер не затрагивается + +### Requirement: Отсутствие дублей и гонок при синхронизации +Проверка синхронизации SHALL выполняться последовательно (без одновременных проходов по одной +галерее) и SHALL NOT ставить повторную загрузку, пока по тому же элементу уже есть задание в +очереди. Метка происхождения в разгруженном файле SHALL хранить режим синхронизации, а не +состояние элемента, чтобы восстановление не меняло режим галереи. + +#### Scenario: Повторная проверка во время загрузки +- **WHEN** проверка запускается снова, пока загрузка элемента ещё стоит в очереди +- **THEN** повторная загрузка не ставится + +#### Scenario: Метка происхождения хранит режим +- **WHEN** разгруженный файл восстанавливается после переустановки +- **THEN** режим галереи берётся из метки как режим (перенос/копии), а не как состояние элемента + +### Requirement: Подпись карточки альбома +Карточка альбома SHALL показывать в первой строке имя альбома, а для связанной пары — со +стрелкой имя связанного серверного альбома, и только когда имена различаются. Вторая строка +SHALL показывать значками расположение содержимого и число элементов: значок карты памяти для +локального без синхронизации, значки карты памяти и облака для режима копий, значок облака для +режима переноса; для серверного альбома — значок облака. + +#### Scenario: Локальный без синхронизации +- **WHEN** показан локальный альбом без синхронизации +- **THEN** вторая строка показывает значок карты памяти и число элементов + +#### Scenario: Режим копий +- **WHEN** локальный альбом синхронизируется в режиме копий +- **THEN** вторая строка показывает значки карты памяти и облака и число элементов + +#### Scenario: Режим переноса +- **WHEN** локальный альбом синхронизируется в режиме переноса +- **THEN** вторая строка показывает значок облака и число элементов + +#### Scenario: Разные имена связанной пары +- **WHEN** имя локального альбома не совпадает с именем связанного серверного +- **THEN** первая строка показывает «имя → имя серверного» + +#### Scenario: Одинаковые имена +- **WHEN** имена совпадают +- **THEN** стрелка и имя серверного не показываются + +### Requirement: Панель атрибутов и действий альбома +Приложение SHALL открывать панель «Атрибуты и действия» по свайпу справа налево на экране +любого альбома — серверного и локального. Панель SHALL повторять структуру web: разделы +«Обложка», «Информация», «Навигация» и раздел «Действия». + +#### Scenario: Открытие свайпом +- **WHEN** пользователь свайпает справа налево в открытом альбоме +- **THEN** открывается панель «Атрибуты и действия» + +#### Scenario: Структура панели +- **WHEN** панель открыта +- **THEN** показаны разделы «Обложка», «Информация», «Навигация» и «Действия» + +#### Scenario: Закрытие панели +- **WHEN** пользователь закрывает панель (свайп в обратную сторону или касание вне панели) +- **THEN** панель скрывается, сетка медиа снова доступна + +### Requirement: Источник атрибутов локального альбома +Для локального альбома приложение SHALL показывать ту же панель «Атрибуты и действия», что и +для серверного, а метаданные SHALL брать локально (имя, число элементов, локальная обложка, +режим синхронизации, связанный серверный альбом). Действия локального альбома SHALL +применяться локально. + +#### Scenario: Локальные метаданные +- **WHEN** пользователь открывает панель локального альбома +- **THEN** раздел «Информация» показывает локальные имя, число элементов и обложку + +#### Scenario: Неактивная навигация без связи +- **WHEN** локальный альбом не связан с серверным +- **THEN** строки «Участники» и «Ссылки для просмотра» показаны, но неактивны + +#### Scenario: Активная навигация при связи +- **WHEN** локальный альбом связан с серверным +- **THEN** строки «Участники» и «Ссылки для просмотра» активны и относятся к связанному серверному альбому + +### Requirement: Переименование и обложка локального альбома +Для локального альбома действие «Переименовать» SHALL менять отображаемое имя в приложении, не +изменяя медиа или bucket на устройстве, а «Выбрать обложку»/«Сбросить» SHALL назначать или +снимать обложку из медиа этого альбома. Имя и обложка SHALL храниться локально. + +#### Scenario: Переименование +- **WHEN** пользователь вводит новое имя локального альбома +- **THEN** панель, заголовок и карточка в списке альбомов показывают новое имя + +#### Scenario: Назначение и сброс обложки +- **WHEN** пользователь выбирает медиа обложкой или сбрасывает её +- **THEN** панель и карточка альбома отражают выбор + +### Requirement: Удаление локального альбома с телефона +Действие «Удалить» локального альбома SHALL после подтверждения удалять все фото и видео +этого альбома с устройства, запрашивая при необходимости системное согласие. + +#### Scenario: Подтверждение +- **WHEN** пользователь выбирает «Удалить» локального альбома +- **THEN** открывается диалог с предупреждением, что медиа будут удалены с телефона + +#### Scenario: Удаление +- **WHEN** пользователь подтверждает удаление +- **THEN** медиа удаляются с устройства, а альбом исчезает из приложения + +### Requirement: Атрибуты альбома +Панель SHALL показывать обложку с выбором и сбросом, информацию (название, описание, число +фото и участников) и переходы к участникам, настройкам и ссылкам для просмотра. Управление +обложкой и раздел «Навигация» SHALL быть доступны только владельцу и администратору. + +#### Scenario: Управление обложкой +- **WHEN** владелец или администратор выбирает или сбрасывает обложку +- **THEN** изменение сохраняется на сервере и отражается в списке и панели + +#### Scenario: Наблюдатель +- **WHEN** наблюдатель открывает панель +- **THEN** управление обложкой не показывается, а строки «Настройки» и «Ссылки для просмотра» неактивны + +#### Scenario: Переход к настройкам +- **WHEN** владелец или администратор открывает настройки из раздела «Навигация» +- **THEN** доступны правка названия и описания и управление ссылками для просмотра + +### Requirement: Действия с альбомом +Панель SHALL содержать действия «Переименовать» и «Удалить». Удаление SHALL подтверждаться +диалогом, а удалённый альбом SHALL помещать свои фото в корзину. + +#### Scenario: Переименование +- **WHEN** владелец или администратор выбирает «Переименовать» и вводит новое название +- **THEN** название сохраняется на сервере и отражается в списке и заголовке + +#### Scenario: Удаление с подтверждением +- **WHEN** владелец выбирает «Удалить» и подтверждает в диалоге +- **THEN** альбом удаляется, а его фото попадают в корзину + +### Requirement: Лист атрибутов и действий фото +Полноэкранный просмотр SHALL открывать лист «Атрибуты и действия» по свайпу вверх как в +серверном, так и в device-просмотре. Лист SHALL повторять структуру web: разделы +«Информация», «Метки» и «Действия». При зуме изображения, панораме или активном управлении +видео свайп вверх SHALL NOT открывать лист. + +#### Scenario: Открытие листа +- **WHEN** пользователь свайпает вверх во вписанном изображении +- **THEN** открывается лист «Атрибуты и действия» для текущего элемента + +#### Scenario: Зум или панорама +- **WHEN** изображение увеличено или открыта панорама, и пользователь свайпает вверх +- **THEN** лист не открывается, жест обрабатывается изображением + +#### Scenario: Видео +- **WHEN** открыт видеоэлемент, и пользователь взаимодействует с плеером +- **THEN** свайп вверх не мешает управлению воспроизведением + +### Requirement: Атрибуты фото в листе +Раздел «Информация» SHALL показывать автора, размер файла, тип, разрешение, EXIF-атрибуты, +дату съёмки, координаты и дату загрузки. Раздел «Метки» SHALL позволять добавлять и снимать +метки с поиском по меткам пользователя и созданием новых. + +#### Scenario: Информация +- **WHEN** лист открыт +- **THEN** раздел «Информация» показывает метаданные и EXIF текущего фото + +#### Scenario: Метки +- **WHEN** пользователь добавляет или снимает метку в разделе «Метки» +- **THEN** изменение сразу отражается в просмотре без перезагрузки + +### Requirement: Настройки списка атрибутов +Раздел «Информация» SHALL давать доступ к настройкам со списком EXIF-атрибутов, где +пользователь отмечает отображаемые атрибуты и задаёт их подписи, с действиями «отметить все» +и «снять все». Настройки SHALL применяться ко всем фото и сохраняться в профиле пользователя. + +#### Scenario: Открытие настроек +- **WHEN** пользователь открывает настройки атрибутов +- **THEN** показан список EXIF-атрибутов с галочками видимости и полями подписей + +#### Scenario: Сохранение настроек +- **WHEN** пользователь отмечает атрибуты, задаёт подписи и сохраняет +- **THEN** настройки сохраняются в профиле и применяются в разделе «Информация» + +#### Scenario: Сброс настроек +- **WHEN** пользователь сбрасывает настройки +- **THEN** применяется базовый набор атрибутов с исходными подписями + +### Requirement: Действия с фото в листе +Раздел «Действия» SHALL содержать «Скачать», «Переместить», «Копировать», «Установить как +обложку альбома» (по правам) и «Удалить»; из сетки также «Открыть (просмотр)». Удаление SHALL +подтверждаться диалогом. Переименование SHALL выполняться по тапу на имя файла. + +#### Scenario: Состав действий +- **WHEN** лист открыт +- **THEN** показаны действия «Скачать», «Переместить», «Копировать», «Установить как обложку» и «Удалить» по правам + +#### Scenario: Удаление фото +- **WHEN** пользователь выбирает «Удалить» и подтверждает +- **THEN** фото уходит в корзину, а просмотр переходит к соседнему элементу + +#### Scenario: Переименование фото +- **WHEN** автор, владелец или администратор меняет имя файла на непустое +- **THEN** новое имя сохраняется и отражается в просмотре и сетке + +### Requirement: Источник атрибутов device-медиа +Для device-медиа приложение SHALL показывать атрибуты и действия сопоставленного серверного +фото, когда сопоставление есть, и локальные атрибуты и действия (имя, размер, тип, +разрешение, дата, путь) при его отсутствии. + +#### Scenario: Сопоставлено серверному фото +- **WHEN** device-медиа сопоставлено серверному фото +- **THEN** лист показывает серверные атрибуты и серверные действия этого фото + +#### Scenario: Не сопоставлено +- **WHEN** сопоставления с серверным фото нет +- **THEN** лист показывает локальные атрибуты и локальные действия элемента diff --git a/openspec/changes/archive/2026-10-13-add-mobile-album-photo-actions/tasks.md b/openspec/changes/archive/2026-10-13-add-mobile-album-photo-actions/tasks.md new file mode 100644 index 0000000..5e60ec3 --- /dev/null +++ b/openspec/changes/archive/2026-10-13-add-mobile-album-photo-actions/tasks.md @@ -0,0 +1,65 @@ +# Tasks + +## 1. Клиентский API и DTO + +- [x] 1.1 Добавить в `PhotoDto`/`Photo` поля `exif`, `tags`, `uploader`, `download_url` и разобрать их в маппере; проверить unit-тестом десериализации ответа `GET /photos/:id` +- [x] 1.2 Добавить `preferences` в ответ `me()` (`UserDto`/DTO профиля); проверить unit-тестом, что сохранённые `photo_info` читаются +- [x] 1.3 Добавить в `GaloApi` обновление альбома (`PATCH /albums/:id` с `title`/`description`/`cover_photo_id`/`clear_cover`), удаление (`DELETE`), список участников и share-ссылки; проверить unit-тестами запросов +- [x] 1.4 Добавить в `GaloApi` копирование (`POST /photo-batch/copy`), метки (`POST/DELETE /photos/:id/tags`, `GET /tags?q=`) и сохранение профиля (`PATCH /auth/me/preferences`); проверить unit-тестами +- [x] 1.5 Обновить `android/README.md` разделом о новых операциях API и проверить, что приведённые команды/примеры соответствуют коду + +## 2. Модель атрибутов и резолвинг источника + +- [x] 2.1 Ввести модель «атрибуты альбома» и резолвер источник→модель (серверный / локальный связанный / локальный без связи) отдельным классом домена; проверить unit-тестами все три случая +- [x] 2.2 Передавать сопоставление device-медиа→серверное фото (`serverPhotoByMediaId`) в `DeviceViewerScreen`; проверить unit-тестом, что элемент без сопоставления получает локальный источник, а со сопоставлением — серверный + +## 3. Панель «Атрибуты и действия» на экранах альбомов + +- [x] 3.1 Добавить на `AlbumScreen` end-drawer, открываемый свайпом справа налево, с общим контентом панели; проверить androidTest-жестом открытия и закрытия +- [x] 3.2 Добавить такой же end-drawer на `DeviceAlbumScreen`, не меняя левый drawer настроек синхронизации; проверить androidTest, что оба drawer'а открываются независимо +- [x] 3.3 Реализовать разделы «Обложка», «Информация», «Навигация» с гейтингом по роли (владелец/админ); проверить androidTest: у наблюдателя нет управления обложкой и навигации +- [x] 3.4 Реализовать действия «Переименовать» и «Удалить» с диалогом подтверждения и постановкой фото в корзину; проверить androidTest обоих сценариев +- [x] 3.5 Добавить экран настроек альбома (правка названия/описания, список участников, управление share-ссылками) и переход к нему из «Навигации»; проверить androidTest сохранения названия и создания/отзыва ссылки + +## 4. Лист «Атрибуты и действия» в просмотре + +- [x] 4.1 Добавить `ModalBottomSheet` в `ViewerScreen` и `DeviceViewerScreen` с открытием по свайпу вверх и условием «не открывать при зуме, панораме и активном видео»; проверить androidTest: при зуме/панораме лист не открывается +- [x] 4.2 Реализовать раздел «Информация» (автор, файл, тип, разрешение, EXIF, снято, координаты, загружено) и раздел «Метки» с добавлением/снятием и поиском; проверить androidTest появления метки и обновления без перезагрузки +- [x] 4.3 Реализовать действия «Скачать», «Переместить», «Копировать», «Установить как обложку», «Удалить» (по правам) и переименование по тапу на имя; проверить androidTest каждого действия, включая подтверждение удаления + +## 5. Настройки списка атрибутов + +- [x] 5.1 Перенести каталог EXIF-атрибутов и базовый набор (эквивалент web `exif.ts`) в Android; проверить unit-тестом, что базовый набор и подписи совпадают с web +- [x] 5.2 Реализовать экран настроек атрибутов (галочки видимости, поля подписей, «отметить все»/«снять все») и сохранение/сброс через `preferences`; проверить androidTest сохранения и сброса настроек + +## 6. Интеграция и проверка + +- [x] 6.1 Прогнать сквозные сценарии: серверный альбом (панель+настройки), device-альбом связанный и без связи, просмотр серверный и device (лист, метки, действия); проверить androidTest-сценариями +- [x] 6.2 Собрать и прогнать статический анализ: `./gradlew :app:assembleDebug :app:lint :app:testDebugUnitTest` без ошибок + +## 7. Локальные атрибуты и действия (все альбомы) + +- [x] 7.1 Добавить app-only метаданные локального альбома (Room `device_album_meta`: отображаемое имя и обложка) и применять их в панели и списке альбомов; проверить unit- и Robolectric-тестами +- [x] 7.2 Показывать раздел «Навигация» у всех альбомов; строки «Участники»/«Ссылки» активны только при связи с серверным альбомом, иначе неактивны; проверить тестом +- [x] 7.3 Реализовать «Переименовать» (отображаемое имя в приложении) и локальную обложку (выбор из медиа альбома/сброс) для локального альбома; проверить тестами +- [x] 7.4 Реализовать «Удалить» локального альбома: подтверждение + удаление медиа с телефона через системное согласие (`MediaStore.createDeleteRequest`); проверить тестами + +## 8. Полная проверка синхронизации + +- [x] 8.1 `SyncWorker` реконсилирует все известные галереи (связанные + с локальным индексом), восстанавливает частичные связи через `recover` и перевыставляет незавершённые загрузки; проверить unit-тестами (`reconcileAll`, `indexedAlbumIds`) +- [x] 8.2 Запускать стартовую проверку после загрузки настроек (base URL) и вызывать `reconcile` при смене режима на серверный (не создавая связь с пустым серверным альбомом); проверить сборкой и тестами +- [x] 8.3 Разгрузка в режиме переноса выполняется автоматически после загрузки и повторно на следующем проходе (без баннера и отдельных запросов согласия); проверить unit-тестом повтора `AWAITING_FREE` +- [x] 8.4 Проверять доступ «Все файлы» при запуске и предлагать включить (диалог + системный экран); проверить Robolectric-тестом +- [x] 8.5 Проверка и исправление при открытии галереи: любой ассет, уже загруженный на сервер, но всё ещё с оригиналом на телефоне, разгружается в режиме переноса; проверить unit-тестами +- [x] 8.6 Возврат оригиналов при перенос→копии/выключено: восстановление не обрывается на единичных ошибках + довосстановление при запуске и открытии альбома; проверить тестами (`restoreTargets`, матрица переходов) +- [x] 8.7 Единая политика и проверка «режим × состояние» (`GallerySyncPolicy`/`SyncCheck`) с восстановлением индекса по метке; применяется при запуске и открытии альбома; проверить тестами на все сочетания +- [x] 8.8 Устранить гонки и дубли: сериализация `reconcile`, пропуск повторной загрузки при наличии задания в очереди, метка происхождения хранит режим (не состояние); проверить unit-тестами и инструментальными E2E на эмуляторе + +## 9. Подпись карточки альбома + +- [x] 9.1 Переделать подпись карточки: первая строка — имя и «→ имя серверного» только при различии имён; вторая строка — значки расположения (карта памяти / карта памяти + облако / облако) и число элементов; проверить Robolectric-тестами + +## Workflow follow-up + +- Архивация change после прохождения ревью проекта. +- Проверка результата архивации. diff --git a/openspec/specs/android-client/spec.md b/openspec/specs/android-client/spec.md index 30bd0c9..96e5045 100644 --- a/openspec/specs/android-client/spec.md +++ b/openspec/specs/android-client/spec.md @@ -356,3 +356,266 @@ SHALL выполняться свайпом. - **WHEN** оригинал загружен или недоступен - **THEN** индикатор загрузки скрыт + +### Requirement: Проверка доступа «Все файлы» при запуске +Приложение SHALL при запуске, когда оно уже настроено и авторизовано, проверять наличие доступа +«Все файлы» и при его отсутствии SHALL предлагать его включить, открывая системный экран настроек. + +#### Scenario: Доступ не выдан +- **WHEN** приложение запущено и доступ «Все файлы» не выдан +- **THEN** показывается диалог с предложением включить доступ + +#### Scenario: Включение доступа +- **WHEN** пользователь подтверждает +- **THEN** открывается системный экран выдачи доступа «Все файлы» + +#### Scenario: Доступ уже выдан +- **WHEN** доступ уже выдан +- **THEN** диалог не показывается + +### Requirement: Возврат оригиналов при выходе из переноса +При переключении альбома из режима переноса в «копии» или «выключено» приложение SHALL +возвращать оригиналы с сервера (когда пользователь выбирает возврат), а незавершённый возврат +SHALL довосстанавливаться при следующей проверке (при запуске и при открытии альбома). + +#### Scenario: Перенос → копии +- **WHEN** пользователь переключает альбом из переноса в копии и подтверждает возврат оригиналов +- **THEN** оригиналы скачиваются с сервера и заменяют превью на устройстве + +#### Scenario: Довосстановление +- **WHEN** альбом не в режиме переноса, но содержит превью с серверным фото +- **THEN** при запуске или открытии альбома оригиналы довосстанавливаются + +#### Scenario: Перенос → выключено +- **WHEN** пользователь переключает перенос в выключено +- **THEN** предлагается вернуть оригиналы или оставить превью + +### Requirement: Единая проверка соответствия режиму +При запуске и при открытии альбома приложение SHALL приводить локальное содержимое в +соответствие режиму для всех сочетаний состояния ассета: в «переносе» разгружать уже загруженные +оригиналы, а в «копиях»/«выключено» восстанавливать превью; отсутствующий локальный индекс SHALL +восстанавливаться по метке происхождения. + +#### Scenario: Копии с разгруженными файлами +- **WHEN** альбом в режиме копий, а часть файлов разгружена (превью) +- **THEN** проверка восстанавливает оригиналы + +#### Scenario: Перенос с неразгруженными файлами +- **WHEN** альбом в режиме переноса, файл есть на сервере, но оригинал ещё на телефоне +- **THEN** проверка разгружает оригинал + +#### Scenario: Потерянный индекс +- **WHEN** у разгруженного медиа нет записи в локальном индексе +- **THEN** связь и происхождение восстанавливаются по метке в файле + +#### Scenario: Выключенный режим +- **WHEN** альбом без синхронизации содержит превью +- **THEN** оригиналы восстанавливаются, а сервер не затрагивается + +### Requirement: Отсутствие дублей и гонок при синхронизации +Проверка синхронизации SHALL выполняться последовательно (без одновременных проходов по одной +галерее) и SHALL NOT ставить повторную загрузку, пока по тому же элементу уже есть задание в +очереди. Метка происхождения в разгруженном файле SHALL хранить режим синхронизации, а не +состояние элемента, чтобы восстановление не меняло режим галереи. + +#### Scenario: Повторная проверка во время загрузки +- **WHEN** проверка запускается снова, пока загрузка элемента ещё стоит в очереди +- **THEN** повторная загрузка не ставится + +#### Scenario: Метка происхождения хранит режим +- **WHEN** разгруженный файл восстанавливается после переустановки +- **THEN** режим галереи берётся из метки как режим (перенос/копии), а не как состояние элемента + +### Requirement: Подпись карточки альбома +Карточка альбома SHALL показывать в первой строке имя альбома, а для связанной пары — со +стрелкой имя связанного серверного альбома, и только когда имена различаются. Вторая строка +SHALL показывать значками расположение содержимого и число элементов: значок карты памяти для +локального без синхронизации, значки карты памяти и облака для режима копий, значок облака для +режима переноса; для серверного альбома — значок облака. + +#### Scenario: Локальный без синхронизации +- **WHEN** показан локальный альбом без синхронизации +- **THEN** вторая строка показывает значок карты памяти и число элементов + +#### Scenario: Режим копий +- **WHEN** локальный альбом синхронизируется в режиме копий +- **THEN** вторая строка показывает значки карты памяти и облака и число элементов + +#### Scenario: Режим переноса +- **WHEN** локальный альбом синхронизируется в режиме переноса +- **THEN** вторая строка показывает значок облака и число элементов + +#### Scenario: Разные имена связанной пары +- **WHEN** имя локального альбома не совпадает с именем связанного серверного +- **THEN** первая строка показывает «имя → имя серверного» + +#### Scenario: Одинаковые имена +- **WHEN** имена совпадают +- **THEN** стрелка и имя серверного не показываются + +### Requirement: Панель атрибутов и действий альбома +Приложение SHALL открывать панель «Атрибуты и действия» по свайпу справа налево на экране +любого альбома — серверного и локального. Панель SHALL повторять структуру web: разделы +«Обложка», «Информация», «Навигация» и раздел «Действия». + +#### Scenario: Открытие свайпом +- **WHEN** пользователь свайпает справа налево в открытом альбоме +- **THEN** открывается панель «Атрибуты и действия» + +#### Scenario: Структура панели +- **WHEN** панель открыта +- **THEN** показаны разделы «Обложка», «Информация», «Навигация» и «Действия» + +#### Scenario: Закрытие панели +- **WHEN** пользователь закрывает панель (свайп в обратную сторону или касание вне панели) +- **THEN** панель скрывается, сетка медиа снова доступна + +### Requirement: Источник атрибутов локального альбома +Для локального альбома приложение SHALL показывать ту же панель «Атрибуты и действия», что и +для серверного, а метаданные SHALL брать локально (имя, число элементов, локальная обложка, +режим синхронизации, связанный серверный альбом). Действия локального альбома SHALL +применяться локально. + +#### Scenario: Локальные метаданные +- **WHEN** пользователь открывает панель локального альбома +- **THEN** раздел «Информация» показывает локальные имя, число элементов и обложку + +#### Scenario: Неактивная навигация без связи +- **WHEN** локальный альбом не связан с серверным +- **THEN** строки «Участники» и «Ссылки для просмотра» показаны, но неактивны + +#### Scenario: Активная навигация при связи +- **WHEN** локальный альбом связан с серверным +- **THEN** строки «Участники» и «Ссылки для просмотра» активны и относятся к связанному серверному альбому + +### Requirement: Переименование и обложка локального альбома +Для локального альбома действие «Переименовать» SHALL менять отображаемое имя в приложении, не +изменяя медиа или bucket на устройстве, а «Выбрать обложку»/«Сбросить» SHALL назначать или +снимать обложку из медиа этого альбома. Имя и обложка SHALL храниться локально. + +#### Scenario: Переименование +- **WHEN** пользователь вводит новое имя локального альбома +- **THEN** панель, заголовок и карточка в списке альбомов показывают новое имя + +#### Scenario: Назначение и сброс обложки +- **WHEN** пользователь выбирает медиа обложкой или сбрасывает её +- **THEN** панель и карточка альбома отражают выбор + +### Requirement: Удаление локального альбома с телефона +Действие «Удалить» локального альбома SHALL после подтверждения удалять все фото и видео +этого альбома с устройства, запрашивая при необходимости системное согласие. + +#### Scenario: Подтверждение +- **WHEN** пользователь выбирает «Удалить» локального альбома +- **THEN** открывается диалог с предупреждением, что медиа будут удалены с телефона + +#### Scenario: Удаление +- **WHEN** пользователь подтверждает удаление +- **THEN** медиа удаляются с устройства, а альбом исчезает из приложения + +### Requirement: Атрибуты альбома +Панель SHALL показывать обложку с выбором и сбросом, информацию (название, описание, число +фото и участников) и переходы к участникам, настройкам и ссылкам для просмотра. Управление +обложкой и раздел «Навигация» SHALL быть доступны только владельцу и администратору. + +#### Scenario: Управление обложкой +- **WHEN** владелец или администратор выбирает или сбрасывает обложку +- **THEN** изменение сохраняется на сервере и отражается в списке и панели + +#### Scenario: Наблюдатель +- **WHEN** наблюдатель открывает панель +- **THEN** управление обложкой не показывается, а строки «Настройки» и «Ссылки для просмотра» неактивны + +#### Scenario: Переход к настройкам +- **WHEN** владелец или администратор открывает настройки из раздела «Навигация» +- **THEN** доступны правка названия и описания и управление ссылками для просмотра + +### Requirement: Действия с альбомом +Панель SHALL содержать действия «Переименовать» и «Удалить». Удаление SHALL подтверждаться +диалогом, а удалённый альбом SHALL помещать свои фото в корзину. + +#### Scenario: Переименование +- **WHEN** владелец или администратор выбирает «Переименовать» и вводит новое название +- **THEN** название сохраняется на сервере и отражается в списке и заголовке + +#### Scenario: Удаление с подтверждением +- **WHEN** владелец выбирает «Удалить» и подтверждает в диалоге +- **THEN** альбом удаляется, а его фото попадают в корзину + +### Requirement: Лист атрибутов и действий фото +Полноэкранный просмотр SHALL открывать лист «Атрибуты и действия» по свайпу вверх как в +серверном, так и в device-просмотре. Лист SHALL повторять структуру web: разделы +«Информация», «Метки» и «Действия». При зуме изображения, панораме или активном управлении +видео свайп вверх SHALL NOT открывать лист. + +#### Scenario: Открытие листа +- **WHEN** пользователь свайпает вверх во вписанном изображении +- **THEN** открывается лист «Атрибуты и действия» для текущего элемента + +#### Scenario: Зум или панорама +- **WHEN** изображение увеличено или открыта панорама, и пользователь свайпает вверх +- **THEN** лист не открывается, жест обрабатывается изображением + +#### Scenario: Видео +- **WHEN** открыт видеоэлемент, и пользователь взаимодействует с плеером +- **THEN** свайп вверх не мешает управлению воспроизведением + +### Requirement: Атрибуты фото в листе +Раздел «Информация» SHALL показывать автора, размер файла, тип, разрешение, EXIF-атрибуты, +дату съёмки, координаты и дату загрузки. Раздел «Метки» SHALL позволять добавлять и снимать +метки с поиском по меткам пользователя и созданием новых. + +#### Scenario: Информация +- **WHEN** лист открыт +- **THEN** раздел «Информация» показывает метаданные и EXIF текущего фото + +#### Scenario: Метки +- **WHEN** пользователь добавляет или снимает метку в разделе «Метки» +- **THEN** изменение сразу отражается в просмотре без перезагрузки + +### Requirement: Настройки списка атрибутов +Раздел «Информация» SHALL давать доступ к настройкам со списком EXIF-атрибутов, где +пользователь отмечает отображаемые атрибуты и задаёт их подписи, с действиями «отметить все» +и «снять все». Настройки SHALL применяться ко всем фото и сохраняться в профиле пользователя. + +#### Scenario: Открытие настроек +- **WHEN** пользователь открывает настройки атрибутов +- **THEN** показан список EXIF-атрибутов с галочками видимости и полями подписей + +#### Scenario: Сохранение настроек +- **WHEN** пользователь отмечает атрибуты, задаёт подписи и сохраняет +- **THEN** настройки сохраняются в профиле и применяются в разделе «Информация» + +#### Scenario: Сброс настроек +- **WHEN** пользователь сбрасывает настройки +- **THEN** применяется базовый набор атрибутов с исходными подписями + +### Requirement: Действия с фото в листе +Раздел «Действия» SHALL содержать «Скачать», «Переместить», «Копировать», «Установить как +обложку альбома» (по правам) и «Удалить»; из сетки также «Открыть (просмотр)». Удаление SHALL +подтверждаться диалогом. Переименование SHALL выполняться по тапу на имя файла. + +#### Scenario: Состав действий +- **WHEN** лист открыт +- **THEN** показаны действия «Скачать», «Переместить», «Копировать», «Установить как обложку» и «Удалить» по правам + +#### Scenario: Удаление фото +- **WHEN** пользователь выбирает «Удалить» и подтверждает +- **THEN** фото уходит в корзину, а просмотр переходит к соседнему элементу + +#### Scenario: Переименование фото +- **WHEN** автор, владелец или администратор меняет имя файла на непустое +- **THEN** новое имя сохраняется и отражается в просмотре и сетке + +### Requirement: Источник атрибутов device-медиа +Для device-медиа приложение SHALL показывать атрибуты и действия сопоставленного серверного +фото, когда сопоставление есть, и локальные атрибуты и действия (имя, размер, тип, +разрешение, дата, путь) при его отсутствии. + +#### Scenario: Сопоставлено серверному фото +- **WHEN** device-медиа сопоставлено серверному фото +- **THEN** лист показывает серверные атрибуты и серверные действия этого фото + +#### Scenario: Не сопоставлено +- **WHEN** сопоставления с серверным фото нет +- **THEN** лист показывает локальные атрибуты и локальные действия элемента