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

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

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

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

Verified: unit/Robolectric suite green, :app:assembleDebug, :app:lint, and the
DeviceSyncE2ETest instrumented suite on the emulator (7/7).
This commit is contained in:
iu
2026-10-13 21:49:12 +03:00
parent d1bb964685
commit fa210e6c7f
75 changed files with 6094 additions and 167 deletions
@@ -211,6 +211,101 @@ class DeviceSyncE2ETest {
}
}
@Test
fun switchingFromMoveToCopyRestoresTheOriginal() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val serverAlbum = E2E.createAlbum(token, "MoveToCopy ${System.currentTimeMillis()}")
val (uri, bucket) = insertImage()
val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri))
container.syncRepository.link(bucket, "MC", serverAlbum, SyncMode.MOVE)
assertTrue(container.syncCoordinator.reconcile(bucket))
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW))
// Switch to copies and run the check: the original comes back.
container.syncRepository.link(bucket, "MC", serverAlbum, SyncMode.COPY)
container.syncCheck.checkGallery(bucket)
assertTrue(
"original should be restored on move→copy",
waitForState(container, bucket, key, LocalAssetState.LOCAL),
)
// The restored original must not be re-uploaded as a duplicate.
container.syncCoordinator.reconcile(bucket)
delay(2_000)
assertEquals(1, E2E.listPhotos(token, serverAlbum).size)
}
@Test
fun unlinkedAlbumWithPreviewsRestoresOriginals() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val serverAlbum = E2E.createAlbum(token, "UnlinkRestore ${System.currentTimeMillis()}")
val (uri, bucket) = insertImage()
val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri))
container.syncRepository.link(bucket, "U", serverAlbum, SyncMode.MOVE)
assertTrue(container.syncCoordinator.reconcile(bucket))
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW))
// Unlink (mode off): previews must still be restored, without touching the server.
container.syncRepository.unlink(bucket)
container.syncCheck.checkGallery(bucket)
assertTrue(
"originals should be restored when no longer moving",
waitForState(container, bucket, key, LocalAssetState.LOCAL),
)
assertEquals(1, E2E.listPhotos(token, serverAlbum).size)
}
@Test
fun checkRebuildsLostIndexFromMarker() = runBlocking {
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
val container = app.container
E2E.configure(container)
val token = E2E.registerAuthenticated(container)
val serverAlbum = E2E.createAlbum(token, "LostIndex ${System.currentTimeMillis()}")
val (uri, bucket) = insertImage()
val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri))
container.syncRepository.link(bucket, "L", serverAlbum, SyncMode.MOVE)
assertTrue(container.syncCoordinator.reconcile(bucket))
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW))
// Simulate a lost index/link: the marker in the preview file is the only trail.
container.syncRepository.forgetAlbum(bucket)
container.syncCheck.checkGallery(bucket, recoverIndex = true)
val link = waitForLink(container, bucket)
assertTrue("link should be rebuilt from the marker", link != null)
assertEquals(SyncMode.MOVE, SyncMode.from(link?.mode))
assertTrue(
"an offloaded asset stays offloaded in move mode",
waitForState(container, bucket, key, LocalAssetState.PREVIEW),
)
}
private suspend fun waitForLink(
container: com.galo.android.di.AppContainer,
bucket: String,
): com.galo.android.data.db.entity.SyncedAlbumEntity? {
val deadline = System.currentTimeMillis() + 10_000
while (System.currentTimeMillis() < deadline) {
val link = container.syncRepository.currentLink(bucket)
if (link != null && link.serverAlbumId.isNotBlank()) return link
delay(200)
}
return container.syncRepository.currentLink(bucket)
}
private suspend fun waitForState(
container: com.galo.android.di.AppContainer,
bucket: String,
@@ -9,6 +9,7 @@ import com.galo.android.data.db.dao.PhotoDao
import com.galo.android.data.db.dao.SyncDao
import com.galo.android.data.db.dao.UploadDao
import com.galo.android.data.db.entity.AlbumEntity
import com.galo.android.data.db.entity.DeviceAlbumMetaEntity
import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.data.db.entity.PhotoEntity
import com.galo.android.data.db.entity.SyncedAlbumEntity
@@ -21,11 +22,11 @@ import com.galo.android.data.db.entity.UploadJobEntity
UploadJobEntity::class,
SyncedAlbumEntity::class,
DeviceAssetEntity::class,
DeviceAlbumMetaEntity::class,
],
version = 4,
version = 6,
exportSchema = false,
)
abstract class GaloDatabase : RoomDatabase() {
)abstract class GaloDatabase : RoomDatabase() {
abstract fun albumDao(): AlbumDao
@@ -3,6 +3,7 @@ package com.galo.android.data.db.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import com.galo.android.data.db.entity.DeviceAlbumMetaEntity
import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.data.db.entity.SyncedAlbumEntity
import kotlinx.coroutines.flow.Flow
@@ -27,8 +28,25 @@ interface SyncDao {
@Query("DELETE FROM synced_albums WHERE deviceAlbumId = :deviceAlbumId")
suspend fun deleteSyncedAlbum(deviceAlbumId: String)
// --- Local album metadata (display name, cover) ------------------------
@Query("SELECT * FROM device_album_meta")
fun observeAlbumMeta(): Flow<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 ---------------------------------
@Query("SELECT DISTINCT deviceAlbumId FROM device_assets")
suspend fun indexedAlbumIds(): List<String>
@Query("SELECT * FROM device_assets WHERE deviceAlbumId = :deviceAlbumId")
fun observeAssets(deviceAlbumId: String): Flow<List<DeviceAssetEntity>>
@@ -20,6 +20,8 @@ data class PhotoEntity(
val size: Long,
val takenAt: String?,
val caption: String,
val latitude: Double? = null,
val longitude: Double? = null,
val isPanorama: Boolean,
val thumbUrl: String?,
val mediumUrl: String?,
@@ -28,4 +30,8 @@ data class PhotoEntity(
val originalUrl: String?,
val downloadUrl: String?,
val createdAt: String?,
val tagsJson: String = "[]",
val exifJson: String = "{}",
val uploaderName: String? = null,
val uploaderAvatarUrl: String? = null,
)
@@ -14,6 +14,18 @@ data class SyncedAlbumEntity(
val updatedAt: Long,
)
/**
* Local, app-only metadata for a device album: a display name and a chosen
* cover. Stored regardless of whether the album is linked to a server album.
*/
@Entity(tableName = "device_album_meta")
data class DeviceAlbumMetaEntity(
@PrimaryKey val deviceAlbumId: String,
val displayName: String?,
val coverUri: String?,
val updatedAt: Long,
)
/**
* Local index of a device asset plus provenance: the server photo it was
* uploaded as (when known) and the content hash used to match it.
@@ -2,12 +2,18 @@ package com.galo.android.data.mapper
import com.galo.android.data.db.entity.AlbumEntity
import com.galo.android.data.db.entity.PhotoEntity
import com.galo.android.data.network.GaloJson
import com.galo.android.data.network.ObjectUrlResolver
import com.galo.android.data.network.dto.AlbumDto
import com.galo.android.data.network.dto.PhotoDto
import com.galo.android.data.network.dto.TagDto
import com.galo.android.domain.model.Album
import com.galo.android.domain.model.MediaKind
import com.galo.android.domain.model.Photo
import com.galo.android.domain.model.Tag
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
// Remote DTO -> persisted entity (URLs kept raw so the cache survives a base-URL change).
fun AlbumDto.toEntity(): AlbumEntity = AlbumEntity(
@@ -35,6 +41,8 @@ fun PhotoDto.toEntity(): PhotoEntity = PhotoEntity(
size = size,
takenAt = takenAt,
caption = caption,
latitude = latitude,
longitude = longitude,
isPanorama = tags.any { MediaKind.isPanoTag(it.name) },
thumbUrl = thumbUrl,
mediumUrl = mediumUrl,
@@ -43,6 +51,10 @@ fun PhotoDto.toEntity(): PhotoEntity = PhotoEntity(
originalUrl = originalUrl,
downloadUrl = downloadUrl,
createdAt = createdAt,
tagsJson = encodeTags(tags),
exifJson = encodeExif(exif),
uploaderName = uploader?.name,
uploaderAvatarUrl = uploader?.avatarUrl,
)
// Persisted entity -> domain (URLs resolved against the current base URL).
@@ -71,6 +83,8 @@ fun PhotoEntity.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
size = size,
takenAt = takenAt,
caption = caption,
latitude = latitude,
longitude = longitude,
isPanorama = isPanorama,
thumbUrl = resolver.resolve(thumbUrl),
mediumUrl = resolver.resolve(mediumUrl),
@@ -79,6 +93,10 @@ fun PhotoEntity.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
originalUrl = resolver.resolve(originalUrl),
downloadUrl = resolver.resolve(downloadUrl),
createdAt = createdAt,
tags = decodeTags(tagsJson),
exif = decodeExif(exifJson),
uploaderName = uploaderName,
uploaderAvatarUrl = resolver.resolve(uploaderAvatarUrl),
)
fun PhotoDto.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
@@ -93,6 +111,8 @@ fun PhotoDto.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
size = size,
takenAt = takenAt,
caption = caption,
latitude = latitude,
longitude = longitude,
isPanorama = tags.any { MediaKind.isPanoTag(it.name) },
thumbUrl = resolver.resolve(thumbUrl),
mediumUrl = resolver.resolve(mediumUrl),
@@ -101,4 +121,31 @@ fun PhotoDto.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
originalUrl = resolver.resolve(originalUrl),
downloadUrl = resolver.resolve(downloadUrl),
createdAt = createdAt,
tags = tags.map { Tag(it.id, it.name) },
exif = exif.mapValues { it.value.asExifString() },
uploaderName = uploader?.name,
uploaderAvatarUrl = resolver.resolve(uploader?.avatarUrl),
)
private fun encodeTags(tags: List<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> =
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> =
query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaKind.IMAGE) +
query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, MediaKind.VIDEO)
@@ -1,6 +1,7 @@
package com.galo.android.data.network
import com.galo.android.data.network.dto.AddIdentifierRequest
import com.galo.android.data.network.dto.AddTagRequest
import com.galo.android.data.network.dto.AlbumDto
import com.galo.android.data.network.dto.AlbumEnvelopeDto
import com.galo.android.data.network.dto.AlbumListDto
@@ -12,12 +13,23 @@ import com.galo.android.data.network.dto.DeleteAccountRequest
import com.galo.android.data.network.dto.HealthDto
import com.galo.android.data.network.dto.LoginRequest
import com.galo.android.data.network.dto.LogoutRequest
import com.galo.android.data.network.dto.MemberDto
import com.galo.android.data.network.dto.MemberListDto
import com.galo.android.data.network.dto.PhotoDto
import com.galo.android.data.network.dto.PhotoEnvelopeDto
import com.galo.android.data.network.dto.PhotoListDto
import com.galo.android.data.network.dto.PreferencesDto
import com.galo.android.data.network.dto.PreferencesEnvelopeDto
import com.galo.android.data.network.dto.RegisterRequest
import com.galo.android.data.network.dto.RenamePhotoRequest
import com.galo.android.data.network.dto.ShareLinkDto
import com.galo.android.data.network.dto.ShareLinkEnvelopeDto
import com.galo.android.data.network.dto.ShareLinkListDto
import com.galo.android.data.network.dto.ShareLinkRequest
import com.galo.android.data.network.dto.TagDto
import com.galo.android.data.network.dto.TagListDto
import com.galo.android.data.network.dto.TokenEnvelopeDto
import com.galo.android.data.network.dto.UpdateAlbumRequest
import com.galo.android.data.network.dto.UpdateProfileRequest
import com.galo.android.data.network.dto.UploadResponseDto
import com.galo.android.data.network.dto.UserDto
@@ -76,6 +88,15 @@ class GaloApi(
override suspend fun me(): UserDto =
get("api/auth/me").decode<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 =
patchJson("api/auth/me", json.encodeToString(UpdateProfileRequest(name)))
.decode<UserEnvelopeDto>()
@@ -127,6 +148,10 @@ class GaloApi(
suspend fun album(albumId: String): AlbumDto =
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. */
suspend fun createAlbum(title: String): AlbumDto =
postJson("api/albums", json.encodeToString(CreateAlbumRequest(title)))
@@ -183,6 +208,70 @@ class GaloApi(
postJson("api/photo-batch/move", json.encodeToString(BatchRequest(photoIds, albumId)))
}
/** Copies photos to another album in one batch. */
suspend fun copyPhotos(photoIds: List<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.
* 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.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class HealthDto(
@@ -37,6 +38,7 @@ data class UserDto(
@Serializable
data class UserEnvelopeDto(
val user: UserDto = UserDto(),
val preferences: PreferencesDto? = null,
)
@Serializable
@@ -141,7 +143,11 @@ data class PhotoDto(
val size: Long = 0,
@SerialName("taken_at") val takenAt: String? = null,
val caption: String = "",
val latitude: Double? = null,
val longitude: Double? = null,
val tags: List<TagDto> = emptyList(),
val exif: Map<String, JsonElement> = emptyMap(),
val uploader: UserDto? = null,
@SerialName("thumb_url") val thumbUrl: String? = null,
@SerialName("medium_url") val mediumUrl: String? = null,
@SerialName("preview_url") val previewUrl: String? = null,
@@ -183,3 +189,74 @@ data class ErrorBodyDto(
val code: String = "internal_error",
val message: String = "internal server error",
)
@Serializable
data class UpdateAlbumRequest(
val title: String? = null,
val description: String? = null,
@SerialName("cover_photo_id") val coverPhotoId: String? = null,
@SerialName("clear_cover") val clearCover: Boolean? = null,
)
@Serializable
data class ShareLinkRequest(
@SerialName("expires_in") val expiresIn: String = "168h",
)
@Serializable
data class ShareLinkDto(
val id: String = "",
@SerialName("album_id") val albumId: String = "",
val token: String = "",
@SerialName("expires_at") val expiresAt: String? = null,
@SerialName("revoked_at") val revokedAt: String? = null,
)
@Serializable
data class ShareLinkEnvelopeDto(
@SerialName("share_link") val shareLink: ShareLinkDto = ShareLinkDto(),
)
@Serializable
data class ShareLinkListDto(
@SerialName("share_links") val shareLinks: List<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
import com.galo.android.data.db.dao.SyncDao
import com.galo.android.data.db.entity.DeviceAlbumMetaEntity
import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.data.db.entity.SyncedAlbumEntity
import com.galo.android.data.media.MediaStoreDeviceSource
import com.galo.android.domain.model.DeviceAlbum
import com.galo.android.domain.model.DeviceAlbumMeta
import com.galo.android.domain.model.LocalAssetState
import com.galo.android.domain.model.SyncMode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/** Device albums, their links to server albums and their asset index. */
class SyncRepository(
@@ -22,6 +25,9 @@ class SyncRepository(
suspend fun assets(deviceAlbumId: String): List<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 currentLink(deviceAlbumId: String): SyncedAlbumEntity? =
@@ -48,6 +54,61 @@ class SyncRepository(
suspend fun unlink(deviceAlbumId: String) = dao.deleteSyncedAlbum(deviceAlbumId)
// --- Local album metadata (display name, cover) ------------------------
/** App-only metadata for device albums, keyed by device album id. */
fun observeAlbumMeta(): Flow<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). */
suspend fun recordUploaded(
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.domain.model.LocalAssetState
import com.galo.android.domain.model.MediaKind
import com.galo.android.domain.model.SyncMode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -50,12 +51,18 @@ class OffloadManager(
mutex.withLock {
runCatching {
val uri = Uri.parse(asset.uri)
val serverAlbumId = dao.getSyncedAlbum(asset.deviceAlbumId)?.serverAlbumId
val link = dao.getSyncedAlbum(asset.deviceAlbumId)
val serverAlbumId = link?.serverAlbumId
val syncMode = link?.mode
if (kindOf(asset.mimeType) == MediaKind.VIDEO) {
offloadVideo(asset, uri, serverAlbumId)
offloadVideo(asset, uri, serverAlbumId, syncMode)
} else {
offloadPhoto(asset, uri, serverAlbumId)
offloadPhoto(asset, uri, serverAlbumId, syncMode)
}
}.onSuccess {
android.util.Log.i(TAG, "offload ${asset.assetKey}: $it")
}.onFailure {
android.util.Log.w(TAG, "offload ${asset.assetKey} failed", it)
}.getOrDefault(OffloadResult.Failed)
}
}
@@ -76,7 +83,12 @@ class OffloadManager(
null
}
private suspend fun offloadPhoto(asset: DeviceAssetEntity, uri: Uri, serverAlbumId: String?): OffloadResult {
private suspend fun offloadPhoto(
asset: DeviceAssetEntity,
uri: Uri,
serverAlbumId: String?,
syncMode: String?,
): OffloadResult {
val preview = PreviewGenerator.tempPreviewFile(context, asset.assetKey)
val sourceExif = runCatching {
context.contentResolver.openInputStream(uri)?.use { ExifInterface(it) }
@@ -84,7 +96,7 @@ class OffloadManager(
if (!PreviewGenerator.generate(context, uri, MediaKind.IMAGE, preview)) return OffloadResult.Failed
ProvenanceStamp.copyDateTaken(sourceExif, preview)
ProvenanceStamp.write(preview, provenanceOf(asset, serverAlbumId))
ProvenanceStamp.write(preview, provenanceOf(asset, serverAlbumId, syncMode))
return try {
writeInPlace(uri, preview)
@@ -97,10 +109,15 @@ class OffloadManager(
}
}
private suspend fun offloadVideo(asset: DeviceAssetEntity, uri: Uri, serverAlbumId: String?): OffloadResult {
private suspend fun offloadVideo(
asset: DeviceAssetEntity,
uri: Uri,
serverAlbumId: String?,
syncMode: String?,
): OffloadResult {
val poster = PreviewGenerator.tempPreviewFile(context, asset.assetKey)
if (!PreviewGenerator.generate(context, uri, MediaKind.VIDEO, poster)) return OffloadResult.Failed
ProvenanceStamp.write(poster, provenanceOf(asset, serverAlbumId))
ProvenanceStamp.write(poster, provenanceOf(asset, serverAlbumId, syncMode))
val relativePath = relativePathOf(uri)
return try {
@@ -152,12 +169,18 @@ class OffloadManager(
?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
}.getOrNull().orEmpty().ifBlank { "DCIM/" }
private fun provenanceOf(asset: DeviceAssetEntity, serverAlbumId: String?) = Provenance(
private fun provenanceOf(
asset: DeviceAssetEntity,
serverAlbumId: String?,
syncMode: String?,
) = Provenance(
serverAlbumId = serverAlbumId,
serverPhotoId = asset.serverPhotoId,
originalHash = asset.contentHash,
deviceAlbumId = asset.deviceAlbumId,
mode = LocalAssetState.PREVIEW.name,
// The album's synchronization mode (not the asset state) — recovery reads
// this back to rebuild the link after a reinstall.
mode = syncMode ?: SyncMode.MOVE.name,
assetKey = asset.assetKey,
)
@@ -167,4 +190,8 @@ class OffloadManager(
private fun kindOf(mimeType: String?): MediaKind =
if (mimeType?.startsWith("video/") == true) MediaKind.VIDEO else MediaKind.IMAGE
private companion object {
const val TAG = "OffloadManager"
}
}
@@ -42,6 +42,12 @@ class RestoreManager(
/** Restoring must fit in the free space; otherwise it is deferred. */
fun canRestore(requiredBytes: Long, availableBytes: Long): Boolean =
availableBytes >= requiredBytes
/** Assets whose originals live on the server and should be brought back. */
fun restoreTargets(assets: List<DeviceAssetEntity>): List<DeviceAssetEntity> =
assets.filter {
it.serverPhotoId != null && it.state == LocalAssetState.PREVIEW.name
}
}
suspend fun restore(assets: List<DeviceAssetEntity>): RestoreReport = withContext(Dispatchers.IO) {
@@ -52,6 +58,7 @@ class RestoreManager(
for (asset in assets) {
val photoId = asset.serverPhotoId ?: continue
val data = runCatching { fetcher.fetch(photoId) }.getOrNull() ?: continue
if (data.isEmpty()) continue
if (!canRestore(data.size.toLong(), availableBytes())) {
return@withContext RestoreReport(restored, bytes, needsConsent, outOfSpace = true)
}
@@ -62,6 +69,8 @@ class RestoreManager(
bytes += data.size
} catch (e: SecurityException) {
needsConsent++
} catch (e: Exception) {
// Leave the asset as is and continue with the rest.
}
}
RestoreReport(restored, bytes, needsConsent, outOfSpace = false)
@@ -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.SyncMode
import com.galo.android.domain.sync.SyncPolicy
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Reconciles a device album with its server album. Mode A (DISABLED) is local
@@ -19,13 +21,16 @@ class SyncCoordinator(
private val offloader: AssetOffloader,
) {
/** Serializes reconciles so rapid triggers never double-upload or double-offload. */
private val mutex = Mutex()
/** @return true when the server was contacted, false for a local-only album. */
suspend fun reconcile(deviceAlbumId: String): Boolean {
val link = dao.getSyncedAlbum(deviceAlbumId) ?: return false
suspend fun reconcile(deviceAlbumId: String): Boolean = mutex.withLock {
val link = dao.getSyncedAlbum(deviceAlbumId) ?: return@withLock false
val mode = SyncMode.from(link.mode)
if (!SyncPolicy.contactsServer(mode)) return false
if (!SyncPolicy.contactsServer(mode)) return@withLock false
// A mode can be chosen before a server album; nothing to sync until then.
if (link.serverAlbumId.isBlank()) return false
if (link.serverAlbumId.isBlank()) return@withLock false
val serverChecksums = remote.serverChecksums(link.serverAlbumId)
val livePhotoIds = serverChecksums.values.toSet()
@@ -41,20 +46,40 @@ class SyncCoordinator(
// Mode C: assets already on the server (e.g. after switching from copies)
// are replaced with their offloaded versions, right away.
if (mode == SyncMode.MOVE) offloadUploaded(deviceAlbumId, assets)
return true
return@withLock true
}
/**
* Offloads assets that are already uploaded but still have their original
* locally. When the OS requires consent, the asset is marked as awaiting
* freeing so the album screen can request it.
* Reconciles every given device album, so a startup/background pass covers all
* galleries (including partially synced ones). @return true when any album
* contacted the server.
*/
suspend fun reconcileAll(deviceAlbumIds: Collection<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>) {
val localKeys = localAssets.mapTo(HashSet()) { it.assetKey }
for (entity in dao.getAssets(deviceAlbumId)) {
if (entity.serverPhotoId == null) continue
if (entity.assetKey !in localKeys) continue
if (entity.state != LocalAssetState.UPLOADED.name) continue
// Anything linked to a server photo that still has its original on the
// device (uploaded, awaiting free, or otherwise) is offloaded; only the
// already-offloaded preview/server-only states are skipped.
if (entity.state == LocalAssetState.PREVIEW.name ||
entity.state == LocalAssetState.SERVER_ONLY.name
) {
continue
}
val result = runCatching { offloader.offload(entity) }.getOrNull() ?: continue
when (result) {
is OffloadResult.Done, OffloadResult.Failed -> Unit
@@ -82,7 +107,11 @@ class SyncCoordinator(
record(deviceAlbumId, action.asset, action.photoId, mode)
is SyncAction.Upload ->
uploadQueue.enqueue(deviceAlbumId, serverAlbumId, action.asset, mode)
// Skip if an upload for this asset is already queued/running, so
// overlapping passes do not duplicate it.
if (!uploadQueue.isPending(action.asset.assetKey)) {
uploadQueue.enqueue(deviceAlbumId, serverAlbumId, action.asset, mode)
}
is SyncAction.RenameOnServer -> {
remote.renamePhoto(action.photoId, action.newName)
@@ -49,12 +49,17 @@ class SyncRecovery(
),
)
marker.serverAlbumId?.let { serverAlbumId ->
val existing = dao.getSyncedAlbum(deviceAlbumId)
val markerMode = marker.mode?.let { raw -> SyncMode.parseOrNull(raw)?.name }
dao.upsertSyncedAlbum(
SyncedAlbumEntity(
deviceAlbumId = deviceAlbumId,
deviceAlbumName = media.albumName,
serverAlbumId = serverAlbumId,
mode = marker.mode ?: SyncMode.MOVE.name,
deviceAlbumName = existing?.deviceAlbumName?.ifBlank { null } ?: media.albumName,
serverAlbumId = existing?.serverAlbumId?.takeIf { it.isNotBlank() } ?: serverAlbumId,
// Keep an existing link's mode; only fall back to the marker
// when creating a link from scratch. The marker must carry a
// real sync mode, never an asset state.
mode = existing?.mode ?: markerMode ?: SyncMode.MOVE.name,
updatedAt = System.currentTimeMillis(),
),
)
@@ -11,6 +11,9 @@ interface SyncUploadQueue {
asset: LocalAsset,
mode: SyncMode,
)
/** True when this asset already has a queued/running upload. */
suspend fun isPending(assetKey: String): Boolean
}
/** [SyncUploadQueue] backed by the existing [UploadRepository]/WorkManager queue. */
@@ -37,4 +40,6 @@ class RepositorySyncUploadQueue(
wifiOnly = wifiOnly(),
)
}
override suspend fun isPending(assetKey: String): Boolean = uploads.isPending(assetKey)
}
@@ -26,14 +26,27 @@ class SyncWorker(
override suspend fun doWork(): Result {
val app = applicationContext as? GaloApplication ?: return Result.failure()
val container = app.container
val links = runCatching { container.syncRepository.observeLinks().first() }
val sync = container.syncRepository
val links = runCatching { sync.observeLinks().first() }
.getOrElse { return Result.retry() }
val linkedIds = links.map { it.deviceAlbumId }
val indexedIds = runCatching { sync.indexedAlbumIds() }.getOrDefault(emptyList())
// Check and fix every gallery we know about (linked now or with a local
// index). recoverIndex rebuilds provenance from markers, covering a lost
// index and partial syncs.
val toCheck = (linkedIds + indexedIds).toSet()
var failed = false
for (link in links) {
runCatching { container.syncCoordinator.reconcile(link.deviceAlbumId) }
for (id in toCheck) {
runCatching { container.syncCheck.checkGallery(id, recoverIndex = true) }
.onFailure { failed = true }
}
// Resume unfinished uploads so partially transferred galleries continue.
runCatching { container.uploadRepository.requeuePending() }
.onFailure { failed = true }
return if (failed) Result.retry() else Result.success()
}
}
@@ -106,6 +106,10 @@ class UploadRepository(
suspend fun getById(id: String): UploadJobEntity? = dao.getById(id)
/** True when a sync upload for this asset is already queued or running. */
suspend fun isPending(assetKey: String): Boolean =
dao.getPending().any { it.assetKey == assetKey }
suspend fun updateState(
id: String,
status: UploadStatus,
@@ -27,6 +27,7 @@ import com.galo.android.data.sync.DeviceMediaObserver
import com.galo.android.data.sync.OffloadManager
import com.galo.android.data.sync.RepositorySyncUploadQueue
import com.galo.android.data.sync.RestoreManager
import com.galo.android.data.sync.SyncCheck
import com.galo.android.data.sync.SyncCoordinator
import com.galo.android.data.sync.SyncRecovery
import com.galo.android.data.sync.SyncRemote
@@ -107,17 +108,24 @@ class AppContainer(context: Context) {
val syncRecovery = SyncRecovery(appContext, database.syncDao(), deviceSource)
/** Unified per-gallery check and fix (startup + album open). */
val syncCheck = SyncCheck(syncRepository, syncRecovery, restoreManager, syncCoordinator)
fun start() {
scope.launch {
settingsRepository.flow.collect { settings ->
val first = settingsState == null
settingsState = settings
runCatching { SyncScheduler.ensurePeriodic(appContext, settings.wifiOnlyUploads) }
// Run the full synchronization check once the base URL is known, so
// the startup pass can actually reach the server (all galleries,
// including partially synced ones).
if (first) {
runCatching { uploadRepository.requeuePending() }
runCatching { SyncScheduler.runNow(appContext) }
}
}
}
scope.launch {
runCatching { uploadRepository.requeuePending() }
}
runCatching { SyncScheduler.runNow(appContext) }
runCatching {
DeviceMediaObserver(appContext) { runCatching { SyncScheduler.runNow(appContext) } }.start()
}
@@ -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
/** An album of the device (a MediaStore bucket). */
/** A device album of the device (a MediaStore bucket). */
data class DeviceAlbum(
val id: String,
val name: String,
@@ -9,6 +9,13 @@ data class DeviceAlbum(
val relativePath: String,
)
/** App-only metadata for a device album: display name override and chosen cover. */
data class DeviceAlbumMeta(
val deviceAlbumId: String,
val displayName: String?,
val coverUri: String?,
)
/** A single media item of the device found in a [DeviceAlbum]. */
data class DeviceMedia(
val id: Long,
@@ -63,6 +63,8 @@ data class Photo(
val size: Long,
val takenAt: String?,
val caption: String,
val latitude: Double? = null,
val longitude: Double? = null,
val isPanorama: Boolean,
val thumbUrl: String?,
val mediumUrl: String?,
@@ -71,9 +73,19 @@ data class Photo(
val originalUrl: String?,
val downloadUrl: String?,
val createdAt: String?,
val tags: List<Tag> = emptyList(),
val exif: Map<String, String> = emptyMap(),
val uploaderName: String? = null,
val uploaderAvatarUrl: String? = null,
)
data class Page<T>(
val items: List<T>,
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 {
fun from(raw: String?): SyncMode =
entries.firstOrNull { it.name == raw } ?: DISABLED
/** Parses a stored mode, returning null for anything that is not a mode. */
fun parseOrNull(raw: String?): SyncMode? =
entries.firstOrNull { it.name == raw }
}
}
@@ -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.fillMaxSize
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
@@ -15,10 +26,13 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.galo.android.GaloApplication
import com.galo.android.data.prefs.SettingsState
import com.galo.android.domain.media.AllFilesAccess
import com.galo.android.ui.album.AlbumScreen
import com.galo.android.ui.album.AlbumSettingsScreen
import com.galo.android.ui.albums.AlbumsScreen
import com.galo.android.ui.auth.AuthScreen
import com.galo.android.ui.devicealbum.DeviceAlbumScreen
import com.galo.android.ui.photo.AttributeSettingsScreen
import com.galo.android.ui.profile.ProfileScreen
import com.galo.android.ui.setup.SetupScreen
import com.galo.android.ui.upload.UploadScreen
@@ -31,11 +45,15 @@ object Routes {
const val UPLOADS = "uploads"
const val PROFILE = "profile"
const val ALBUM = "album/{albumId}"
const val ALBUM_SETTINGS = "album/{albumId}/settings"
const val DEVICE_ALBUM = "deviceAlbum/{deviceAlbumId}"
const val VIEWER = "album/{albumId}/viewer?index={index}"
const val ATTRIBUTE_SETTINGS = "attributeSettings"
fun album(albumId: String) = "album/$albumId"
fun albumSettings(albumId: String) = "album/$albumId/settings"
fun deviceAlbum(deviceAlbumId: String) = "deviceAlbum/$deviceAlbumId"
fun viewer(albumId: String, index: Int) = "album/$albumId/viewer?index=$index"
@@ -57,6 +75,22 @@ fun GaloNavHost(app: GaloApplication) {
}
val navController = rememberNavController()
// On launch (once the app is operational) ask for the "all files" access that
// makes moving originals to the server work without per-item prompts.
val context = LocalContext.current
var allFilesPrompt by remember { mutableStateOf(false) }
val allFilesLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) {
allFilesPrompt = !AllFilesAccess.isGranted()
}
val operational = current.baseUrl.isNotBlank() &&
!container.tokenStore.accessToken().isNullOrBlank()
LaunchedEffect(operational) {
if (operational && !AllFilesAccess.isGranted()) allFilesPrompt = true
}
val start = when {
current.baseUrl.isBlank() -> Routes.SETUP
container.tokenStore.accessToken().isNullOrBlank() -> Routes.AUTH
@@ -114,6 +148,12 @@ fun GaloNavHost(app: GaloApplication) {
onBack = { navController.popBackStack() },
)
}
composable(Routes.ATTRIBUTE_SETTINGS) {
AttributeSettingsScreen(
app = app,
onBack = { navController.popBackStack() },
)
}
composable(
route = Routes.ALBUM,
arguments = listOf(navArgument("albumId") { type = NavType.StringType }),
@@ -125,6 +165,18 @@ fun GaloNavHost(app: GaloApplication) {
onOpenViewer = { index ->
navController.navigate(Routes.viewer(albumId, index))
},
onOpenSettings = { navController.navigate(Routes.albumSettings(albumId)) },
onBack = { navController.popBackStack() },
)
}
composable(
route = Routes.ALBUM_SETTINGS,
arguments = listOf(navArgument("albumId") { type = NavType.StringType }),
) { entry ->
val albumId = entry.arguments?.getString("albumId").orEmpty()
AlbumSettingsScreen(
app = app,
albumId = albumId,
onBack = { navController.popBackStack() },
)
}
@@ -136,6 +188,7 @@ fun GaloNavHost(app: GaloApplication) {
DeviceAlbumScreen(
app = app,
deviceAlbumId = deviceAlbumId,
onOpenServerSettings = { id -> navController.navigate(Routes.albumSettings(id)) },
onBack = { navController.popBackStack() },
)
}
@@ -156,7 +209,43 @@ fun GaloNavHost(app: GaloApplication) {
albumId = albumId,
initialIndex = index,
onBack = { navController.popBackStack() },
onOpenAttributeSettings = { navController.navigate(Routes.ATTRIBUTE_SETTINGS) },
)
}
}
if (allFilesPrompt) {
AllFilesAccessDialog(
onEnable = {
runCatching { allFilesLauncher.launch(AllFilesAccess.settingsIntent(context)) }
},
onDismiss = { allFilesPrompt = false },
)
}
}
const val TAG_ALL_FILES_PROMPT_DIALOG = "all_files_prompt_dialog"
const val TAG_ALL_FILES_PROMPT_ENABLE = "all_files_prompt_enable"
/** Startup prompt to grant the "all files" access needed to free space on move. */
@Composable
fun AllFilesAccessDialog(onEnable: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.testTag(TAG_ALL_FILES_PROMPT_DIALOG),
title = { Text("Доступ к файлам") },
text = {
Text(
"Чтобы переносить фото на сервер и освобождать место на телефоне, " +
"разрешите galo доступ «Все файлы».",
)
},
confirmButton = {
TextButton(
onClick = onEnable,
modifier = Modifier.testTag(TAG_ALL_FILES_PROMPT_ENABLE),
) { Text("Включить") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Позже") } },
)
}
@@ -8,28 +8,36 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -40,13 +48,24 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.galo.android.GaloApplication
import com.galo.android.domain.attributes.AlbumAttributeSource
import com.galo.android.domain.attributes.AlbumAttributesResolver
import com.galo.android.domain.model.MediaKind
import com.galo.android.domain.model.Photo
import com.galo.android.ui.common.GaloAsyncImage
import com.galo.android.ui.panel.AlbumAttributesPanel
import com.galo.android.ui.panel.EndDrawerScaffold
const val TAG_ALBUM_GRID = "album_grid"
const val TAG_ALBUM_OFFLINE = "album_offline"
const val TAG_PHOTO_PREFIX = "photo_"
const val TAG_ALBUM_RENAME_DIALOG = "album_rename_dialog"
const val TAG_ALBUM_RENAME_FIELD = "album_rename_field"
const val TAG_ALBUM_RENAME_CONFIRM = "album_rename_confirm"
const val TAG_ALBUM_DELETE_DIALOG = "album_delete_dialog"
const val TAG_ALBUM_DELETE_CONFIRM = "album_delete_confirm"
const val TAG_COVER_PICKER = "cover_picker"
const val TAG_COVER_OPTION_PREFIX = "cover_option_"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -54,6 +73,7 @@ fun AlbumScreen(
app: GaloApplication,
albumId: String,
onOpenViewer: (Int) -> Unit,
onOpenSettings: () -> Unit,
onBack: () -> Unit,
) {
val factory = viewModelFactory {
@@ -62,6 +82,15 @@ fun AlbumScreen(
val viewModel: AlbumViewModel = viewModel(factory = factory)
val state by viewModel.state.collectAsStateWithLifecycle()
var panelOpen by remember { mutableStateOf(false) }
var renameOpen by remember { mutableStateOf(false) }
var deleteOpen by remember { mutableStateOf(false) }
var coverPickOpen by remember { mutableStateOf(false) }
val attributes = state.album?.let {
AlbumAttributesResolver.resolve(AlbumAttributeSource.Server(it, state.role))
}
Scaffold(
topBar = {
TopAppBar(
@@ -79,47 +108,67 @@ fun AlbumScreen(
)
},
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
if (state.offline) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth().testTag(TAG_ALBUM_OFFLINE),
) {
Text(
text = state.error ?: "Офлайн-режим",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
EndDrawerScaffold(
open = panelOpen,
onOpenChange = { panelOpen = it },
modifier = Modifier.padding(padding),
drawerContent = {
if (attributes != null) {
AlbumAttributesPanel(
attributes = attributes,
onPickCover = { coverPickOpen = true },
onResetCover = viewModel::resetCover,
onOpenMembers = onOpenSettings,
onOpenSettings = onOpenSettings,
onOpenShare = onOpenSettings,
onRename = { renameOpen = true },
onDelete = { deleteOpen = true },
)
}
}
when {
state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
state.photos.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("В альбоме пока нет медиа")
}
else -> LazyVerticalGrid(
columns = GridCells.Fixed(3),
contentPadding = PaddingValues(2.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.fillMaxSize().testTag(TAG_ALBUM_GRID),
) {
itemsIndexed(state.photos, key = { _, p -> p.id }) { index, photo ->
PhotoCell(photo = photo, onClick = { onOpenViewer(index) })
},
) {
Column(modifier = Modifier.fillMaxSize()) {
if (state.offline) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth().testTag(TAG_ALBUM_OFFLINE),
) {
Text(
text = state.error ?: "Офлайн-режим",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
if (!state.endReached) {
item(span = { GridItemSpan(maxLineSpan) }) {
LaunchedEffect(state.photos.size) { viewModel.loadNextPage() }
Box(
Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center,
) {
if (state.loadingMore) CircularProgressIndicator(Modifier.size(24.dp))
}
when {
state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
state.photos.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("В альбоме пока нет медиа")
}
else -> LazyVerticalGrid(
columns = GridCells.Fixed(3),
contentPadding = PaddingValues(2.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.fillMaxSize().testTag(TAG_ALBUM_GRID),
) {
itemsIndexed(state.photos, key = { _, p -> p.id }) { index, photo ->
PhotoCell(photo = photo, onClick = { onOpenViewer(index) })
}
if (!state.endReached) {
item(span = { GridItemSpan(maxLineSpan) }) {
LaunchedEffect(state.photos.size) { viewModel.loadNextPage() }
Box(
Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center,
) {
if (state.loadingMore) CircularProgressIndicator(Modifier.size(24.dp))
}
}
}
}
@@ -127,6 +176,129 @@ fun AlbumScreen(
}
}
}
if (renameOpen) {
RenameAlbumDialog(
initial = state.album?.title.orEmpty(),
onConfirm = {
viewModel.rename(it)
renameOpen = false
},
onDismiss = { renameOpen = false },
)
}
if (deleteOpen) {
DeleteAlbumDialog(
title = state.album?.title.orEmpty(),
onConfirm = {
deleteOpen = false
viewModel.delete(onDeleted = onBack)
},
onDismiss = { deleteOpen = false },
)
}
if (coverPickOpen) {
CoverPickerDialog(
photos = state.photos,
onPick = {
viewModel.setCover(it)
coverPickOpen = false
},
onDismiss = { coverPickOpen = false },
)
}
}
@Composable
fun RenameAlbumDialog(
initial: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var text by remember { mutableStateOf(initial) }
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.testTag(TAG_ALBUM_RENAME_DIALOG),
title = { Text("Переименовать альбом") },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
label = { Text("Название") },
modifier = Modifier.fillMaxWidth().testTag(TAG_ALBUM_RENAME_FIELD),
)
},
confirmButton = {
TextButton(
onClick = { onConfirm(text) },
enabled = text.isNotBlank(),
modifier = Modifier.testTag(TAG_ALBUM_RENAME_CONFIRM),
) { Text("Сохранить") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } },
)
}
@Composable
fun DeleteAlbumDialog(
title: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.testTag(TAG_ALBUM_DELETE_DIALOG),
title = { Text("Удалить альбом?") },
text = { Text("«$title» и его фото будут перемещены в корзину.") },
confirmButton = {
TextButton(
onClick = onConfirm,
modifier = Modifier.testTag(TAG_ALBUM_DELETE_CONFIRM),
) { Text("Удалить") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } },
)
}
@Composable
fun CoverPickerDialog(
photos: List<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
@@ -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(
val album: Album? = null,
val role: String = "",
val photos: List<Photo> = emptyList(),
val loading: Boolean = true,
val loadingMore: Boolean = false,
@@ -40,6 +41,7 @@ class AlbumViewModel(
)
private val status = MutableStateFlow(Status())
private val role = MutableStateFlow("")
/** Watermark for `?since=` backfill; advanced as events are applied. */
private var lastSync: String = Instant.now().toString()
@@ -48,9 +50,11 @@ class AlbumViewModel(
container.albumRepository.observeAlbum(albumId),
container.photoRepository.observePhotos(albumId),
status,
) { album, photos, s ->
role,
) { album, photos, s, r ->
UiState(
album = album,
role = r,
photos = photos,
loading = !s.loaded,
loadingMore = s.loadingMore,
@@ -61,11 +65,63 @@ class AlbumViewModel(
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState())
init {
viewModelScope.launch { container.albumRepository.loadAlbum(albumId) }
viewModelScope.launch { loadAlbum() }
loadNextPage()
observeEvents()
}
private suspend fun loadAlbum() {
container.albumRepository.loadAlbum(albumId)
runCatching { container.api.albumEnvelope(albumId).role }
.getOrNull()
?.let { role.value = it }
}
/** Renames the album (title). */
fun rename(title: String) {
val trimmed = title.trim()
if (trimmed.isEmpty()) return
viewModelScope.launch {
runCatching {
container.api.updateAlbum(albumId, title = trimmed)
container.albumRepository.loadAlbum(albumId)
}.onFailure { e ->
status.update { it.copy(error = (e as? Exception)?.message ?: "Ошибка переименования") }
}
}
}
/** Sets a photo as the album cover. */
fun setCover(photoId: String) {
viewModelScope.launch {
runCatching {
container.api.updateAlbum(albumId, coverPhotoId = photoId)
container.albumRepository.loadAlbum(albumId)
}
}
}
/** Clears the album cover. */
fun resetCover() {
viewModelScope.launch {
runCatching {
container.api.updateAlbum(albumId, clearCover = true)
container.albumRepository.loadAlbum(albumId)
}
}
}
/** Deletes the album; [onDeleted] runs after the server confirms. */
fun delete(onDeleted: () -> Unit) {
viewModelScope.launch {
runCatching { container.api.deleteAlbum(albumId) }
.onSuccess { onDeleted() }
.onFailure { e ->
status.update { it.copy(error = (e as? Exception)?.message ?: "Ошибка удаления") }
}
}
}
/** Live updates while the screen is alive; reconnect backfills missed changes. */
private fun observeEvents() {
viewModelScope.launch {
@@ -120,7 +176,7 @@ class AlbumViewModel(
fun refresh() {
status.update { it.copy(loaded = false, endReached = false, nextCursor = null) }
viewModelScope.launch { container.albumRepository.loadAlbum(albumId) }
viewModelScope.launch { loadAlbum() }
loadNextPage()
}
}
@@ -10,13 +10,16 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.SdCard
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -50,9 +53,9 @@ const val TAG_ALBUMS_OFFLINE = "albums_offline"
const val TAG_ALBUMS_AVATAR = "albums_avatar"
const val TAG_ALBUM_CARD_PREFIX = "album_card_"
const val TAG_DEVICE_ALBUM_CARD_PREFIX = "device_album_card_"
const val TAG_ALBUM_SOURCE_PREFIX = "album_source_"
const val TAG_ALBUM_LINK_PREFIX = "album_link_"
const val TAG_ALBUM_MODE_PREFIX = "album_mode_"
const val TAG_ALBUM_STORAGE_DEVICE_PREFIX = "album_storage_device_"
const val TAG_ALBUM_STORAGE_CLOUD_PREFIX = "album_storage_cloud_"
const val TAG_ALBUM_COUNT_PREFIX = "album_count_"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -166,68 +169,71 @@ fun AlbumsContent(
}
}
/** Where a device album's media live. */
private enum class StoragePlacement { DEVICE, DEVICE_AND_CLOUD, CLOUD }
@Composable
private fun AlbumCardBody(
id: String,
title: String,
counter: String,
source: String,
link: String?,
key: String,
mode: String? = null,
placement: StoragePlacement,
count: String,
arrowTo: String?,
) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = counter,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.small,
modifier = Modifier.testTag(TAG_ALBUM_SOURCE_PREFIX + key),
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (arrowTo != null) {
Text(
text = source,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
if (mode != null) {
Text(
text = mode,
style = MaterialTheme.typography.labelSmall,
text = "→ $arrowTo",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(start = 6.dp)
.testTag(TAG_ALBUM_MODE_PREFIX + key),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 6.dp),
)
}
}
if (link != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (placement != StoragePlacement.CLOUD) {
Icon(
imageVector = Icons.Filled.SdCard,
contentDescription = "Карта памяти",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp).testTag(TAG_ALBUM_STORAGE_DEVICE_PREFIX + id),
)
}
if (placement != StoragePlacement.DEVICE) {
Icon(
imageVector = Icons.Filled.Cloud,
contentDescription = "Облако",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(start = if (placement == StoragePlacement.CLOUD) 0.dp else 4.dp)
.size(16.dp)
.testTag(TAG_ALBUM_STORAGE_CLOUD_PREFIX + id),
)
}
Text(
text = link,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
text = count,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(top = 2.dp)
.testTag(TAG_ALBUM_LINK_PREFIX + key),
modifier = Modifier.padding(start = 6.dp).testTag(TAG_ALBUM_COUNT_PREFIX + id),
)
}
}
}
@Composable
private fun ServerAlbumCard(item: AlbumListItem.ServerAlbum, onClick: () -> Unit) { val album = item.album
private fun ServerAlbumCard(item: AlbumListItem.ServerAlbum, onClick: () -> Unit) {
val album = item.album
val key = TAG_ALBUM_CARD_PREFIX + album.id
Card(
modifier = Modifier
@@ -245,12 +251,11 @@ private fun ServerAlbumCard(item: AlbumListItem.ServerAlbum, onClick: () -> Unit
.clip(MaterialTheme.shapes.medium),
)
AlbumCardBody(
id = album.id,
title = album.title,
counter = "${album.photosCount} фото · ${album.membersCount} участн.",
source = "Сервер",
link = item.linkedDeviceNames.takeIf { it.isNotEmpty() }
?.joinToString(prefix = "источник: "),
key = key,
placement = StoragePlacement.CLOUD,
count = "${album.photosCount} фото",
arrowTo = null,
)
}
}
@@ -260,6 +265,7 @@ private fun ServerAlbumCard(item: AlbumListItem.ServerAlbum, onClick: () -> Unit
private fun DeviceAlbumCard(item: AlbumListItem.DeviceAlbumItem, onClick: () -> Unit) {
val album: DeviceAlbum = item.album
val key = TAG_DEVICE_ALBUM_CARD_PREFIX + album.id
val arrowTo = item.linkedServerTitle?.takeIf { it != album.name }
Card(
modifier = Modifier
.fillMaxWidth()
@@ -276,19 +282,19 @@ private fun DeviceAlbumCard(item: AlbumListItem.DeviceAlbumItem, onClick: () ->
.clip(MaterialTheme.shapes.medium),
)
AlbumCardBody(
id = album.id,
title = album.name,
counter = "${album.itemCount} эл.",
source = "Телефон",
link = item.linkedServerTitle?.let { "→ $it" },
key = key,
mode = albumModeLabel(item.mode),
placement = placementOf(item.mode),
count = "${album.itemCount} эл.",
arrowTo = arrowTo,
)
}
}
}
private fun albumModeLabel(mode: SyncMode): String = when (mode) {
SyncMode.DISABLED -> "синхр. выкл."
SyncMode.COPY -> "копии"
SyncMode.MOVE -> "перенос"
/** Storage icon placement for a device album: where its media live. */
private fun placementOf(mode: SyncMode): StoragePlacement = when (mode) {
SyncMode.DISABLED -> StoragePlacement.DEVICE
SyncMode.COPY -> StoragePlacement.DEVICE_AND_CLOUD
SyncMode.MOVE -> StoragePlacement.CLOUD
}
@@ -41,21 +41,31 @@ class AlbumsViewModel(private val container: AppContainer) : ViewModel() {
val state: StateFlow<UiState> = combine(
container.albumRepository.observeAlbums(),
container.syncRepository.observeLinks(),
combine(deviceAlbums, deviceAccess) { devices, access -> devices to access },
combine(deviceAlbums, deviceAccess, container.syncRepository.observeAlbumMeta()) { devices, access, meta ->
Triple(devices, access, meta)
},
status,
avatar,
) { albums, links, device, s, a ->
val withMeta = device.first.map { d ->
val m = device.third[d.id]
d.copy(
name = m?.displayName?.takeIf { it.isNotBlank() } ?: d.name,
coverUri = m?.coverUri ?: d.coverUri,
)
}
val items = AlbumListBuilder.build(
albums = albums,
links = links.map { link ->
AlbumLink(
deviceAlbumId = link.deviceAlbumId,
deviceAlbumName = link.deviceAlbumName,
deviceAlbumName = device.third[link.deviceAlbumId]?.displayName
?.takeIf { it.isNotBlank() } ?: link.deviceAlbumName,
serverAlbumId = link.serverAlbumId,
mode = SyncMode.from(link.mode),
)
},
devices = device.first,
devices = withMeta,
)
UiState(
@@ -1,5 +1,7 @@
package com.galo.android.ui.devicealbum
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
@@ -64,12 +66,26 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.galo.android.GaloApplication
import com.galo.android.domain.attributes.AlbumAttributeSource
import com.galo.android.domain.attributes.AlbumAttributesResolver
import com.galo.android.domain.attributes.PhotoAttributeResolver
import com.galo.android.domain.attributes.PhotoAttributeSource
import com.galo.android.domain.exif.EXIF_BASE
import com.galo.android.domain.media.AllFilesAccess
import com.galo.android.domain.model.Album
import com.galo.android.domain.model.DeviceAlbum
import com.galo.android.domain.model.DeviceMedia
import com.galo.android.domain.model.MediaKind
import com.galo.android.domain.model.Photo
import com.galo.android.domain.model.SyncMode
import com.galo.android.ui.album.RenameAlbumDialog
import com.galo.android.ui.common.GaloAsyncImage
import com.galo.android.ui.panel.AlbumAttributesPanel
import com.galo.android.ui.panel.EndDrawerScaffold
import com.galo.android.ui.photo.DeletePhotoDialog
import com.galo.android.ui.photo.PhotoAttributesSheet
import com.galo.android.ui.photo.localPhotoSheetModel
import com.galo.android.ui.photo.serverPhotoSheetModel
import kotlinx.coroutines.launch
const val TAG_DEVICE_GRID = "device_album_grid"
@@ -103,6 +119,7 @@ const val TAG_CREATE_TARGET = "device_create_target"
fun DeviceAlbumScreen(
app: GaloApplication,
deviceAlbumId: String,
onOpenServerSettings: (String) -> Unit = {},
onBack: () -> Unit,
) {
val factory = viewModelFactory {
@@ -110,7 +127,14 @@ fun DeviceAlbumScreen(
}
val viewModel: DeviceAlbumViewModel = viewModel(factory = factory)
val state by viewModel.state.collectAsStateWithLifecycle()
val role by viewModel.role.collectAsStateWithLifecycle()
val prefs by viewModel.preferences.collectAsStateWithLifecycle()
val meta by viewModel.albumMeta.collectAsStateWithLifecycle()
var viewerIndex by remember { mutableStateOf<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
var allFilesGranted by remember { mutableStateOf(AllFilesAccess.isGranted()) }
@@ -119,6 +143,11 @@ fun DeviceAlbumScreen(
) {
allFilesGranted = AllFilesAccess.isGranted()
}
val deleteLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartIntentSenderForResult(),
) {
viewModel.refreshAfterDelete()
}
LifecycleResumeEffect(Unit) {
allFilesGranted = AllFilesAccess.isGranted()
onPauseOrDispose { }
@@ -130,23 +159,97 @@ fun DeviceAlbumScreen(
items = state.items,
initialIndex = openIndex,
onBack = { viewerIndex = null },
serverPhotoByMediaId = state.serverPhotoByMediaId,
sheet = { media ->
when (val source = PhotoAttributeResolver.resolve(media, state.serverPhotoByMediaId[media.id])) {
is PhotoAttributeSource.Server -> {
var photo by remember(source.photoId) { mutableStateOf<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) },
)
} else {
DeviceAlbumContent(
state = state,
role = role,
localName = localName,
localCoverUrl = localCoverUrl,
onBack = onBack,
onMode = viewModel::setMode,
onTarget = viewModel::setTarget,
onRestoreOriginals = viewModel::restoreOriginals,
onOpen = { index -> viewerIndex = index },
onCreateAlbum = viewModel::createAlbum,
onRenameAlbum = viewModel::renameAlbum,
onSetCover = viewModel::setCover,
onResetCover = { viewModel.setCover(null) },
onDeleteAlbum = {
viewModel.deleteAlbum { sender ->
deleteLauncher.launch(IntentSenderRequest.Builder(sender).build())
}
},
onOpenServerSettings = { state.serverAlbumId?.let(onOpenServerSettings) },
allFilesAccessGranted = allFilesGranted,
onRequestAllFilesAccess = {
runCatching { allFilesLauncher.launch(AllFilesAccess.settingsIntent(context)) }
},
)
}
deletePhotoId?.let { id ->
DeletePhotoDialog(
title = state.items.firstOrNull { state.serverPhotoByMediaId[it.id] == id }?.displayName ?: "",
onConfirm = {
viewModel.deleteServerPhoto(id)
deletePhotoId = null
},
onDismiss = { deletePhotoId = null },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -159,6 +262,14 @@ fun DeviceAlbumContent(
onRestoreOriginals: () -> Unit,
onOpen: (Int) -> Unit,
onCreateAlbum: (String) -> Unit,
role: String = "",
localName: String? = null,
localCoverUrl: String? = null,
onRenameAlbum: (String) -> Unit = {},
onSetCover: (String?) -> Unit = {},
onResetCover: () -> Unit = {},
onDeleteAlbum: () -> Unit = {},
onOpenServerSettings: () -> Unit = {},
allFilesAccessGranted: Boolean = true,
onRequestAllFilesAccess: () -> Unit = {},
) {
@@ -245,6 +356,28 @@ fun DeviceAlbumContent(
)
}
val device = DeviceAlbum(
id = state.name,
name = state.name.ifBlank { "Альбом телефона" },
itemCount = state.items.size,
coverUri = state.items.firstOrNull()?.uri,
relativePath = "",
)
val linkedServer = state.serverAlbums.firstOrNull { it.id == state.serverAlbumId }
val attributes = AlbumAttributesResolver.resolve(
AlbumAttributeSource.Local(
device = device,
title = localName?.takeIf { it.isNotBlank() } ?: device.name,
coverUrl = localCoverUrl ?: device.coverUri,
linkedServer = linkedServer,
role = role,
),
)
var panelOpen by remember { mutableStateOf(false) }
var renameOpen by remember { mutableStateOf(false) }
var deleteOpen by remember { mutableStateOf(false) }
var coverPickOpen by remember { mutableStateOf(false) }
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
@@ -273,7 +406,24 @@ fun DeviceAlbumContent(
)
},
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
EndDrawerScaffold(
open = panelOpen,
onOpenChange = { panelOpen = it },
modifier = Modifier.padding(padding),
drawerContent = {
AlbumAttributesPanel(
attributes = attributes,
onPickCover = { coverPickOpen = true },
onResetCover = onResetCover,
onOpenMembers = onOpenServerSettings,
onOpenSettings = { scope.launch { drawerState.open() } },
onOpenShare = onOpenServerSettings,
onRename = { renameOpen = true },
onDelete = { deleteOpen = true },
)
},
) {
Column(modifier = Modifier.fillMaxSize()) {
if (state.items.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("В альбоме нет медиа")
@@ -296,8 +446,112 @@ fun DeviceAlbumContent(
}
}
}
}
}
}
if (renameOpen) {
RenameAlbumDialog(
initial = attributes.title,
onConfirm = {
onRenameAlbum(it)
renameOpen = false
},
onDismiss = { renameOpen = false },
)
}
if (deleteOpen) {
DeleteLocalAlbumDialog(
title = attributes.title,
itemCount = state.items.size,
onConfirm = {
deleteOpen = false
onDeleteAlbum()
},
onDismiss = { deleteOpen = false },
)
}
if (coverPickOpen) {
LocalCoverPickerDialog(
items = state.items,
onPick = {
onSetCover(it)
coverPickOpen = false
},
onDismiss = { coverPickOpen = false },
)
}
}
const val TAG_LOCAL_DELETE_DIALOG = "local_delete_dialog"
const val TAG_LOCAL_DELETE_CONFIRM = "local_delete_confirm"
const val TAG_LOCAL_COVER_PICKER = "local_cover_picker"
const val TAG_LOCAL_COVER_OPTION_PREFIX = "local_cover_option_"
/** Confirms deleting a device album's photos from the phone itself. */
@Composable
fun DeleteLocalAlbumDialog(
title: String,
itemCount: Int,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.testTag(TAG_LOCAL_DELETE_DIALOG),
title = { Text("Удалить альбом?") },
text = {
Text("«$title» и $itemCount фото будут удалены с телефона. Действие необратимо.")
},
confirmButton = {
TextButton(
onClick = onConfirm,
modifier = Modifier.testTag(TAG_LOCAL_DELETE_CONFIRM),
) { Text("Удалить с телефона", color = MaterialTheme.colorScheme.error) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } },
)
}
/** Picks a cover from the device album's own media. */
@Composable
fun LocalCoverPickerDialog(
items: List<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
@@ -6,15 +6,23 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.galo.android.di.AppContainer
import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.data.mapper.toDomain
import com.galo.android.data.network.dto.PhotoInfoPrefsDto
import com.galo.android.data.sync.RestoreManager
import com.galo.android.domain.model.Album
import com.galo.android.domain.model.DeviceAlbumMeta
import com.galo.android.domain.model.DeviceMedia
import com.galo.android.domain.model.LocalAssetState
import com.galo.android.domain.model.Photo
import com.galo.android.domain.model.SyncMode
import com.galo.android.data.sync.OffloadResult
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -44,6 +52,21 @@ class DeviceAlbumViewModel(
private val pendingMode = MutableStateFlow<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(
container.albumRepository.observeAlbums(),
container.syncRepository.observeLinks(),
@@ -88,23 +111,179 @@ class DeviceAlbumViewModel(
viewModelScope.launch {
runCatching { container.albumRepository.refresh() }
}
viewModelScope.launch { refreshRole() }
viewModelScope.launch {
runCatching { container.api.meEnvelope().preferences?.photoInfo }
.getOrNull()
?.let { _preferences.value = it }
}
// Check and fix this gallery on open (see [SyncCheck]): upload/offload for
// move, restore originals for copy/off when previews are left over.
viewModelScope.launch { container.syncCheck.checkGallery(deviceAlbumId) }
}
/** Loads the caller's role on the linked server album, for panel gating. */
private suspend fun refreshRole() {
val id = state.value.serverAlbumId
_role.value = if (id.isNullOrBlank()) {
""
} else {
runCatching { container.api.albumEnvelope(id).role }.getOrDefault("") ?: ""
}
}
/** Renames the linked server album. */
fun renameServerAlbum(title: String) { val id = state.value.serverAlbumId ?: return
val trimmed = title.trim()
if (trimmed.isEmpty()) return
viewModelScope.launch {
runCatching {
container.api.updateAlbum(id, title = trimmed)
container.albumRepository.loadAlbum(id)
}
}
}
/** Sets a linked-server-album photo as its cover. */
fun setServerCover(photoId: String) {
val id = state.value.serverAlbumId ?: return
viewModelScope.launch {
runCatching {
container.api.updateAlbum(id, coverPhotoId = photoId)
container.albumRepository.loadAlbum(id)
}
}
}
/** Clears the linked server album's cover. */
fun resetServerCover() {
val id = state.value.serverAlbumId ?: return
viewModelScope.launch {
runCatching {
container.api.updateAlbum(id, clearCover = true)
container.albumRepository.loadAlbum(id)
}
}
}
/** Deletes the linked server album; [onDeleted] runs after the server confirms. */
fun deleteServerAlbum(onDeleted: () -> Unit) {
val id = state.value.serverAlbumId ?: return
viewModelScope.launch {
runCatching { container.api.deleteAlbum(id) }
.onSuccess {
unlink()
onDeleted()
}
}
}
/** Snapshot of the linked server album's photos, for the cover picker. */
suspend fun serverAlbumPhotos(): List<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) {
pendingMode.value = mode
if (mode == SyncMode.DISABLED) {
// Turning sync off clears the link to the server album.
viewModelScope.launch { container.syncRepository.unlink(deviceAlbumId) }
return
}
viewModelScope.launch {
if (mode == SyncMode.DISABLED) {
// Turning sync off clears the link to the server album.
container.syncRepository.unlink(deviceAlbumId)
return@launch
}
// Persist the chosen mode even before a server album is picked, so the
// choice survives leaving and reopening the album.
val target = state.value.serverAlbumId
?: container.syncRepository.currentLink(deviceAlbumId)?.serverAlbumId
?: ""
container.syncRepository.link(
deviceAlbumId = deviceAlbumId,
deviceAlbumName = local.value.name,
serverAlbumId = state.value.serverAlbumId ?: "",
serverAlbumId = target,
mode = mode,
)
if (state.value.serverAlbumId != null) reconcileNow()
refreshRole()
// Nothing to sync until a server album is chosen.
if (target.isNotBlank()) reconcileNow()
}
}
@@ -116,6 +295,7 @@ class DeviceAlbumViewModel(
serverAlbumId = serverAlbumId,
mode = pendingMode.value ?: state.value.mode,
)
refreshRole()
reconcileNow()
}
}
@@ -133,6 +313,7 @@ class DeviceAlbumViewModel(
serverAlbumId = album.id,
mode = pendingMode.value ?: state.value.mode,
)
refreshRole()
reconcileNow()
}
}
@@ -170,9 +351,8 @@ class DeviceAlbumViewModel(
/** Returns originals to the device for every offloaded (preview-only) asset. */
fun restoreOriginals() {
viewModelScope.launch {
val assets = container.syncRepository.assets(deviceAlbumId)
.filter { it.state == LocalAssetState.PREVIEW.name }
container.restoreManager.restore(assets)
val targets = RestoreManager.restoreTargets(container.syncRepository.assets(deviceAlbumId))
if (targets.isNotEmpty()) container.restoreManager.restore(targets)
}
}
@@ -1,5 +1,7 @@
package com.galo.android.ui.devicealbum
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
@@ -10,18 +12,22 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.testTag
import com.galo.android.domain.model.DeviceMedia
import com.galo.android.domain.model.MediaKind
@@ -37,6 +43,8 @@ fun DeviceViewerScreen(
items: List<DeviceMedia>,
initialIndex: Int,
onBack: () -> Unit,
serverPhotoByMediaId: Map<Long, String> = emptyMap(),
sheet: (@Composable (DeviceMedia) -> Unit)? = null,
originalSourceProvider: suspend (Long) -> OriginalSource? = { null },
) {
if (items.isEmpty()) {
@@ -49,6 +57,20 @@ fun DeviceViewerScreen(
val current = items.getOrNull(pagerState.currentPage)
var zoomed by remember(current?.id) { mutableStateOf(false) }
var sheetOpen by remember(current?.id) { mutableStateOf(false) }
var dragUp by remember(current?.id) { mutableFloatStateOf(0f) }
val sheetGesture = if (sheet != null) {
Modifier.pointerInput(current?.id, zoomed) {
val canOpen = current != null && !zoomed && current.kind != MediaKind.VIDEO
detectVerticalDragGestures(
onDragStart = { dragUp = 0f },
onDragEnd = { if (canOpen && dragUp < -80f) sheetOpen = true },
) { _, dragAmount -> dragUp += dragAmount }
}
} else {
Modifier
}
Scaffold(
topBar = {
TopAppBar(
@@ -64,6 +86,7 @@ fun DeviceViewerScreen(
},
) { padding ->
Surface(color = Color.Black, modifier = Modifier.fillMaxSize().padding(padding)) {
Box(Modifier.fillMaxSize().then(sheetGesture)) {
HorizontalPager(
state = pagerState,
userScrollEnabled = !zoomed,
@@ -94,6 +117,16 @@ fun DeviceViewerScreen(
)
}
}
}
}
}
if (sheetOpen && sheet != null && current != null) {
ModalBottomSheet(
onDismissRequest = { sheetOpen = false },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
sheet(current)
}
}
}
@@ -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
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@@ -12,26 +15,38 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.galo.android.GaloApplication
import com.galo.android.domain.exif.EXIF_BASE
import com.galo.android.domain.model.MediaKind
import com.galo.android.domain.model.Photo
import com.galo.android.ui.photo.AlbumPickerDialog
import com.galo.android.ui.photo.DeletePhotoDialog
import com.galo.android.ui.photo.PhotoAttributesSheet
import com.galo.android.ui.photo.RenamePhotoDialog
import com.galo.android.ui.photo.serverPhotoSheetModel
const val TAG_VIEWER_PAGER = "viewer_pager"
@@ -42,13 +57,92 @@ fun ViewerScreen(
albumId: String,
initialIndex: Int,
onBack: () -> Unit,
onOpenAttributeSettings: () -> Unit = {},
) {
val factory = viewModelFactory {
initializer { ViewerViewModel(app.container, albumId) }
}
val viewModel: ViewerViewModel = viewModel(factory = factory)
val photos by viewModel.photos.collectAsStateWithLifecycle()
ViewerContent(photos = photos, initialIndex = initialIndex, onBack = onBack)
val role by viewModel.role.collectAsStateWithLifecycle()
val prefs by viewModel.preferences.collectAsStateWithLifecycle()
val albums by viewModel.albums.collectAsStateWithLifecycle()
val context = LocalContext.current
var renameFor by remember { mutableStateOf<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)
@@ -57,6 +151,7 @@ fun ViewerContent(
photos: List<Photo>,
initialIndex: Int,
onBack: () -> Unit,
sheet: (@Composable (Photo) -> Unit)? = null,
) {
if (photos.isEmpty()) {
Scaffold(
@@ -90,6 +185,23 @@ fun ViewerContent(
var zoomed by remember(current?.id) { mutableStateOf(false) }
val pagerScrollEnabled = current?.isPanorama != true && !zoomed
// Swipe up opens the attributes/actions sheet, except while zoomed, on a
// panorama, or on video (where the gesture belongs to the media).
var sheetOpen by remember(current?.id) { mutableStateOf(false) }
var dragUp by remember(current?.id) { mutableFloatStateOf(0f) }
val sheetGesture = if (sheet != null) {
Modifier.pointerInput(current?.id, zoomed) {
val canOpen = current != null && !zoomed && !current.isPanorama &&
current.kind != MediaKind.VIDEO
detectVerticalDragGestures(
onDragStart = { dragUp = 0f },
onDragEnd = { if (canOpen && dragUp < -80f) sheetOpen = true },
) { _, dragAmount -> dragUp += dragAmount }
}
} else {
Modifier
}
Scaffold(
topBar = {
TopAppBar(
@@ -111,21 +223,32 @@ fun ViewerContent(
color = Color.Black,
modifier = Modifier.fillMaxSize().padding(padding),
) {
HorizontalPager(
state = pagerState,
userScrollEnabled = pagerScrollEnabled,
modifier = Modifier.fillMaxSize().testTag(TAG_VIEWER_PAGER),
key = { index -> photos.getOrNull(index)?.id ?: index },
) { page ->
val photo = photos.getOrNull(page) ?: return@HorizontalPager
MediaPage(
photo = photo,
active = pagerState.currentPage == page,
onZoomChanged = { zoomed = it },
)
Box(Modifier.fillMaxSize().then(sheetGesture)) {
HorizontalPager(
state = pagerState,
userScrollEnabled = pagerScrollEnabled,
modifier = Modifier.fillMaxSize().testTag(TAG_VIEWER_PAGER),
key = { index -> photos.getOrNull(index)?.id ?: index },
) { page ->
val photo = photos.getOrNull(page) ?: return@HorizontalPager
MediaPage(
photo = photo,
active = pagerState.currentPage == page,
onZoomChanged = { zoomed = it },
)
}
}
}
}
if (sheetOpen && sheet != null && current != null) {
ModalBottomSheet(
onDismissRequest = { sheetOpen = false },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
sheet(current)
}
}
}
@Composable
@@ -2,10 +2,15 @@ package com.galo.android.ui.viewer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.galo.android.data.network.dto.PhotoInfoPrefsDto
import com.galo.android.di.AppContainer
import com.galo.android.domain.model.Album
import com.galo.android.domain.model.Photo
import com.galo.android.domain.model.Tag
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -17,6 +22,15 @@ class ViewerViewModel(
val photos: StateFlow<List<Photo>> = container.photoRepository.observePhotos(albumId)
.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 {
// Cold start (e.g. restored route): make sure the cache is populated.
viewModelScope.launch {
@@ -24,5 +38,77 @@ class ViewerViewModel(
container.photoRepository.loadNextPage(albumId, cursor = null)
}
}
viewModelScope.launch {
runCatching { container.api.albumEnvelope(albumId).role }
.getOrNull()
?.let { _role.value = it }
}
viewModelScope.launch {
runCatching { container.api.meEnvelope().preferences?.photoInfo }
.getOrNull()
?.let { _preferences.value = it }
}
}
suspend fun tagSuggestions(query: String): List<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
import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.domain.model.LocalAssetState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class RestoreManagerTest {
private fun asset(
key: String,
serverPhotoId: String? = "p1",
state: LocalAssetState = LocalAssetState.PREVIEW,
) = DeviceAssetEntity(
assetKey = key,
deviceAlbumId = "b1",
mediaStoreId = 1L,
uri = "content://media/external/images/media/1",
displayName = "a.jpg",
mimeType = "image/jpeg",
size = 1L,
dateModified = 1L,
contentHash = "h",
serverPhotoId = serverPhotoId,
uploadedAt = 1L,
state = state.name,
)
@Test
fun `restoring fits when there is enough space`() {
assertTrue(RestoreManager.canRestore(requiredBytes = 100, availableBytes = 100))
@@ -16,4 +38,16 @@ class RestoreManagerTest {
fun `restoring is deferred when space is short`() {
assertFalse(RestoreManager.canRestore(requiredBytes = 500, availableBytes = 100))
}
@Test
fun `restore targets are previews that have a server photo`() {
val preview = asset("b1/1")
val local = asset("b1/2", state = LocalAssetState.LOCAL)
val noServer = asset("b1/3", serverPhotoId = null)
val awaiting = asset("b1/4", state = LocalAssetState.AWAITING_FREE)
val targets = RestoreManager.restoreTargets(listOf(preview, local, noServer, awaiting))
assertEquals(listOf("b1/1"), targets.map { it.assetKey })
}
}
@@ -53,6 +53,7 @@ class SyncCoordinatorTest {
private class FakeQueue : SyncUploadQueue {
val enqueued = mutableListOf<Triple<String, String, LocalAsset>>()
val pending = mutableSetOf<String>()
override suspend fun enqueue(
deviceAlbumId: String,
serverAlbumId: String,
@@ -61,6 +62,8 @@ class SyncCoordinatorTest {
) {
enqueued += Triple(deviceAlbumId, serverAlbumId, asset)
}
override suspend fun isPending(assetKey: String): Boolean = assetKey in pending
}
private class FakeProvider : LocalAssetProvider {
@@ -267,9 +270,75 @@ class SyncCoordinatorTest {
assertEquals(LocalAssetState.AWAITING_FREE.name, db.syncDao().getAsset("b1/1")?.state)
}
@Test
fun `awaiting-free assets are retried on a later pass without a prompt`() = runBlocking {
link(SyncMode.MOVE)
remote.serverMap = mapOf("h" to "p1")
db.syncDao().upsertAssets(
listOf(
DeviceAssetEntity(
"b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L,
"h", "p1", 1L, LocalAssetState.AWAITING_FREE.name,
),
),
)
provider.items = listOf(asset("b1/1", hash = "h"))
offloader.result = OffloadResult.Done
coordinator.reconcile("b1")
assertEquals(listOf("b1/1"), offloader.offloaded)
}
@Test
fun `a linked asset still on the device is offloaded in move mode`() = runBlocking {
link(SyncMode.MOVE)
remote.serverMap = mapOf("h" to "p1")
db.syncDao().upsertAssets(
listOf(
DeviceAssetEntity(
"b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L,
"h", "p1", 1L, LocalAssetState.LOCAL.name,
),
),
)
provider.items = listOf(asset("b1/1", hash = "h"))
offloader.result = OffloadResult.Done
coordinator.reconcile("b1")
assertEquals(listOf("b1/1"), offloader.offloaded)
}
@Test
fun `move delegates to the server`() = runBlocking {
coordinator.moveToServerAlbum(listOf("p1", "p2"), "srv2")
assertEquals(listOf(listOf("p1", "p2") to "srv2"), remote.moves)
}
@Test
fun `reconcileAll reconciles every listed gallery including partial ones`() = runBlocking {
db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity("b1", "Camera", "srv1", SyncMode.COPY.name, 1L))
db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity("b2", "Trips", "srv2", SyncMode.COPY.name, 1L))
provider.items = listOf(asset("x/1", hash = "h2"))
val contacted = coordinator.reconcileAll(listOf("b1", "b2", "missing"))
assertTrue(contacted)
assertEquals(setOf("b1", "b2"), queue.enqueued.map { it.first }.toSet())
}
@Test
fun `indexed album ids list galleries with local provenance`() = runBlocking {
db.syncDao().upsertAssets(
listOf(
DeviceAssetEntity(
"b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L,
"h", "p1", 1L, LocalAssetState.UPLOADED.name,
),
),
)
assertEquals(listOf("b1"), db.syncDao().indexedAlbumIds())
}
}
@@ -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_ALBUM_CARD_PREFIX + "a1").assertIsDisplayed()
compose.onNodeWithText("Summer").assertIsDisplayed()
compose.onNodeWithText("4 фото · 2 участн.").assertIsDisplayed()
compose.onNodeWithText("4 фото").assertIsDisplayed()
compose.onNodeWithTag(TAG_ALBUM_STORAGE_CLOUD_PREFIX + "a1", useUnmergedTree = true).assertExists()
compose.onNodeWithTag(TAG_ALBUM_STORAGE_DEVICE_PREFIX + "a1", useUnmergedTree = true).assertDoesNotExist()
}
@Test
fun `mixed list shows device album with source and link`() {
fun `mixed list shows device album with storage and link`() {
val items = AlbumListBuilder.build(
albums = listOf(album("a1", "Отпуск")),
links = listOf(AlbumLink("b1", "Camera", "a1", SyncMode.COPY)),
@@ -88,10 +90,27 @@ class AlbumsContentTest {
compose.onNodeWithTag(TAG_DEVICE_ALBUM_CARD_PREFIX + "b1").assertIsDisplayed()
// The linked server album is collapsed into the device card, not shown twice.
compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + "a1").assertDoesNotExist()
compose.onNodeWithText("Телефон").assertIsDisplayed()
compose.onNodeWithText("9 эл.").assertIsDisplayed()
compose.onNodeWithText("Camera").assertIsDisplayed()
compose.onNodeWithText("→ Отпуск").assertIsDisplayed()
compose.onNodeWithText("копии").assertIsDisplayed()
compose.onNodeWithText("9 эл.").assertIsDisplayed()
compose.onNodeWithTag(TAG_ALBUM_STORAGE_DEVICE_PREFIX + "b1", useUnmergedTree = true).assertExists()
compose.onNodeWithTag(TAG_ALBUM_STORAGE_CLOUD_PREFIX + "b1", useUnmergedTree = true).assertExists()
}
@Test
fun `device album caption uses storage icons and hides matching server name`() {
val items = AlbumListBuilder.build(
albums = listOf(album("a1", "Отпуск")),
links = listOf(AlbumLink("b1", "Отпуск", "a1", SyncMode.DISABLED)),
devices = listOf(device("b1", "Отпуск", 5)),
)
compose.setContent {
screen(AlbumsViewModel.UiState(items = items, deviceAccess = true))
}
compose.onNodeWithText("5 эл.").assertIsDisplayed()
compose.onNodeWithTag(TAG_ALBUM_STORAGE_DEVICE_PREFIX + "b1", useUnmergedTree = true).assertExists()
compose.onNodeWithTag(TAG_ALBUM_STORAGE_CLOUD_PREFIX + "b1", useUnmergedTree = true).assertDoesNotExist()
compose.onNodeWithText("→ Отпуск").assertDoesNotExist()
}
@Test
@@ -2,6 +2,7 @@ package com.galo.android.ui.devicealbum
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.createComposeRule
@@ -10,10 +11,21 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.swipeLeft
import androidx.compose.ui.test.swipeRight
import com.galo.android.domain.model.Album
import com.galo.android.domain.model.DeviceMedia
import com.galo.android.domain.model.MediaKind
import com.galo.android.domain.model.SyncMode
import com.galo.android.ui.album.TAG_ALBUM_RENAME_CONFIRM
import com.galo.android.ui.album.TAG_ALBUM_RENAME_FIELD
import com.galo.android.ui.panel.TAG_ALBUM_PANEL
import com.galo.android.ui.panel.TAG_PANEL_DELETE
import com.galo.android.ui.panel.TAG_PANEL_RENAME
import com.galo.android.ui.theme.GaloTheme
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -78,6 +90,12 @@ class DeviceAlbumContentTest {
onMode: (SyncMode) -> Unit = {},
onTarget: (String) -> Unit = {},
onCreateAlbum: (String) -> Unit = {},
role: String = "",
localName: String? = null,
localCoverUrl: String? = null,
onDeleteAlbum: () -> Unit = {},
onRenameAlbum: (String) -> Unit = {},
onSetCover: (String?) -> Unit = {},
) {
GaloTheme(dynamicColor = false) {
DeviceAlbumContent(
@@ -88,6 +106,12 @@ class DeviceAlbumContentTest {
onRestoreOriginals = {},
onOpen = {},
onCreateAlbum = onCreateAlbum,
role = role,
localName = localName,
localCoverUrl = localCoverUrl,
onDeleteAlbum = onDeleteAlbum,
onRenameAlbum = onRenameAlbum,
onSetCover = onSetCover,
)
}
}
@@ -168,6 +192,45 @@ class DeviceAlbumContentTest {
fun `mode action for the same mode does nothing`() {
assertEquals(null, modeAction(SyncMode.COPY, hasTarget = true, target = SyncMode.COPY))
}
@Test
fun `all mode transition variants are routed correctly`() {
// Off → copy/move without a target: pick one first.
assertEquals(
ModeAction.PickTarget(SyncMode.COPY),
modeAction(SyncMode.DISABLED, hasTarget = false, target = SyncMode.COPY),
)
assertEquals(
ModeAction.PickTarget(SyncMode.MOVE),
modeAction(SyncMode.DISABLED, hasTarget = false, target = SyncMode.MOVE),
)
// Off → copy with a target: confirm.
assertEquals(
ModeAction.Confirm(SyncMode.COPY),
modeAction(SyncMode.DISABLED, hasTarget = true, target = SyncMode.COPY),
)
// Copy → move: confirm.
assertEquals(
ModeAction.Confirm(SyncMode.MOVE),
modeAction(SyncMode.COPY, hasTarget = true, target = SyncMode.MOVE),
)
// Move → copy/off: warn and offer to restore the originals.
assertEquals(
ModeAction.LeaveC(SyncMode.COPY),
modeAction(SyncMode.MOVE, hasTarget = true, target = SyncMode.COPY),
)
assertEquals(
ModeAction.LeaveC(SyncMode.DISABLED),
modeAction(SyncMode.MOVE, hasTarget = true, target = SyncMode.DISABLED),
)
// Copy → off: applies directly.
assertEquals(
ModeAction.Apply(SyncMode.DISABLED),
modeAction(SyncMode.COPY, hasTarget = true, target = SyncMode.DISABLED),
)
// Same mode: nothing.
assertEquals(null, modeAction(SyncMode.MOVE, hasTarget = true, target = SyncMode.MOVE))
}
@Test
fun `target picker lists albums and picks one`() {
var picked: String? = null
@@ -275,4 +338,107 @@ class DeviceAlbumContentTest {
compose.onNodeWithTag(TAG_MODE_PREFIX + SyncMode.MOVE.name).performClick()
assertEquals(SyncMode.MOVE, chosen)
}
@Test
fun `swipe left opens the attributes panel`() {
compose.setContent {
content(
DeviceAlbumViewModel.UiState(
name = "Camera",
items = listOf(media(1), media(2)),
serverAlbumId = "s1",
serverAlbums = listOf(album("s1", "Отпуск")),
),
role = "owner",
)
}
compose.onNodeWithTag(TAG_ALBUM_PANEL).assertDoesNotExist()
compose.onNodeWithTag(TAG_DEVICE_GRID).performTouchInput { swipeLeft() }
compose.waitForIdle()
compose.onNodeWithTag(TAG_ALBUM_PANEL).assertIsDisplayed()
}
@Test
fun `sync settings drawer opens independently of the panel`() {
compose.setContent {
content(
DeviceAlbumViewModel.UiState(
name = "Camera",
items = listOf(media(1)),
serverAlbumId = "s1",
serverAlbums = listOf(album("s1", "Отпуск")),
),
role = "owner",
)
}
openSettings()
compose.onNodeWithTag(TAG_DEVICE_SETTINGS).assertIsDisplayed()
compose.onNodeWithTag(TAG_ALBUM_PANEL).assertDoesNotExist()
}
@Test
fun `swiping right on the open panel closes it instead of opening sync settings`() {
compose.setContent {
content(
DeviceAlbumViewModel.UiState(
name = "Camera",
items = listOf(media(1), media(2)),
serverAlbumId = "s1",
serverAlbums = listOf(album("s1", "Отпуск")),
),
role = "owner",
)
}
compose.onNodeWithTag(TAG_DEVICE_GRID).performTouchInput { swipeLeft() }
compose.waitForIdle()
compose.onNodeWithTag(TAG_ALBUM_PANEL).assertIsDisplayed()
compose.onNodeWithTag(TAG_ALBUM_PANEL).performTouchInput { swipeRight() }
compose.waitForIdle()
compose.onNodeWithTag(TAG_ALBUM_PANEL).assertDoesNotExist()
compose.onNodeWithTag(TAG_DEVICE_SETTINGS).assertIsNotDisplayed()
}
@Test
fun `local panel rename reports the new name`() {
var name: String? = null
compose.setContent {
content(
DeviceAlbumViewModel.UiState(name = "Camera", items = listOf(media(1))),
onRenameAlbum = { name = it },
)
}
compose.onNodeWithTag(TAG_DEVICE_GRID).performTouchInput { swipeLeft() }
compose.waitForIdle()
compose.onNodeWithTag(TAG_PANEL_RENAME).performScrollTo().performClick()
compose.onNodeWithTag(TAG_ALBUM_RENAME_FIELD).performTextClearance()
compose.onNodeWithTag(TAG_ALBUM_RENAME_FIELD).performTextInput("Поездка")
compose.onNodeWithTag(TAG_ALBUM_RENAME_CONFIRM).performClick()
assertEquals("Поездка", name)
}
@Test
fun `local panel delete asks confirmation then deletes locally`() {
var deleted = false
compose.setContent {
content(
DeviceAlbumViewModel.UiState(name = "Camera", items = listOf(media(1), media(2))),
onDeleteAlbum = { deleted = true },
)
}
compose.onNodeWithTag(TAG_DEVICE_GRID).performTouchInput { swipeLeft() }
compose.waitForIdle()
compose.onNodeWithTag(TAG_PANEL_DELETE).performScrollTo().performClick()
compose.onNodeWithTag(TAG_LOCAL_DELETE_CONFIRM).performClick()
assertTrue(deleted)
}
}
@@ -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()
}
}