diff --git a/android/app/src/main/java/com/galo/android/data/db/dao/SyncDao.kt b/android/app/src/main/java/com/galo/android/data/db/dao/SyncDao.kt index 5306f97..752e486 100644 --- a/android/app/src/main/java/com/galo/android/data/db/dao/SyncDao.kt +++ b/android/app/src/main/java/com/galo/android/data/db/dao/SyncDao.kt @@ -8,6 +8,9 @@ import com.galo.android.data.db.entity.DeviceAssetEntity import com.galo.android.data.db.entity.SyncedAlbumEntity import kotlinx.coroutines.flow.Flow +/** Number of indexed assets that already have a server photo, per device album. */ +data class AlbumUploadedCount(val deviceAlbumId: String, val count: Int) + @Dao interface SyncDao { @@ -50,6 +53,18 @@ interface SyncDao { @Query("SELECT * FROM device_assets WHERE deviceAlbumId = :deviceAlbumId") fun observeAssets(deviceAlbumId: String): Flow> + @Query( + "SELECT deviceAlbumId, COUNT(*) AS count FROM device_assets " + + "WHERE serverPhotoId IS NOT NULL GROUP BY deviceAlbumId", + ) + fun observeUploadedCounts(): Flow> + + @Query( + "SELECT deviceAlbumId, COUNT(*) AS count FROM device_assets " + + "WHERE state = 'ORPHANED' GROUP BY deviceAlbumId", + ) + fun observeMissingCopyCounts(): Flow> + @Query("SELECT * FROM device_assets WHERE deviceAlbumId = :deviceAlbumId") suspend fun getAssets(deviceAlbumId: String): List diff --git a/android/app/src/main/java/com/galo/android/data/db/dao/UploadDao.kt b/android/app/src/main/java/com/galo/android/data/db/dao/UploadDao.kt index a5f9da4..be2e2c9 100644 --- a/android/app/src/main/java/com/galo/android/data/db/dao/UploadDao.kt +++ b/android/app/src/main/java/com/galo/android/data/db/dao/UploadDao.kt @@ -12,6 +12,10 @@ interface UploadDao { @Query("SELECT * FROM upload_jobs ORDER BY createdAt DESC") fun observeAll(): Flow> + /** Uploads created by a device-album sync; manual uploads have no device album. */ + @Query("SELECT * FROM upload_jobs WHERE deviceAlbumId IS NOT NULL ORDER BY createdAt ASC") + fun observeSyncJobs(): Flow> + @Query("SELECT * FROM upload_jobs WHERE id = :id") suspend fun getById(id: String): UploadJobEntity? diff --git a/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt b/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt index 9f9295e..796daca 100644 --- a/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt +++ b/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt @@ -23,6 +23,18 @@ class SyncRepository( fun observeAssets(deviceAlbumId: String): Flow> = dao.observeAssets(deviceAlbumId) + /** Assets already on the server, keyed by device album id (absent means none). */ + fun observeUploadedCounts(): Flow> = + dao.observeUploadedCounts().map { counts -> + counts.associate { it.deviceAlbumId to it.count } + } + + /** Assets whose server copy is gone, keyed by device album id (absent means none). */ + fun observeMissingCopyCounts(): Flow> = + dao.observeMissingCopyCounts().map { counts -> + counts.associate { it.deviceAlbumId to it.count } + } + suspend fun assets(deviceAlbumId: String): List = dao.getAssets(deviceAlbumId) /** Device albums that have a local asset index (possibly partial/unlinked). */ diff --git a/android/app/src/main/java/com/galo/android/data/sync/OffloadManager.kt b/android/app/src/main/java/com/galo/android/data/sync/OffloadManager.kt index 7ba52eb..1b420de 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/OffloadManager.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/OffloadManager.kt @@ -26,11 +26,19 @@ sealed interface OffloadResult { data class NeedsWriteConsent(val uris: List) : OffloadResult data class NeedsDeleteConsent(val uris: List) : OffloadResult data object Failed : OffloadResult + + /** There is no confirmed copy of the asset in the target album; nothing was changed. */ + data object NotOnServer : OffloadResult } /** Replaces a device original with its offloaded version (mode C). */ interface AssetOffloader { - suspend fun offload(asset: DeviceAssetEntity): OffloadResult + /** + * Offloads [asset] only when its server photo is in [liveServerPhotoIds], the + * photos currently present in the linked album. Without that proof the + * original is left untouched, so a stale or foreign record can never destroy it. + */ + suspend fun offload(asset: DeviceAssetEntity, liveServerPhotoIds: Set): OffloadResult } /** @@ -46,24 +54,34 @@ class OffloadManager( private val mutex = Mutex() - override suspend fun offload(asset: DeviceAssetEntity): OffloadResult = withContext(Dispatchers.IO) { - // Serialize offloads so two passes never fight over the same asset. - mutex.withLock { - runCatching { - val uri = Uri.parse(asset.uri) - val link = dao.getSyncedAlbum(asset.deviceAlbumId) - val serverAlbumId = link?.serverAlbumId - val syncMode = link?.mode - if (kindOf(asset.mimeType) == MediaKind.VIDEO) { - offloadVideo(asset, uri, serverAlbumId, syncMode) - } else { - 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) + override suspend fun offload( + asset: DeviceAssetEntity, + liveServerPhotoIds: Set, + ): OffloadResult { + val photoId = asset.serverPhotoId + if (photoId == null || photoId !in liveServerPhotoIds) { + android.util.Log.i(TAG, "skip offload ${asset.assetKey}: no confirmed server copy") + return OffloadResult.NotOnServer + } + return withContext(Dispatchers.IO) { + // Serialize offloads so two passes never fight over the same asset. + mutex.withLock { + runCatching { + val uri = Uri.parse(asset.uri) + val link = dao.getSyncedAlbum(asset.deviceAlbumId) + val serverAlbumId = link?.serverAlbumId + val syncMode = link?.mode + if (kindOf(asset.mimeType) == MediaKind.VIDEO) { + offloadVideo(asset, uri, serverAlbumId, syncMode) + } else { + 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) + } } } diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt index 93c3951..64f5a59 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt @@ -2,12 +2,22 @@ package com.galo.android.data.sync import com.galo.android.data.db.dao.SyncDao import com.galo.android.data.db.entity.DeviceAssetEntity +import com.galo.android.data.db.entity.SyncedAlbumEntity 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 +/** Outcome of changing the server album a device album syncs to. */ +sealed interface RetargetResult { + /** The link was switched (or created) and a pass ran. [strandedCopies] could not be carried. */ + data class Linked(val strandedCopies: Int) : RetargetResult + + /** The link was left unchanged because the recorded copies could not be carried over. */ + data class Refused(val reason: String) : RetargetResult +} + /** * Reconciles a device album with its server album. Mode A (DISABLED) is local * only and performs no server requests; the other modes upload missing content, @@ -25,12 +35,15 @@ class SyncCoordinator( private val mutex = Mutex() /** @return true when the server was contacted, false for a local-only album. */ - suspend fun reconcile(deviceAlbumId: String): Boolean = mutex.withLock { - val link = dao.getSyncedAlbum(deviceAlbumId) ?: return@withLock false + suspend fun reconcile(deviceAlbumId: String): Boolean = + mutex.withLock { reconcileLocked(deviceAlbumId) } + + private suspend fun reconcileLocked(deviceAlbumId: String): Boolean { + val link = dao.getSyncedAlbum(deviceAlbumId) ?: return false val mode = SyncMode.from(link.mode) - if (!SyncPolicy.contactsServer(mode)) return@withLock false + if (!SyncPolicy.contactsServer(mode)) return false // A mode can be chosen before a server album; nothing to sync until then. - if (link.serverAlbumId.isBlank()) return@withLock false + if (link.serverAlbumId.isBlank()) return false val serverChecksums = remote.serverChecksums(link.serverAlbumId) val livePhotoIds = serverChecksums.values.toSet() @@ -46,9 +59,72 @@ class SyncCoordinator( for (action in actions) apply(deviceAlbumId, link.serverAlbumId, mode, action, livePhotoIds) // 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@withLock true + // are replaced with their offloaded versions, right away, but only while + // their copy is confirmed in the target album. + if (mode == SyncMode.MOVE) offloadUploaded(deviceAlbumId, assets, livePhotoIds) + return true + } + + /** + * Switches a device album to [newServerAlbumId], carrying the photos this + * client already uploaded over first. The link is only changed once every + * copy still available in the previous album is confirmed in the new one; + * otherwise the previous link is kept and the change is refused. Photos that + * cannot be carried (already lost elsewhere) do not block the switch and are + * reported instead. + */ + suspend fun retarget( + deviceAlbumId: String, + deviceAlbumName: String, + newServerAlbumId: String, + mode: SyncMode, + ): RetargetResult = mutex.withLock { + val previous = dao.getSyncedAlbum(deviceAlbumId) + ?.serverAlbumId + ?.takeIf { it.isNotBlank() } + val recordedPhotoIds = dao.getAssets(deviceAlbumId) + .mapNotNull { it.serverPhotoId } + .distinct() + + var stranded = 0 + if (previous != null && previous != newServerAlbumId && recordedPhotoIds.isNotEmpty()) { + val previousPhotos = runCatching { remote.serverChecksums(previous) }.getOrNull() + ?: return@withLock RetargetResult.Refused( + "Прежний серверный альбом недоступен — фото перенести нельзя.", + ) + val liveInPrevious = previousPhotos.values.toSet() + val toCarry = recordedPhotoIds.filter { it in liveInPrevious } + stranded = recordedPhotoIds.size - toCarry.size + + if (toCarry.isNotEmpty()) { + if (runCatching { remote.movePhotos(toCarry, newServerAlbumId) }.isFailure) { + return@withLock RetargetResult.Refused("Не удалось перенести фото в новый альбом.") + } + val newPhotos = runCatching { remote.serverChecksums(newServerAlbumId) }.getOrNull() + ?: return@withLock RetargetResult.Refused( + "Не удалось проверить перенос в новый альбом.", + ) + val liveInNew = newPhotos.values.toSet() + val missing = toCarry.count { it !in liveInNew } + if (missing > 0) { + return@withLock RetargetResult.Refused( + "В новый альбом не перенеслось фото: $missing.", + ) + } + } + } + + dao.upsertSyncedAlbum( + SyncedAlbumEntity( + deviceAlbumId = deviceAlbumId, + deviceAlbumName = deviceAlbumName, + serverAlbumId = newServerAlbumId, + mode = mode.name, + updatedAt = System.currentTimeMillis(), + ), + ) + reconcileLocked(deviceAlbumId) + return@withLock RetargetResult.Linked(stranded) } /** @@ -93,9 +169,14 @@ class SyncCoordinator( /** * 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. + * so freeing completes automatically on a later run without any prompt. Only a + * photo confirmed in the target album ([livePhotoIds]) may replace the original. */ - private suspend fun offloadUploaded(deviceAlbumId: String, localAssets: List) { + private suspend fun offloadUploaded( + deviceAlbumId: String, + localAssets: List, + livePhotoIds: Set, + ) { val localKeys = localAssets.mapTo(HashSet()) { it.assetKey } for (entity in dao.getAssets(deviceAlbumId)) { if (entity.serverPhotoId == null) continue @@ -109,9 +190,9 @@ class SyncCoordinator( ) { continue } - val result = runCatching { offloader.offload(entity) }.getOrNull() ?: continue + val result = runCatching { offloader.offload(entity, livePhotoIds) }.getOrNull() ?: continue when (result) { - is OffloadResult.Done, OffloadResult.Failed -> Unit + is OffloadResult.Done, OffloadResult.Failed, OffloadResult.NotOnServer -> Unit is OffloadResult.NeedsWriteConsent, is OffloadResult.NeedsDeleteConsent -> dao.markState(entity.assetKey, LocalAssetState.AWAITING_FREE.name) } diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncPlan.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncPlan.kt index be9e11f..2f71937 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/SyncPlan.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncPlan.kt @@ -35,6 +35,10 @@ sealed interface SyncAction { * Computes the changes to apply to a server album from the device's current * content, the local index (provenance) and the server's photo checksums. * + * The recorded server photo id is trusted only when that photo is present in the + * target album (`serverChecksums` values). A stale id is re-uploaded when the + * original is local, and never causes a delete of a photo that lives elsewhere. + * * Rules: * - content already on the server is linked instead of uploaded (dedup); * - an indexed asset whose content changed is re-uploaded and its old upload is @@ -52,6 +56,7 @@ object SyncPlanner { hashesPresentElsewhere: Set, ): List { val actions = mutableListOf() + val livePhotoIds = serverChecksums.values.toSet() for (asset in local) { val indexed = index.firstOrNull { it.assetKey == asset.assetKey } @@ -72,16 +77,17 @@ object SyncPlanner { } continue } + val liveIndexedPhotoId = indexed?.serverPhotoId?.takeIf { it in livePhotoIds } when { - indexed?.serverPhotoId != null -> { + liveIndexedPhotoId != null -> { val changed = asset.contentHash != null && indexed.contentHash != null && asset.contentHash != indexed.contentHash if (changed) { actions += SyncAction.Upload(asset) - actions += SyncAction.DeleteOnServer(indexed.serverPhotoId, asset.assetKey) + actions += SyncAction.DeleteOnServer(liveIndexedPhotoId, asset.assetKey) } else if (asset.displayName != indexed.displayName) { - actions += SyncAction.RenameOnServer(indexed.serverPhotoId, asset.displayName) + actions += SyncAction.RenameOnServer(liveIndexedPhotoId, asset.displayName) } } @@ -99,6 +105,8 @@ object SyncPlanner { if (indexed.assetKey in localKeys) continue val hash = indexed.contentHash if (hash != null && hash in hashesPresentElsewhere) continue + // The remote delete only happens while this photo is in the target + // album (see [SyncCoordinator]); a stale id may be another album's. actions += SyncAction.DeleteOnServer(photoId, indexed.assetKey) } diff --git a/android/app/src/main/java/com/galo/android/data/upload/UploadRepository.kt b/android/app/src/main/java/com/galo/android/data/upload/UploadRepository.kt index 898cab2..b7368ec 100644 --- a/android/app/src/main/java/com/galo/android/data/upload/UploadRepository.kt +++ b/android/app/src/main/java/com/galo/android/data/upload/UploadRepository.kt @@ -12,6 +12,7 @@ import androidx.work.WorkManager import androidx.work.workDataOf import com.galo.android.data.db.dao.UploadDao import com.galo.android.data.db.entity.UploadJobEntity +import com.galo.android.domain.model.SyncUpload import com.galo.android.domain.model.UploadJob import com.galo.android.domain.model.UploadStatus import kotlinx.coroutines.flow.Flow @@ -32,6 +33,24 @@ class UploadRepository( fun observeJobs(): Flow> = dao.observeAll().map { jobs -> jobs.map { it.toDomain() } } + /** + * Uploads belonging to a device-album synchronization, so the album list can + * show the per-album copy operation. Manual uploads are excluded. + */ + fun observeSyncUploads(): Flow> = + dao.observeSyncJobs().map { jobs -> + jobs.mapNotNull { job -> + job.deviceAlbumId?.let { deviceAlbumId -> + SyncUpload( + deviceAlbumId = deviceAlbumId, + status = UploadStatus.from(job.status), + progress = job.progress, + wifiOnly = job.wifiOnly, + ) + } + } + } + suspend fun enqueue(albumId: String, uris: List, wifiOnly: Boolean): Int { var queued = 0 for (uri in uris) { diff --git a/android/app/src/main/java/com/galo/android/data/upload/UploadWorker.kt b/android/app/src/main/java/com/galo/android/data/upload/UploadWorker.kt index 32ba887..76bfe92 100644 --- a/android/app/src/main/java/com/galo/android/data/upload/UploadWorker.kt +++ b/android/app/src/main/java/com/galo/android/data/upload/UploadWorker.kt @@ -97,7 +97,8 @@ class UploadWorker( // the OS write consent, which is requested from the album screen. if (mode == SyncMode.MOVE) { container.syncRepository.getAsset(assetKey)?.let { asset -> - runCatching { container.offloadManager.offload(asset) } + // The upload just succeeded, so the copy is live in the target. + runCatching { container.offloadManager.offload(asset, setOf(photoId)) } } } } diff --git a/android/app/src/main/java/com/galo/android/domain/model/Upload.kt b/android/app/src/main/java/com/galo/android/domain/model/Upload.kt index 2875dbb..33ac4df 100644 --- a/android/app/src/main/java/com/galo/android/domain/model/Upload.kt +++ b/android/app/src/main/java/com/galo/android/domain/model/Upload.kt @@ -26,3 +26,15 @@ data class UploadJob( val wifiOnly: Boolean, val createdAt: Long, ) + +/** + * A queued upload that belongs to a device-album synchronization, used to show + * the album's copy operation on its card. Manual uploads chosen from the upload + * screen have no device album and are not represented here. + */ +data class SyncUpload( + val deviceAlbumId: String, + val status: UploadStatus, + val progress: Int, + val wifiOnly: Boolean, +) diff --git a/android/app/src/main/java/com/galo/android/domain/sync/AlbumSyncProgress.kt b/android/app/src/main/java/com/galo/android/domain/sync/AlbumSyncProgress.kt new file mode 100644 index 0000000..1513e94 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/domain/sync/AlbumSyncProgress.kt @@ -0,0 +1,87 @@ +package com.galo.android.domain.sync + +import com.galo.android.domain.model.SyncMode +import com.galo.android.domain.model.SyncUpload +import com.galo.android.domain.model.UploadStatus + +/** + * How an album's upload operation is going, resolved for the card's third line. + * Applies to both synchronized modes: copies (B) and move (C) upload originals. + * + * [percent] is present only while uploads are running; a card pulses only when + * there is a number to pulse, so a Wi-Fi wait never looks like work. + */ +data class AlbumSyncProgress( + val mode: SyncMode, + val running: Boolean, + val queued: Int, + val waitingForWifi: Boolean, + val failed: Boolean, + val percent: Int?, + /** Photos whose server copy is gone; the most severe state and shown first. */ + val missingCopies: Int = 0, +) { + + /** Pulsing accompanies a live percentage, never a waiting, failed or lost state. */ + val pulsing: Boolean get() = percent != null && missingCopies == 0 + + /** The third line of the card: the mode label plus the operation state. */ + fun cardText(): String = when { + missingCopies > 0 -> "${modeLabel()} — потеряна копия: $missingCopies" + running && percent != null -> "${modeLabel()} $percent%" + failed -> "${modeLabel()} — ошибка" + waitingForWifi -> "${modeLabel()} — ждёт Wi-Fi" + queued > 0 -> "${modeLabel()} $queued в очереди" + else -> modeLabel() + } + + private fun modeLabel(): String = if (mode == SyncMode.MOVE) "Перенос" else "Копии" + + companion object { + + /** + * Resolves the card status for a device album, or null when there is + * nothing to show (sync off). [uploaded] counts the assets already on the + * server per the provenance index; [total] is the album's item count. + * + * The percentage is `(uploaded + running fractions) / total`, which keeps + * it from dropping when a file finishes: the finished file's fraction + * leaves [running] as the same file enters [uploaded]. It is shown for + * any synchronized mode, since both upload originals. + */ + fun resolve( + mode: SyncMode, + uploads: List, + uploaded: Int, + total: Int, + missingCopies: Int = 0, + ): AlbumSyncProgress? { + if (mode == SyncMode.DISABLED) return null + + val runningUploads = uploads.filter { it.status == UploadStatus.RUNNING } + val queuedUploads = uploads.filter { it.status == UploadStatus.QUEUED } + val running = runningUploads.isNotEmpty() + val failed = uploads.any { it.status == UploadStatus.ERROR } + // Only a plain network wait reads as such: nothing running, and every + // waiting job blocked on the Wi-Fi-only constraint. + val waitingForWifi = queuedUploads.isNotEmpty() && !running && + queuedUploads.all { it.wifiOnly } + val percent = if (running && total > 0) { + val runningCredits = runningUploads.sumOf { it.progress } / 100.0 + (((uploaded + runningCredits) * 100) / total).toInt().coerceIn(0, 100) + } else { + null + } + + return AlbumSyncProgress( + mode = mode, + running = running, + queued = queuedUploads.size, + waitingForWifi = waitingForWifi, + failed = failed, + percent = percent, + missingCopies = missingCopies, + ) + } + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/albums/AlbumListItem.kt b/android/app/src/main/java/com/galo/android/ui/albums/AlbumListItem.kt index 2808ac1..510d590 100644 --- a/android/app/src/main/java/com/galo/android/ui/albums/AlbumListItem.kt +++ b/android/app/src/main/java/com/galo/android/ui/albums/AlbumListItem.kt @@ -6,6 +6,7 @@ import com.galo.android.domain.model.AlbumSortChoice import com.galo.android.domain.model.DeviceAlbum import com.galo.android.domain.model.SyncMode import com.galo.android.domain.model.updatedAtMillis +import com.galo.android.domain.sync.AlbumSyncProgress /** One row of the mixed album list: a server album or an album of the device. */ sealed interface AlbumListItem { @@ -22,6 +23,8 @@ sealed interface AlbumListItem { val album: DeviceAlbum, val linkedServerTitle: String?, val mode: SyncMode, + /** Copy-operation status for the card's third line; null when there is none. */ + val progress: AlbumSyncProgress? = null, ) : AlbumListItem { override val key: String get() = "device:${album.id}" } @@ -50,6 +53,7 @@ object AlbumListBuilder { links: List, devices: List, choice: AlbumSortChoice = AlbumSortChoice(), + progressByDevice: Map = emptyMap(), ): List { val linksByDevice = links.associateBy { it.deviceAlbumId } val linkedServerIds = links @@ -65,6 +69,7 @@ object AlbumListBuilder { serverTitles[serverId] ?: serverId }, mode = link?.mode ?: SyncMode.DISABLED, + progress = progressByDevice[device.id], ) } diff --git a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt index a05eee1..507b20e 100644 --- a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt +++ b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt @@ -1,5 +1,11 @@ package com.galo.android.ui.albums +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -42,6 +48,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow @@ -52,6 +59,7 @@ import com.galo.android.domain.model.Album import com.galo.android.domain.model.AlbumSort import com.galo.android.domain.model.DeviceAlbum import com.galo.android.domain.model.SyncMode +import com.galo.android.domain.sync.AlbumSyncProgress import com.galo.android.ui.GaloViewModelFactory import com.galo.android.ui.common.GaloAsyncImage import com.galo.android.ui.common.RequestMediaAccess @@ -70,6 +78,7 @@ const val TAG_DEVICE_ALBUM_CARD_PREFIX = "device_album_card_" 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_" +const val TAG_ALBUM_STATUS_PREFIX = "album_status_" @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -268,6 +277,7 @@ private fun AlbumCardBody( placement: StoragePlacement, count: String, arrowTo: String?, + sync: AlbumSyncProgress? = null, ) { Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -317,6 +327,49 @@ private fun AlbumCardBody( modifier = Modifier.padding(start = 6.dp).testTag(TAG_ALBUM_COUNT_PREFIX + id), ) } + if (sync != null) { + AlbumSyncLine(id = id, sync = sync, modifier = Modifier.padding(top = 2.dp)) + } + } +} + +/** + * Third card line: the sync mode and, for copies, the operation state. The + * percentage pulses only while copies are actually in flight. + */ +@Composable +private fun AlbumSyncLine(id: String, sync: AlbumSyncProgress, modifier: Modifier = Modifier) { + val text = sync.cardText() + val error = sync.missingCopies > 0 || (sync.failed && !sync.running) + val color = if (error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant + if (sync.pulsing) { + val transition = rememberInfiniteTransition(label = "album-sync") + val alpha by transition.animateFloat( + initialValue = 0.35f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 900, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "album-sync-alpha", + ) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier.alpha(alpha).testTag(TAG_ALBUM_STATUS_PREFIX + id), + ) + } else { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier.testTag(TAG_ALBUM_STATUS_PREFIX + id), + ) } } @@ -376,6 +429,7 @@ private fun DeviceAlbumCard(item: AlbumListItem.DeviceAlbumItem, onClick: () -> placement = placementOf(item.mode), count = "${album.itemCount} эл.", arrowTo = arrowTo, + sync = item.progress, ) } } @@ -384,6 +438,7 @@ private fun DeviceAlbumCard(item: AlbumListItem.DeviceAlbumItem, onClick: () -> /** 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 + // Copies and move both keep something locally: originals for copies, previews + // for move. The mode label on the third line tells them apart. + SyncMode.COPY, SyncMode.MOVE -> StoragePlacement.DEVICE_AND_CLOUD } diff --git a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt index 2ada26d..9e028cf 100644 --- a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt +++ b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt @@ -8,7 +8,11 @@ import com.galo.android.data.repo.LiveAlbumSource import com.galo.android.domain.model.AlbumSort import com.galo.android.domain.model.AlbumSortChoice import com.galo.android.domain.model.DeviceAlbum +import com.galo.android.domain.model.DeviceAlbumMeta import com.galo.android.domain.model.SyncMode +import com.galo.android.domain.model.SyncUpload +import com.galo.android.domain.sync.AlbumSyncProgress +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -52,29 +56,51 @@ class AlbumsViewModel( /** Current list ordering; restored from preferences and changed from the UI. */ private val sort = MutableStateFlow(AlbumSortChoice()) + /** Everything the mixed list needs from the device side, in one emission. */ + private data class DeviceInput( + val devices: List, + val access: Boolean, + val meta: Map, + val uploads: List, + val uploadedCounts: Map, + val missingCounts: Map, + ) + + private val deviceInput: Flow = combine( + deviceAlbums, + deviceAccess, + container.syncRepository.observeAlbumMeta(), + container.uploadRepository.observeSyncUploads(), + combine( + container.syncRepository.observeUploadedCounts(), + container.syncRepository.observeMissingCopyCounts(), + ) { uploaded, missing -> uploaded to missing }, + ) { devices, access, meta, uploads, counts -> + DeviceInput(devices, access, meta, uploads, counts.first, counts.second) + } + val state: StateFlow = combine( container.albumRepository.observeAlbums(), container.syncRepository.observeLinks(), - combine(deviceAlbums, deviceAccess, container.syncRepository.observeAlbumMeta()) { devices, access, meta -> - Triple(devices, access, meta) - }, + deviceInput, status, combine(avatar, sort) { url, choice -> url to choice }, ) { albums, links, device, s, header -> val (avatarUrl, sortChoice) = header - val withMeta = device.first.map { d -> - val m = device.third[d.id] + val withMeta = device.devices.map { d -> + val m = device.meta[d.id] d.copy( name = m?.displayName?.takeIf { it.isNotBlank() } ?: d.name, coverUri = m?.coverUri ?: d.coverUri, ) } + val modeByDevice = links.associate { it.deviceAlbumId to SyncMode.from(it.mode) } val items = AlbumListBuilder.build( albums = albums, links = links.map { link -> AlbumLink( deviceAlbumId = link.deviceAlbumId, - deviceAlbumName = device.third[link.deviceAlbumId]?.displayName + deviceAlbumName = device.meta[link.deviceAlbumId]?.displayName ?.takeIf { it.isNotBlank() } ?: link.deviceAlbumName, serverAlbumId = link.serverAlbumId, mode = SyncMode.from(link.mode), @@ -82,6 +108,13 @@ class AlbumsViewModel( }, devices = withMeta, choice = sortChoice, + progressByDevice = albumSyncProgressByDevice( + devices = withMeta, + modeByDevice = modeByDevice, + uploads = device.uploads, + uploadedCounts = device.uploadedCounts, + missingCounts = device.missingCounts, + ), ) UiState( @@ -90,7 +123,7 @@ class AlbumsViewModel( offline = s.offline, error = s.error, avatarUrl = avatarUrl, - deviceAccess = device.second, + deviceAccess = device.access, sort = sortChoice.sort, reversed = sortChoice.reversed, ) @@ -148,3 +181,27 @@ class AlbumsViewModel( onDone() } } + +/** + * Copy-operation status per device album, for the cards' third line. Top-level so + * it can be tested directly: the device list comes from MediaStore and is empty + * in a plain unit test, while this mapping is the whole wiring under test. + */ +internal fun albumSyncProgressByDevice( + devices: List, + modeByDevice: Map, + uploads: List, + uploadedCounts: Map, + missingCounts: Map = emptyMap(), +): Map { + val uploadsByDevice = uploads.groupBy { it.deviceAlbumId } + return devices.mapNotNull { device -> + AlbumSyncProgress.resolve( + mode = modeByDevice[device.id] ?: SyncMode.DISABLED, + uploads = uploadsByDevice[device.id].orEmpty(), + uploaded = uploadedCounts[device.id] ?: 0, + total = device.itemCount, + missingCopies = missingCounts[device.id] ?: 0, + )?.let { device.id to it } + }.toMap() +} diff --git a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt index dae81aa..d9bda3e 100644 --- a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt +++ b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt @@ -94,6 +94,7 @@ const val TAG_DEVICE_GRID = "device_album_grid" const val TAG_DEVICE_ITEM_PREFIX = "device_item_" const val TAG_TRANSFERRED_PREFIX = "transferred_" const val TAG_ORPHANED_PREFIX = "orphaned_" +const val TAG_DEVICE_LOST_COPY = "device_lost_copy" const val TAG_DEVICE_SETTINGS = "device_album_settings" const val TAG_MODE_PREFIX = "sync_mode_" const val TAG_TARGET_PREFIX = "sync_target_" @@ -131,6 +132,7 @@ fun DeviceAlbumScreen( val viewModel: DeviceAlbumViewModel = viewModel(factory = factory) val state by viewModel.state.collectAsStateWithLifecycle() val role by viewModel.role.collectAsStateWithLifecycle() + val notice by viewModel.notice.collectAsStateWithLifecycle() val prefs by viewModel.preferences.collectAsStateWithLifecycle() val meta by viewModel.albumMeta.collectAsStateWithLifecycle() var viewerIndex by remember { mutableStateOf(null) } @@ -242,6 +244,8 @@ fun DeviceAlbumScreen( role = role, localName = localName, localCoverUrl = localCoverUrl, + notice = notice, + onDismissNotice = viewModel::dismissNotice, onBack = onBack, onMode = viewModel::setMode, onTarget = viewModel::setTarget, @@ -289,6 +293,8 @@ fun DeviceAlbumContent( role: String = "", localName: String? = null, localCoverUrl: String? = null, + notice: String? = null, + onDismissNotice: () -> Unit = {}, onRenameAlbum: (String) -> Unit = {}, onSetCover: (String?) -> Unit = {}, onResetCover: () -> Unit = {}, @@ -380,6 +386,16 @@ fun DeviceAlbumContent( ) } + notice?.let { message -> + AlertDialog( + onDismissRequest = onDismissNotice, + text = { Text(message) }, + confirmButton = { + TextButton(onClick = onDismissNotice) { Text("Понятно") } + }, + ) + } + val device = DeviceAlbum( id = state.name, name = state.name.ifBlank { "Альбом телефона" }, @@ -448,6 +464,9 @@ fun DeviceAlbumContent( }, ) { Column(modifier = Modifier.fillMaxSize()) { + if (state.orphanedMediaIds.isNotEmpty()) { + LostCopyBanner(count = state.orphanedMediaIds.size) + } if (state.items.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("В альбоме нет медиа") @@ -775,6 +794,22 @@ fun TargetPickerDialog( ) } +/** Shown when some of the album's photos lost their server copy. */ +@Composable +private fun LostCopyBanner(count: Int) { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth().testTag(TAG_DEVICE_LOST_COPY), + ) { + Text( + text = "Нет серверной копии: $count. Превью сохранены и не удаляются автоматически.", + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } +} + /** Confirms a mode change and explains what the mode does. */ @Composable fun ModeConfirmDialog( diff --git a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt index 83484a5..2b08098 100644 --- a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt +++ b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt @@ -9,6 +9,7 @@ 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.data.sync.RetargetResult import com.galo.android.domain.model.Album import com.galo.android.domain.model.DeviceAlbumMeta import com.galo.android.domain.model.DeviceMedia @@ -63,6 +64,15 @@ class DeviceAlbumViewModel( /** Saved photo-attribute preferences (visibility + labels). */ val preferences: StateFlow = _preferences.asStateFlow() + private val _notice = MutableStateFlow(null) + + /** A one-off message for the user (for example a refused target change). */ + val notice: StateFlow = _notice.asStateFlow() + + fun dismissNotice() { + _notice.value = null + } + /** App-only metadata (display name, chosen cover) for this device album. */ val albumMeta: StateFlow = container.syncRepository.observeAlbumMeta() .map { it[deviceAlbumId] } @@ -291,14 +301,24 @@ class DeviceAlbumViewModel( fun setTarget(serverAlbumId: String) { viewModelScope.launch { - container.syncRepository.link( - deviceAlbumId = deviceAlbumId, - deviceAlbumName = local.value.name, - serverAlbumId = serverAlbumId, - mode = pendingMode.value ?: state.value.mode, - ) - refreshRole() - reconcileNow() + val mode = pendingMode.value ?: state.value.mode + when ( + val result = container.syncCoordinator.retarget( + deviceAlbumId = deviceAlbumId, + deviceAlbumName = local.value.name, + newServerAlbumId = serverAlbumId, + mode = mode, + ) + ) { + is RetargetResult.Linked -> { + refreshRole() + if (result.strandedCopies > 0) { + _notice.value = "Фото без серверной копии: ${result.strandedCopies}." + } + } + + is RetargetResult.Refused -> _notice.value = result.reason + } } } @@ -309,14 +329,18 @@ class DeviceAlbumViewModel( viewModelScope.launch { val album = runCatching { container.api.createAlbum(title) }.getOrNull() ?: return@launch runCatching { container.albumRepository.refresh() } - container.syncRepository.link( - deviceAlbumId = deviceAlbumId, - deviceAlbumName = local.value.name, - serverAlbumId = album.id, - mode = pendingMode.value ?: state.value.mode, - ) - refreshRole() - reconcileNow() + val mode = pendingMode.value ?: state.value.mode + when ( + val result = container.syncCoordinator.retarget( + deviceAlbumId = deviceAlbumId, + deviceAlbumName = local.value.name, + newServerAlbumId = album.id, + mode = mode, + ) + ) { + is RetargetResult.Linked -> refreshRole() + is RetargetResult.Refused -> _notice.value = result.reason + } } } @@ -332,14 +356,24 @@ class DeviceAlbumViewModel( */ fun freeSpace(onNeedsConsent: (IntentSender) -> Unit) { viewModelScope.launch { + // Free only what the target album confirms it holds; on any doubt, + // nothing is freed so no original is replaced without a server copy. + val targetAlbumId = container.syncRepository.currentLink(deviceAlbumId) + ?.serverAlbumId + ?.takeIf { it.isNotBlank() } + val live = targetAlbumId + ?.let { runCatching { container.syncRemote.serverChecksums(it) }.getOrNull() } + ?.values + ?.toSet() + ?: emptySet() val assets = container.syncRepository.assets(deviceAlbumId) .filter { it.state == LocalAssetState.AWAITING_FREE.name } val writeUris = mutableListOf() val deleteUris = mutableListOf() for (asset in assets) { - val result = runCatching { container.offloadManager.offload(asset) }.getOrNull() ?: continue + val result = runCatching { container.offloadManager.offload(asset, live) }.getOrNull() ?: continue when (result) { - OffloadResult.Done, OffloadResult.Failed -> Unit + OffloadResult.Done, OffloadResult.Failed, OffloadResult.NotOnServer -> Unit is OffloadResult.NeedsWriteConsent -> writeUris += result.uris is OffloadResult.NeedsDeleteConsent -> deleteUris += result.uris } diff --git a/android/app/src/test/java/com/galo/android/data/repo/SyncRepositoryTest.kt b/android/app/src/test/java/com/galo/android/data/repo/SyncRepositoryTest.kt index 8ffa331..3b8d5ac 100644 --- a/android/app/src/test/java/com/galo/android/data/repo/SyncRepositoryTest.kt +++ b/android/app/src/test/java/com/galo/android/data/repo/SyncRepositoryTest.kt @@ -68,4 +68,54 @@ class SyncRepositoryTest { // Provenance survives, and no server photo is touched by unlink. assertEquals("p1", db.syncDao().getAsset("b1/1")?.serverPhotoId) } + + @Test + fun `uploaded counts are per album and skip assets without a server photo`() = runBlocking { + db.syncDao().upsertAssets( + listOf( + asset("b1/1", "b1", "p1"), + asset("b1/2", "b1", null), + asset("b2/1", "b2", "p2"), + ), + ) + + val counts = repo.observeUploadedCounts().first() + + assertEquals(mapOf("b1" to 1, "b2" to 1), counts) + assertEquals(0, counts["b3"] ?: 0) + } + + @Test + fun `missing copy counts track only orphaned assets`() = runBlocking { + db.syncDao().upsertAssets( + listOf( + asset("b1/1", "b1", "p1", LocalAssetState.ORPHANED.name), + asset("b1/2", "b1", "p2", LocalAssetState.PREVIEW.name), + asset("b2/1", "b2", "p3", LocalAssetState.ORPHANED.name), + ), + ) + + assertEquals(mapOf("b1" to 1, "b2" to 1), repo.observeMissingCopyCounts().first()) + } + + private fun asset( + key: String, + deviceAlbumId: String, + serverPhotoId: String?, + state: String = LocalAssetState.UPLOADED.name, + ) = + DeviceAssetEntity( + assetKey = key, + deviceAlbumId = deviceAlbumId, + mediaStoreId = 0L, + uri = "u", + displayName = "a.jpg", + mimeType = "image/jpeg", + size = 0L, + dateModified = 0L, + contentHash = null, + serverPhotoId = serverPhotoId, + uploadedAt = null, + state = state, + ) } diff --git a/android/app/src/test/java/com/galo/android/data/sync/OffloadManagerTest.kt b/android/app/src/test/java/com/galo/android/data/sync/OffloadManagerTest.kt index f9bf4a3..e42ef62 100644 --- a/android/app/src/test/java/com/galo/android/data/sync/OffloadManagerTest.kt +++ b/android/app/src/test/java/com/galo/android/data/sync/OffloadManagerTest.kt @@ -38,26 +38,37 @@ class OffloadManagerTest { @Test fun `offload of an unreadable asset returns a result instead of throwing`() = runBlocking { - val asset = DeviceAssetEntity( - assetKey = "b/1", - deviceAlbumId = "b", - mediaStoreId = 1L, - uri = "content://does/not/exist", - displayName = "n.jpg", - mimeType = "image/jpeg", - size = 0L, - dateModified = 0L, - contentHash = null, - serverPhotoId = null, - uploadedAt = null, - state = LocalAssetState.UPLOADED.name, - ) + val asset = asset(serverPhotoId = "p1") - val result = manager.offload(asset) + val result = manager.offload(asset, setOf("p1")) assertTrue( "expected Failed or a consent request, got $result", result == OffloadResult.Failed || result is OffloadResult.NeedsWriteConsent, ) } + + @Test + fun `no confirmed copy leaves the original untouched`() = runBlocking { + val asset = asset(serverPhotoId = "p1") + + val result = manager.offload(asset, emptySet()) + + assertTrue("expected NotOnServer, got $result", result == OffloadResult.NotOnServer) + } + + private fun asset(serverPhotoId: String?) = DeviceAssetEntity( + assetKey = "b/1", + deviceAlbumId = "b", + mediaStoreId = 1L, + uri = "content://does/not/exist", + displayName = "n.jpg", + mimeType = "image/jpeg", + size = 0L, + dateModified = 0L, + contentHash = null, + serverPhotoId = serverPhotoId, + uploadedAt = null, + state = LocalAssetState.UPLOADED.name, + ) } diff --git a/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt b/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt index 41e426c..ae99c3e 100644 --- a/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt +++ b/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt @@ -32,10 +32,13 @@ class SyncCoordinatorTest { val deletes = mutableListOf() val moves = mutableListOf, String>>() var serverMap: Map = emptyMap() + val serverMapByAlbum = mutableMapOf>() + var failChecksumsFor: Set = emptySet() override suspend fun serverChecksums(serverAlbumId: String): Map { checksumCalls++ - return serverMap + if (serverAlbumId in failChecksumsFor) throw RuntimeException("album unavailable") + return serverMapByAlbum[serverAlbumId] ?: serverMap } override suspend fun renamePhoto(photoId: String, newName: String) { @@ -77,7 +80,13 @@ class SyncCoordinatorTest { private class FakeOffloader : AssetOffloader { val offloaded = mutableListOf() var result: OffloadResult = OffloadResult.Done - override suspend fun offload(asset: DeviceAssetEntity): OffloadResult { + override suspend fun offload( + asset: DeviceAssetEntity, + liveServerPhotoIds: Set, + ): OffloadResult { + // Mirror the real contract: without a confirmed copy nothing is replaced. + val photoId = asset.serverPhotoId + if (photoId == null || photoId !in liveServerPhotoIds) return OffloadResult.NotOnServer offloaded += asset.assetKey return result } @@ -192,6 +201,7 @@ class SyncCoordinatorTest { @Test fun `a rename is propagated`() = runBlocking { link(SyncMode.COPY) + remote.serverMap = mapOf("h" to "p1") provider.items = listOf(asset("b1/1", name = "b.jpg", hash = "h")) db.syncDao().upsertAssets( listOf( @@ -401,6 +411,112 @@ class SyncCoordinatorTest { assertEquals(listOf("b1/1"), offloader.offloaded) } + @Test + fun `a stale recorded copy is not offloaded but re-uploaded`() = runBlocking { + link(SyncMode.MOVE) + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "h", "p1", 1L, LocalAssetState.UPLOADED.name, + ), + ), + ) + provider.items = listOf(asset("b1/1", hash = "h")) + + coordinator.reconcile("b1") + + assertEquals(1, queue.enqueued.size) + assertTrue("no confirmed copy means no offload", offloader.offloaded.isEmpty()) + } + + @Test + fun `changing the target carries uploaded copies over`() = runBlocking { + link(SyncMode.MOVE) + remote.serverMapByAlbum["srv1"] = mapOf("h" to "p1") + remote.serverMapByAlbum["srv2"] = mapOf("h" to "p1") + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "h", "p1", 1L, LocalAssetState.PREVIEW.name, + ), + ), + ) + provider.items = listOf(asset("b1/1", hash = "preview")) + + val result = coordinator.retarget("b1", "Camera", "srv2", SyncMode.MOVE) + + assertEquals(RetargetResult.Linked(0), result) + assertEquals(listOf(listOf("p1") to "srv2"), remote.moves) + assertEquals("srv2", db.syncDao().getSyncedAlbum("b1")?.serverAlbumId) + } + + @Test + fun `a target change is refused when the previous album is unavailable`() = runBlocking { + link(SyncMode.MOVE) + remote.failChecksumsFor = setOf("srv1") + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "h", "p1", 1L, LocalAssetState.PREVIEW.name, + ), + ), + ) + + val result = coordinator.retarget("b1", "Camera", "srv2", SyncMode.MOVE) + + assertTrue(result is RetargetResult.Refused) + assertEquals("srv1", db.syncDao().getSyncedAlbum("b1")?.serverAlbumId) + assertTrue(remote.moves.isEmpty()) + } + + @Test + fun `a target change is refused when the copies do not arrive`() = runBlocking { + link(SyncMode.MOVE) + remote.serverMapByAlbum["srv1"] = mapOf("h" to "p1") + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "h", "p1", 1L, LocalAssetState.PREVIEW.name, + ), + ), + ) + provider.items = listOf(asset("b1/1", hash = "preview")) + + val result = coordinator.retarget("b1", "Camera", "srv2", SyncMode.MOVE) + + assertTrue(result is RetargetResult.Refused) + assertEquals("srv1", db.syncDao().getSyncedAlbum("b1")?.serverAlbumId) + } + + @Test + fun `a target change reports copies that could not be carried`() = runBlocking { + link(SyncMode.MOVE) + remote.serverMapByAlbum["srv1"] = mapOf("h" to "p1") + remote.serverMapByAlbum["srv2"] = mapOf("h" to "p1") + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "h", "p1", 1L, LocalAssetState.PREVIEW.name, + ), + DeviceAssetEntity( + "b1/2", "b1", 2L, "u", "b.jpg", "image/jpeg", 1L, 1L, + "h2", "p9", 1L, LocalAssetState.ORPHANED.name, + ), + ), + ) + provider.items = listOf(asset("b1/1", hash = "preview"), asset("b1/2", hash = "preview2")) + + val result = coordinator.retarget("b1", "Camera", "srv2", SyncMode.MOVE) + + assertEquals(RetargetResult.Linked(strandedCopies = 1), result) + assertEquals(listOf(listOf("p1") to "srv2"), remote.moves) + } + @Test fun `move delegates to the server`() = runBlocking { coordinator.moveToServerAlbum(listOf("p1", "p2"), "srv2") diff --git a/android/app/src/test/java/com/galo/android/data/sync/SyncPlannerTest.kt b/android/app/src/test/java/com/galo/android/data/sync/SyncPlannerTest.kt index 2b7367b..419e4d4 100644 --- a/android/app/src/test/java/com/galo/android/data/sync/SyncPlannerTest.kt +++ b/android/app/src/test/java/com/galo/android/data/sync/SyncPlannerTest.kt @@ -50,7 +50,7 @@ class SyncPlannerTest { val actions = SyncPlanner.plan( local = emptyList(), index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg")), - serverChecksums = emptyMap(), + serverChecksums = mapOf("h1" to "p1"), hashesPresentElsewhere = emptySet(), ) assertEquals(listOf(SyncAction.DeleteOnServer("p1", "b1/1")), actions) @@ -84,7 +84,7 @@ class SyncPlannerTest { val actions = SyncPlanner.plan( local = listOf(asset), index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg")), - serverChecksums = emptyMap(), + serverChecksums = mapOf("h1" to "p1"), hashesPresentElsewhere = emptySet(), ) assertEquals(listOf(SyncAction.RenameOnServer("p1", "b.jpg")), actions) @@ -152,7 +152,7 @@ class SyncPlannerTest { val actions = SyncPlanner.plan( local = listOf(asset), index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg")), - serverChecksums = emptyMap(), + serverChecksums = mapOf("h1" to "p1"), hashesPresentElsewhere = emptySet(), ) assertEquals( @@ -160,4 +160,28 @@ class SyncPlannerTest { actions, ) } + + @Test + fun `a stale recorded photo id with the original local is uploaded again`() { + val asset = local("b1/1", hash = "h1") + val actions = SyncPlanner.plan( + local = listOf(asset), + index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg")), + serverChecksums = emptyMap(), + hashesPresentElsewhere = emptySet(), + ) + assertEquals(listOf(SyncAction.Upload(asset)), actions) + } + + @Test + fun `a stale recorded photo id whose content is in the target is linked`() { + val asset = local("b1/1", hash = "h1") + val actions = SyncPlanner.plan( + local = listOf(asset), + index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg")), + serverChecksums = mapOf("h1" to "p9"), + hashesPresentElsewhere = emptySet(), + ) + assertEquals(listOf(SyncAction.Link(asset, "p9")), actions) + } } diff --git a/android/app/src/test/java/com/galo/android/data/upload/SyncUploadTest.kt b/android/app/src/test/java/com/galo/android/data/upload/SyncUploadTest.kt new file mode 100644 index 0000000..5855d6c --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/upload/SyncUploadTest.kt @@ -0,0 +1,97 @@ +package com.galo.android.data.upload + +import android.content.Context +import android.net.Uri +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.work.testing.WorkManagerTestInitHelper +import com.galo.android.data.db.GaloDatabase +import com.galo.android.domain.model.UploadStatus +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class SyncUploadTest { + + private lateinit var context: Context + private lateinit var database: GaloDatabase + private lateinit var repository: UploadRepository + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + WorkManagerTestInitHelper.initializeTestWorkManager(context) + database = Room.inMemoryDatabaseBuilder(context, GaloDatabase::class.java) + .allowMainThreadQueries() + .build() + repository = UploadRepository(context, database.uploadDao()) + } + + @After + fun tearDown() { + database.close() + } + + private fun tempFile(name: String): Uri { + val file = File(context.cacheDir, name).apply { writeBytes(ByteArray(8) { it.toByte() }) } + return Uri.fromFile(file) + } + + private suspend fun enqueueSync(deviceAlbumId: String, assetKey: String, wifiOnly: Boolean = false) = + repository.enqueueSync( + deviceAlbumId = deviceAlbumId, + serverAlbumId = "srv-$deviceAlbumId", + assetKey = assetKey, + uri = "content://media/external/images/media/$assetKey", + filename = "$assetKey.jpg", + mimeType = "image/jpeg", + contentHash = "hash-$assetKey", + mode = "COPY", + wifiOnly = wifiOnly, + ) + + @Test + fun `manual uploads without a device album are not reported`() = runTest { + repository.enqueue("srv-1", listOf(tempFile("manual.jpg")), wifiOnly = false) + enqueueSync("b1", "1") + + val sync = repository.observeSyncUploads().first() + + assertEquals(1, sync.size) + assertEquals("b1", sync.single().deviceAlbumId) + } + + @Test + fun `sync uploads keep their own device album`() = runTest { + enqueueSync("b1", "1") + enqueueSync("b2", "2") + + val byDevice = repository.observeSyncUploads().first().associateBy { it.deviceAlbumId } + + assertEquals(setOf("b1", "b2"), byDevice.keys) + assertEquals(UploadStatus.QUEUED, byDevice.getValue("b1").status) + assertEquals(UploadStatus.QUEUED, byDevice.getValue("b2").status) + } + + @Test + fun `progress and wifi flag follow the job state`() = runTest { + enqueueSync("b1", "1", wifiOnly = true) + val job = database.uploadDao().observeSyncJobs().first().single() + repository.updateState(job.id, UploadStatus.RUNNING, 42, null, 0, null) + + val sync = repository.observeSyncUploads().first().single() + + assertEquals(UploadStatus.RUNNING, sync.status) + assertEquals(42, sync.progress) + assertEquals(true, sync.wifiOnly) + } +} diff --git a/android/app/src/test/java/com/galo/android/domain/sync/AlbumSyncProgressTest.kt b/android/app/src/test/java/com/galo/android/domain/sync/AlbumSyncProgressTest.kt new file mode 100644 index 0000000..346ccf5 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/domain/sync/AlbumSyncProgressTest.kt @@ -0,0 +1,206 @@ +package com.galo.android.domain.sync + +import com.galo.android.domain.model.SyncMode +import com.galo.android.domain.model.SyncUpload +import com.galo.android.domain.model.UploadStatus +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 AlbumSyncProgressTest { + + private fun upload( + status: UploadStatus, + progress: Int = 0, + wifiOnly: Boolean = false, + deviceAlbumId: String = "b1", + ) = SyncUpload(deviceAlbumId, status, progress, wifiOnly) + + @Test + fun `sync off has no card status`() { + assertNull(AlbumSyncProgress.resolve(SyncMode.DISABLED, emptyList(), uploaded = 0, total = 5)) + } + + @Test + fun `a running upload pulses with the operation percentage`() { + val progress = AlbumSyncProgress.resolve( + SyncMode.COPY, + listOf(upload(RUNNING, progress = 50)), + uploaded = 0, + total = 4, + )!! + + assertTrue(progress.running) + assertTrue(progress.pulsing) + assertEquals(12, progress.percent) + assertEquals("Копии 12%", progress.cardText()) + } + + @Test + fun `queued without active work shows the count and does not pulse`() { + val progress = AlbumSyncProgress.resolve( + SyncMode.COPY, + listOf(upload(QUEUED), upload(QUEUED)), + uploaded = 1, + total = 5, + )!! + + assertFalse(progress.running) + assertFalse(progress.pulsing) + assertNull(progress.percent) + assertEquals(2, progress.queued) + assertEquals("Копии 2 в очереди", progress.cardText()) + } + + @Test + fun `a wifi-only wait is called out and does not pulse`() { + val progress = AlbumSyncProgress.resolve( + SyncMode.COPY, + listOf(upload(QUEUED, wifiOnly = true)), + uploaded = 0, + total = 5, + )!! + + assertTrue(progress.waitingForWifi) + assertFalse(progress.pulsing) + assertEquals("Копии — ждёт Wi-Fi", progress.cardText()) + } + + @Test + fun `a waiting job without the wifi constraint is just queued`() { + val progress = AlbumSyncProgress.resolve( + SyncMode.COPY, + listOf(upload(QUEUED, wifiOnly = false)), + uploaded = 0, + total = 5, + )!! + + assertFalse(progress.waitingForWifi) + assertEquals("Копии 1 в очереди", progress.cardText()) + } + + @Test + fun `a failed upload is shown instead of the label`() { + val progress = AlbumSyncProgress.resolve( + SyncMode.COPY, + listOf(upload(ERROR)), + uploaded = 3, + total = 5, + )!! + + assertTrue(progress.failed) + assertFalse(progress.pulsing) + assertEquals("Копии — ошибка", progress.cardText()) + } + + @Test + fun `an idle operation shows only the mode label`() { + val progress = AlbumSyncProgress.resolve( + SyncMode.COPY, + emptyList(), + uploaded = 3, + total = 5, + )!! + + assertFalse(progress.running) + assertNull(progress.percent) + assertEquals("Копии", progress.cardText()) + } + + @Test + fun `the percentage is clamped to a hundred`() { + val progress = AlbumSyncProgress.resolve( + SyncMode.COPY, + listOf(upload(RUNNING, progress = 100)), + uploaded = 10, + total = 4, + )!! + + assertEquals(100, progress.percent) + } + + @Test + fun `an empty album has no percentage to show`() { + val progress = AlbumSyncProgress.resolve( + SyncMode.COPY, + listOf(upload(RUNNING, progress = 50)), + uploaded = 0, + total = 0, + )!! + + assertNull(progress.percent) + assertFalse(progress.pulsing) + assertEquals("Копии", progress.cardText()) + } + + @Test + fun `a lost server copy overrides the operation state`() { + val progress = AlbumSyncProgress.resolve( + SyncMode.COPY, + listOf(upload(RUNNING, progress = 50)), + uploaded = 1, + total = 4, + missingCopies = 2, + )!! + + assertEquals(2, progress.missingCopies) + assertFalse(progress.pulsing) + assertEquals("Копии — потеряна копия: 2", progress.cardText()) + } + + @Test + fun `move uploads show their own percentage`() { + val progress = AlbumSyncProgress.resolve( + SyncMode.MOVE, + listOf(upload(RUNNING, progress = 50)), + uploaded = 0, + total = 4, + )!! + + assertEquals(12, progress.percent) + assertTrue(progress.pulsing) + assertEquals("Перенос 12%", progress.cardText()) + } + + @Test + fun `an idle move shows only its label`() { + val progress = AlbumSyncProgress.resolve( + SyncMode.MOVE, + emptyList(), + uploaded = 2, + total = 4, + )!! + + assertNull(progress.percent) + assertFalse(progress.pulsing) + assertEquals("Перенос", progress.cardText()) + } + + @Test + fun `finishing one file does not drop the percentage`() { + val before = AlbumSyncProgress.resolve( + SyncMode.COPY, + listOf(upload(RUNNING, progress = 50), upload(RUNNING, progress = 50)), + uploaded = 0, + total = 4, + )!! + val after = AlbumSyncProgress.resolve( + SyncMode.COPY, + listOf(upload(RUNNING, progress = 50)), + uploaded = 1, + total = 4, + )!! + + assertTrue(after.percent!! >= before.percent!!) + assertEquals(25, before.percent) + assertEquals(37, after.percent) + } + + private companion object { + val RUNNING = UploadStatus.RUNNING + val QUEUED = UploadStatus.QUEUED + val ERROR = UploadStatus.ERROR + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/albums/AlbumListBuilderTest.kt b/android/app/src/test/java/com/galo/android/ui/albums/AlbumListBuilderTest.kt index 875300d..72dc0a9 100644 --- a/android/app/src/test/java/com/galo/android/ui/albums/AlbumListBuilderTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/albums/AlbumListBuilderTest.kt @@ -5,6 +5,7 @@ import com.galo.android.domain.model.AlbumSort import com.galo.android.domain.model.AlbumSortChoice import com.galo.android.domain.model.DeviceAlbum import com.galo.android.domain.model.SyncMode +import com.galo.android.domain.sync.AlbumSyncProgress import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -81,6 +82,33 @@ class AlbumListBuilderTest { assertEquals(SyncMode.COPY, deviceItem.mode) } + @Test + fun `a device card carries its sync status and server cards carry none`() { + val status = AlbumSyncProgress.resolve(SyncMode.COPY, emptyList(), uploaded = 1, total = 3)!! + val items = AlbumListBuilder.build( + albums = listOf(album("s1", "Отпуск"), album("s2", "Работа")), + links = listOf(AlbumLink("b1", "Camera", "s1", SyncMode.COPY)), + devices = listOf(device("b1", "Camera")), + progressByDevice = mapOf("b1" to status), + ) + + val deviceItem = items.filterIsInstance().single() + assertEquals(status, deviceItem.progress) + // The independent server album has no operation to report. + assertEquals("s2", items.filterIsInstance().single().album.id) + } + + @Test + fun `a device album without a status stays null`() { + val items = AlbumListBuilder.build( + albums = emptyList(), + links = listOf(AlbumLink("b1", "Camera", "", SyncMode.COPY)), + devices = listOf(device("b1", "Camera")), + ) + + assertEquals(null, (items.single() as AlbumListItem.DeviceAlbumItem).progress) + } + /** * Device b1 "Alpha" changed between s1 and s2, so each key gives its own order: * updated s2,b1,s1 / title b1,s2,s1 / photos b1,s1,s2. diff --git a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt index f2f8bc5..cba77bd 100644 --- a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt @@ -15,6 +15,9 @@ import com.galo.android.domain.model.Album import com.galo.android.domain.model.AlbumSortChoice import com.galo.android.domain.model.DeviceAlbum import com.galo.android.domain.model.SyncMode +import com.galo.android.domain.model.SyncUpload +import com.galo.android.domain.model.UploadStatus +import com.galo.android.domain.sync.AlbumSyncProgress import com.galo.android.ui.common.TAG_USER_AVATAR_IMAGE import com.galo.android.ui.common.TAG_USER_AVATAR_PLACEHOLDER import com.galo.android.ui.theme.GaloTheme @@ -90,6 +93,8 @@ class AlbumsContentTest { 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() + // A server album has no synchronization mode or operation to report. + compose.onNodeWithTag(TAG_ALBUM_STATUS_PREFIX + "a1", useUnmergedTree = true).assertDoesNotExist() } @Test @@ -126,6 +131,118 @@ class AlbumsContentTest { 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() + // Sync is off, so the card has no third line. + compose.onNodeWithTag(TAG_ALBUM_STATUS_PREFIX + "b1", useUnmergedTree = true).assertDoesNotExist() + } + + @Test + fun `move shows the memory card, the cloud and its mode label`() { + val status = AlbumSyncProgress.resolve(SyncMode.MOVE, emptyList(), uploaded = 0, total = 5)!! + val items = AlbumListBuilder.build( + albums = listOf(album("a1", "Отпуск")), + links = listOf(AlbumLink("b1", "Camera", "a1", SyncMode.MOVE)), + devices = listOf(device("b1", "Camera", 5)), + progressByDevice = mapOf("b1" to status), + ) + compose.setContent { + screen(AlbumsViewModel.UiState(items = items, deviceAccess = true)) + } + compose.onNodeWithTag(TAG_ALBUM_STORAGE_DEVICE_PREFIX + "b1", useUnmergedTree = true).assertExists() + compose.onNodeWithTag(TAG_ALBUM_STORAGE_CLOUD_PREFIX + "b1", useUnmergedTree = true).assertExists() + compose.onNodeWithText("Перенос").assertIsDisplayed() + } + + @Test + fun `copies without an operation show only the mode label`() { + val status = AlbumSyncProgress.resolve(SyncMode.COPY, emptyList(), uploaded = 2, total = 5)!! + val items = AlbumListBuilder.build( + albums = listOf(album("a1", "Отпуск")), + links = listOf(AlbumLink("b1", "Camera", "a1", SyncMode.COPY)), + devices = listOf(device("b1", "Camera", 5)), + progressByDevice = mapOf("b1" to status), + ) + compose.setContent { + screen(AlbumsViewModel.UiState(items = items, deviceAccess = true)) + } + compose.onNodeWithTag(TAG_ALBUM_STATUS_PREFIX + "b1", useUnmergedTree = true).assertExists() + compose.onNodeWithText("Копии").assertIsDisplayed() + } + + @Test + fun `a running copy shows the operation percentage`() { + val status = AlbumSyncProgress.resolve( + SyncMode.COPY, + listOf(SyncUpload("b1", UploadStatus.RUNNING, progress = 50, wifiOnly = false)), + uploaded = 1, + total = 4, + )!! + val items = AlbumListBuilder.build( + albums = listOf(album("a1", "Отпуск")), + links = listOf(AlbumLink("b1", "Camera", "a1", SyncMode.COPY)), + devices = listOf(device("b1", "Camera", 4)), + progressByDevice = mapOf("b1" to status), + ) + compose.setContent { + screen(AlbumsViewModel.UiState(items = items, deviceAccess = true)) + } + compose.onNodeWithTag(TAG_ALBUM_STATUS_PREFIX + "b1", useUnmergedTree = true).assertExists() + // (1 uploaded + 0.5 running) / 4 = 37% + compose.onNodeWithText("Копии 37%").assertIsDisplayed() + } + + @Test + fun `a running move shows the upload percentage`() { + val status = AlbumSyncProgress.resolve( + SyncMode.MOVE, + listOf(SyncUpload("b1", UploadStatus.RUNNING, progress = 50, wifiOnly = false)), + uploaded = 1, + total = 4, + )!! + val items = AlbumListBuilder.build( + albums = listOf(album("a1", "Отпуск")), + links = listOf(AlbumLink("b1", "Camera", "a1", SyncMode.MOVE)), + devices = listOf(device("b1", "Camera", 4)), + progressByDevice = mapOf("b1" to status), + ) + compose.setContent { + screen(AlbumsViewModel.UiState(items = items, deviceAccess = true)) + } + compose.onNodeWithText("Перенос 37%").assertIsDisplayed() + } + + @Test + fun `a failed copy shows the error state`() { + val status = AlbumSyncProgress.resolve( + SyncMode.COPY, + listOf(SyncUpload("b1", UploadStatus.ERROR, progress = 0, wifiOnly = false)), + uploaded = 1, + total = 4, + )!! + val items = AlbumListBuilder.build( + albums = listOf(album("a1", "Отпуск")), + links = listOf(AlbumLink("b1", "Camera", "a1", SyncMode.COPY)), + devices = listOf(device("b1", "Camera", 4)), + progressByDevice = mapOf("b1" to status), + ) + compose.setContent { + screen(AlbumsViewModel.UiState(items = items, deviceAccess = true)) + } + compose.onNodeWithText("Копии — ошибка").assertIsDisplayed() + } + + @Test + fun `a device album with lost copies warns on its card`() { + val status = AlbumSyncProgress.resolve(SyncMode.COPY, emptyList(), uploaded = 1, total = 4, missingCopies = 2)!! + val items = AlbumListBuilder.build( + albums = listOf(album("a1", "Отпуск")), + links = listOf(AlbumLink("b1", "Camera", "a1", SyncMode.COPY)), + devices = listOf(device("b1", "Camera", 4)), + progressByDevice = mapOf("b1" to status), + ) + compose.setContent { + screen(AlbumsViewModel.UiState(items = items, deviceAccess = true)) + } + compose.onNodeWithText("Копии — потеряна копия: 2").assertIsDisplayed() } @Test diff --git a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsViewModelTest.kt b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsViewModelTest.kt index 753fbcb..34c4eb0 100644 --- a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsViewModelTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsViewModelTest.kt @@ -9,6 +9,10 @@ import com.galo.android.data.repo.LiveAlbumSource import com.galo.android.di.AppContainer import com.galo.android.domain.model.AlbumSort import com.galo.android.domain.model.AlbumSortChoice +import com.galo.android.domain.model.DeviceAlbum +import com.galo.android.domain.model.SyncMode +import com.galo.android.domain.model.SyncUpload +import com.galo.android.domain.model.UploadStatus import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -20,6 +24,7 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -113,6 +118,65 @@ class AlbumsViewModelTest { } } + @Test + fun `the album status combines the queue with the provenance count`() { + val device = DeviceAlbum( + id = "b1", + name = "Camera", + itemCount = 4, + coverUri = null, + relativePath = "DCIM/Camera/", + ) + + val progress = albumSyncProgressByDevice( + devices = listOf(device), + modeByDevice = mapOf("b1" to SyncMode.COPY), + uploads = listOf(SyncUpload("b1", UploadStatus.RUNNING, progress = 50, wifiOnly = false)), + uploadedCounts = mapOf("b1" to 1), + ).getValue("b1") + + // (1 uploaded + 0.5 running) / 4 = 37% + assertEquals(37, progress.percent) + assertEquals("Копии 37%", progress.cardText()) + } + + @Test + fun `a lost server copy is reflected in the album status`() { + val device = DeviceAlbum( + id = "b1", + name = "Camera", + itemCount = 4, + coverUri = null, + relativePath = "DCIM/Camera/", + ) + + val progress = albumSyncProgressByDevice( + devices = listOf(device), + modeByDevice = mapOf("b1" to SyncMode.COPY), + uploads = emptyList(), + uploadedCounts = mapOf("b1" to 1), + missingCounts = mapOf("b1" to 2), + ).getValue("b1") + + assertEquals(2, progress.missingCopies) + assertEquals("Копии — потеряна копия: 2", progress.cardText()) + } + + @Test + fun `an album with sync off has no status`() { + val device = DeviceAlbum( + id = "b1", + name = "Camera", + itemCount = 4, + coverUri = null, + relativePath = "DCIM/Camera/", + ) + + val progress = albumSyncProgressByDevice(listOf(device), emptyMap(), emptyList(), emptyMap()) + + assertTrue(progress.isEmpty()) + } + /** Waits for the server block to settle on [expected], then returns what it saw. */ private suspend fun awaitServerIds(viewModel: AlbumsViewModel, expected: List): List { val deadline = System.currentTimeMillis() + 10_000 diff --git a/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumContentTest.kt b/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumContentTest.kt index 77bfdf2..b9468bd 100644 --- a/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumContentTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumContentTest.kt @@ -96,6 +96,8 @@ class DeviceAlbumContentTest { onDeleteAlbum: () -> Unit = {}, onRenameAlbum: (String) -> Unit = {}, onSetCover: (String?) -> Unit = {}, + notice: String? = null, + onDismissNotice: () -> Unit = {}, ) { GaloTheme(dynamicColor = false) { DeviceAlbumContent( @@ -112,6 +114,8 @@ class DeviceAlbumContentTest { onDeleteAlbum = onDeleteAlbum, onRenameAlbum = onRenameAlbum, onSetCover = onSetCover, + notice = notice, + onDismissNotice = onDismissNotice, ) } } @@ -340,6 +344,45 @@ class DeviceAlbumContentTest { compose.onNodeWithTag(TAG_TRANSFERRED_PREFIX + 1, useUnmergedTree = true).assertDoesNotExist() } + @Test + fun `a lost server copy shows a warning banner`() { + compose.setContent { + content( + DeviceAlbumViewModel.UiState( + name = "Camera", + items = listOf(media(1), media(2)), + orphanedMediaIds = setOf(1L), + ), + ) + } + compose.onNodeWithTag(TAG_DEVICE_LOST_COPY).assertIsDisplayed() + compose.onNodeWithText("Нет серверной копии: 1. Превью сохранены и не удаляются автоматически.") + .assertIsDisplayed() + } + + @Test + fun `no banner without lost copies`() { + compose.setContent { + content(DeviceAlbumViewModel.UiState(name = "Camera", items = listOf(media(1)))) + } + compose.onNodeWithTag(TAG_DEVICE_LOST_COPY).assertDoesNotExist() + } + + @Test + fun `a notice is shown and dismissed`() { + var dismissed = false + compose.setContent { + content( + DeviceAlbumViewModel.UiState(name = "Camera", items = listOf(media(1))), + notice = "Не удалось перенести фото в новый альбом.", + onDismissNotice = { dismissed = true }, + ) + } + compose.onNodeWithText("Не удалось перенести фото в новый альбом.").assertIsDisplayed() + compose.onNodeWithText("Понятно").performClick() + assertTrue(dismissed) + } + @Test fun `settings panel offers the three sync modes`() { compose.setContent { settings(DeviceAlbumViewModel.UiState(name = "Camera")) } diff --git a/openspec/changes/android-album-card-sync-status/.openspec.yaml b/openspec/changes/android-album-card-sync-status/.openspec.yaml new file mode 100644 index 0000000..0cb4765 --- /dev/null +++ b/openspec/changes/android-album-card-sync-status/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-16 diff --git a/openspec/changes/android-album-card-sync-status/design.md b/openspec/changes/android-album-card-sync-status/design.md new file mode 100644 index 0000000..2364de3 --- /dev/null +++ b/openspec/changes/android-album-card-sync-status/design.md @@ -0,0 +1,114 @@ +# Design + +## Context + +См. `proposal.md` — Why. Ниже только то, что определяет подход. + +- Карточка собирается из данных без представления о синхронизации: `AlbumListBuilder.build` + получает `List` (серверные), `List` и `List`, а + `AlbumsScreen.AlbumCardBody` рисует вторую строку по `StoragePlacement`, выведенному из + `SyncMode`. +- Очередь загрузки уже привязана к альбому устройства: `UploadJobEntity.deviceAlbumId` и + `assetKey` заполняются `enqueueSync`, но доменная модель `UploadJob` эти поля отбрасывает, + а `UploadDao` умеет только `observeAll()`. +- Индекс провенанса (`device_assets`) хранит строку на каждый ассет, у которого есть серверное + фото (`serverPhotoId`), и состояние из `LocalAssetState`; `SyncDao` читает его по одному + альбому (`observeAssets`), агрегата «сколько загружено по всем альбомам» нет. +- Прогресс пишется пофайлово: `StreamingRequestBody` считает `sent/total`, `UploadWorker` + сохраняет `progress` 0..100 на RUNNING-джобе (throttled по изменению процента). +- `job.size` для sync-джобов равен `0L` (реальная длина берётся из file descriptor в момент + отправки), поэтому байтовое взвешивание по очереди недоступно без изменения схемы. +- В приложении нет ни одной бесконечной анимации: `rememberInfiniteTransition` не + используется, «уменьшение движения» нигде не обрабатывается. + +## Goals / Non-Goals + +**Goals:** + +- Различить режимы B и C на карточке, не теряя информацию о локальной копии в C. +- Показать ход операции копирования прямо на карточке, включая ожидание и ошибку. +- Держать прогресс монотонным и вычислять его из уже существующих данных (без изменения схемы + Room). +- Сохранить чистую, тестируемую сборку списка. + +**Non-Goals:** + +- Прогресс показывается для обоих синхронизируемых режимов (B и C): и копии, и перенос + загружают оригиналы, поэтому процент операции не должен зависеть от режима. +- Ручные загрузки в серверный альбом (у них `deviceAlbumId = null`, отдельный экран). +- Изменение самой синхронизации, её политик или серверной части. +- Веб-клиент. + +## Decisions + +**Метка режима вместо вариантов облака.** Третья строка называет режим («Копии»/«Перенос»), +а значок карты памяти показывается и в C. Альтернатива — варианты глифа облака +(«облако↑»/«облако✓») — отклонена: в сетке медиа те же облако/перечёркнутое облако уже +означают состояние **элемента**, и третья трактовка глифа запутала бы. Альтернатива +«переопределить SD как «есть локальная копия», режимы не различать» отклонена: B и C +сливаются, а режим — главное, что различает карточки. + +**SD в режиме переноса означает «есть локальная копия» (превью), а не «оригинал здесь».** +Трактовка расширяется относительно текущей спеки; описана в delta-спеке явно. + +**Формула процента — гибрид, а не просто счётчик.** `percent = clamp((uploaded + running) / +total * 100)`, где `uploaded` — число строк индекса с `serverPhotoId`, `running` — сумма +`progress/100` по RUNNING-джобам альбома, `total` — `itemCount` альбома устройства. Формула +общая для копий и переноса. Альтернативы: `uploaded/total` замирает на большом видео; +среднее по `job.progress` «едет назад», потому что `clearCompleted` удаляет SUCCESS-джобы и +знаменатель тает. Гибрид монотонен: при завершении файла его дробь `1.0` уходит из `running`, +а `uploaded` увеличивается на единицу. + +**Процент считается по альбому, а не по текущей очереди.** Знаменатель — полное число +элементов альбома, поэтому добавление новых фото корректно понижает процент, а не +перезапускает его. Дедуп (`SyncAction.Link`, без загрузки) тоже засчитывается как «дошло» и +может дать скачок вверх — это осознанно. + +**Пульсация включается только при наличии RUNNING-загрузок.** Иначе альбом, ждущий Wi‑Fi +(`wifiOnlyUploads` + отсутствие `UNMETERED`), пульсировал бы вечно. Состояния разводятся по +статусу: `RUNNING` → процент с пульсацией, `QUEUED` → «в очереди», `QUEUED` при Wi‑Fi-only → +«ждёт Wi‑Fi», `ERROR` → акцент ошибки, ничего нет → «Копии». + +**Данные для списка — агрегатные запросы, а не N подписок.** В `UploadDao` добавляется поток +sync-джобов (`deviceAlbumId IS NOT NULL`), в `SyncDao` — `GROUP BY deviceAlbumId` по числу +строк с `serverPhotoId`. Оба потока сводятся в `AlbumsViewModel` вместе с существующей +`combine`, а в `AlbumListBuilder` попадает готовая карта «deviceAlbumId → статус», поэтому +сама сборка остаётся чистой функцией и тестируется без Room. + +**Новый доменный тип статуса.** `AlbumSyncProgress` (режим + процент/счётчик очереди/ошибка/ +ожидание Wi‑Fi) с чистой функцией сборки из (режим, агрегат очереди, число загруженных, +`itemCount`). Альтернатива — тащить поля в `AlbumListItem` по отдельности — отклонена: +размазывает правило и усложняет тесты. + +**Пульсация — альфа-анимация текста.** `rememberInfiniteTransition` поверх прозрачности +процента; полосы нет. Дешёвая анимация в одной строке, не меняет высоту плитки. + +**Граничные случаи.** `total == 0` → процент не показывается, состояние падает в очередь/ +простой. Неизвестный режим в БД по-прежнему трактуется как `DISABLED` (существующее +поведение `SyncMode.from`). + +## Risks / Trade-offs + +- [Процент скачет из-за дедупа] → принято: скачок вверх честно отражает, что файл уже был на + сервере; монотонность важнее равномерности. +- [`total` из MediaStore и индекс расходятся (reconcile ещё не проходил)] → процент стартует + не с нуля и может превысить 100; клампим и опираемся на статус очереди, а не только на число. +- [Несколько загрузок параллельно делают `running` многократным] → сумма дробей корректна, но + процент может «забегать» вперёд при параллелизме; это допустимо для индикатора хода. +- [Первая в приложении бесконечная анимация] → нет обработки «уменьшения движения»; риск для + пользователей с motion sensitivity. Митигация: пульсирует только текст и только при активной + загрузке; вопрос reduce-motion вынесен в открытые. +- [Производительность: агрегаты на каждую эмиссию списка] → запросы покрыты индексами + (`upload_jobs.status`, `upload_jobs.albumId`) и `GROUP BY` по `device_assets`; объёмы на + устройстве невелики. +- [Провал, попавший в `ERROR` после `MAX_ATTEMPTS`, оставляет процент ниже 100] → карточка + показывает состояние ошибки вместо тихого плато. + +## Migration Plan + +Схема Room не меняется, миграций нет. Раскатка — обычная сборка APK; откат — релиз без +изменения (старые данные совместимы, поле `deviceAlbumId` уже присутствует). + +## Open Questions + +- Нужна ли поддержка «уменьшения движения» (отключение пульсации по системной настройке). diff --git a/openspec/changes/android-album-card-sync-status/proposal.md b/openspec/changes/android-album-card-sync-status/proposal.md new file mode 100644 index 0000000..5b65de7 --- /dev/null +++ b/openspec/changes/android-album-card-sync-status/proposal.md @@ -0,0 +1,62 @@ +# Proposal + +## Why + +Карточка альбома в Android-клиенте кодирует режим синхронизации одними значками, и это +перестало быть информативным. В режиме переноса (C) на телефоне остаются превью, но +карточка показывает только облако — «локальной копии» не видно. Добавить карте памяти в C +нельзя без последствий: тогда карты памяти и облако будут у копий (B) и переноса (C) +одинаковы, и режимы станут неразличимы. + +Отдельно невидим прогресс копирования (B). Единственный след работы — счётчик в рейле и +экран «Загрузки»; на самой карточке не понять, сколько уже на сервере, идёт ли заливка, +ждёт ли она Wi‑Fi или упала. Провал заливки сейчас вообще не проявляется в списке альбомов. + +## What Changes + +- Карточка альбома устройства получает **метку режима** третьей строкой: «Копии» для B, + «Перенос» для C, ничего для выключенной синхронизации (A) и серверных альбомов. +- Карта памяти (SD) теперь показывается и для режима переноса: `[SD][Cloud]`. Режимы + различаются не значками, а меткой режима, поэтому SD читается как «есть локальная копия» + (оригинал в B, превью в C). +- Для режима копий третья строка показывает **процент операции**, пока заливка активна: + `«Копии 62%»`. Текст процента пульсирует, пока есть работающие загрузки. +- Состояния вместо/рядом с процентом: `«Копии 8 в очереди»` для ожидающих, `«Копии — ждёт + Wi‑Fi»` для ожидающих по ограничению сети, `«Копии — ошибка»` при провале, просто + `«Копии»` в простое. +- Процент считается по альбому: `(uploaded + running) / total`, где `uploaded` — записи + индекса провенанса с серверным фото, `running` — дроби `progress/100` работающих загрузок, + `total` — число элементов альбома устройства. Формула держит монотонность и не «едет + назад» при завершении файлов. +- Пульсация включается только при наличии работающей загрузки, поэтому альбом, ждущий + Wi‑Fi, не выглядит как активный. +- **BREAKING**: нет. Для серверных альбомов и выключенной синхронизации карточка не меняется; + меняется только вид карточек альбомов устройства с включённой синхронизацией. + +## Capabilities + +### New Capabilities + +_Нет — поведение расширяет существующую способность `android-client`._ + +### Modified Capabilities + +- `android-client`: требование «Подпись карточки альбома» переформулируется (метка режима + третьей строкой; карта памяти показывается и для переноса) и дополняется новым + требованием о прогрессе операции копирования с его состояниями. + +## Impact + +- **Код (Android):** `ui/albums/AlbumListItem` (модель строки: метка режима и прогресс), + `ui/albums/AlbumListBuilder` (сборка прогресса в элемент списка), `ui/albums/AlbumsViewModel` + (агрегат очереди загрузок и индекса), `ui/albums/AlbumsScreen` (`AlbumCardBody`: третья + строка, пульсация), доменная модель `domain/model/Upload` (поле привязки к альбому + устройства), `data/upload/UploadRepository`, `data/db/dao/UploadDao` и `SyncDao` + (счётные запросы по альбому). +- **Схема Room:** новых таблиц/колонок не требуется — `upload_jobs.deviceAlbumId` и + `device_assets` уже хранят всё нужное; добавляются только запросы. +- **API и бэкенд:** не затрагиваются. +- **Веб-клиент:** не затрагивается. +- **Не затрагивается / отдельно:** прогресс в фазе загрузки режима переноса (C) — тот же + механизм применим, но в объём этого изменения не входит; ручные загрузки в серверный + альбом на карточке не отражаются (у них нет привязки к альбому устройства). diff --git a/openspec/changes/android-album-card-sync-status/specs/android-client/spec.md b/openspec/changes/android-album-card-sync-status/specs/android-client/spec.md new file mode 100644 index 0000000..a44f993 --- /dev/null +++ b/openspec/changes/android-album-card-sync-status/specs/android-client/spec.md @@ -0,0 +1,73 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Подпись карточки альбома +Карточка альбома SHALL показывать в первой строке имя альбома, а для связанной пары — со +стрелкой имя связанного серверного альбома, и только когда имена различаются. Вторая строка +SHALL показывать значками расположение содержимого и число элементов: значок карты памяти для +локального без синхронизации, значки карты памяти и облака для режимов копий и переноса; для +серверного альбома — значок облака. Третья строка SHALL называть режим синхронизации +(«Копии» или «Перенос») и SHALL отсутствовать у выключенной синхронизации и у серверного +альбома. + +#### Scenario: Локальный без синхронизации +- **WHEN** показан локальный альбом без синхронизации +- **THEN** вторая строка показывает значок карты памяти и число элементов, а третьей строки нет + +#### Scenario: Режим копий +- **WHEN** локальный альбом синхронизируется в режиме копий +- **THEN** вторая строка показывает значки карты памяти и облака и число элементов, а третья называет режим «Копии» + +#### Scenario: Режим переноса +- **WHEN** локальный альбом синхронизируется в режиме переноса +- **THEN** вторая строка показывает значки карты памяти и облака и число элементов, а третья называет режим «Перенос» + +#### Scenario: Серверный альбом +- **WHEN** показан серверный альбом +- **THEN** вторая строка показывает значок облака и число фото, а третьей строки нет + +#### Scenario: Разные имена связанной пары +- **WHEN** имя локального альбома не совпадает с именем связанного серверного +- **THEN** первая строка показывает «имя → имя серверного» + +#### Scenario: Одинаковые имена +- **WHEN** имена совпадают +- **THEN** стрелка и имя серверного не показываются + +## ADDED Requirements + +### Requirement: Прогресс операции загрузки +Для альбома устройства с включённой синхронизацией (копии или перенос) карточка SHALL +показывать ход загрузки в третьей строке: процент, пока есть работающие загрузки (текст +пульсирует); число ожидающих без активной работы; отдельные состояния ожидания Wi-Fi и +ошибки; в простое — только метку режима. Процент SHALL считаться по альбому и SHALL NOT +убывать при завершении отдельных файлов. + +#### Scenario: Идёт копирование +- **WHEN** у альбома в режиме копий есть хотя бы одна работающая загрузка +- **THEN** третья строка показывает «Копии <процент>%», и текст пульсирует + +#### Scenario: Идёт перенос +- **WHEN** у альбома в режиме переноса есть хотя бы одна работающая загрузка +- **THEN** третья строка показывает «Перенос <процент>%», и текст пульсирует + +#### Scenario: Очередь без активной работы +- **WHEN** загрузки альбома ожидают, но ни одна не работает +- **THEN** третья строка показывает число ожидающих, и пульсации нет + +#### Scenario: Ожидание Wi-Fi +- **WHEN** загрузки ждут только из-за ограничения «только Wi-Fi» +- **THEN** третья строка сообщает ожидание Wi-Fi, и пульсации нет + +#### Scenario: Ошибка +- **WHEN** последняя попытка загрузки файла провалилась +- **THEN** третья строка показывает ошибку выделенным цветом, и пульсации нет + +#### Scenario: Простой +- **WHEN** незавершённых загрузок нет +- **THEN** третья строка показывает только метку режима + +#### Scenario: Монотонность +- **WHEN** один файл завершается, пока остальные ещё загружаются +- **THEN** показанный процент не уменьшается diff --git a/openspec/changes/android-album-card-sync-status/tasks.md b/openspec/changes/android-album-card-sync-status/tasks.md new file mode 100644 index 0000000..b86ca5d --- /dev/null +++ b/openspec/changes/android-album-card-sync-status/tasks.md @@ -0,0 +1,32 @@ +# Tasks + +## 1. Агрегаты данных для карточки + +- [x] 1.1 Добавить в `UploadDao`/`UploadRepository` поток sync-загрузок с полями `deviceAlbumId`, `status`, `progress`, `wifiOnly` (джобы ручной загрузки с `deviceAlbumId = null` исключены) и доменную модель для него; проверить тестом в `data/repo`, что выборка не смешивает альбомы и отбрасывает ручные загрузки (`cd android && ./gradlew test --tests "*SyncUpload*"`). +- [x] 1.2 Добавить в `SyncDao` групповой запрос числа ассетов с непустым `serverPhotoId` по `deviceAlbumId` и метод в `SyncRepository`; проверить тестом, что альбом без серверных фото даёт 0, а альбом с провенансом — фактическое число (`cd android && ./gradlew test --tests "*SyncRepository*"`). + +## 2. Модель статуса синхронизации карточки + +- [x] 2.1 Добавить доменный тип `AlbumSyncProgress` и чистую функцию сборки из (режим, список sync-загрузок альбома, число загруженных, `itemCount`); покрыть юнит-тестами пульсацию при `RUNNING`, «в очереди» без активных, «ждёт Wi‑Fi» для `QUEUED` + `wifiOnly`, ошибку при `ERROR`, простой без загрузок, кламп процента и случай `total == 0` (`cd android && ./gradlew test --tests "*AlbumSyncProgress*"`). +- [x] 2.2 Покрыть тестами монотонность формулы: при завершении одного файла из нескольких показанный процент не уменьшается (`cd android && ./gradlew test --tests "*AlbumSyncProgress*"`). + +## 3. Сборка списка + +- [x] 3.1 Расширить `AlbumListItem.DeviceAlbumItem` статусом и принимать карту «deviceAlbumId → AlbumSyncProgress» в `AlbumListBuilder.build`; проверить в `AlbumListBuilderTest`, что статус попадает в нужную карточку, а серверные карточки остаются без него (`cd android && ./gradlew test --tests "*AlbumListBuilderTest"`). +- [x] 3.2 Связать агрегаты (задачи 1.1–1.2) с существующей `combine` в `AlbumsViewModel` и покрыть `AlbumsViewModelTest` случаем, когда очередь и индекс дают ожидаемый статус для альбома устройства (`cd android && ./gradlew test --tests "*AlbumsViewModelTest"`). + +## 4. Карточка альбома + +- [x] 4.1 Изменить `placementOf` так, чтобы режим переноса давал `DEVICE_AND_CLOUD` (карта памяти + облако); обновить/добавить проверку в `AlbumsContentTest` для трёх режимов (`cd android && ./gradlew test --tests "*AlbumsContentTest"`). +- [x] 4.2 Добавить третью строку карточки: метка режима («Копии»/«Перенос») и текст статуса копий (процент, «в очереди», «ждёт Wi‑Fi», «ошибка»); процент пульсирует через `rememberInfiniteTransition` только при `RUNNING`; покрыть `AlbumsContentTest` и завести тест-теги для строки статуса (`cd android && ./gradlew test --tests "*AlbumsContentTest"`). + +- [x] 4.3 Показывать процент операции и в режиме переноса: загрузка оригиналов идёт и в C, поэтому процент считается по общей формуле для B и C, а метка даёт «Перенос <процент>%»; покрыть `AlbumSyncProgressTest` и `AlbumsContentTest` (`cd android && ./gradlew :app:testDebugUnitTest --tests "*AlbumSyncProgressTest" --tests "*AlbumsContentTest"`). + +## 5. Интеграционная проверка + +- [x] 5.1 Прогнать весь Android-набор и убедиться, что он зелёный, затем собрать debug-APK (`cd android && ./gradlew test && ./gradlew :app:assembleDebug`). + +## Workflow follow-up + +- Заархивировать изменение после прохождения ревью проекта. +- Проверить результат архивации. diff --git a/openspec/changes/safe-album-offload/.openspec.yaml b/openspec/changes/safe-album-offload/.openspec.yaml new file mode 100644 index 0000000..0cb4765 --- /dev/null +++ b/openspec/changes/safe-album-offload/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-16 diff --git a/openspec/changes/safe-album-offload/design.md b/openspec/changes/safe-album-offload/design.md new file mode 100644 index 0000000..fdbdc45 --- /dev/null +++ b/openspec/changes/safe-album-offload/design.md @@ -0,0 +1,92 @@ +# Design + +## Context + +См. `proposal.md` — Why. Ниже — что в коде определяет подход. + +- `SyncCoordinator.reconcile` получает `serverChecksums(link.serverAlbumId)` и вычисляет + `livePhotoIds = serverChecksums.values.toSet()`, но `SyncPlanner.plan` и `offloadUploaded` + этот набор не используют: первый смотрит только на `indexed.serverPhotoId != null`, второй — + на `entity.serverPhotoId != null`. `reflectLostCopies` умеет лишь перекрасить + `PREVIEW ↔ ORPHANED` и тоже по `livePhotoIds`. +- Разгрузка вызывается из трёх мест: `SyncCoordinator.offloadUploaded` (режим C), + `UploadWorker.recordProvenance` (сразу после успешной загрузки) и + `DeviceAlbumViewModel.freeSpace` (ручное освобождение по согласию системы). +- `AssetOffloader.offload(asset)` не требует доказательств, что копия жива. +- `SyncRepository.link` перезаписывает `serverAlbumId`; смену цели обслуживают + `DeviceAlbumViewModel.setTarget` и `createAlbum` — оба просто линкуют и запускают reconcile. +- Серверный перенос фото есть: `SyncRemote.movePhotos` → `POST /api/photo-batch/move`, + который пропускает удалённые фото и возвращает число перенесённых, не сообщая об ошибке за + пропуск. +- `LocalAssetState.ORPHANED` уже вычисляется и показывается перечёркнутым облаком на элементе; + счётчика и заметного предупреждения нет. + +## Goals / Non-Goals + +**Goals:** + +- Сделать невозможной разгрузку без подтверждённой копии в текущем целевом альбоме. +- Не терять связь с уже загруженными фото при смене целевого альбома. +- Сделать потерю серверной копии видимой и оставить её устранение явным действием. + +**Non-Goals:** + +- Восстановление уже потерянных копий из корзины сервера. +- Предупреждение об удалении серверного альбома из веб-клиента: сервер и веб не знают о + зависимости альбома устройства. +- Ручные загрузки в серверный альбом и их режимы. + +## Decisions + +**Единственный источник доверия — живой список фото целевого альбома.** И `plan`, и разгрузка +опираются на `livePhotoIds`, уже полученный в `reconcile`. Альтернатива — отдельный запрос +«фото существует» — отклонена: лишние round-trip и рассинхрон. + +**Планировщик лечит устаревшую запись, а не игнорирует её.** Ветка «записанный `photoId`» +срабатывает только если `photoId ∈ livePhotoIds`; иначе ассет проходит обычный путь: дедуп по +контрольной сумме, а при отсутствии — загрузка. Удаление серверных фото в конце плана тоже +ограничено `livePhotoIds`, чтобы не тронуть чужую копию. + +**Разгрузка требует доказательства в подписи.** `AssetOffloader.offload(asset, +liveServerPhotoIds)` и новый исход `OffloadResult.NotOnServer`, если копии нет. Так инвариант +живёт в одном месте, а вызывающий обязан доказать, что копия жива: +- `offloadUploaded` передаёт `livePhotoIds` из `reconcile`; +- `recordProvenance` передаёт `setOf(photo.id)` — только что созданное фото; +- `freeSpace` сначала читает `serverChecksums` текущего линка и передаёт его значения; при + ошибке не разгружает ничего. + +**Смена цели — транзакция «перенести, проверить, переключить».** `SyncCoordinator.retarget` +переносит фото провенанса этого альбома в новый альбом, затем перечитывает новый альбом и +убеждается, что все переносимые копии на месте; только тогда переключает связь. Если прежний +альбом недоступен или копии не доехали — связь остаётся прежней, результат возвращается как +отказ. Альтернатива «переключить и попробовать потом» отклонена: именно она дала инцидент. + +**Уже потерянные копии не блокируют смену цели, но сообщаются.** Фото, которых нет ни в +прежнем альбоме, ни в новом (например, давно осиротевшие), перенести нельзя; они считаются и +возвращаются в результате, чтобы пользователь знал, но не мешают переключить альбом. + +**Потеря копии показывается из `ORPHANED`, а не из проверки существования альбома.** +Существование целевого альбома при офлайне недоказуемо, а `ORPHANED` выставляется только после +успешного чтения альбома. Один новый запрос `GROUP BY deviceAlbumId` по `state = 'ORPHANED'` +даёт счётчик для карточки и баннера; на карточке потеря копии перекрывает прогресс операции. + +## Risks / Trade-offs + +- [Перенос пропускает удалённые фото без ошибки] → проверяем результат чтением нового + альбома, а не ответом `move`; ненулевое расхождение — отказ. +- [Лишние `serverChecksums` в `freeSpace` и `retarget`] → это редкие явные действия, не цикл; + при ошибке сети ничего не разгружаем и не переключаем. +- [Планировщик начинает перезаливать содержимое, если сервер не отдал контрольную сумму] → + `serverChecksums` падает вместе с `reconcile` (исключение до применения плана), поэтому + «пустой живой список из-за ошибки» не доходит до загрузок. +- [Счётчик `ORPHANED` в карточке устаревает между проходами] → приемлемо: он обновляется тем + же потоком Room, что и всё представление, и означает «на последнем успешном проходе». + +## Migration Plan + +Схема Room не меняется; миграций нет. Откат — обычная сборка без изменения. + +## Open Questions + +- Нужен ли отдельный сценарий «перенести потерянные копии из прежнего альбома» как ручное + действие, когда включённый режим уже сменился — отдельным изменением. diff --git a/openspec/changes/safe-album-offload/proposal.md b/openspec/changes/safe-album-offload/proposal.md new file mode 100644 index 0000000..688893a --- /dev/null +++ b/openspec/changes/safe-album-offload/proposal.md @@ -0,0 +1,61 @@ +# Proposal + +## Why + +Реальный инцидент: альбом устройства U2 синхронизировался в режиме переноса в серверный +альбом A (фото загружены, локальные оригиналы заменены превью), затем альбом A удалили на +сервере, а альбом устройства перепривязали к новому серверному альбому U2. В U2 уехало всего +1 фото: остальные на телефоне уже были превью, и загрузить их заново было нечем. Оригиналы +остались только в корзине сервера, а приложение молча оставило телефон с превью. + +Три причины: +- решение о разгрузке принимается по `serverPhotoId` в локальном индексе, без проверки, что + это фото вообще есть в **текущем** целевом альбоме; +- смена целевого альбома только перезаписывает связь: уже загруженные фото никто не переносит + на сервере, а из превью повторно не загрузить; +- потеря серверной копии — тихий тупик: `ORPHANED` виден, но нигде не сказано, что оригинал + утрачен, и нет явного предупреждения. + +## What Changes + +- **Доверие только к живой копии.** Записанный `serverPhotoId` считается действительным, + только если это фото присутствует в целевом альбоме. Если копии нет: при локальном + оригинале ассет загружается заново, при одном превью элемент помечается потерявшим копию. +- **Разгрузка только под защитой.** Замена оригинала превью выполняется, только если копия + подтверждена в целевом альбоме; разгрузка «по памяти индекса» запрещена. Автоматическая + разгрузка в режиме переноса сохраняется. +- **Смена цели переносит копии.** При смене целевого альбома приложение сначала переносит на + сервере уже загруженные этим альбомом устройства фото в новый альбом и переключает связь + только если перенос удался; иначе связь не меняется и пользователь получает отказ. +- **Потеря копии видна.** Экран альбома устройства и карточка альбома в списке показывают, + сколько фото осталось без серверной копии; никакого автоматического удаления превью. +- **BREAKING**: нет. Меняются только защитные условия; успешные сценарии (загрузка, + разгрузка, повторная синхронизация) сохраняют поведение. + +## Capabilities + +### New Capabilities + +_Нет — поведение относится к существующим способностям._ + +### Modified Capabilities + +- `phone-album-sync`: разгрузка требует живой копии в текущем целевом альбоме; записанный + `serverPhotoId` перепроверяется по целевому альбому; смена целевого альбома переносит уже + загруженные фото и отклоняется, если перенести нельзя. +- `android-client`: потеря серверной копии показывается на экране альбома устройства и на + карточке в списке; автоматического удаления превью нет. + +## Impact + +- **Код (Android):** `data/sync/SyncPlan` (доверие к живой копии), `data/sync/SyncCoordinator` + (защищённая разгрузка, перенос при смене цели), `data/sync/OffloadManager` (условие разгрузки), + `data/upload/UploadWorker` (передача живой копии после загрузки), `data/db/dao/SyncDao` и + `data/repo/SyncRepository` (счёт потерянных копий), `ui/devicealbum/DeviceAlbumViewModel` + (смена цели, разгрузка, предупреждение), `ui/devicealbum/DeviceAlbumScreen` (баннер), + `domain/sync/AlbumSyncProgress` и `ui/albums/*` (сигнал на карточке). +- **Схема Room:** без изменений; добавляются только запросы. +- **API и бэкенд:** не затрагиваются (используется существующий `/api/photo-batch/move`). +- **Веб-клиент:** не затрагивается. +- **Не затрагивается:** ручные загрузки в серверный альбом; восстановление уже потерянных + копий из корзины сервера (по решению пользователя это отдельная задача). diff --git a/openspec/changes/safe-album-offload/specs/android-client/spec.md b/openspec/changes/safe-album-offload/specs/android-client/spec.md new file mode 100644 index 0000000..6d8ef02 --- /dev/null +++ b/openspec/changes/safe-album-offload/specs/android-client/spec.md @@ -0,0 +1,24 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: Видимость потерянной серверной копии + +Приложение SHALL явно показывать, сколько фото альбома устройства остались без серверной +копии: на экране альбома устройства и на его карточке в списке. Приложение SHALL NOT +удалять такие превью автоматически. + +#### Scenario: Экран альбома устройства + +- **WHEN** хотя бы одно фото альбома устройства потеряло серверную копию +- **THEN** экран альбома показывает предупреждение с числом таких фото + +#### Scenario: Карточка в списке + +- **WHEN** у альбома устройства есть фото без серверной копии +- **THEN** карточка показывает это в третьей строке выделенным цветом + +#### Scenario: Автоматического удаления нет + +- **WHEN** элемент помечен как потерявший серверную копию +- **THEN** превью остаётся на устройстве, пока пользователь сам его не удалит diff --git a/openspec/changes/safe-album-offload/specs/phone-album-sync/spec.md b/openspec/changes/safe-album-offload/specs/phone-album-sync/spec.md new file mode 100644 index 0000000..dca25c7 --- /dev/null +++ b/openspec/changes/safe-album-offload/specs/phone-album-sync/spec.md @@ -0,0 +1,82 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Разгрузка оригиналов в режиме переноса + +В режиме C после успешной загрузки приложение SHALL автоматически заменять оригинал +на устройстве превью, без отдельного действия на экране, но SHALL делать это только если +серверная копия подтверждена в текущем целевом альбоме; видео SHALL заменяться +постером-кадром. При включении режима C приложение SHALL запрашивать широкий доступ +к файлам, чтобы замена выполнялась без системного диалога согласия. + +#### Scenario: Разгрузка фото + +- **WHEN** оригинал фото успешно загружен +- **THEN** файл в галерее автоматически заменяется превью с сохранением даты съёмки и места в альбоме + +#### Scenario: Разгрузка видео + +- **WHEN** оригинал видео успешно загружен +- **THEN** видео в галерее автоматически заменяется статичным постером-кадром, а элемент в приложении остаётся видео с оригиналом на сервере + +#### Scenario: Доступ к файлам для разгрузки + +- **WHEN** пользователь включает режим переноса +- **THEN** приложение запрашивает широкий доступ к файлам, и разгрузка выполняется без системного диалога согласия + +#### Scenario: Копия не подтверждена + +- **WHEN** серверная копия ассета отсутствует в текущем целевом альбоме +- **THEN** оригинал на устройстве не заменяется превью + +## ADDED Requirements + +### Requirement: Доверие только к живой серверной копии + +Приложение SHALL считать записанный идентификатор серверного фото действительным только +если это фото присутствует в целевом альбоме. Если копии в целевом альбоме нет, приложение +SHALL при локальном оригинале загрузить ассет заново, а при одном локальном превью SHALL +пометить элемент как потерявший серверную копию. + +#### Scenario: Копия пропала, оригинал локально + +- **WHEN** записанное серверное фото ассета отсутствует в целевом альбоме, а оригинал ещё на устройстве +- **THEN** приложение загружает ассет в целевой альбом заново + +#### Scenario: Копия пропала, локально только превью + +- **WHEN** записанное серверное фото ассета отсутствует в целевом альбоме, а на устройстве только превью +- **THEN** ассет не загружается повторно и помечается как потерявший серверную копию + +#### Scenario: Копия есть + +- **WHEN** записанное серверное фото присутствует в целевом альбоме +- **THEN** ассет считается синхронизированным, и повторной загрузки не происходит + +#### Scenario: Чужое фото не удаляется + +- **WHEN** ассет исчез с устройства, а его записанное серверное фото не принадлежит целевому альбому +- **THEN** приложение не удаляет это фото на сервере + +### Requirement: Перенос копий при смене целевого альбома + +При смене целевого серверного альбома приложение SHALL перенести в новый альбом уже +загруженные этим альбомом устройства фото и SHALL переключить связь только если все +копии, доступные в прежнем альбоме, оказались в новом. Если перенести нельзя, связь +SHALL остаться прежней, а пользователь SHALL получить уведомление об отказе. + +#### Scenario: Перенос удался + +- **WHEN** пользователь выбирает новый целевой альбом, а прежние загрузки доступны +- **THEN** приложение переносит их в новый альбом и переключает связь + +#### Scenario: Прежний альбом недоступен + +- **WHEN** прежний целевой альбом удалён или недоступен +- **THEN** связь не меняется, и пользователь уведомляется, что фото перенести не удалось + +#### Scenario: Часть копий перенести нельзя + +- **WHEN** часть прежних загрузок недоступна для переноса +- **THEN** связь не переключается, и пользователь уведомляется, сколько фото не удалось перенести diff --git a/openspec/changes/safe-album-offload/tasks.md b/openspec/changes/safe-album-offload/tasks.md new file mode 100644 index 0000000..e490c45 --- /dev/null +++ b/openspec/changes/safe-album-offload/tasks.md @@ -0,0 +1,31 @@ +# Tasks + +## 1. Доверие к живой серверной копии в планировщике + +- [x] 1.1 В `SyncPlanner.plan` учитывать только идентификаторы фото, присутствующих в целевом альбоме (значения `serverChecksums`): устаревшая запись ведёт к дедупу или повторной загрузке, а удаление серверных фото ограничивается живым набором; покрыть `SyncPlannerTest` случаями «копия пропала, оригинал локально», «копия пропала, локально превью» и «чужое фото не удаляется» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncPlannerTest"`). +- [x] 1.2 Проверить сценарии координатора: устаревший `serverPhotoId` при локальном оригинале ставит загрузку в очередь, а не разгружает, и не порождает удаление чужого фото (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncCoordinatorTest"`). + +## 2. Защищённая разгрузка + +- [x] 2.1 Изменить `AssetOffloader.offload`, принимая живой набор серверных фото, и добавить исход «копии нет»; `OffloadManager` SHALL отказываться от разгрузки без живой копии — проверить `OffloadManagerTest` (`cd android && ./gradlew :app:testDebugUnitTest --tests "*OffloadManagerTest"`). +- [x] 2.2 Передать живой набор в разгрузку из `SyncCoordinator.offloadUploaded`, из `UploadWorker.recordProvenance` (только что созданное фото) и из `DeviceAlbumViewModel.freeSpace` (набор из текущего целевого альбома, при ошибке — без разгрузки); покрыть `SyncCoordinatorTest` случаем «копия не подтверждена — разгрузки нет» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncCoordinatorTest"`). + +## 3. Перенос копий при смене целевого альбома + +- [x] 3.1 Добавить в `SyncCoordinator` операцию смены цели: перенести фото провенанса в новый альбом, перечитать новый альбом и переключить связь только при подтверждённом переносе; при отказе связь не менять и вернуть результат с числом непереносимых фото; покрыть `SyncCoordinatorTest` тремя сценариями (перенос удался, прежний альбом недоступен, часть копий не переносится) (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncCoordinatorTest"`). +- [x] 3.2 Перевести `DeviceAlbumViewModel.setTarget` и `createAlbum` на новую операцию и показать отказ пользователю; покрыть `DeviceAlbumViewModel` тестом либо тестом экрана, что при отказе связь сохраняется (`cd android && ./gradlew :app:testDebugUnitTest --tests "*DeviceAlbum*"`). + +## 4. Видимость потерянной серверной копии + +- [x] 4.1 Добавить в `SyncDao`/`SyncRepository` счётчик ассетов в состоянии «потеряна копия» по альбомам и юнит-тест на него (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncRepositoryTest"`). +- [x] 4.2 Расширить `AlbumSyncProgress` числом потерянных копий так, чтобы это состояние перекрывало прогресс и выделялось цветом; покрыть `AlbumSyncProgressTest` и связать со счётчиком в `AlbumsViewModel` (`cd android && ./gradlew :app:testDebugUnitTest --tests "*AlbumSyncProgressTest" --tests "*AlbumsViewModelTest"`). +- [x] 4.3 Показать предупреждение с числом потерянных копий на экране альбома устройства и на карточке в списке; покрыть `AlbumsContentTest` и `DeviceAlbumContentTest`, убедившись, что автоматического удаления превью нет (`cd android && ./gradlew :app:testDebugUnitTest --tests "*AlbumsContentTest" --tests "*DeviceAlbumContentTest"`). + +## 5. Интеграционная проверка + +- [x] 5.1 Прогнать весь Android-набор и собрать debug-APK (`cd android && ./gradlew test :app:assembleDebug`). + +## Workflow follow-up + +- Заархивировать изменение после прохождения ревью проекта. +- Проверить результат архивации.