feat(android): статус синхронизации на карточке и безопасная разгрузка

Карточка альбома устройства получает третью строку: метку режима («Копии»/
«Перенос») и ход загрузки. Процент считается как (uploaded + running) / total
для обоих синхронизируемых режимов, пульсирует только при активной загрузке,
показывает очередь, ожидание Wi-Fi и ошибку. В режиме переноса карточка
показывает и карту памяти, и облако — режимы различает метка, а не значки.

Разгрузка оригиналов теперь требует подтверждённой серверной копии в текущем
целевом альбоме: устаревший serverPhotoId перепроверяется по альбому, смена
цели переносит уже загруженные фото и отклоняется, если перенести нельзя.
Потеря серверной копии видна на карточке и на экране альбома, автозатирания
превью нет.

Изменения OpenSpec: android-album-card-sync-status, safe-album-offload.
This commit is contained in:
iu
2026-10-16 18:36:51 +03:00
parent 98aaa3156a
commit 1eb1177273
36 changed files with 1855 additions and 81 deletions
@@ -8,6 +8,9 @@ import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.data.db.entity.SyncedAlbumEntity import com.galo.android.data.db.entity.SyncedAlbumEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
/** Number of indexed assets that already have a server photo, per device album. */
data class AlbumUploadedCount(val deviceAlbumId: String, val count: Int)
@Dao @Dao
interface SyncDao { interface SyncDao {
@@ -50,6 +53,18 @@ interface SyncDao {
@Query("SELECT * FROM device_assets WHERE deviceAlbumId = :deviceAlbumId") @Query("SELECT * FROM device_assets WHERE deviceAlbumId = :deviceAlbumId")
fun observeAssets(deviceAlbumId: String): Flow<List<DeviceAssetEntity>> fun observeAssets(deviceAlbumId: String): Flow<List<DeviceAssetEntity>>
@Query(
"SELECT deviceAlbumId, COUNT(*) AS count FROM device_assets " +
"WHERE serverPhotoId IS NOT NULL GROUP BY deviceAlbumId",
)
fun observeUploadedCounts(): Flow<List<AlbumUploadedCount>>
@Query(
"SELECT deviceAlbumId, COUNT(*) AS count FROM device_assets " +
"WHERE state = 'ORPHANED' GROUP BY deviceAlbumId",
)
fun observeMissingCopyCounts(): Flow<List<AlbumUploadedCount>>
@Query("SELECT * FROM device_assets WHERE deviceAlbumId = :deviceAlbumId") @Query("SELECT * FROM device_assets WHERE deviceAlbumId = :deviceAlbumId")
suspend fun getAssets(deviceAlbumId: String): List<DeviceAssetEntity> suspend fun getAssets(deviceAlbumId: String): List<DeviceAssetEntity>
@@ -12,6 +12,10 @@ interface UploadDao {
@Query("SELECT * FROM upload_jobs ORDER BY createdAt DESC") @Query("SELECT * FROM upload_jobs ORDER BY createdAt DESC")
fun observeAll(): Flow<List<UploadJobEntity>> fun observeAll(): Flow<List<UploadJobEntity>>
/** 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<List<UploadJobEntity>>
@Query("SELECT * FROM upload_jobs WHERE id = :id") @Query("SELECT * FROM upload_jobs WHERE id = :id")
suspend fun getById(id: String): UploadJobEntity? suspend fun getById(id: String): UploadJobEntity?
@@ -23,6 +23,18 @@ class SyncRepository(
fun observeAssets(deviceAlbumId: String): Flow<List<DeviceAssetEntity>> = fun observeAssets(deviceAlbumId: String): Flow<List<DeviceAssetEntity>> =
dao.observeAssets(deviceAlbumId) dao.observeAssets(deviceAlbumId)
/** Assets already on the server, keyed by device album id (absent means none). */
fun observeUploadedCounts(): Flow<Map<String, Int>> =
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<Map<String, Int>> =
dao.observeMissingCopyCounts().map { counts ->
counts.associate { it.deviceAlbumId to it.count }
}
suspend fun assets(deviceAlbumId: String): List<DeviceAssetEntity> = dao.getAssets(deviceAlbumId) suspend fun assets(deviceAlbumId: String): List<DeviceAssetEntity> = dao.getAssets(deviceAlbumId)
/** Device albums that have a local asset index (possibly partial/unlinked). */ /** Device albums that have a local asset index (possibly partial/unlinked). */
@@ -26,11 +26,19 @@ sealed interface OffloadResult {
data class NeedsWriteConsent(val uris: List<Uri>) : OffloadResult data class NeedsWriteConsent(val uris: List<Uri>) : OffloadResult
data class NeedsDeleteConsent(val uris: List<Uri>) : OffloadResult data class NeedsDeleteConsent(val uris: List<Uri>) : OffloadResult
data object Failed : 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). */ /** Replaces a device original with its offloaded version (mode C). */
interface AssetOffloader { 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<String>): OffloadResult
} }
/** /**
@@ -46,7 +54,16 @@ class OffloadManager(
private val mutex = Mutex() private val mutex = Mutex()
override suspend fun offload(asset: DeviceAssetEntity): OffloadResult = withContext(Dispatchers.IO) { override suspend fun offload(
asset: DeviceAssetEntity,
liveServerPhotoIds: Set<String>,
): 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. // Serialize offloads so two passes never fight over the same asset.
mutex.withLock { mutex.withLock {
runCatching { runCatching {
@@ -66,6 +83,7 @@ class OffloadManager(
}.getOrDefault(OffloadResult.Failed) }.getOrDefault(OffloadResult.Failed)
} }
} }
}
/** A batch write-consent request for images that must be overwritten. */ /** A batch write-consent request for images that must be overwritten. */
fun writeConsent(uris: List<Uri>): IntentSender? = fun writeConsent(uris: List<Uri>): IntentSender? =
@@ -2,12 +2,22 @@ package com.galo.android.data.sync
import com.galo.android.data.db.dao.SyncDao import com.galo.android.data.db.dao.SyncDao
import com.galo.android.data.db.entity.DeviceAssetEntity import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.data.db.entity.SyncedAlbumEntity
import com.galo.android.domain.model.LocalAssetState import com.galo.android.domain.model.LocalAssetState
import com.galo.android.domain.model.SyncMode import com.galo.android.domain.model.SyncMode
import com.galo.android.domain.sync.SyncPolicy import com.galo.android.domain.sync.SyncPolicy
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock 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 * 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, * only and performs no server requests; the other modes upload missing content,
@@ -25,12 +35,15 @@ class SyncCoordinator(
private val mutex = Mutex() private val mutex = Mutex()
/** @return true when the server was contacted, false for a local-only album. */ /** @return true when the server was contacted, false for a local-only album. */
suspend fun reconcile(deviceAlbumId: String): Boolean = mutex.withLock { suspend fun reconcile(deviceAlbumId: String): Boolean =
val link = dao.getSyncedAlbum(deviceAlbumId) ?: return@withLock false mutex.withLock { reconcileLocked(deviceAlbumId) }
private suspend fun reconcileLocked(deviceAlbumId: String): Boolean {
val link = dao.getSyncedAlbum(deviceAlbumId) ?: return false
val mode = SyncMode.from(link.mode) 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. // 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 serverChecksums = remote.serverChecksums(link.serverAlbumId)
val livePhotoIds = serverChecksums.values.toSet() val livePhotoIds = serverChecksums.values.toSet()
@@ -46,9 +59,72 @@ class SyncCoordinator(
for (action in actions) apply(deviceAlbumId, link.serverAlbumId, mode, action, livePhotoIds) for (action in actions) apply(deviceAlbumId, link.serverAlbumId, mode, action, livePhotoIds)
// Mode C: assets already on the server (e.g. after switching from copies) // Mode C: assets already on the server (e.g. after switching from copies)
// are replaced with their offloaded versions, right away. // are replaced with their offloaded versions, right away, but only while
if (mode == SyncMode.MOVE) offloadUploaded(deviceAlbumId, assets) // their copy is confirmed in the target album.
return@withLock true 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 * 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, * 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<LocalAsset>) { private suspend fun offloadUploaded(
deviceAlbumId: String,
localAssets: List<LocalAsset>,
livePhotoIds: Set<String>,
) {
val localKeys = localAssets.mapTo(HashSet()) { it.assetKey } val localKeys = localAssets.mapTo(HashSet()) { it.assetKey }
for (entity in dao.getAssets(deviceAlbumId)) { for (entity in dao.getAssets(deviceAlbumId)) {
if (entity.serverPhotoId == null) continue if (entity.serverPhotoId == null) continue
@@ -109,9 +190,9 @@ class SyncCoordinator(
) { ) {
continue continue
} }
val result = runCatching { offloader.offload(entity) }.getOrNull() ?: continue val result = runCatching { offloader.offload(entity, livePhotoIds) }.getOrNull() ?: continue
when (result) { when (result) {
is OffloadResult.Done, OffloadResult.Failed -> Unit is OffloadResult.Done, OffloadResult.Failed, OffloadResult.NotOnServer -> Unit
is OffloadResult.NeedsWriteConsent, is OffloadResult.NeedsDeleteConsent -> is OffloadResult.NeedsWriteConsent, is OffloadResult.NeedsDeleteConsent ->
dao.markState(entity.assetKey, LocalAssetState.AWAITING_FREE.name) dao.markState(entity.assetKey, LocalAssetState.AWAITING_FREE.name)
} }
@@ -35,6 +35,10 @@ sealed interface SyncAction {
* Computes the changes to apply to a server album from the device's current * 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. * 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: * Rules:
* - content already on the server is linked instead of uploaded (dedup); * - 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 * - an indexed asset whose content changed is re-uploaded and its old upload is
@@ -52,6 +56,7 @@ object SyncPlanner {
hashesPresentElsewhere: Set<String>, hashesPresentElsewhere: Set<String>,
): List<SyncAction> { ): List<SyncAction> {
val actions = mutableListOf<SyncAction>() val actions = mutableListOf<SyncAction>()
val livePhotoIds = serverChecksums.values.toSet()
for (asset in local) { for (asset in local) {
val indexed = index.firstOrNull { it.assetKey == asset.assetKey } val indexed = index.firstOrNull { it.assetKey == asset.assetKey }
@@ -72,16 +77,17 @@ object SyncPlanner {
} }
continue continue
} }
val liveIndexedPhotoId = indexed?.serverPhotoId?.takeIf { it in livePhotoIds }
when { when {
indexed?.serverPhotoId != null -> { liveIndexedPhotoId != null -> {
val changed = asset.contentHash != null && val changed = asset.contentHash != null &&
indexed.contentHash != null && indexed.contentHash != null &&
asset.contentHash != indexed.contentHash asset.contentHash != indexed.contentHash
if (changed) { if (changed) {
actions += SyncAction.Upload(asset) actions += SyncAction.Upload(asset)
actions += SyncAction.DeleteOnServer(indexed.serverPhotoId, asset.assetKey) actions += SyncAction.DeleteOnServer(liveIndexedPhotoId, asset.assetKey)
} else if (asset.displayName != indexed.displayName) { } 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 if (indexed.assetKey in localKeys) continue
val hash = indexed.contentHash val hash = indexed.contentHash
if (hash != null && hash in hashesPresentElsewhere) continue 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) actions += SyncAction.DeleteOnServer(photoId, indexed.assetKey)
} }
@@ -12,6 +12,7 @@ import androidx.work.WorkManager
import androidx.work.workDataOf import androidx.work.workDataOf
import com.galo.android.data.db.dao.UploadDao import com.galo.android.data.db.dao.UploadDao
import com.galo.android.data.db.entity.UploadJobEntity 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.UploadJob
import com.galo.android.domain.model.UploadStatus import com.galo.android.domain.model.UploadStatus
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -32,6 +33,24 @@ class UploadRepository(
fun observeJobs(): Flow<List<UploadJob>> = fun observeJobs(): Flow<List<UploadJob>> =
dao.observeAll().map { jobs -> jobs.map { it.toDomain() } } 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<List<SyncUpload>> =
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<Uri>, wifiOnly: Boolean): Int { suspend fun enqueue(albumId: String, uris: List<Uri>, wifiOnly: Boolean): Int {
var queued = 0 var queued = 0
for (uri in uris) { for (uri in uris) {
@@ -97,7 +97,8 @@ class UploadWorker(
// the OS write consent, which is requested from the album screen. // the OS write consent, which is requested from the album screen.
if (mode == SyncMode.MOVE) { if (mode == SyncMode.MOVE) {
container.syncRepository.getAsset(assetKey)?.let { asset -> 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)) }
} }
} }
} }
@@ -26,3 +26,15 @@ data class UploadJob(
val wifiOnly: Boolean, val wifiOnly: Boolean,
val createdAt: Long, 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,
)
@@ -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<SyncUpload>,
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,
)
}
}
}
@@ -6,6 +6,7 @@ import com.galo.android.domain.model.AlbumSortChoice
import com.galo.android.domain.model.DeviceAlbum import com.galo.android.domain.model.DeviceAlbum
import com.galo.android.domain.model.SyncMode import com.galo.android.domain.model.SyncMode
import com.galo.android.domain.model.updatedAtMillis 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. */ /** One row of the mixed album list: a server album or an album of the device. */
sealed interface AlbumListItem { sealed interface AlbumListItem {
@@ -22,6 +23,8 @@ sealed interface AlbumListItem {
val album: DeviceAlbum, val album: DeviceAlbum,
val linkedServerTitle: String?, val linkedServerTitle: String?,
val mode: SyncMode, val mode: SyncMode,
/** Copy-operation status for the card's third line; null when there is none. */
val progress: AlbumSyncProgress? = null,
) : AlbumListItem { ) : AlbumListItem {
override val key: String get() = "device:${album.id}" override val key: String get() = "device:${album.id}"
} }
@@ -50,6 +53,7 @@ object AlbumListBuilder {
links: List<AlbumLink>, links: List<AlbumLink>,
devices: List<DeviceAlbum>, devices: List<DeviceAlbum>,
choice: AlbumSortChoice = AlbumSortChoice(), choice: AlbumSortChoice = AlbumSortChoice(),
progressByDevice: Map<String, AlbumSyncProgress> = emptyMap(),
): List<AlbumListItem> { ): List<AlbumListItem> {
val linksByDevice = links.associateBy { it.deviceAlbumId } val linksByDevice = links.associateBy { it.deviceAlbumId }
val linkedServerIds = links val linkedServerIds = links
@@ -65,6 +69,7 @@ object AlbumListBuilder {
serverTitles[serverId] ?: serverId serverTitles[serverId] ?: serverId
}, },
mode = link?.mode ?: SyncMode.DISABLED, mode = link?.mode ?: SyncMode.DISABLED,
progress = progressByDevice[device.id],
) )
} }
@@ -1,5 +1,11 @@
package com.galo.android.ui.albums 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -42,6 +48,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow 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.AlbumSort
import com.galo.android.domain.model.DeviceAlbum import com.galo.android.domain.model.DeviceAlbum
import com.galo.android.domain.model.SyncMode 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.GaloViewModelFactory
import com.galo.android.ui.common.GaloAsyncImage import com.galo.android.ui.common.GaloAsyncImage
import com.galo.android.ui.common.RequestMediaAccess 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_DEVICE_PREFIX = "album_storage_device_"
const val TAG_ALBUM_STORAGE_CLOUD_PREFIX = "album_storage_cloud_" const val TAG_ALBUM_STORAGE_CLOUD_PREFIX = "album_storage_cloud_"
const val TAG_ALBUM_COUNT_PREFIX = "album_count_" const val TAG_ALBUM_COUNT_PREFIX = "album_count_"
const val TAG_ALBUM_STATUS_PREFIX = "album_status_"
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -268,6 +277,7 @@ private fun AlbumCardBody(
placement: StoragePlacement, placement: StoragePlacement,
count: String, count: String,
arrowTo: String?, arrowTo: String?,
sync: AlbumSyncProgress? = null,
) { ) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -317,6 +327,49 @@ private fun AlbumCardBody(
modifier = Modifier.padding(start = 6.dp).testTag(TAG_ALBUM_COUNT_PREFIX + id), 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), placement = placementOf(item.mode),
count = "${album.itemCount} эл.", count = "${album.itemCount} эл.",
arrowTo = arrowTo, 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. */ /** Storage icon placement for a device album: where its media live. */
private fun placementOf(mode: SyncMode): StoragePlacement = when (mode) { private fun placementOf(mode: SyncMode): StoragePlacement = when (mode) {
SyncMode.DISABLED -> StoragePlacement.DEVICE SyncMode.DISABLED -> StoragePlacement.DEVICE
SyncMode.COPY -> StoragePlacement.DEVICE_AND_CLOUD // Copies and move both keep something locally: originals for copies, previews
SyncMode.MOVE -> StoragePlacement.CLOUD // for move. The mode label on the third line tells them apart.
SyncMode.COPY, SyncMode.MOVE -> StoragePlacement.DEVICE_AND_CLOUD
} }
@@ -8,7 +8,11 @@ import com.galo.android.data.repo.LiveAlbumSource
import com.galo.android.domain.model.AlbumSort import com.galo.android.domain.model.AlbumSort
import com.galo.android.domain.model.AlbumSortChoice import com.galo.android.domain.model.AlbumSortChoice
import com.galo.android.domain.model.DeviceAlbum import com.galo.android.domain.model.DeviceAlbum
import com.galo.android.domain.model.DeviceAlbumMeta
import com.galo.android.domain.model.SyncMode 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.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -52,29 +56,51 @@ class AlbumsViewModel(
/** Current list ordering; restored from preferences and changed from the UI. */ /** Current list ordering; restored from preferences and changed from the UI. */
private val sort = MutableStateFlow(AlbumSortChoice()) private val sort = MutableStateFlow(AlbumSortChoice())
/** Everything the mixed list needs from the device side, in one emission. */
private data class DeviceInput(
val devices: List<DeviceAlbum>,
val access: Boolean,
val meta: Map<String, DeviceAlbumMeta>,
val uploads: List<SyncUpload>,
val uploadedCounts: Map<String, Int>,
val missingCounts: Map<String, Int>,
)
private val deviceInput: Flow<DeviceInput> = 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<UiState> = combine( val state: StateFlow<UiState> = combine(
container.albumRepository.observeAlbums(), container.albumRepository.observeAlbums(),
container.syncRepository.observeLinks(), container.syncRepository.observeLinks(),
combine(deviceAlbums, deviceAccess, container.syncRepository.observeAlbumMeta()) { devices, access, meta -> deviceInput,
Triple(devices, access, meta)
},
status, status,
combine(avatar, sort) { url, choice -> url to choice }, combine(avatar, sort) { url, choice -> url to choice },
) { albums, links, device, s, header -> ) { albums, links, device, s, header ->
val (avatarUrl, sortChoice) = header val (avatarUrl, sortChoice) = header
val withMeta = device.first.map { d -> val withMeta = device.devices.map { d ->
val m = device.third[d.id] val m = device.meta[d.id]
d.copy( d.copy(
name = m?.displayName?.takeIf { it.isNotBlank() } ?: d.name, name = m?.displayName?.takeIf { it.isNotBlank() } ?: d.name,
coverUri = m?.coverUri ?: d.coverUri, coverUri = m?.coverUri ?: d.coverUri,
) )
} }
val modeByDevice = links.associate { it.deviceAlbumId to SyncMode.from(it.mode) }
val items = AlbumListBuilder.build( val items = AlbumListBuilder.build(
albums = albums, albums = albums,
links = links.map { link -> links = links.map { link ->
AlbumLink( AlbumLink(
deviceAlbumId = link.deviceAlbumId, deviceAlbumId = link.deviceAlbumId,
deviceAlbumName = device.third[link.deviceAlbumId]?.displayName deviceAlbumName = device.meta[link.deviceAlbumId]?.displayName
?.takeIf { it.isNotBlank() } ?: link.deviceAlbumName, ?.takeIf { it.isNotBlank() } ?: link.deviceAlbumName,
serverAlbumId = link.serverAlbumId, serverAlbumId = link.serverAlbumId,
mode = SyncMode.from(link.mode), mode = SyncMode.from(link.mode),
@@ -82,6 +108,13 @@ class AlbumsViewModel(
}, },
devices = withMeta, devices = withMeta,
choice = sortChoice, choice = sortChoice,
progressByDevice = albumSyncProgressByDevice(
devices = withMeta,
modeByDevice = modeByDevice,
uploads = device.uploads,
uploadedCounts = device.uploadedCounts,
missingCounts = device.missingCounts,
),
) )
UiState( UiState(
@@ -90,7 +123,7 @@ class AlbumsViewModel(
offline = s.offline, offline = s.offline,
error = s.error, error = s.error,
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
deviceAccess = device.second, deviceAccess = device.access,
sort = sortChoice.sort, sort = sortChoice.sort,
reversed = sortChoice.reversed, reversed = sortChoice.reversed,
) )
@@ -148,3 +181,27 @@ class AlbumsViewModel(
onDone() 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<DeviceAlbum>,
modeByDevice: Map<String, SyncMode>,
uploads: List<SyncUpload>,
uploadedCounts: Map<String, Int>,
missingCounts: Map<String, Int> = emptyMap(),
): Map<String, AlbumSyncProgress> {
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()
}
@@ -94,6 +94,7 @@ const val TAG_DEVICE_GRID = "device_album_grid"
const val TAG_DEVICE_ITEM_PREFIX = "device_item_" const val TAG_DEVICE_ITEM_PREFIX = "device_item_"
const val TAG_TRANSFERRED_PREFIX = "transferred_" const val TAG_TRANSFERRED_PREFIX = "transferred_"
const val TAG_ORPHANED_PREFIX = "orphaned_" 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_DEVICE_SETTINGS = "device_album_settings"
const val TAG_MODE_PREFIX = "sync_mode_" const val TAG_MODE_PREFIX = "sync_mode_"
const val TAG_TARGET_PREFIX = "sync_target_" const val TAG_TARGET_PREFIX = "sync_target_"
@@ -131,6 +132,7 @@ fun DeviceAlbumScreen(
val viewModel: DeviceAlbumViewModel = viewModel(factory = factory) val viewModel: DeviceAlbumViewModel = viewModel(factory = factory)
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val role by viewModel.role.collectAsStateWithLifecycle() val role by viewModel.role.collectAsStateWithLifecycle()
val notice by viewModel.notice.collectAsStateWithLifecycle()
val prefs by viewModel.preferences.collectAsStateWithLifecycle() val prefs by viewModel.preferences.collectAsStateWithLifecycle()
val meta by viewModel.albumMeta.collectAsStateWithLifecycle() val meta by viewModel.albumMeta.collectAsStateWithLifecycle()
var viewerIndex by remember { mutableStateOf<Int?>(null) } var viewerIndex by remember { mutableStateOf<Int?>(null) }
@@ -242,6 +244,8 @@ fun DeviceAlbumScreen(
role = role, role = role,
localName = localName, localName = localName,
localCoverUrl = localCoverUrl, localCoverUrl = localCoverUrl,
notice = notice,
onDismissNotice = viewModel::dismissNotice,
onBack = onBack, onBack = onBack,
onMode = viewModel::setMode, onMode = viewModel::setMode,
onTarget = viewModel::setTarget, onTarget = viewModel::setTarget,
@@ -289,6 +293,8 @@ fun DeviceAlbumContent(
role: String = "", role: String = "",
localName: String? = null, localName: String? = null,
localCoverUrl: String? = null, localCoverUrl: String? = null,
notice: String? = null,
onDismissNotice: () -> Unit = {},
onRenameAlbum: (String) -> Unit = {}, onRenameAlbum: (String) -> Unit = {},
onSetCover: (String?) -> Unit = {}, onSetCover: (String?) -> Unit = {},
onResetCover: () -> 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( val device = DeviceAlbum(
id = state.name, id = state.name,
name = state.name.ifBlank { "Альбом телефона" }, name = state.name.ifBlank { "Альбом телефона" },
@@ -448,6 +464,9 @@ fun DeviceAlbumContent(
}, },
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
if (state.orphanedMediaIds.isNotEmpty()) {
LostCopyBanner(count = state.orphanedMediaIds.size)
}
if (state.items.isEmpty()) { if (state.items.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("В альбоме нет медиа") 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. */ /** Confirms a mode change and explains what the mode does. */
@Composable @Composable
fun ModeConfirmDialog( fun ModeConfirmDialog(
@@ -9,6 +9,7 @@ import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.data.mapper.toDomain import com.galo.android.data.mapper.toDomain
import com.galo.android.data.network.dto.PhotoInfoPrefsDto import com.galo.android.data.network.dto.PhotoInfoPrefsDto
import com.galo.android.data.sync.RestoreManager 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.Album
import com.galo.android.domain.model.DeviceAlbumMeta import com.galo.android.domain.model.DeviceAlbumMeta
import com.galo.android.domain.model.DeviceMedia import com.galo.android.domain.model.DeviceMedia
@@ -63,6 +64,15 @@ class DeviceAlbumViewModel(
/** Saved photo-attribute preferences (visibility + labels). */ /** Saved photo-attribute preferences (visibility + labels). */
val preferences: StateFlow<PhotoInfoPrefsDto?> = _preferences.asStateFlow() val preferences: StateFlow<PhotoInfoPrefsDto?> = _preferences.asStateFlow()
private val _notice = MutableStateFlow<String?>(null)
/** A one-off message for the user (for example a refused target change). */
val notice: StateFlow<String?> = _notice.asStateFlow()
fun dismissNotice() {
_notice.value = null
}
/** App-only metadata (display name, chosen cover) for this device album. */ /** App-only metadata (display name, chosen cover) for this device album. */
val albumMeta: StateFlow<DeviceAlbumMeta?> = container.syncRepository.observeAlbumMeta() val albumMeta: StateFlow<DeviceAlbumMeta?> = container.syncRepository.observeAlbumMeta()
.map { it[deviceAlbumId] } .map { it[deviceAlbumId] }
@@ -291,14 +301,24 @@ class DeviceAlbumViewModel(
fun setTarget(serverAlbumId: String) { fun setTarget(serverAlbumId: String) {
viewModelScope.launch { viewModelScope.launch {
container.syncRepository.link( val mode = pendingMode.value ?: state.value.mode
when (
val result = container.syncCoordinator.retarget(
deviceAlbumId = deviceAlbumId, deviceAlbumId = deviceAlbumId,
deviceAlbumName = local.value.name, deviceAlbumName = local.value.name,
serverAlbumId = serverAlbumId, newServerAlbumId = serverAlbumId,
mode = pendingMode.value ?: state.value.mode, mode = mode,
) )
) {
is RetargetResult.Linked -> {
refreshRole() refreshRole()
reconcileNow() if (result.strandedCopies > 0) {
_notice.value = "Фото без серверной копии: ${result.strandedCopies}."
}
}
is RetargetResult.Refused -> _notice.value = result.reason
}
} }
} }
@@ -309,14 +329,18 @@ class DeviceAlbumViewModel(
viewModelScope.launch { viewModelScope.launch {
val album = runCatching { container.api.createAlbum(title) }.getOrNull() ?: return@launch val album = runCatching { container.api.createAlbum(title) }.getOrNull() ?: return@launch
runCatching { container.albumRepository.refresh() } runCatching { container.albumRepository.refresh() }
container.syncRepository.link( val mode = pendingMode.value ?: state.value.mode
when (
val result = container.syncCoordinator.retarget(
deviceAlbumId = deviceAlbumId, deviceAlbumId = deviceAlbumId,
deviceAlbumName = local.value.name, deviceAlbumName = local.value.name,
serverAlbumId = album.id, newServerAlbumId = album.id,
mode = pendingMode.value ?: state.value.mode, mode = mode,
) )
refreshRole() ) {
reconcileNow() is RetargetResult.Linked -> refreshRole()
is RetargetResult.Refused -> _notice.value = result.reason
}
} }
} }
@@ -332,14 +356,24 @@ class DeviceAlbumViewModel(
*/ */
fun freeSpace(onNeedsConsent: (IntentSender) -> Unit) { fun freeSpace(onNeedsConsent: (IntentSender) -> Unit) {
viewModelScope.launch { 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) val assets = container.syncRepository.assets(deviceAlbumId)
.filter { it.state == LocalAssetState.AWAITING_FREE.name } .filter { it.state == LocalAssetState.AWAITING_FREE.name }
val writeUris = mutableListOf<Uri>() val writeUris = mutableListOf<Uri>()
val deleteUris = mutableListOf<Uri>() val deleteUris = mutableListOf<Uri>()
for (asset in assets) { 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) { when (result) {
OffloadResult.Done, OffloadResult.Failed -> Unit OffloadResult.Done, OffloadResult.Failed, OffloadResult.NotOnServer -> Unit
is OffloadResult.NeedsWriteConsent -> writeUris += result.uris is OffloadResult.NeedsWriteConsent -> writeUris += result.uris
is OffloadResult.NeedsDeleteConsent -> deleteUris += result.uris is OffloadResult.NeedsDeleteConsent -> deleteUris += result.uris
} }
@@ -68,4 +68,54 @@ class SyncRepositoryTest {
// Provenance survives, and no server photo is touched by unlink. // Provenance survives, and no server photo is touched by unlink.
assertEquals("p1", db.syncDao().getAsset("b1/1")?.serverPhotoId) 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,
)
} }
@@ -38,7 +38,26 @@ class OffloadManagerTest {
@Test @Test
fun `offload of an unreadable asset returns a result instead of throwing`() = runBlocking { fun `offload of an unreadable asset returns a result instead of throwing`() = runBlocking {
val asset = DeviceAssetEntity( val asset = asset(serverPhotoId = "p1")
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", assetKey = "b/1",
deviceAlbumId = "b", deviceAlbumId = "b",
mediaStoreId = 1L, mediaStoreId = 1L,
@@ -48,16 +67,8 @@ class OffloadManagerTest {
size = 0L, size = 0L,
dateModified = 0L, dateModified = 0L,
contentHash = null, contentHash = null,
serverPhotoId = null, serverPhotoId = serverPhotoId,
uploadedAt = null, uploadedAt = null,
state = LocalAssetState.UPLOADED.name, state = LocalAssetState.UPLOADED.name,
) )
val result = manager.offload(asset)
assertTrue(
"expected Failed or a consent request, got $result",
result == OffloadResult.Failed || result is OffloadResult.NeedsWriteConsent,
)
}
} }
@@ -32,10 +32,13 @@ class SyncCoordinatorTest {
val deletes = mutableListOf<String>() val deletes = mutableListOf<String>()
val moves = mutableListOf<Pair<List<String>, String>>() val moves = mutableListOf<Pair<List<String>, String>>()
var serverMap: Map<String, String> = emptyMap() var serverMap: Map<String, String> = emptyMap()
val serverMapByAlbum = mutableMapOf<String, Map<String, String>>()
var failChecksumsFor: Set<String> = emptySet()
override suspend fun serverChecksums(serverAlbumId: String): Map<String, String> { override suspend fun serverChecksums(serverAlbumId: String): Map<String, String> {
checksumCalls++ checksumCalls++
return serverMap if (serverAlbumId in failChecksumsFor) throw RuntimeException("album unavailable")
return serverMapByAlbum[serverAlbumId] ?: serverMap
} }
override suspend fun renamePhoto(photoId: String, newName: String) { override suspend fun renamePhoto(photoId: String, newName: String) {
@@ -77,7 +80,13 @@ class SyncCoordinatorTest {
private class FakeOffloader : AssetOffloader { private class FakeOffloader : AssetOffloader {
val offloaded = mutableListOf<String>() val offloaded = mutableListOf<String>()
var result: OffloadResult = OffloadResult.Done var result: OffloadResult = OffloadResult.Done
override suspend fun offload(asset: DeviceAssetEntity): OffloadResult { override suspend fun offload(
asset: DeviceAssetEntity,
liveServerPhotoIds: Set<String>,
): 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 offloaded += asset.assetKey
return result return result
} }
@@ -192,6 +201,7 @@ class SyncCoordinatorTest {
@Test @Test
fun `a rename is propagated`() = runBlocking { fun `a rename is propagated`() = runBlocking {
link(SyncMode.COPY) link(SyncMode.COPY)
remote.serverMap = mapOf("h" to "p1")
provider.items = listOf(asset("b1/1", name = "b.jpg", hash = "h")) provider.items = listOf(asset("b1/1", name = "b.jpg", hash = "h"))
db.syncDao().upsertAssets( db.syncDao().upsertAssets(
listOf( listOf(
@@ -401,6 +411,112 @@ class SyncCoordinatorTest {
assertEquals(listOf("b1/1"), offloader.offloaded) 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 @Test
fun `move delegates to the server`() = runBlocking { fun `move delegates to the server`() = runBlocking {
coordinator.moveToServerAlbum(listOf("p1", "p2"), "srv2") coordinator.moveToServerAlbum(listOf("p1", "p2"), "srv2")
@@ -50,7 +50,7 @@ class SyncPlannerTest {
val actions = SyncPlanner.plan( val actions = SyncPlanner.plan(
local = emptyList(), local = emptyList(),
index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg")), index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg")),
serverChecksums = emptyMap(), serverChecksums = mapOf("h1" to "p1"),
hashesPresentElsewhere = emptySet(), hashesPresentElsewhere = emptySet(),
) )
assertEquals(listOf(SyncAction.DeleteOnServer("p1", "b1/1")), actions) assertEquals(listOf(SyncAction.DeleteOnServer("p1", "b1/1")), actions)
@@ -84,7 +84,7 @@ class SyncPlannerTest {
val actions = SyncPlanner.plan( val actions = SyncPlanner.plan(
local = listOf(asset), local = listOf(asset),
index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg")), index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg")),
serverChecksums = emptyMap(), serverChecksums = mapOf("h1" to "p1"),
hashesPresentElsewhere = emptySet(), hashesPresentElsewhere = emptySet(),
) )
assertEquals(listOf(SyncAction.RenameOnServer("p1", "b.jpg")), actions) assertEquals(listOf(SyncAction.RenameOnServer("p1", "b.jpg")), actions)
@@ -152,7 +152,7 @@ class SyncPlannerTest {
val actions = SyncPlanner.plan( val actions = SyncPlanner.plan(
local = listOf(asset), local = listOf(asset),
index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg")), index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg")),
serverChecksums = emptyMap(), serverChecksums = mapOf("h1" to "p1"),
hashesPresentElsewhere = emptySet(), hashesPresentElsewhere = emptySet(),
) )
assertEquals( assertEquals(
@@ -160,4 +160,28 @@ class SyncPlannerTest {
actions, 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)
}
} }
@@ -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)
}
}
@@ -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
}
}
@@ -5,6 +5,7 @@ import com.galo.android.domain.model.AlbumSort
import com.galo.android.domain.model.AlbumSortChoice import com.galo.android.domain.model.AlbumSortChoice
import com.galo.android.domain.model.DeviceAlbum import com.galo.android.domain.model.DeviceAlbum
import com.galo.android.domain.model.SyncMode import com.galo.android.domain.model.SyncMode
import com.galo.android.domain.sync.AlbumSyncProgress
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -81,6 +82,33 @@ class AlbumListBuilderTest {
assertEquals(SyncMode.COPY, deviceItem.mode) 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<AlbumListItem.DeviceAlbumItem>().single()
assertEquals(status, deviceItem.progress)
// The independent server album has no operation to report.
assertEquals("s2", items.filterIsInstance<AlbumListItem.ServerAlbum>().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: * 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. * updated s2,b1,s1 / title b1,s2,s1 / photos b1,s1,s2.
@@ -15,6 +15,9 @@ import com.galo.android.domain.model.Album
import com.galo.android.domain.model.AlbumSortChoice import com.galo.android.domain.model.AlbumSortChoice
import com.galo.android.domain.model.DeviceAlbum import com.galo.android.domain.model.DeviceAlbum
import com.galo.android.domain.model.SyncMode 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_IMAGE
import com.galo.android.ui.common.TAG_USER_AVATAR_PLACEHOLDER import com.galo.android.ui.common.TAG_USER_AVATAR_PLACEHOLDER
import com.galo.android.ui.theme.GaloTheme import com.galo.android.ui.theme.GaloTheme
@@ -90,6 +93,8 @@ class AlbumsContentTest {
compose.onNodeWithText("4 фото").assertIsDisplayed() compose.onNodeWithText("4 фото").assertIsDisplayed()
compose.onNodeWithTag(TAG_ALBUM_STORAGE_CLOUD_PREFIX + "a1", useUnmergedTree = true).assertExists() compose.onNodeWithTag(TAG_ALBUM_STORAGE_CLOUD_PREFIX + "a1", useUnmergedTree = true).assertExists()
compose.onNodeWithTag(TAG_ALBUM_STORAGE_DEVICE_PREFIX + "a1", useUnmergedTree = true).assertDoesNotExist() 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 @Test
@@ -126,6 +131,118 @@ class AlbumsContentTest {
compose.onNodeWithTag(TAG_ALBUM_STORAGE_DEVICE_PREFIX + "b1", useUnmergedTree = true).assertExists() compose.onNodeWithTag(TAG_ALBUM_STORAGE_DEVICE_PREFIX + "b1", useUnmergedTree = true).assertExists()
compose.onNodeWithTag(TAG_ALBUM_STORAGE_CLOUD_PREFIX + "b1", useUnmergedTree = true).assertDoesNotExist() compose.onNodeWithTag(TAG_ALBUM_STORAGE_CLOUD_PREFIX + "b1", useUnmergedTree = true).assertDoesNotExist()
compose.onNodeWithText("→ Отпуск").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 @Test
@@ -9,6 +9,10 @@ import com.galo.android.data.repo.LiveAlbumSource
import com.galo.android.di.AppContainer import com.galo.android.di.AppContainer
import com.galo.android.domain.model.AlbumSort import com.galo.android.domain.model.AlbumSort
import com.galo.android.domain.model.AlbumSortChoice 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.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -20,6 +24,7 @@ import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith 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. */ /** Waits for the server block to settle on [expected], then returns what it saw. */
private suspend fun awaitServerIds(viewModel: AlbumsViewModel, expected: List<String>): List<String> { private suspend fun awaitServerIds(viewModel: AlbumsViewModel, expected: List<String>): List<String> {
val deadline = System.currentTimeMillis() + 10_000 val deadline = System.currentTimeMillis() + 10_000
@@ -96,6 +96,8 @@ class DeviceAlbumContentTest {
onDeleteAlbum: () -> Unit = {}, onDeleteAlbum: () -> Unit = {},
onRenameAlbum: (String) -> Unit = {}, onRenameAlbum: (String) -> Unit = {},
onSetCover: (String?) -> Unit = {}, onSetCover: (String?) -> Unit = {},
notice: String? = null,
onDismissNotice: () -> Unit = {},
) { ) {
GaloTheme(dynamicColor = false) { GaloTheme(dynamicColor = false) {
DeviceAlbumContent( DeviceAlbumContent(
@@ -112,6 +114,8 @@ class DeviceAlbumContentTest {
onDeleteAlbum = onDeleteAlbum, onDeleteAlbum = onDeleteAlbum,
onRenameAlbum = onRenameAlbum, onRenameAlbum = onRenameAlbum,
onSetCover = onSetCover, onSetCover = onSetCover,
notice = notice,
onDismissNotice = onDismissNotice,
) )
} }
} }
@@ -340,6 +344,45 @@ class DeviceAlbumContentTest {
compose.onNodeWithTag(TAG_TRANSFERRED_PREFIX + 1, useUnmergedTree = true).assertDoesNotExist() 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 @Test
fun `settings panel offers the three sync modes`() { fun `settings panel offers the three sync modes`() {
compose.setContent { settings(DeviceAlbumViewModel.UiState(name = "Camera")) } compose.setContent { settings(DeviceAlbumViewModel.UiState(name = "Camera")) }
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-16
@@ -0,0 +1,114 @@
# Design
## Context
См. `proposal.md` — Why. Ниже только то, что определяет подход.
- Карточка собирается из данных без представления о синхронизации: `AlbumListBuilder.build`
получает `List<Album>` (серверные), `List<AlbumLink>` и `List<DeviceAlbum>`, а
`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
- Нужна ли поддержка «уменьшения движения» (отключение пульсации по системной настройке).
@@ -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) — тот же
механизм применим, но в объём этого изменения не входит; ручные загрузки в серверный
альбом на карточке не отражаются (у них нет привязки к альбому устройства).
@@ -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** показанный процент не уменьшается
@@ -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
- Заархивировать изменение после прохождения ревью проекта.
- Проверить результат архивации.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-16
@@ -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
- Нужен ли отдельный сценарий «перенести потерянные копии из прежнего альбома» как ручное
действие, когда включённый режим уже сменился — отдельным изменением.
@@ -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`).
- **Веб-клиент:** не затрагивается.
- **Не затрагивается:** ручные загрузки в серверный альбом; восстановление уже потерянных
копий из корзины сервера (по решению пользователя это отдельная задача).
@@ -0,0 +1,24 @@
# Spec Delta
## ADDED Requirements
### Requirement: Видимость потерянной серверной копии
Приложение SHALL явно показывать, сколько фото альбома устройства остались без серверной
копии: на экране альбома устройства и на его карточке в списке. Приложение SHALL NOT
удалять такие превью автоматически.
#### Scenario: Экран альбома устройства
- **WHEN** хотя бы одно фото альбома устройства потеряло серверную копию
- **THEN** экран альбома показывает предупреждение с числом таких фото
#### Scenario: Карточка в списке
- **WHEN** у альбома устройства есть фото без серверной копии
- **THEN** карточка показывает это в третьей строке выделенным цветом
#### Scenario: Автоматического удаления нет
- **WHEN** элемент помечен как потерявший серверную копию
- **THEN** превью остаётся на устройстве, пока пользователь сам его не удалит
@@ -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** связь не переключается, и пользователь уведомляется, сколько фото не удалось перенести
@@ -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
- Заархивировать изменение после прохождения ревью проекта.
- Проверить результат архивации.