feat(android): album/photo attribute-actions and device gallery sync fixes

Album panel and photo sheet with attributes/actions for every album (server and
device), local album rename/cover/delete, EXIF attribute settings, and a unified
device-gallery sync check:

- add repository "Атрибуты и действия" panel (swipe left) on server and device
  albums: cover, info, navigation, rename/delete; album settings screen; photo
  sheet (swipe up) with info, tags and actions; EXIF attribute settings backed by
  profile preferences
- local albums use local metadata and actions: app-only display name and cover
  (new device_album_meta table), delete removes photos from the phone via the
  system delete consent
- unified mode x state policy (GallerySyncPolicy/SyncCheck) runs on start and on
  album open: upload/offload in move, restore originals in copy/off
- fix move->copy restore, offload of any linked-but-local original, provenance
  recovery that no longer overwrites the album mode, serialized reconcile and
  duplicate-upload suppression; prompt for "all files" access on start
- album card caption: name (with linked server name when different) and storage
  icons (memory card / cloud) with item count

Sync delta specs into android-client and archive the change
add-mobile-album-photo-actions.

Verified: unit/Robolectric suite green, :app:assembleDebug, :app:lint, and the
DeviceSyncE2ETest instrumented suite on the emulator (7/7).
This commit is contained in:
iu
2026-10-13 21:49:12 +03:00
parent d1bb964685
commit fa210e6c7f
75 changed files with 6094 additions and 167 deletions
+33 -1
View File
@@ -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/sse/albums/:id?token=` and backfills missed changes via
`GET /api/albums/:id/photos?since=` after a reconnect. `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 ## Device albums and synchronization
The album list mixes the server's albums with the device's own albums (MediaStore 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 cache/repository fallback, page merging, the object cache key, SSE event
parsing/reconnect, HEIC detection, and the panorama camera math. Robolectric parsing/reconnect, HEIC detection, and the panorama camera math. Robolectric
Compose tests cover the albums grid/offline banner, the viewer swipe navigation, 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, Device-only checks (real server E2E, Wi-Fi-only gating, background continuation,
HEIC conversion of a real asset) require a device or emulator. HEIC conversion of a real asset) require a device or emulator.
@@ -211,6 +211,101 @@ class DeviceSyncE2ETest {
} }
} }
@Test
fun switchingFromMoveToCopyRestoresTheOriginal() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val serverAlbum = E2E.createAlbum(token, "MoveToCopy ${System.currentTimeMillis()}")
val (uri, bucket) = insertImage()
val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri))
container.syncRepository.link(bucket, "MC", serverAlbum, SyncMode.MOVE)
assertTrue(container.syncCoordinator.reconcile(bucket))
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW))
// Switch to copies and run the check: the original comes back.
container.syncRepository.link(bucket, "MC", serverAlbum, SyncMode.COPY)
container.syncCheck.checkGallery(bucket)
assertTrue(
"original should be restored on move→copy",
waitForState(container, bucket, key, LocalAssetState.LOCAL),
)
// The restored original must not be re-uploaded as a duplicate.
container.syncCoordinator.reconcile(bucket)
delay(2_000)
assertEquals(1, E2E.listPhotos(token, serverAlbum).size)
}
@Test
fun unlinkedAlbumWithPreviewsRestoresOriginals() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val serverAlbum = E2E.createAlbum(token, "UnlinkRestore ${System.currentTimeMillis()}")
val (uri, bucket) = insertImage()
val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri))
container.syncRepository.link(bucket, "U", serverAlbum, SyncMode.MOVE)
assertTrue(container.syncCoordinator.reconcile(bucket))
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW))
// Unlink (mode off): previews must still be restored, without touching the server.
container.syncRepository.unlink(bucket)
container.syncCheck.checkGallery(bucket)
assertTrue(
"originals should be restored when no longer moving",
waitForState(container, bucket, key, LocalAssetState.LOCAL),
)
assertEquals(1, E2E.listPhotos(token, serverAlbum).size)
}
@Test
fun checkRebuildsLostIndexFromMarker() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val serverAlbum = E2E.createAlbum(token, "LostIndex ${System.currentTimeMillis()}")
val (uri, bucket) = insertImage()
val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri))
container.syncRepository.link(bucket, "L", serverAlbum, SyncMode.MOVE)
assertTrue(container.syncCoordinator.reconcile(bucket))
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW))
// Simulate a lost index/link: the marker in the preview file is the only trail.
container.syncRepository.forgetAlbum(bucket)
container.syncCheck.checkGallery(bucket, recoverIndex = true)
val link = waitForLink(container, bucket)
assertTrue("link should be rebuilt from the marker", link != null)
assertEquals(SyncMode.MOVE, SyncMode.from(link?.mode))
assertTrue(
"an offloaded asset stays offloaded in move mode",
waitForState(container, bucket, key, LocalAssetState.PREVIEW),
)
}
private suspend fun waitForLink(
container: com.galo.android.di.AppContainer,
bucket: String,
): com.galo.android.data.db.entity.SyncedAlbumEntity? {
val deadline = System.currentTimeMillis() + 10_000
while (System.currentTimeMillis() < deadline) {
val link = container.syncRepository.currentLink(bucket)
if (link != null && link.serverAlbumId.isNotBlank()) return link
delay(200)
}
return container.syncRepository.currentLink(bucket)
}
private suspend fun waitForState( private suspend fun waitForState(
container: com.galo.android.di.AppContainer, container: com.galo.android.di.AppContainer,
bucket: String, bucket: String,
@@ -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.SyncDao
import com.galo.android.data.db.dao.UploadDao import com.galo.android.data.db.dao.UploadDao
import com.galo.android.data.db.entity.AlbumEntity 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.DeviceAssetEntity
import com.galo.android.data.db.entity.PhotoEntity import com.galo.android.data.db.entity.PhotoEntity
import com.galo.android.data.db.entity.SyncedAlbumEntity import com.galo.android.data.db.entity.SyncedAlbumEntity
@@ -21,11 +22,11 @@ import com.galo.android.data.db.entity.UploadJobEntity
UploadJobEntity::class, UploadJobEntity::class,
SyncedAlbumEntity::class, SyncedAlbumEntity::class,
DeviceAssetEntity::class, DeviceAssetEntity::class,
DeviceAlbumMetaEntity::class,
], ],
version = 4, version = 6,
exportSchema = false, exportSchema = false,
) )abstract class GaloDatabase : RoomDatabase() {
abstract class GaloDatabase : RoomDatabase() {
abstract fun albumDao(): AlbumDao abstract fun albumDao(): AlbumDao
@@ -3,6 +3,7 @@ package com.galo.android.data.db.dao
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Query import androidx.room.Query
import androidx.room.Upsert 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.DeviceAssetEntity
import com.galo.android.data.db.entity.SyncedAlbumEntity import com.galo.android.data.db.entity.SyncedAlbumEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -27,8 +28,25 @@ interface SyncDao {
@Query("DELETE FROM synced_albums WHERE deviceAlbumId = :deviceAlbumId") @Query("DELETE FROM synced_albums WHERE deviceAlbumId = :deviceAlbumId")
suspend fun deleteSyncedAlbum(deviceAlbumId: String) suspend fun deleteSyncedAlbum(deviceAlbumId: String)
// --- Local album metadata (display name, cover) ------------------------
@Query("SELECT * FROM device_album_meta")
fun observeAlbumMeta(): Flow<List<DeviceAlbumMetaEntity>>
@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 --------------------------------- // --- Device asset index and provenance ---------------------------------
@Query("SELECT DISTINCT deviceAlbumId FROM device_assets")
suspend fun indexedAlbumIds(): List<String>
@Query("SELECT * FROM device_assets WHERE deviceAlbumId = :deviceAlbumId") @Query("SELECT * FROM device_assets WHERE deviceAlbumId = :deviceAlbumId")
fun observeAssets(deviceAlbumId: String): Flow<List<DeviceAssetEntity>> fun observeAssets(deviceAlbumId: String): Flow<List<DeviceAssetEntity>>
@@ -20,6 +20,8 @@ data class PhotoEntity(
val size: Long, val size: Long,
val takenAt: String?, val takenAt: String?,
val caption: String, val caption: String,
val latitude: Double? = null,
val longitude: Double? = null,
val isPanorama: Boolean, val isPanorama: Boolean,
val thumbUrl: String?, val thumbUrl: String?,
val mediumUrl: String?, val mediumUrl: String?,
@@ -28,4 +30,8 @@ data class PhotoEntity(
val originalUrl: String?, val originalUrl: String?,
val downloadUrl: String?, val downloadUrl: String?,
val createdAt: String?, val createdAt: String?,
val tagsJson: String = "[]",
val exifJson: String = "{}",
val uploaderName: String? = null,
val uploaderAvatarUrl: String? = null,
) )
@@ -14,6 +14,18 @@ data class SyncedAlbumEntity(
val updatedAt: Long, 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 * 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. * uploaded as (when known) and the content hash used to match it.
@@ -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.AlbumEntity
import com.galo.android.data.db.entity.PhotoEntity 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.ObjectUrlResolver
import com.galo.android.data.network.dto.AlbumDto import com.galo.android.data.network.dto.AlbumDto
import com.galo.android.data.network.dto.PhotoDto 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.Album
import com.galo.android.domain.model.MediaKind import com.galo.android.domain.model.MediaKind
import com.galo.android.domain.model.Photo 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). // Remote DTO -> persisted entity (URLs kept raw so the cache survives a base-URL change).
fun AlbumDto.toEntity(): AlbumEntity = AlbumEntity( fun AlbumDto.toEntity(): AlbumEntity = AlbumEntity(
@@ -35,6 +41,8 @@ fun PhotoDto.toEntity(): PhotoEntity = PhotoEntity(
size = size, size = size,
takenAt = takenAt, takenAt = takenAt,
caption = caption, caption = caption,
latitude = latitude,
longitude = longitude,
isPanorama = tags.any { MediaKind.isPanoTag(it.name) }, isPanorama = tags.any { MediaKind.isPanoTag(it.name) },
thumbUrl = thumbUrl, thumbUrl = thumbUrl,
mediumUrl = mediumUrl, mediumUrl = mediumUrl,
@@ -43,6 +51,10 @@ fun PhotoDto.toEntity(): PhotoEntity = PhotoEntity(
originalUrl = originalUrl, originalUrl = originalUrl,
downloadUrl = downloadUrl, downloadUrl = downloadUrl,
createdAt = createdAt, createdAt = createdAt,
tagsJson = encodeTags(tags),
exifJson = encodeExif(exif),
uploaderName = uploader?.name,
uploaderAvatarUrl = uploader?.avatarUrl,
) )
// Persisted entity -> domain (URLs resolved against the current base URL). // Persisted entity -> domain (URLs resolved against the current base URL).
@@ -71,6 +83,8 @@ fun PhotoEntity.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
size = size, size = size,
takenAt = takenAt, takenAt = takenAt,
caption = caption, caption = caption,
latitude = latitude,
longitude = longitude,
isPanorama = isPanorama, isPanorama = isPanorama,
thumbUrl = resolver.resolve(thumbUrl), thumbUrl = resolver.resolve(thumbUrl),
mediumUrl = resolver.resolve(mediumUrl), mediumUrl = resolver.resolve(mediumUrl),
@@ -79,6 +93,10 @@ fun PhotoEntity.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
originalUrl = resolver.resolve(originalUrl), originalUrl = resolver.resolve(originalUrl),
downloadUrl = resolver.resolve(downloadUrl), downloadUrl = resolver.resolve(downloadUrl),
createdAt = createdAt, createdAt = createdAt,
tags = decodeTags(tagsJson),
exif = decodeExif(exifJson),
uploaderName = uploaderName,
uploaderAvatarUrl = resolver.resolve(uploaderAvatarUrl),
) )
fun PhotoDto.toDomain(resolver: ObjectUrlResolver): Photo = Photo( fun PhotoDto.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
@@ -93,6 +111,8 @@ fun PhotoDto.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
size = size, size = size,
takenAt = takenAt, takenAt = takenAt,
caption = caption, caption = caption,
latitude = latitude,
longitude = longitude,
isPanorama = tags.any { MediaKind.isPanoTag(it.name) }, isPanorama = tags.any { MediaKind.isPanoTag(it.name) },
thumbUrl = resolver.resolve(thumbUrl), thumbUrl = resolver.resolve(thumbUrl),
mediumUrl = resolver.resolve(mediumUrl), mediumUrl = resolver.resolve(mediumUrl),
@@ -101,4 +121,31 @@ fun PhotoDto.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
originalUrl = resolver.resolve(originalUrl), originalUrl = resolver.resolve(originalUrl),
downloadUrl = resolver.resolve(downloadUrl), downloadUrl = resolver.resolve(downloadUrl),
createdAt = createdAt, 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<TagDto>): String =
runCatching { GaloJson.encodeToString(tags) }.getOrDefault("[]")
private fun decodeTags(json: String): List<Tag> =
runCatching {
GaloJson.decodeFromString<List<TagDto>>(json).map { Tag(it.id, it.name) }
}.getOrDefault(emptyList())
private fun encodeExif(exif: Map<String, JsonElement>): String =
runCatching {
GaloJson.encodeToString(exif.mapValues { it.value.asExifString() })
}.getOrDefault("{}")
private fun decodeExif(json: String): Map<String, String> =
runCatching { GaloJson.decodeFromString<Map<String, String>>(json) }
.getOrDefault(emptyMap())
private fun JsonElement.asExifString(): String =
when (this) {
is JsonPrimitive -> content
else -> toString()
}
@@ -95,6 +95,19 @@ class MediaStoreDeviceSource(private val context: Context) {
fun items(albumId: String): List<DeviceMedia> = fun items(albumId: String): List<DeviceMedia> =
allMedia().filter { it.albumId == albumId } 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<Uri>): 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<DeviceMedia> = private fun allMedia(): List<DeviceMedia> =
query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaKind.IMAGE) + query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaKind.IMAGE) +
query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, MediaKind.VIDEO) query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, MediaKind.VIDEO)
@@ -1,6 +1,7 @@
package com.galo.android.data.network package com.galo.android.data.network
import com.galo.android.data.network.dto.AddIdentifierRequest 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.AlbumDto
import com.galo.android.data.network.dto.AlbumEnvelopeDto import com.galo.android.data.network.dto.AlbumEnvelopeDto
import com.galo.android.data.network.dto.AlbumListDto 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.HealthDto
import com.galo.android.data.network.dto.LoginRequest import com.galo.android.data.network.dto.LoginRequest
import com.galo.android.data.network.dto.LogoutRequest 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.PhotoDto
import com.galo.android.data.network.dto.PhotoEnvelopeDto import com.galo.android.data.network.dto.PhotoEnvelopeDto
import com.galo.android.data.network.dto.PhotoListDto 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.RegisterRequest
import com.galo.android.data.network.dto.RenamePhotoRequest 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.TokenEnvelopeDto
import com.galo.android.data.network.dto.UpdateAlbumRequest
import com.galo.android.data.network.dto.UpdateProfileRequest import com.galo.android.data.network.dto.UpdateProfileRequest
import com.galo.android.data.network.dto.UploadResponseDto import com.galo.android.data.network.dto.UploadResponseDto
import com.galo.android.data.network.dto.UserDto import com.galo.android.data.network.dto.UserDto
@@ -76,6 +88,15 @@ class GaloApi(
override suspend fun me(): UserDto = override suspend fun me(): UserDto =
get("api/auth/me").decode<UserEnvelopeDto>().user get("api/auth/me").decode<UserEnvelopeDto>().user
/** Full account payload including saved preferences and their `photo_info`. */
suspend fun meEnvelope(): UserEnvelopeDto = get("api/auth/me").decode<UserEnvelopeDto>()
/** 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<PreferencesEnvelopeDto>()
.preferences
override suspend fun updateProfile(name: String): UserDto = override suspend fun updateProfile(name: String): UserDto =
patchJson("api/auth/me", json.encodeToString(UpdateProfileRequest(name))) patchJson("api/auth/me", json.encodeToString(UpdateProfileRequest(name)))
.decode<UserEnvelopeDto>() .decode<UserEnvelopeDto>()
@@ -127,6 +148,10 @@ class GaloApi(
suspend fun album(albumId: String): AlbumDto = suspend fun album(albumId: String): AlbumDto =
get("api/albums/$albumId").decode<AlbumEnvelopeDto>().album get("api/albums/$albumId").decode<AlbumEnvelopeDto>().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. */ /** Creates a server album and returns it. */
suspend fun createAlbum(title: String): AlbumDto = suspend fun createAlbum(title: String): AlbumDto =
postJson("api/albums", json.encodeToString(CreateAlbumRequest(title))) postJson("api/albums", json.encodeToString(CreateAlbumRequest(title)))
@@ -183,6 +208,70 @@ class GaloApi(
postJson("api/photo-batch/move", json.encodeToString(BatchRequest(photoIds, albumId))) postJson("api/photo-batch/move", json.encodeToString(BatchRequest(photoIds, albumId)))
} }
/** Copies photos to another album in one batch. */
suspend fun copyPhotos(photoIds: List<String>, 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<AlbumEnvelopeDto>()
.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<MemberDto> =
get("api/albums/$albumId/members").decode<MemberListDto>().members
suspend fun shareLinks(albumId: String): List<ShareLinkDto> =
get("api/albums/$albumId/share").decode<ShareLinkListDto>().shareLinks
suspend fun createShareLink(albumId: String, expiresIn: String = "168h"): ShareLinkDto =
postJson("api/albums/$albumId/share", json.encodeToString(ShareLinkRequest(expiresIn)))
.decode<ShareLinkEnvelopeDto>()
.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<PhotoEnvelopeDto>()
.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<PhotoEnvelopeDto>().photo
/** Lists the user's labels, optionally filtered by a query. */
suspend fun tags(query: String = ""): List<TagDto> {
val suffix = if (query.isBlank()) "" else "?q=" + urlEncode(query)
return get("api/tags$suffix").decode<TagListDto>().tags
}
/** /**
* Control sums of an album's photos keyed by checksum, following pagination. * Control sums of an album's photos keyed by checksum, following pagination.
* Used by the device-album sync to avoid re-uploading existing content. * Used by the device-album sync to avoid re-uploading existing content.
@@ -2,6 +2,7 @@ package com.galo.android.data.network.dto
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable @Serializable
data class HealthDto( data class HealthDto(
@@ -37,6 +38,7 @@ data class UserDto(
@Serializable @Serializable
data class UserEnvelopeDto( data class UserEnvelopeDto(
val user: UserDto = UserDto(), val user: UserDto = UserDto(),
val preferences: PreferencesDto? = null,
) )
@Serializable @Serializable
@@ -141,7 +143,11 @@ data class PhotoDto(
val size: Long = 0, val size: Long = 0,
@SerialName("taken_at") val takenAt: String? = null, @SerialName("taken_at") val takenAt: String? = null,
val caption: String = "", val caption: String = "",
val latitude: Double? = null,
val longitude: Double? = null,
val tags: List<TagDto> = emptyList(), val tags: List<TagDto> = emptyList(),
val exif: Map<String, JsonElement> = emptyMap(),
val uploader: UserDto? = null,
@SerialName("thumb_url") val thumbUrl: String? = null, @SerialName("thumb_url") val thumbUrl: String? = null,
@SerialName("medium_url") val mediumUrl: String? = null, @SerialName("medium_url") val mediumUrl: String? = null,
@SerialName("preview_url") val previewUrl: String? = null, @SerialName("preview_url") val previewUrl: String? = null,
@@ -183,3 +189,74 @@ data class ErrorBodyDto(
val code: String = "internal_error", val code: String = "internal_error",
val message: String = "internal server 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<ShareLinkDto> = 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<MemberDto> = emptyList(),
)
@Serializable
data class TagListDto(
val tags: List<TagDto> = emptyList(),
)
@Serializable
data class AddTagRequest(
val name: String,
)
@Serializable
data class PhotoInfoPrefsDto(
val visible: List<String> = emptyList(),
val labels: Map<String, String> = emptyMap(),
)
@Serializable
data class PreferencesDto(
@SerialName("photo_info") val photoInfo: PhotoInfoPrefsDto? = null,
)
@Serializable
data class PreferencesEnvelopeDto(
val preferences: PreferencesDto = PreferencesDto(),
)
@@ -1,13 +1,16 @@
package com.galo.android.data.repo package com.galo.android.data.repo
import com.galo.android.data.db.dao.SyncDao 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.DeviceAssetEntity
import com.galo.android.data.db.entity.SyncedAlbumEntity import com.galo.android.data.db.entity.SyncedAlbumEntity
import com.galo.android.data.media.MediaStoreDeviceSource import com.galo.android.data.media.MediaStoreDeviceSource
import com.galo.android.domain.model.DeviceAlbum 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.LocalAssetState
import com.galo.android.domain.model.SyncMode import com.galo.android.domain.model.SyncMode
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/** Device albums, their links to server albums and their asset index. */ /** Device albums, their links to server albums and their asset index. */
class SyncRepository( class SyncRepository(
@@ -22,6 +25,9 @@ class SyncRepository(
suspend fun assets(deviceAlbumId: String): List<DeviceAssetEntity> = dao.getAssets(deviceAlbumId) suspend fun assets(deviceAlbumId: String): List<DeviceAssetEntity> = dao.getAssets(deviceAlbumId)
/** Device albums that have a local asset index (possibly partial/unlinked). */
suspend fun indexedAlbumIds(): List<String> = dao.indexedAlbumIds()
suspend fun getAsset(assetKey: String): DeviceAssetEntity? = dao.getAsset(assetKey) suspend fun getAsset(assetKey: String): DeviceAssetEntity? = dao.getAsset(assetKey)
suspend fun currentLink(deviceAlbumId: String): SyncedAlbumEntity? = suspend fun currentLink(deviceAlbumId: String): SyncedAlbumEntity? =
@@ -48,6 +54,61 @@ class SyncRepository(
suspend fun unlink(deviceAlbumId: String) = dao.deleteSyncedAlbum(deviceAlbumId) 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<Map<String, DeviceAlbumMeta>> =
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<android.net.Uri>) = deviceSource.deleteConsent(uris)
fun deviceItemUris(albumId: String): List<android.net.Uri> =
deviceSource.items(albumId).map { android.net.Uri.parse(it.uri) }
/** Records the server photo created for a device asset (called after upload). */ /** Records the server photo created for a device asset (called after upload). */
suspend fun recordUploaded( suspend fun recordUploaded(
deviceAlbumId: String, deviceAlbumId: String,
@@ -12,6 +12,7 @@ import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.data.media.PreviewGenerator import com.galo.android.data.media.PreviewGenerator
import com.galo.android.domain.model.LocalAssetState import com.galo.android.domain.model.LocalAssetState
import com.galo.android.domain.model.MediaKind import com.galo.android.domain.model.MediaKind
import com.galo.android.domain.model.SyncMode
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@@ -50,12 +51,18 @@ class OffloadManager(
mutex.withLock { mutex.withLock {
runCatching { runCatching {
val uri = Uri.parse(asset.uri) 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) { if (kindOf(asset.mimeType) == MediaKind.VIDEO) {
offloadVideo(asset, uri, serverAlbumId) offloadVideo(asset, uri, serverAlbumId, syncMode)
} else { } 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) }.getOrDefault(OffloadResult.Failed)
} }
} }
@@ -76,7 +83,12 @@ class OffloadManager(
null 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 preview = PreviewGenerator.tempPreviewFile(context, asset.assetKey)
val sourceExif = runCatching { val sourceExif = runCatching {
context.contentResolver.openInputStream(uri)?.use { ExifInterface(it) } context.contentResolver.openInputStream(uri)?.use { ExifInterface(it) }
@@ -84,7 +96,7 @@ class OffloadManager(
if (!PreviewGenerator.generate(context, uri, MediaKind.IMAGE, preview)) return OffloadResult.Failed if (!PreviewGenerator.generate(context, uri, MediaKind.IMAGE, preview)) return OffloadResult.Failed
ProvenanceStamp.copyDateTaken(sourceExif, preview) ProvenanceStamp.copyDateTaken(sourceExif, preview)
ProvenanceStamp.write(preview, provenanceOf(asset, serverAlbumId)) ProvenanceStamp.write(preview, provenanceOf(asset, serverAlbumId, syncMode))
return try { return try {
writeInPlace(uri, preview) 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) val poster = PreviewGenerator.tempPreviewFile(context, asset.assetKey)
if (!PreviewGenerator.generate(context, uri, MediaKind.VIDEO, poster)) return OffloadResult.Failed 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) val relativePath = relativePathOf(uri)
return try { return try {
@@ -152,12 +169,18 @@ class OffloadManager(
?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } ?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
}.getOrNull().orEmpty().ifBlank { "DCIM/" } }.getOrNull().orEmpty().ifBlank { "DCIM/" }
private fun provenanceOf(asset: DeviceAssetEntity, serverAlbumId: String?) = Provenance( private fun provenanceOf(
asset: DeviceAssetEntity,
serverAlbumId: String?,
syncMode: String?,
) = Provenance(
serverAlbumId = serverAlbumId, serverAlbumId = serverAlbumId,
serverPhotoId = asset.serverPhotoId, serverPhotoId = asset.serverPhotoId,
originalHash = asset.contentHash, originalHash = asset.contentHash,
deviceAlbumId = asset.deviceAlbumId, 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, assetKey = asset.assetKey,
) )
@@ -167,4 +190,8 @@ class OffloadManager(
private fun kindOf(mimeType: String?): MediaKind = private fun kindOf(mimeType: String?): MediaKind =
if (mimeType?.startsWith("video/") == true) MediaKind.VIDEO else MediaKind.IMAGE if (mimeType?.startsWith("video/") == true) MediaKind.VIDEO else MediaKind.IMAGE
private companion object {
const val TAG = "OffloadManager"
}
} }
@@ -42,6 +42,12 @@ class RestoreManager(
/** Restoring must fit in the free space; otherwise it is deferred. */ /** Restoring must fit in the free space; otherwise it is deferred. */
fun canRestore(requiredBytes: Long, availableBytes: Long): Boolean = fun canRestore(requiredBytes: Long, availableBytes: Long): Boolean =
availableBytes >= requiredBytes availableBytes >= requiredBytes
/** Assets whose originals live on the server and should be brought back. */
fun restoreTargets(assets: List<DeviceAssetEntity>): List<DeviceAssetEntity> =
assets.filter {
it.serverPhotoId != null && it.state == LocalAssetState.PREVIEW.name
}
} }
suspend fun restore(assets: List<DeviceAssetEntity>): RestoreReport = withContext(Dispatchers.IO) { suspend fun restore(assets: List<DeviceAssetEntity>): RestoreReport = withContext(Dispatchers.IO) {
@@ -52,6 +58,7 @@ class RestoreManager(
for (asset in assets) { for (asset in assets) {
val photoId = asset.serverPhotoId ?: continue val photoId = asset.serverPhotoId ?: continue
val data = runCatching { fetcher.fetch(photoId) }.getOrNull() ?: continue val data = runCatching { fetcher.fetch(photoId) }.getOrNull() ?: continue
if (data.isEmpty()) continue
if (!canRestore(data.size.toLong(), availableBytes())) { if (!canRestore(data.size.toLong(), availableBytes())) {
return@withContext RestoreReport(restored, bytes, needsConsent, outOfSpace = true) return@withContext RestoreReport(restored, bytes, needsConsent, outOfSpace = true)
} }
@@ -62,6 +69,8 @@ class RestoreManager(
bytes += data.size bytes += data.size
} catch (e: SecurityException) { } catch (e: SecurityException) {
needsConsent++ needsConsent++
} catch (e: Exception) {
// Leave the asset as is and continue with the rest.
} }
} }
RestoreReport(restored, bytes, needsConsent, outOfSpace = false) RestoreReport(restored, bytes, needsConsent, outOfSpace = false)
@@ -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<String>, recoverIndex: Boolean = true) {
deviceAlbumIds.forEach { checkGallery(it, recoverIndex) }
}
}
@@ -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.LocalAssetState
import com.galo.android.domain.model.SyncMode import com.galo.android.domain.model.SyncMode
import com.galo.android.domain.sync.SyncPolicy 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 * Reconciles a device album with its server album. Mode A (DISABLED) is local
@@ -19,13 +21,16 @@ class SyncCoordinator(
private val offloader: AssetOffloader, 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. */ /** @return true when the server was contacted, false for a local-only album. */
suspend fun reconcile(deviceAlbumId: String): Boolean { suspend fun reconcile(deviceAlbumId: String): Boolean = mutex.withLock {
val link = dao.getSyncedAlbum(deviceAlbumId) ?: return false val link = dao.getSyncedAlbum(deviceAlbumId) ?: return@withLock false
val mode = SyncMode.from(link.mode) 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. // 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 serverChecksums = remote.serverChecksums(link.serverAlbumId)
val livePhotoIds = serverChecksums.values.toSet() val livePhotoIds = serverChecksums.values.toSet()
@@ -41,20 +46,40 @@ class SyncCoordinator(
// Mode C: assets already on the server (e.g. after switching from copies) // Mode C: assets already on the server (e.g. after switching from copies)
// are replaced with their offloaded versions, right away. // are replaced with their offloaded versions, right away.
if (mode == SyncMode.MOVE) offloadUploaded(deviceAlbumId, assets) if (mode == SyncMode.MOVE) offloadUploaded(deviceAlbumId, assets)
return true return@withLock true
} }
/** /**
* Offloads assets that are already uploaded but still have their original * Reconciles every given device album, so a startup/background pass covers all
* locally. When the OS requires consent, the asset is marked as awaiting * galleries (including partially synced ones). @return true when any album
* freeing so the album screen can request it. * contacted the server.
*/
suspend fun reconcileAll(deviceAlbumIds: Collection<String>): 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<LocalAsset>) { private suspend fun offloadUploaded(deviceAlbumId: String, localAssets: List<LocalAsset>) {
val localKeys = localAssets.mapTo(HashSet()) { it.assetKey } val localKeys = localAssets.mapTo(HashSet()) { it.assetKey }
for (entity in dao.getAssets(deviceAlbumId)) { for (entity in dao.getAssets(deviceAlbumId)) {
if (entity.serverPhotoId == null) continue if (entity.serverPhotoId == null) continue
if (entity.assetKey !in localKeys) 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 val result = runCatching { offloader.offload(entity) }.getOrNull() ?: continue
when (result) { when (result) {
is OffloadResult.Done, OffloadResult.Failed -> Unit is OffloadResult.Done, OffloadResult.Failed -> Unit
@@ -82,7 +107,11 @@ class SyncCoordinator(
record(deviceAlbumId, action.asset, action.photoId, mode) record(deviceAlbumId, action.asset, action.photoId, mode)
is SyncAction.Upload -> 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 -> { is SyncAction.RenameOnServer -> {
remote.renamePhoto(action.photoId, action.newName) remote.renamePhoto(action.photoId, action.newName)
@@ -49,12 +49,17 @@ class SyncRecovery(
), ),
) )
marker.serverAlbumId?.let { serverAlbumId -> marker.serverAlbumId?.let { serverAlbumId ->
val existing = dao.getSyncedAlbum(deviceAlbumId)
val markerMode = marker.mode?.let { raw -> SyncMode.parseOrNull(raw)?.name }
dao.upsertSyncedAlbum( dao.upsertSyncedAlbum(
SyncedAlbumEntity( SyncedAlbumEntity(
deviceAlbumId = deviceAlbumId, deviceAlbumId = deviceAlbumId,
deviceAlbumName = media.albumName, deviceAlbumName = existing?.deviceAlbumName?.ifBlank { null } ?: media.albumName,
serverAlbumId = serverAlbumId, serverAlbumId = existing?.serverAlbumId?.takeIf { it.isNotBlank() } ?: serverAlbumId,
mode = marker.mode ?: SyncMode.MOVE.name, // 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(), updatedAt = System.currentTimeMillis(),
), ),
) )
@@ -11,6 +11,9 @@ interface SyncUploadQueue {
asset: LocalAsset, asset: LocalAsset,
mode: SyncMode, 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. */ /** [SyncUploadQueue] backed by the existing [UploadRepository]/WorkManager queue. */
@@ -37,4 +40,6 @@ class RepositorySyncUploadQueue(
wifiOnly = wifiOnly(), wifiOnly = wifiOnly(),
) )
} }
override suspend fun isPending(assetKey: String): Boolean = uploads.isPending(assetKey)
} }
@@ -26,14 +26,27 @@ class SyncWorker(
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val app = applicationContext as? GaloApplication ?: return Result.failure() val app = applicationContext as? GaloApplication ?: return Result.failure()
val container = app.container 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() } .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 var failed = false
for (link in links) { for (id in toCheck) {
runCatching { container.syncCoordinator.reconcile(link.deviceAlbumId) } runCatching { container.syncCheck.checkGallery(id, recoverIndex = true) }
.onFailure { failed = 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() return if (failed) Result.retry() else Result.success()
} }
} }
@@ -106,6 +106,10 @@ class UploadRepository(
suspend fun getById(id: String): UploadJobEntity? = dao.getById(id) 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( suspend fun updateState(
id: String, id: String,
status: UploadStatus, status: UploadStatus,
@@ -27,6 +27,7 @@ import com.galo.android.data.sync.DeviceMediaObserver
import com.galo.android.data.sync.OffloadManager import com.galo.android.data.sync.OffloadManager
import com.galo.android.data.sync.RepositorySyncUploadQueue import com.galo.android.data.sync.RepositorySyncUploadQueue
import com.galo.android.data.sync.RestoreManager 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.SyncCoordinator
import com.galo.android.data.sync.SyncRecovery import com.galo.android.data.sync.SyncRecovery
import com.galo.android.data.sync.SyncRemote import com.galo.android.data.sync.SyncRemote
@@ -107,17 +108,24 @@ class AppContainer(context: Context) {
val syncRecovery = SyncRecovery(appContext, database.syncDao(), deviceSource) 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() { fun start() {
scope.launch { scope.launch {
settingsRepository.flow.collect { settings -> settingsRepository.flow.collect { settings ->
val first = settingsState == null
settingsState = settings settingsState = settings
runCatching { SyncScheduler.ensurePeriodic(appContext, settings.wifiOnlyUploads) } 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 { runCatching {
DeviceMediaObserver(appContext) { runCatching { SyncScheduler.runNow(appContext) } }.start() DeviceMediaObserver(appContext) { runCatching { SyncScheduler.runNow(appContext) } }.start()
} }
@@ -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,
)
}
}
@@ -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)
}
}
@@ -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<String, String> = 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<String> = 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<String, Map<Int, String>> = 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<String> = listOf(
"Make",
"Model",
"LensModel",
"ExposureTime",
"FNumber",
"ISOSpeedRatings",
"FocalLength",
"FocalLengthIn35mmFilm",
)
/** All known attribute keys, mirroring web `EXIF_CATALOG`. */
val EXIF_CATALOG: List<String> = 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<String, String>,
labels: Map<String, String> = emptyMap(),
): List<ExifRow> =
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) }
@@ -1,6 +1,6 @@
package com.galo.android.domain.model 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( data class DeviceAlbum(
val id: String, val id: String,
val name: String, val name: String,
@@ -9,6 +9,13 @@ data class DeviceAlbum(
val relativePath: String, 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]. */ /** A single media item of the device found in a [DeviceAlbum]. */
data class DeviceMedia( data class DeviceMedia(
val id: Long, val id: Long,
@@ -63,6 +63,8 @@ data class Photo(
val size: Long, val size: Long,
val takenAt: String?, val takenAt: String?,
val caption: String, val caption: String,
val latitude: Double? = null,
val longitude: Double? = null,
val isPanorama: Boolean, val isPanorama: Boolean,
val thumbUrl: String?, val thumbUrl: String?,
val mediumUrl: String?, val mediumUrl: String?,
@@ -71,9 +73,19 @@ data class Photo(
val originalUrl: String?, val originalUrl: String?,
val downloadUrl: String?, val downloadUrl: String?,
val createdAt: String?, val createdAt: String?,
val tags: List<Tag> = emptyList(),
val exif: Map<String, String> = emptyMap(),
val uploaderName: String? = null,
val uploaderAvatarUrl: String? = null,
) )
data class Page<T>( data class Page<T>(
val items: List<T>, val items: List<T>,
val nextCursor: String?, val nextCursor: String?,
) )
/** A user-scoped label attached to a photo. */
data class Tag(
val id: String,
val name: String,
)
@@ -15,6 +15,10 @@ enum class SyncMode {
companion object { companion object {
fun from(raw: String?): SyncMode = fun from(raw: String?): SyncMode =
entries.firstOrNull { it.name == raw } ?: DISABLED 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 }
} }
} }
@@ -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
}
@@ -2,11 +2,22 @@ package com.galo.android.ui
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier 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.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@@ -15,10 +26,13 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.galo.android.GaloApplication import com.galo.android.GaloApplication
import com.galo.android.data.prefs.SettingsState 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.AlbumScreen
import com.galo.android.ui.album.AlbumSettingsScreen
import com.galo.android.ui.albums.AlbumsScreen import com.galo.android.ui.albums.AlbumsScreen
import com.galo.android.ui.auth.AuthScreen import com.galo.android.ui.auth.AuthScreen
import com.galo.android.ui.devicealbum.DeviceAlbumScreen 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.profile.ProfileScreen
import com.galo.android.ui.setup.SetupScreen import com.galo.android.ui.setup.SetupScreen
import com.galo.android.ui.upload.UploadScreen import com.galo.android.ui.upload.UploadScreen
@@ -31,11 +45,15 @@ object Routes {
const val UPLOADS = "uploads" const val UPLOADS = "uploads"
const val PROFILE = "profile" const val PROFILE = "profile"
const val ALBUM = "album/{albumId}" const val ALBUM = "album/{albumId}"
const val ALBUM_SETTINGS = "album/{albumId}/settings"
const val DEVICE_ALBUM = "deviceAlbum/{deviceAlbumId}" const val DEVICE_ALBUM = "deviceAlbum/{deviceAlbumId}"
const val VIEWER = "album/{albumId}/viewer?index={index}" const val VIEWER = "album/{albumId}/viewer?index={index}"
const val ATTRIBUTE_SETTINGS = "attributeSettings"
fun album(albumId: String) = "album/$albumId" fun album(albumId: String) = "album/$albumId"
fun albumSettings(albumId: String) = "album/$albumId/settings"
fun deviceAlbum(deviceAlbumId: String) = "deviceAlbum/$deviceAlbumId" fun deviceAlbum(deviceAlbumId: String) = "deviceAlbum/$deviceAlbumId"
fun viewer(albumId: String, index: Int) = "album/$albumId/viewer?index=$index" fun viewer(albumId: String, index: Int) = "album/$albumId/viewer?index=$index"
@@ -57,6 +75,22 @@ fun GaloNavHost(app: GaloApplication) {
} }
val navController = rememberNavController() 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 { val start = when {
current.baseUrl.isBlank() -> Routes.SETUP current.baseUrl.isBlank() -> Routes.SETUP
container.tokenStore.accessToken().isNullOrBlank() -> Routes.AUTH container.tokenStore.accessToken().isNullOrBlank() -> Routes.AUTH
@@ -114,6 +148,12 @@ fun GaloNavHost(app: GaloApplication) {
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
} }
composable(Routes.ATTRIBUTE_SETTINGS) {
AttributeSettingsScreen(
app = app,
onBack = { navController.popBackStack() },
)
}
composable( composable(
route = Routes.ALBUM, route = Routes.ALBUM,
arguments = listOf(navArgument("albumId") { type = NavType.StringType }), arguments = listOf(navArgument("albumId") { type = NavType.StringType }),
@@ -125,6 +165,18 @@ fun GaloNavHost(app: GaloApplication) {
onOpenViewer = { index -> onOpenViewer = { index ->
navController.navigate(Routes.viewer(albumId, 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() }, onBack = { navController.popBackStack() },
) )
} }
@@ -136,6 +188,7 @@ fun GaloNavHost(app: GaloApplication) {
DeviceAlbumScreen( DeviceAlbumScreen(
app = app, app = app,
deviceAlbumId = deviceAlbumId, deviceAlbumId = deviceAlbumId,
onOpenServerSettings = { id -> navController.navigate(Routes.albumSettings(id)) },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
} }
@@ -156,7 +209,43 @@ fun GaloNavHost(app: GaloApplication) {
albumId = albumId, albumId = albumId,
initialIndex = index, initialIndex = index,
onBack = { navController.popBackStack() }, 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("Позже") } },
)
} }
@@ -8,28 +8,36 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.galo.android.GaloApplication 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.MediaKind
import com.galo.android.domain.model.Photo import com.galo.android.domain.model.Photo
import com.galo.android.ui.common.GaloAsyncImage 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_GRID = "album_grid"
const val TAG_ALBUM_OFFLINE = "album_offline" const val TAG_ALBUM_OFFLINE = "album_offline"
const val TAG_PHOTO_PREFIX = "photo_" 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -54,6 +73,7 @@ fun AlbumScreen(
app: GaloApplication, app: GaloApplication,
albumId: String, albumId: String,
onOpenViewer: (Int) -> Unit, onOpenViewer: (Int) -> Unit,
onOpenSettings: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val factory = viewModelFactory { val factory = viewModelFactory {
@@ -62,6 +82,15 @@ fun AlbumScreen(
val viewModel: AlbumViewModel = viewModel(factory = factory) val viewModel: AlbumViewModel = viewModel(factory = factory)
val state by viewModel.state.collectAsStateWithLifecycle() 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( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -79,47 +108,67 @@ fun AlbumScreen(
) )
}, },
) { padding -> ) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) { EndDrawerScaffold(
if (state.offline) { open = panelOpen,
Surface( onOpenChange = { panelOpen = it },
color = MaterialTheme.colorScheme.errorContainer, modifier = Modifier.padding(padding),
modifier = Modifier.fillMaxWidth().testTag(TAG_ALBUM_OFFLINE), drawerContent = {
) { if (attributes != null) {
Text( AlbumAttributesPanel(
text = state.error ?: "Офлайн-режим", attributes = attributes,
color = MaterialTheme.colorScheme.onErrorContainer, onPickCover = { coverPickOpen = true },
style = MaterialTheme.typography.bodySmall, onResetCover = viewModel::resetCover,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), onOpenMembers = onOpenSettings,
onOpenSettings = onOpenSettings,
onOpenShare = onOpenSettings,
onRename = { renameOpen = true },
onDelete = { deleteOpen = true },
) )
} }
} },
when { ) {
state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(modifier = Modifier.fillMaxSize()) {
CircularProgressIndicator() if (state.offline) {
} Surface(
color = MaterialTheme.colorScheme.errorContainer,
state.photos.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { modifier = Modifier.fillMaxWidth().testTag(TAG_ALBUM_OFFLINE),
Text("В альбоме пока нет медиа") ) {
} Text(
text = state.error ?: "Офлайн-режим",
else -> LazyVerticalGrid( color = MaterialTheme.colorScheme.onErrorContainer,
columns = GridCells.Fixed(3), style = MaterialTheme.typography.bodySmall,
contentPadding = PaddingValues(2.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.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) }) { when {
LaunchedEffect(state.photos.size) { viewModel.loadNextPage() } state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Box( CircularProgressIndicator()
Modifier.fillMaxWidth().padding(16.dp), }
contentAlignment = Alignment.Center,
) { state.photos.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
if (state.loadingMore) CircularProgressIndicator(Modifier.size(24.dp)) 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<Photo>,
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 @Composable
@@ -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(" Отозвать")
}
}
}
}
}
}
@@ -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<MemberDto> = emptyList(),
val shareLinks: List<ShareLinkDto> = emptyList(),
val loading: Boolean = true,
val saved: Boolean = false,
val error: String? = null,
)
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _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) } }
}
}
}
@@ -22,6 +22,7 @@ class AlbumViewModel(
data class UiState( data class UiState(
val album: Album? = null, val album: Album? = null,
val role: String = "",
val photos: List<Photo> = emptyList(), val photos: List<Photo> = emptyList(),
val loading: Boolean = true, val loading: Boolean = true,
val loadingMore: Boolean = false, val loadingMore: Boolean = false,
@@ -40,6 +41,7 @@ class AlbumViewModel(
) )
private val status = MutableStateFlow(Status()) private val status = MutableStateFlow(Status())
private val role = MutableStateFlow("")
/** Watermark for `?since=` backfill; advanced as events are applied. */ /** Watermark for `?since=` backfill; advanced as events are applied. */
private var lastSync: String = Instant.now().toString() private var lastSync: String = Instant.now().toString()
@@ -48,9 +50,11 @@ class AlbumViewModel(
container.albumRepository.observeAlbum(albumId), container.albumRepository.observeAlbum(albumId),
container.photoRepository.observePhotos(albumId), container.photoRepository.observePhotos(albumId),
status, status,
) { album, photos, s -> role,
) { album, photos, s, r ->
UiState( UiState(
album = album, album = album,
role = r,
photos = photos, photos = photos,
loading = !s.loaded, loading = !s.loaded,
loadingMore = s.loadingMore, loadingMore = s.loadingMore,
@@ -61,11 +65,63 @@ class AlbumViewModel(
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState())
init { init {
viewModelScope.launch { container.albumRepository.loadAlbum(albumId) } viewModelScope.launch { loadAlbum() }
loadNextPage() loadNextPage()
observeEvents() 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. */ /** Live updates while the screen is alive; reconnect backfills missed changes. */
private fun observeEvents() { private fun observeEvents() {
viewModelScope.launch { viewModelScope.launch {
@@ -120,7 +176,7 @@ class AlbumViewModel(
fun refresh() { fun refresh() {
status.update { it.copy(loaded = false, endReached = false, nextCursor = null) } status.update { it.copy(loaded = false, endReached = false, nextCursor = null) }
viewModelScope.launch { container.albumRepository.loadAlbum(albumId) } viewModelScope.launch { loadAlbum() }
loadNextPage() loadNextPage()
} }
} }
@@ -10,13 +10,16 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout 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.CloudUpload
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.SdCard
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api 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_ALBUMS_AVATAR = "albums_avatar"
const val TAG_ALBUM_CARD_PREFIX = "album_card_" const val TAG_ALBUM_CARD_PREFIX = "album_card_"
const val TAG_DEVICE_ALBUM_CARD_PREFIX = "device_album_card_" const val TAG_DEVICE_ALBUM_CARD_PREFIX = "device_album_card_"
const val TAG_ALBUM_SOURCE_PREFIX = "album_source_" const val TAG_ALBUM_STORAGE_DEVICE_PREFIX = "album_storage_device_"
const val TAG_ALBUM_LINK_PREFIX = "album_link_" const val TAG_ALBUM_STORAGE_CLOUD_PREFIX = "album_storage_cloud_"
const val TAG_ALBUM_MODE_PREFIX = "album_mode_" const val TAG_ALBUM_COUNT_PREFIX = "album_count_"
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -166,68 +169,71 @@ fun AlbumsContent(
} }
} }
/** Where a device album's media live. */
private enum class StoragePlacement { DEVICE, DEVICE_AND_CLOUD, CLOUD }
@Composable @Composable
private fun AlbumCardBody( private fun AlbumCardBody(
id: String,
title: String, title: String,
counter: String, placement: StoragePlacement,
source: String, count: String,
link: String?, arrowTo: String?,
key: String,
mode: String? = null,
) { ) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { 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) { Row(verticalAlignment = Alignment.CenterVertically) {
Surface( Text(
color = MaterialTheme.colorScheme.secondaryContainer, text = title,
shape = MaterialTheme.shapes.small, style = MaterialTheme.typography.titleSmall,
modifier = Modifier.testTag(TAG_ALBUM_SOURCE_PREFIX + key), maxLines = 1,
) { overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (arrowTo != null) {
Text( Text(
text = source, text = "→ $arrowTo",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
if (mode != null) {
Text(
text = mode,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier maxLines = 1,
.padding(start = 6.dp) overflow = TextOverflow.Ellipsis,
.testTag(TAG_ALBUM_MODE_PREFIX + key), 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(
text = link, text = count,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(start = 6.dp).testTag(TAG_ALBUM_COUNT_PREFIX + id),
modifier = Modifier
.padding(top = 2.dp)
.testTag(TAG_ALBUM_LINK_PREFIX + key),
) )
} }
} }
} }
@Composable @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 val key = TAG_ALBUM_CARD_PREFIX + album.id
Card( Card(
modifier = Modifier modifier = Modifier
@@ -245,12 +251,11 @@ private fun ServerAlbumCard(item: AlbumListItem.ServerAlbum, onClick: () -> Unit
.clip(MaterialTheme.shapes.medium), .clip(MaterialTheme.shapes.medium),
) )
AlbumCardBody( AlbumCardBody(
id = album.id,
title = album.title, title = album.title,
counter = "${album.photosCount} фото · ${album.membersCount} участн.", placement = StoragePlacement.CLOUD,
source = "Сервер", count = "${album.photosCount} фото",
link = item.linkedDeviceNames.takeIf { it.isNotEmpty() } arrowTo = null,
?.joinToString(prefix = "источник: "),
key = key,
) )
} }
} }
@@ -260,6 +265,7 @@ private fun ServerAlbumCard(item: AlbumListItem.ServerAlbum, onClick: () -> Unit
private fun DeviceAlbumCard(item: AlbumListItem.DeviceAlbumItem, onClick: () -> Unit) { private fun DeviceAlbumCard(item: AlbumListItem.DeviceAlbumItem, onClick: () -> Unit) {
val album: DeviceAlbum = item.album val album: DeviceAlbum = item.album
val key = TAG_DEVICE_ALBUM_CARD_PREFIX + album.id val key = TAG_DEVICE_ALBUM_CARD_PREFIX + album.id
val arrowTo = item.linkedServerTitle?.takeIf { it != album.name }
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -276,19 +282,19 @@ private fun DeviceAlbumCard(item: AlbumListItem.DeviceAlbumItem, onClick: () ->
.clip(MaterialTheme.shapes.medium), .clip(MaterialTheme.shapes.medium),
) )
AlbumCardBody( AlbumCardBody(
id = album.id,
title = album.name, title = album.name,
counter = "${album.itemCount} эл.", placement = placementOf(item.mode),
source = "Телефон", count = "${album.itemCount} эл.",
link = item.linkedServerTitle?.let { "→ $it" }, arrowTo = arrowTo,
key = key,
mode = albumModeLabel(item.mode),
) )
} }
} }
} }
private fun albumModeLabel(mode: SyncMode): String = when (mode) { /** Storage icon placement for a device album: where its media live. */
SyncMode.DISABLED -> "синхр. выкл." private fun placementOf(mode: SyncMode): StoragePlacement = when (mode) {
SyncMode.COPY -> "копии" SyncMode.DISABLED -> StoragePlacement.DEVICE
SyncMode.MOVE -> "перенос" SyncMode.COPY -> StoragePlacement.DEVICE_AND_CLOUD
SyncMode.MOVE -> StoragePlacement.CLOUD
} }
@@ -41,21 +41,31 @@ class AlbumsViewModel(private val container: AppContainer) : ViewModel() {
val state: StateFlow<UiState> = combine( val state: StateFlow<UiState> = combine(
container.albumRepository.observeAlbums(), container.albumRepository.observeAlbums(),
container.syncRepository.observeLinks(), 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, status,
avatar, avatar,
) { albums, links, device, s, a -> ) { 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( val items = AlbumListBuilder.build(
albums = albums, albums = albums,
links = links.map { link -> links = links.map { link ->
AlbumLink( AlbumLink(
deviceAlbumId = link.deviceAlbumId, deviceAlbumId = link.deviceAlbumId,
deviceAlbumName = link.deviceAlbumName, deviceAlbumName = device.third[link.deviceAlbumId]?.displayName
?.takeIf { it.isNotBlank() } ?: link.deviceAlbumName,
serverAlbumId = link.serverAlbumId, serverAlbumId = link.serverAlbumId,
mode = SyncMode.from(link.mode), mode = SyncMode.from(link.mode),
) )
}, },
devices = device.first, devices = withMeta,
) )
UiState( UiState(
@@ -1,5 +1,7 @@
package com.galo.android.ui.devicealbum package com.galo.android.ui.devicealbum
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.IntentSenderRequest import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts 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.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.galo.android.GaloApplication 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.media.AllFilesAccess
import com.galo.android.domain.model.Album 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.DeviceMedia
import com.galo.android.domain.model.MediaKind 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.domain.model.SyncMode
import com.galo.android.ui.album.RenameAlbumDialog
import com.galo.android.ui.common.GaloAsyncImage 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 import kotlinx.coroutines.launch
const val TAG_DEVICE_GRID = "device_album_grid" const val TAG_DEVICE_GRID = "device_album_grid"
@@ -103,6 +119,7 @@ const val TAG_CREATE_TARGET = "device_create_target"
fun DeviceAlbumScreen( fun DeviceAlbumScreen(
app: GaloApplication, app: GaloApplication,
deviceAlbumId: String, deviceAlbumId: String,
onOpenServerSettings: (String) -> Unit = {},
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val factory = viewModelFactory { val factory = viewModelFactory {
@@ -110,7 +127,14 @@ fun DeviceAlbumScreen(
} }
val viewModel: DeviceAlbumViewModel = viewModel(factory = factory) val viewModel: DeviceAlbumViewModel = viewModel(factory = factory)
val state by viewModel.state.collectAsStateWithLifecycle() 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<Int?>(null) } var viewerIndex by remember { mutableStateOf<Int?>(null) }
var deletePhotoId by remember { mutableStateOf<String?>(null) }
val localName = meta?.displayName?.takeIf { it.isNotBlank() } ?: state.name
val localCoverUrl = meta?.coverUri
val context = LocalContext.current val context = LocalContext.current
var allFilesGranted by remember { mutableStateOf(AllFilesAccess.isGranted()) } var allFilesGranted by remember { mutableStateOf(AllFilesAccess.isGranted()) }
@@ -119,6 +143,11 @@ fun DeviceAlbumScreen(
) { ) {
allFilesGranted = AllFilesAccess.isGranted() allFilesGranted = AllFilesAccess.isGranted()
} }
val deleteLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartIntentSenderForResult(),
) {
viewModel.refreshAfterDelete()
}
LifecycleResumeEffect(Unit) { LifecycleResumeEffect(Unit) {
allFilesGranted = AllFilesAccess.isGranted() allFilesGranted = AllFilesAccess.isGranted()
onPauseOrDispose { } onPauseOrDispose { }
@@ -130,23 +159,97 @@ fun DeviceAlbumScreen(
items = state.items, items = state.items,
initialIndex = openIndex, initialIndex = openIndex,
onBack = { viewerIndex = null }, 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<Photo?>(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<List<String>>(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) }, originalSourceProvider = { mediaId -> viewModel.originalSource(mediaId) },
) )
} else { } else {
DeviceAlbumContent( DeviceAlbumContent(
state = state, state = state,
role = role,
localName = localName,
localCoverUrl = localCoverUrl,
onBack = onBack, onBack = onBack,
onMode = viewModel::setMode, onMode = viewModel::setMode,
onTarget = viewModel::setTarget, onTarget = viewModel::setTarget,
onRestoreOriginals = viewModel::restoreOriginals, onRestoreOriginals = viewModel::restoreOriginals,
onOpen = { index -> viewerIndex = index }, onOpen = { index -> viewerIndex = index },
onCreateAlbum = viewModel::createAlbum, 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, allFilesAccessGranted = allFilesGranted,
onRequestAllFilesAccess = { onRequestAllFilesAccess = {
runCatching { allFilesLauncher.launch(AllFilesAccess.settingsIntent(context)) } 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) @OptIn(ExperimentalMaterial3Api::class)
@@ -159,6 +262,14 @@ fun DeviceAlbumContent(
onRestoreOriginals: () -> Unit, onRestoreOriginals: () -> Unit,
onOpen: (Int) -> Unit, onOpen: (Int) -> Unit,
onCreateAlbum: (String) -> 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, allFilesAccessGranted: Boolean = true,
onRequestAllFilesAccess: () -> Unit = {}, 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( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
drawerContent = { drawerContent = {
@@ -273,7 +406,24 @@ fun DeviceAlbumContent(
) )
}, },
) { padding -> ) { 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()) { if (state.items.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("В альбоме нет медиа") 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<DeviceMedia>,
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 @Composable
@@ -6,15 +6,23 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.galo.android.di.AppContainer import com.galo.android.di.AppContainer
import com.galo.android.data.db.entity.DeviceAssetEntity 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.Album
import com.galo.android.domain.model.DeviceAlbumMeta
import com.galo.android.domain.model.DeviceMedia import com.galo.android.domain.model.DeviceMedia
import com.galo.android.domain.model.LocalAssetState 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.domain.model.SyncMode
import com.galo.android.data.sync.OffloadResult import com.galo.android.data.sync.OffloadResult
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -44,6 +52,21 @@ class DeviceAlbumViewModel(
private val pendingMode = MutableStateFlow<SyncMode?>(null) private val pendingMode = MutableStateFlow<SyncMode?>(null)
private val _role = MutableStateFlow("")
/** Caller's role on the linked server album (empty when unlinked). */
val role: StateFlow<String> = _role.asStateFlow()
private val _preferences = MutableStateFlow<PhotoInfoPrefsDto?>(null)
/** Saved photo-attribute preferences (visibility + labels). */
val preferences: StateFlow<PhotoInfoPrefsDto?> = _preferences.asStateFlow()
/** App-only metadata (display name, chosen cover) for this device album. */
val albumMeta: StateFlow<DeviceAlbumMeta?> = container.syncRepository.observeAlbumMeta()
.map { it[deviceAlbumId] }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
val state: StateFlow<UiState> = combine( val state: StateFlow<UiState> = combine(
container.albumRepository.observeAlbums(), container.albumRepository.observeAlbums(),
container.syncRepository.observeLinks(), container.syncRepository.observeLinks(),
@@ -88,23 +111,179 @@ class DeviceAlbumViewModel(
viewModelScope.launch { viewModelScope.launch {
runCatching { container.albumRepository.refresh() } 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<Photo> {
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<String> =
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) { fun setMode(mode: SyncMode) {
pendingMode.value = mode 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 { viewModelScope.launch {
if (mode == SyncMode.DISABLED) { // Persist the chosen mode even before a server album is picked, so the
// Turning sync off clears the link to the server album. // choice survives leaving and reopening the album.
container.syncRepository.unlink(deviceAlbumId) val target = state.value.serverAlbumId
return@launch ?: container.syncRepository.currentLink(deviceAlbumId)?.serverAlbumId
} ?: ""
container.syncRepository.link( container.syncRepository.link(
deviceAlbumId = deviceAlbumId, deviceAlbumId = deviceAlbumId,
deviceAlbumName = local.value.name, deviceAlbumName = local.value.name,
serverAlbumId = state.value.serverAlbumId ?: "", serverAlbumId = target,
mode = mode, 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, serverAlbumId = serverAlbumId,
mode = pendingMode.value ?: state.value.mode, mode = pendingMode.value ?: state.value.mode,
) )
refreshRole()
reconcileNow() reconcileNow()
} }
} }
@@ -133,6 +313,7 @@ class DeviceAlbumViewModel(
serverAlbumId = album.id, serverAlbumId = album.id,
mode = pendingMode.value ?: state.value.mode, mode = pendingMode.value ?: state.value.mode,
) )
refreshRole()
reconcileNow() reconcileNow()
} }
} }
@@ -170,9 +351,8 @@ class DeviceAlbumViewModel(
/** Returns originals to the device for every offloaded (preview-only) asset. */ /** Returns originals to the device for every offloaded (preview-only) asset. */
fun restoreOriginals() { fun restoreOriginals() {
viewModelScope.launch { viewModelScope.launch {
val assets = container.syncRepository.assets(deviceAlbumId) val targets = RestoreManager.restoreTargets(container.syncRepository.assets(deviceAlbumId))
.filter { it.state == LocalAssetState.PREVIEW.name } if (targets.isNotEmpty()) container.restoreManager.restore(targets)
container.restoreManager.restore(assets)
} }
} }
@@ -1,5 +1,7 @@
package com.galo.android.ui.devicealbum 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.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
@@ -10,18 +12,22 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import com.galo.android.domain.model.DeviceMedia import com.galo.android.domain.model.DeviceMedia
import com.galo.android.domain.model.MediaKind import com.galo.android.domain.model.MediaKind
@@ -37,6 +43,8 @@ fun DeviceViewerScreen(
items: List<DeviceMedia>, items: List<DeviceMedia>,
initialIndex: Int, initialIndex: Int,
onBack: () -> Unit, onBack: () -> Unit,
serverPhotoByMediaId: Map<Long, String> = emptyMap(),
sheet: (@Composable (DeviceMedia) -> Unit)? = null,
originalSourceProvider: suspend (Long) -> OriginalSource? = { null }, originalSourceProvider: suspend (Long) -> OriginalSource? = { null },
) { ) {
if (items.isEmpty()) { if (items.isEmpty()) {
@@ -49,6 +57,20 @@ fun DeviceViewerScreen(
val current = items.getOrNull(pagerState.currentPage) val current = items.getOrNull(pagerState.currentPage)
var zoomed by remember(current?.id) { mutableStateOf(false) } 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( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -64,6 +86,7 @@ fun DeviceViewerScreen(
}, },
) { padding -> ) { padding ->
Surface(color = Color.Black, modifier = Modifier.fillMaxSize().padding(padding)) { Surface(color = Color.Black, modifier = Modifier.fillMaxSize().padding(padding)) {
Box(Modifier.fillMaxSize().then(sheetGesture)) {
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
userScrollEnabled = !zoomed, userScrollEnabled = !zoomed,
@@ -94,6 +117,16 @@ fun DeviceViewerScreen(
) )
} }
} }
}
}
}
if (sheetOpen && sheet != null && current != null) {
ModalBottomSheet(
onDismissRequest = { sheetOpen = false },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
sheet(current)
} }
} }
} }
@@ -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,
)
}
}
}
@@ -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()
}
}
}
}
@@ -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),
)
}
}
}
}
}
}
@@ -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<String> = EXIF_BASE.toSet(),
val labels: Map<String, String> = emptyMap(),
val loading: Boolean = true,
val saved: Boolean = false,
val error: String? = null,
)
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _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) } }
}
}
}
@@ -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<String> = 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]}"
}
@@ -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<Album>,
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("Отмена") } },
)
}
@@ -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<ExifRow> = emptyList(),
val hiddenAttributeCount: Int = 0,
val tags: List<String> = 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<String>,
labels: Map<String, String>,
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,
)
@@ -1,5 +1,8 @@
package com.galo.android.ui.viewer 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.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -12,26 +15,38 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.compose.ui.platform.testTag
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.galo.android.GaloApplication 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.MediaKind
import com.galo.android.domain.model.Photo 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" const val TAG_VIEWER_PAGER = "viewer_pager"
@@ -42,13 +57,92 @@ fun ViewerScreen(
albumId: String, albumId: String,
initialIndex: Int, initialIndex: Int,
onBack: () -> Unit, onBack: () -> Unit,
onOpenAttributeSettings: () -> Unit = {},
) { ) {
val factory = viewModelFactory { val factory = viewModelFactory {
initializer { ViewerViewModel(app.container, albumId) } initializer { ViewerViewModel(app.container, albumId) }
} }
val viewModel: ViewerViewModel = viewModel(factory = factory) val viewModel: ViewerViewModel = viewModel(factory = factory)
val photos by viewModel.photos.collectAsStateWithLifecycle() 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<Photo?>(null) }
var deleteFor by remember { mutableStateOf<Photo?>(null) }
var transferFor by remember { mutableStateOf<Pair<Photo, Boolean>?>(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<List<String>>(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) @OptIn(ExperimentalMaterial3Api::class)
@@ -57,6 +151,7 @@ fun ViewerContent(
photos: List<Photo>, photos: List<Photo>,
initialIndex: Int, initialIndex: Int,
onBack: () -> Unit, onBack: () -> Unit,
sheet: (@Composable (Photo) -> Unit)? = null,
) { ) {
if (photos.isEmpty()) { if (photos.isEmpty()) {
Scaffold( Scaffold(
@@ -90,6 +185,23 @@ fun ViewerContent(
var zoomed by remember(current?.id) { mutableStateOf(false) } var zoomed by remember(current?.id) { mutableStateOf(false) }
val pagerScrollEnabled = current?.isPanorama != true && !zoomed 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( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -111,21 +223,32 @@ fun ViewerContent(
color = Color.Black, color = Color.Black,
modifier = Modifier.fillMaxSize().padding(padding), modifier = Modifier.fillMaxSize().padding(padding),
) { ) {
HorizontalPager( Box(Modifier.fillMaxSize().then(sheetGesture)) {
state = pagerState, HorizontalPager(
userScrollEnabled = pagerScrollEnabled, state = pagerState,
modifier = Modifier.fillMaxSize().testTag(TAG_VIEWER_PAGER), userScrollEnabled = pagerScrollEnabled,
key = { index -> photos.getOrNull(index)?.id ?: index }, modifier = Modifier.fillMaxSize().testTag(TAG_VIEWER_PAGER),
) { page -> key = { index -> photos.getOrNull(index)?.id ?: index },
val photo = photos.getOrNull(page) ?: return@HorizontalPager ) { page ->
MediaPage( val photo = photos.getOrNull(page) ?: return@HorizontalPager
photo = photo, MediaPage(
active = pagerState.currentPage == page, photo = photo,
onZoomChanged = { zoomed = it }, active = pagerState.currentPage == page,
) onZoomChanged = { zoomed = it },
)
}
} }
} }
} }
if (sheetOpen && sheet != null && current != null) {
ModalBottomSheet(
onDismissRequest = { sheetOpen = false },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
sheet(current)
}
}
} }
@Composable @Composable
@@ -2,10 +2,15 @@ package com.galo.android.ui.viewer
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.galo.android.data.network.dto.PhotoInfoPrefsDto
import com.galo.android.di.AppContainer 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.Photo
import com.galo.android.domain.model.Tag
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -17,6 +22,15 @@ class ViewerViewModel(
val photos: StateFlow<List<Photo>> = container.photoRepository.observePhotos(albumId) val photos: StateFlow<List<Photo>> = container.photoRepository.observePhotos(albumId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _role = MutableStateFlow("")
val role: StateFlow<String> = _role.asStateFlow()
private val _preferences = MutableStateFlow<PhotoInfoPrefsDto?>(null)
val preferences: StateFlow<PhotoInfoPrefsDto?> = _preferences.asStateFlow()
val albums: StateFlow<List<Album>> = container.albumRepository.observeAlbums()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
init { init {
// Cold start (e.g. restored route): make sure the cache is populated. // Cold start (e.g. restored route): make sure the cache is populated.
viewModelScope.launch { viewModelScope.launch {
@@ -24,5 +38,77 @@ class ViewerViewModel(
container.photoRepository.loadNextPage(albumId, cursor = null) 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<String> =
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)
} }
} }
@@ -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)
}
}
@@ -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<PhotoDto>(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<PhotoDto>(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<PhotoDto>(minimal).toDomain(resolver)
assertTrue(photo.exif.isEmpty())
assertTrue(photo.tags.isEmpty())
assertEquals(null, photo.uploaderName)
}
}
@@ -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<UserEnvelopeDto>(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<UserEnvelopeDto>("""{"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<PreferencesDto>(encoded),
)
}
}
@@ -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"))
}
}
@@ -1,11 +1,33 @@
package com.galo.android.data.sync 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.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class RestoreManagerTest { 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 @Test
fun `restoring fits when there is enough space`() { fun `restoring fits when there is enough space`() {
assertTrue(RestoreManager.canRestore(requiredBytes = 100, availableBytes = 100)) assertTrue(RestoreManager.canRestore(requiredBytes = 100, availableBytes = 100))
@@ -16,4 +38,16 @@ class RestoreManagerTest {
fun `restoring is deferred when space is short`() { fun `restoring is deferred when space is short`() {
assertFalse(RestoreManager.canRestore(requiredBytes = 500, availableBytes = 100)) 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 })
}
} }
@@ -53,6 +53,7 @@ class SyncCoordinatorTest {
private class FakeQueue : SyncUploadQueue { private class FakeQueue : SyncUploadQueue {
val enqueued = mutableListOf<Triple<String, String, LocalAsset>>() val enqueued = mutableListOf<Triple<String, String, LocalAsset>>()
val pending = mutableSetOf<String>()
override suspend fun enqueue( override suspend fun enqueue(
deviceAlbumId: String, deviceAlbumId: String,
serverAlbumId: String, serverAlbumId: String,
@@ -61,6 +62,8 @@ class SyncCoordinatorTest {
) { ) {
enqueued += Triple(deviceAlbumId, serverAlbumId, asset) enqueued += Triple(deviceAlbumId, serverAlbumId, asset)
} }
override suspend fun isPending(assetKey: String): Boolean = assetKey in pending
} }
private class FakeProvider : LocalAssetProvider { private class FakeProvider : LocalAssetProvider {
@@ -267,9 +270,75 @@ class SyncCoordinatorTest {
assertEquals(LocalAssetState.AWAITING_FREE.name, db.syncDao().getAsset("b1/1")?.state) 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 @Test
fun `move delegates to the server`() = runBlocking { fun `move delegates to the server`() = runBlocking {
coordinator.moveToServerAlbum(listOf("p1", "p2"), "srv2") coordinator.moveToServerAlbum(listOf("p1", "p2"), "srv2")
assertEquals(listOf(listOf("p1", "p2") to "srv2"), remote.moves) 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())
}
} }
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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))
}
}
@@ -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)
}
}
}
}
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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<String, String>? = 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)
}
}
@@ -72,11 +72,13 @@ class AlbumsContentTest {
compose.onNodeWithTag(TAG_ALBUMS_GRID).assertIsDisplayed() compose.onNodeWithTag(TAG_ALBUMS_GRID).assertIsDisplayed()
compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + "a1").assertIsDisplayed() compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + "a1").assertIsDisplayed()
compose.onNodeWithText("Summer").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 @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( val items = AlbumListBuilder.build(
albums = listOf(album("a1", "Отпуск")), albums = listOf(album("a1", "Отпуск")),
links = listOf(AlbumLink("b1", "Camera", "a1", SyncMode.COPY)), links = listOf(AlbumLink("b1", "Camera", "a1", SyncMode.COPY)),
@@ -88,10 +90,27 @@ class AlbumsContentTest {
compose.onNodeWithTag(TAG_DEVICE_ALBUM_CARD_PREFIX + "b1").assertIsDisplayed() compose.onNodeWithTag(TAG_DEVICE_ALBUM_CARD_PREFIX + "b1").assertIsDisplayed()
// The linked server album is collapsed into the device card, not shown twice. // The linked server album is collapsed into the device card, not shown twice.
compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + "a1").assertDoesNotExist() compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + "a1").assertDoesNotExist()
compose.onNodeWithText("Телефон").assertIsDisplayed() compose.onNodeWithText("Camera").assertIsDisplayed()
compose.onNodeWithText("9 эл.").assertIsDisplayed()
compose.onNodeWithText("→ Отпуск").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 @Test
@@ -2,6 +2,7 @@ package com.galo.android.ui.devicealbum
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.createComposeRule 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.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode 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.Album
import com.galo.android.domain.model.DeviceMedia import com.galo.android.domain.model.DeviceMedia
import com.galo.android.domain.model.MediaKind import com.galo.android.domain.model.MediaKind
import com.galo.android.domain.model.SyncMode 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 com.galo.android.ui.theme.GaloTheme
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@@ -78,6 +90,12 @@ class DeviceAlbumContentTest {
onMode: (SyncMode) -> Unit = {}, onMode: (SyncMode) -> Unit = {},
onTarget: (String) -> Unit = {}, onTarget: (String) -> Unit = {},
onCreateAlbum: (String) -> Unit = {}, onCreateAlbum: (String) -> Unit = {},
role: String = "",
localName: String? = null,
localCoverUrl: String? = null,
onDeleteAlbum: () -> Unit = {},
onRenameAlbum: (String) -> Unit = {},
onSetCover: (String?) -> Unit = {},
) { ) {
GaloTheme(dynamicColor = false) { GaloTheme(dynamicColor = false) {
DeviceAlbumContent( DeviceAlbumContent(
@@ -88,6 +106,12 @@ class DeviceAlbumContentTest {
onRestoreOriginals = {}, onRestoreOriginals = {},
onOpen = {}, onOpen = {},
onCreateAlbum = onCreateAlbum, 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`() { fun `mode action for the same mode does nothing`() {
assertEquals(null, modeAction(SyncMode.COPY, hasTarget = true, target = SyncMode.COPY)) 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 @Test
fun `target picker lists albums and picks one`() { fun `target picker lists albums and picks one`() {
var picked: String? = null var picked: String? = null
@@ -275,4 +338,107 @@ class DeviceAlbumContentTest {
compose.onNodeWithTag(TAG_MODE_PREFIX + SyncMode.MOVE.name).performClick() compose.onNodeWithTag(TAG_MODE_PREFIX + SyncMode.MOVE.name).performClick()
assertEquals(SyncMode.MOVE, chosen) 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)
}
} }
@@ -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()
}
}
@@ -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)
}
}
@@ -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<Boolean>) {
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()
}
}
@@ -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()
}
}
@@ -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<String> = 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()
}
}
@@ -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)
}
}
@@ -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()
}
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-13
@@ -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
Нет — все решения, влияющие на спек и декомпозицию, приняты выше.
@@ -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
<!-- none -->
### 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`).
@@ -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** лист показывает локальные атрибуты и локальные действия элемента
@@ -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 после прохождения ревью проекта.
- Проверка результата архивации.
+263
View File
@@ -356,3 +356,266 @@ SHALL выполняться свайпом.
- **WHEN** оригинал загружен или недоступен - **WHEN** оригинал загружен или недоступен
- **THEN** индикатор загрузки скрыт - **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** лист показывает локальные атрибуты и локальные действия элемента