feat(android): статус синхронизации на карточке и безопасная разгрузка
Карточка альбома устройства получает третью строку: метку режима («Копии»/ «Перенос») и ход загрузки. Процент считается как (uploaded + running) / total для обоих синхронизируемых режимов, пульсирует только при активной загрузке, показывает очередь, ожидание Wi-Fi и ошибку. В режиме переноса карточка показывает и карту памяти, и облако — режимы различает метка, а не значки. Разгрузка оригиналов теперь требует подтверждённой серверной копии в текущем целевом альбоме: устаревший serverPhotoId перепроверяется по альбому, смена цели переносит уже загруженные фото и отклоняется, если перенести нельзя. Потеря серверной копии видна на карточке и на экране альбома, автозатирания превью нет. Изменения OpenSpec: android-album-card-sync-status, safe-album-offload.
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
- Заархивировать изменение после прохождения ревью проекта.
|
||||||
|
- Проверить результат архивации.
|
||||||
Reference in New Issue
Block a user