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

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

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

Изменения OpenSpec: android-album-card-sync-status, safe-album-offload.
This commit is contained in:
iu
2026-10-16 18:36:51 +03:00
parent 98aaa3156a
commit 1eb1177273
36 changed files with 1855 additions and 81 deletions
@@ -8,6 +8,9 @@ import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.data.db.entity.SyncedAlbumEntity
import kotlinx.coroutines.flow.Flow
/** Number of indexed assets that already have a server photo, per device album. */
data class AlbumUploadedCount(val deviceAlbumId: String, val count: Int)
@Dao
interface SyncDao {
@@ -50,6 +53,18 @@ interface SyncDao {
@Query("SELECT * FROM device_assets WHERE deviceAlbumId = :deviceAlbumId")
fun observeAssets(deviceAlbumId: String): Flow<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")
suspend fun getAssets(deviceAlbumId: String): List<DeviceAssetEntity>
@@ -12,6 +12,10 @@ interface UploadDao {
@Query("SELECT * FROM upload_jobs ORDER BY createdAt DESC")
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")
suspend fun getById(id: String): UploadJobEntity?
@@ -23,6 +23,18 @@ class SyncRepository(
fun observeAssets(deviceAlbumId: String): Flow<List<DeviceAssetEntity>> =
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)
/** 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 NeedsDeleteConsent(val uris: List<Uri>) : OffloadResult
data object Failed : OffloadResult
/** There is no confirmed copy of the asset in the target album; nothing was changed. */
data object NotOnServer : OffloadResult
}
/** Replaces a device original with its offloaded version (mode C). */
interface AssetOffloader {
suspend fun offload(asset: DeviceAssetEntity): OffloadResult
/**
* Offloads [asset] only when its server photo is in [liveServerPhotoIds], the
* photos currently present in the linked album. Without that proof the
* original is left untouched, so a stale or foreign record can never destroy it.
*/
suspend fun offload(asset: DeviceAssetEntity, liveServerPhotoIds: Set<String>): OffloadResult
}
/**
@@ -46,24 +54,34 @@ class OffloadManager(
private val mutex = Mutex()
override suspend fun offload(asset: DeviceAssetEntity): OffloadResult = withContext(Dispatchers.IO) {
// Serialize offloads so two passes never fight over the same asset.
mutex.withLock {
runCatching {
val uri = Uri.parse(asset.uri)
val link = dao.getSyncedAlbum(asset.deviceAlbumId)
val serverAlbumId = link?.serverAlbumId
val syncMode = link?.mode
if (kindOf(asset.mimeType) == MediaKind.VIDEO) {
offloadVideo(asset, uri, serverAlbumId, syncMode)
} else {
offloadPhoto(asset, uri, serverAlbumId, syncMode)
}
}.onSuccess {
android.util.Log.i(TAG, "offload ${asset.assetKey}: $it")
}.onFailure {
android.util.Log.w(TAG, "offload ${asset.assetKey} failed", it)
}.getOrDefault(OffloadResult.Failed)
override suspend fun offload(
asset: DeviceAssetEntity,
liveServerPhotoIds: Set<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.
mutex.withLock {
runCatching {
val uri = Uri.parse(asset.uri)
val link = dao.getSyncedAlbum(asset.deviceAlbumId)
val serverAlbumId = link?.serverAlbumId
val syncMode = link?.mode
if (kindOf(asset.mimeType) == MediaKind.VIDEO) {
offloadVideo(asset, uri, serverAlbumId, syncMode)
} else {
offloadPhoto(asset, uri, serverAlbumId, syncMode)
}
}.onSuccess {
android.util.Log.i(TAG, "offload ${asset.assetKey}: $it")
}.onFailure {
android.util.Log.w(TAG, "offload ${asset.assetKey} failed", it)
}.getOrDefault(OffloadResult.Failed)
}
}
}
@@ -2,12 +2,22 @@ package com.galo.android.data.sync
import com.galo.android.data.db.dao.SyncDao
import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.data.db.entity.SyncedAlbumEntity
import com.galo.android.domain.model.LocalAssetState
import com.galo.android.domain.model.SyncMode
import com.galo.android.domain.sync.SyncPolicy
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/** Outcome of changing the server album a device album syncs to. */
sealed interface RetargetResult {
/** The link was switched (or created) and a pass ran. [strandedCopies] could not be carried. */
data class Linked(val strandedCopies: Int) : RetargetResult
/** The link was left unchanged because the recorded copies could not be carried over. */
data class Refused(val reason: String) : RetargetResult
}
/**
* Reconciles a device album with its server album. Mode A (DISABLED) is local
* only and performs no server requests; the other modes upload missing content,
@@ -25,12 +35,15 @@ class SyncCoordinator(
private val mutex = Mutex()
/** @return true when the server was contacted, false for a local-only album. */
suspend fun reconcile(deviceAlbumId: String): Boolean = mutex.withLock {
val link = dao.getSyncedAlbum(deviceAlbumId) ?: return@withLock false
suspend fun reconcile(deviceAlbumId: String): Boolean =
mutex.withLock { reconcileLocked(deviceAlbumId) }
private suspend fun reconcileLocked(deviceAlbumId: String): Boolean {
val link = dao.getSyncedAlbum(deviceAlbumId) ?: return false
val mode = SyncMode.from(link.mode)
if (!SyncPolicy.contactsServer(mode)) return@withLock false
if (!SyncPolicy.contactsServer(mode)) return false
// A mode can be chosen before a server album; nothing to sync until then.
if (link.serverAlbumId.isBlank()) return@withLock false
if (link.serverAlbumId.isBlank()) return false
val serverChecksums = remote.serverChecksums(link.serverAlbumId)
val livePhotoIds = serverChecksums.values.toSet()
@@ -46,9 +59,72 @@ class SyncCoordinator(
for (action in actions) apply(deviceAlbumId, link.serverAlbumId, mode, action, livePhotoIds)
// Mode C: assets already on the server (e.g. after switching from copies)
// are replaced with their offloaded versions, right away.
if (mode == SyncMode.MOVE) offloadUploaded(deviceAlbumId, assets)
return@withLock true
// are replaced with their offloaded versions, right away, but only while
// their copy is confirmed in the target album.
if (mode == SyncMode.MOVE) offloadUploaded(deviceAlbumId, assets, livePhotoIds)
return true
}
/**
* Switches a device album to [newServerAlbumId], carrying the photos this
* client already uploaded over first. The link is only changed once every
* copy still available in the previous album is confirmed in the new one;
* otherwise the previous link is kept and the change is refused. Photos that
* cannot be carried (already lost elsewhere) do not block the switch and are
* reported instead.
*/
suspend fun retarget(
deviceAlbumId: String,
deviceAlbumName: String,
newServerAlbumId: String,
mode: SyncMode,
): RetargetResult = mutex.withLock {
val previous = dao.getSyncedAlbum(deviceAlbumId)
?.serverAlbumId
?.takeIf { it.isNotBlank() }
val recordedPhotoIds = dao.getAssets(deviceAlbumId)
.mapNotNull { it.serverPhotoId }
.distinct()
var stranded = 0
if (previous != null && previous != newServerAlbumId && recordedPhotoIds.isNotEmpty()) {
val previousPhotos = runCatching { remote.serverChecksums(previous) }.getOrNull()
?: return@withLock RetargetResult.Refused(
"Прежний серверный альбом недоступен — фото перенести нельзя.",
)
val liveInPrevious = previousPhotos.values.toSet()
val toCarry = recordedPhotoIds.filter { it in liveInPrevious }
stranded = recordedPhotoIds.size - toCarry.size
if (toCarry.isNotEmpty()) {
if (runCatching { remote.movePhotos(toCarry, newServerAlbumId) }.isFailure) {
return@withLock RetargetResult.Refused("Не удалось перенести фото в новый альбом.")
}
val newPhotos = runCatching { remote.serverChecksums(newServerAlbumId) }.getOrNull()
?: return@withLock RetargetResult.Refused(
"Не удалось проверить перенос в новый альбом.",
)
val liveInNew = newPhotos.values.toSet()
val missing = toCarry.count { it !in liveInNew }
if (missing > 0) {
return@withLock RetargetResult.Refused(
"В новый альбом не перенеслось фото: $missing.",
)
}
}
}
dao.upsertSyncedAlbum(
SyncedAlbumEntity(
deviceAlbumId = deviceAlbumId,
deviceAlbumName = deviceAlbumName,
serverAlbumId = newServerAlbumId,
mode = mode.name,
updatedAt = System.currentTimeMillis(),
),
)
reconcileLocked(deviceAlbumId)
return@withLock RetargetResult.Linked(stranded)
}
/**
@@ -93,9 +169,14 @@ class SyncCoordinator(
/**
* Offloads assets that are already on the server but still have their original
* locally (mode C). Retries assets left as "awaiting free" from a previous pass,
* so freeing completes automatically on a later run without any prompt.
* so freeing completes automatically on a later run without any prompt. Only a
* photo confirmed in the target album ([livePhotoIds]) may replace the original.
*/
private suspend fun offloadUploaded(deviceAlbumId: String, localAssets: List<LocalAsset>) {
private suspend fun offloadUploaded(
deviceAlbumId: String,
localAssets: List<LocalAsset>,
livePhotoIds: Set<String>,
) {
val localKeys = localAssets.mapTo(HashSet()) { it.assetKey }
for (entity in dao.getAssets(deviceAlbumId)) {
if (entity.serverPhotoId == null) continue
@@ -109,9 +190,9 @@ class SyncCoordinator(
) {
continue
}
val result = runCatching { offloader.offload(entity) }.getOrNull() ?: continue
val result = runCatching { offloader.offload(entity, livePhotoIds) }.getOrNull() ?: continue
when (result) {
is OffloadResult.Done, OffloadResult.Failed -> Unit
is OffloadResult.Done, OffloadResult.Failed, OffloadResult.NotOnServer -> Unit
is OffloadResult.NeedsWriteConsent, is OffloadResult.NeedsDeleteConsent ->
dao.markState(entity.assetKey, LocalAssetState.AWAITING_FREE.name)
}
@@ -35,6 +35,10 @@ sealed interface SyncAction {
* Computes the changes to apply to a server album from the device's current
* content, the local index (provenance) and the server's photo checksums.
*
* The recorded server photo id is trusted only when that photo is present in the
* target album (`serverChecksums` values). A stale id is re-uploaded when the
* original is local, and never causes a delete of a photo that lives elsewhere.
*
* Rules:
* - content already on the server is linked instead of uploaded (dedup);
* - an indexed asset whose content changed is re-uploaded and its old upload is
@@ -52,6 +56,7 @@ object SyncPlanner {
hashesPresentElsewhere: Set<String>,
): List<SyncAction> {
val actions = mutableListOf<SyncAction>()
val livePhotoIds = serverChecksums.values.toSet()
for (asset in local) {
val indexed = index.firstOrNull { it.assetKey == asset.assetKey }
@@ -72,16 +77,17 @@ object SyncPlanner {
}
continue
}
val liveIndexedPhotoId = indexed?.serverPhotoId?.takeIf { it in livePhotoIds }
when {
indexed?.serverPhotoId != null -> {
liveIndexedPhotoId != null -> {
val changed = asset.contentHash != null &&
indexed.contentHash != null &&
asset.contentHash != indexed.contentHash
if (changed) {
actions += SyncAction.Upload(asset)
actions += SyncAction.DeleteOnServer(indexed.serverPhotoId, asset.assetKey)
actions += SyncAction.DeleteOnServer(liveIndexedPhotoId, asset.assetKey)
} else if (asset.displayName != indexed.displayName) {
actions += SyncAction.RenameOnServer(indexed.serverPhotoId, asset.displayName)
actions += SyncAction.RenameOnServer(liveIndexedPhotoId, asset.displayName)
}
}
@@ -99,6 +105,8 @@ object SyncPlanner {
if (indexed.assetKey in localKeys) continue
val hash = indexed.contentHash
if (hash != null && hash in hashesPresentElsewhere) continue
// The remote delete only happens while this photo is in the target
// album (see [SyncCoordinator]); a stale id may be another album's.
actions += SyncAction.DeleteOnServer(photoId, indexed.assetKey)
}
@@ -12,6 +12,7 @@ import androidx.work.WorkManager
import androidx.work.workDataOf
import com.galo.android.data.db.dao.UploadDao
import com.galo.android.data.db.entity.UploadJobEntity
import com.galo.android.domain.model.SyncUpload
import com.galo.android.domain.model.UploadJob
import com.galo.android.domain.model.UploadStatus
import kotlinx.coroutines.flow.Flow
@@ -32,6 +33,24 @@ class UploadRepository(
fun observeJobs(): Flow<List<UploadJob>> =
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 {
var queued = 0
for (uri in uris) {
@@ -97,7 +97,8 @@ class UploadWorker(
// the OS write consent, which is requested from the album screen.
if (mode == SyncMode.MOVE) {
container.syncRepository.getAsset(assetKey)?.let { asset ->
runCatching { container.offloadManager.offload(asset) }
// The upload just succeeded, so the copy is live in the target.
runCatching { container.offloadManager.offload(asset, setOf(photoId)) }
}
}
}
@@ -26,3 +26,15 @@ data class UploadJob(
val wifiOnly: Boolean,
val createdAt: Long,
)
/**
* A queued upload that belongs to a device-album synchronization, used to show
* the album's copy operation on its card. Manual uploads chosen from the upload
* screen have no device album and are not represented here.
*/
data class SyncUpload(
val deviceAlbumId: String,
val status: UploadStatus,
val progress: Int,
val wifiOnly: Boolean,
)
@@ -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.SyncMode
import com.galo.android.domain.model.updatedAtMillis
import com.galo.android.domain.sync.AlbumSyncProgress
/** One row of the mixed album list: a server album or an album of the device. */
sealed interface AlbumListItem {
@@ -22,6 +23,8 @@ sealed interface AlbumListItem {
val album: DeviceAlbum,
val linkedServerTitle: String?,
val mode: SyncMode,
/** Copy-operation status for the card's third line; null when there is none. */
val progress: AlbumSyncProgress? = null,
) : AlbumListItem {
override val key: String get() = "device:${album.id}"
}
@@ -50,6 +53,7 @@ object AlbumListBuilder {
links: List<AlbumLink>,
devices: List<DeviceAlbum>,
choice: AlbumSortChoice = AlbumSortChoice(),
progressByDevice: Map<String, AlbumSyncProgress> = emptyMap(),
): List<AlbumListItem> {
val linksByDevice = links.associateBy { it.deviceAlbumId }
val linkedServerIds = links
@@ -65,6 +69,7 @@ object AlbumListBuilder {
serverTitles[serverId] ?: serverId
},
mode = link?.mode ?: SyncMode.DISABLED,
progress = progressByDevice[device.id],
)
}
@@ -1,5 +1,11 @@
package com.galo.android.ui.albums
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -42,6 +48,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
@@ -52,6 +59,7 @@ import com.galo.android.domain.model.Album
import com.galo.android.domain.model.AlbumSort
import com.galo.android.domain.model.DeviceAlbum
import com.galo.android.domain.model.SyncMode
import com.galo.android.domain.sync.AlbumSyncProgress
import com.galo.android.ui.GaloViewModelFactory
import com.galo.android.ui.common.GaloAsyncImage
import com.galo.android.ui.common.RequestMediaAccess
@@ -70,6 +78,7 @@ const val TAG_DEVICE_ALBUM_CARD_PREFIX = "device_album_card_"
const val TAG_ALBUM_STORAGE_DEVICE_PREFIX = "album_storage_device_"
const val TAG_ALBUM_STORAGE_CLOUD_PREFIX = "album_storage_cloud_"
const val TAG_ALBUM_COUNT_PREFIX = "album_count_"
const val TAG_ALBUM_STATUS_PREFIX = "album_status_"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -268,6 +277,7 @@ private fun AlbumCardBody(
placement: StoragePlacement,
count: String,
arrowTo: String?,
sync: AlbumSyncProgress? = null,
) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -317,6 +327,49 @@ private fun AlbumCardBody(
modifier = Modifier.padding(start = 6.dp).testTag(TAG_ALBUM_COUNT_PREFIX + id),
)
}
if (sync != null) {
AlbumSyncLine(id = id, sync = sync, modifier = Modifier.padding(top = 2.dp))
}
}
}
/**
* Third card line: the sync mode and, for copies, the operation state. The
* percentage pulses only while copies are actually in flight.
*/
@Composable
private fun AlbumSyncLine(id: String, sync: AlbumSyncProgress, modifier: Modifier = Modifier) {
val text = sync.cardText()
val error = sync.missingCopies > 0 || (sync.failed && !sync.running)
val color = if (error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
if (sync.pulsing) {
val transition = rememberInfiniteTransition(label = "album-sync")
val alpha by transition.animateFloat(
initialValue = 0.35f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 900, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "album-sync-alpha",
)
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier.alpha(alpha).testTag(TAG_ALBUM_STATUS_PREFIX + id),
)
} else {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier.testTag(TAG_ALBUM_STATUS_PREFIX + id),
)
}
}
@@ -376,6 +429,7 @@ private fun DeviceAlbumCard(item: AlbumListItem.DeviceAlbumItem, onClick: () ->
placement = placementOf(item.mode),
count = "${album.itemCount} эл.",
arrowTo = arrowTo,
sync = item.progress,
)
}
}
@@ -384,6 +438,7 @@ private fun DeviceAlbumCard(item: AlbumListItem.DeviceAlbumItem, onClick: () ->
/** Storage icon placement for a device album: where its media live. */
private fun placementOf(mode: SyncMode): StoragePlacement = when (mode) {
SyncMode.DISABLED -> StoragePlacement.DEVICE
SyncMode.COPY -> StoragePlacement.DEVICE_AND_CLOUD
SyncMode.MOVE -> StoragePlacement.CLOUD
// Copies and move both keep something locally: originals for copies, previews
// for move. The mode label on the third line tells them apart.
SyncMode.COPY, SyncMode.MOVE -> StoragePlacement.DEVICE_AND_CLOUD
}
@@ -8,7 +8,11 @@ import com.galo.android.data.repo.LiveAlbumSource
import com.galo.android.domain.model.AlbumSort
import com.galo.android.domain.model.AlbumSortChoice
import com.galo.android.domain.model.DeviceAlbum
import com.galo.android.domain.model.DeviceAlbumMeta
import com.galo.android.domain.model.SyncMode
import com.galo.android.domain.model.SyncUpload
import com.galo.android.domain.sync.AlbumSyncProgress
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -52,29 +56,51 @@ class AlbumsViewModel(
/** Current list ordering; restored from preferences and changed from the UI. */
private val sort = MutableStateFlow(AlbumSortChoice())
/** Everything the mixed list needs from the device side, in one emission. */
private data class DeviceInput(
val devices: List<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(
container.albumRepository.observeAlbums(),
container.syncRepository.observeLinks(),
combine(deviceAlbums, deviceAccess, container.syncRepository.observeAlbumMeta()) { devices, access, meta ->
Triple(devices, access, meta)
},
deviceInput,
status,
combine(avatar, sort) { url, choice -> url to choice },
) { albums, links, device, s, header ->
val (avatarUrl, sortChoice) = header
val withMeta = device.first.map { d ->
val m = device.third[d.id]
val withMeta = device.devices.map { d ->
val m = device.meta[d.id]
d.copy(
name = m?.displayName?.takeIf { it.isNotBlank() } ?: d.name,
coverUri = m?.coverUri ?: d.coverUri,
)
}
val modeByDevice = links.associate { it.deviceAlbumId to SyncMode.from(it.mode) }
val items = AlbumListBuilder.build(
albums = albums,
links = links.map { link ->
AlbumLink(
deviceAlbumId = link.deviceAlbumId,
deviceAlbumName = device.third[link.deviceAlbumId]?.displayName
deviceAlbumName = device.meta[link.deviceAlbumId]?.displayName
?.takeIf { it.isNotBlank() } ?: link.deviceAlbumName,
serverAlbumId = link.serverAlbumId,
mode = SyncMode.from(link.mode),
@@ -82,6 +108,13 @@ class AlbumsViewModel(
},
devices = withMeta,
choice = sortChoice,
progressByDevice = albumSyncProgressByDevice(
devices = withMeta,
modeByDevice = modeByDevice,
uploads = device.uploads,
uploadedCounts = device.uploadedCounts,
missingCounts = device.missingCounts,
),
)
UiState(
@@ -90,7 +123,7 @@ class AlbumsViewModel(
offline = s.offline,
error = s.error,
avatarUrl = avatarUrl,
deviceAccess = device.second,
deviceAccess = device.access,
sort = sortChoice.sort,
reversed = sortChoice.reversed,
)
@@ -148,3 +181,27 @@ class AlbumsViewModel(
onDone()
}
}
/**
* Copy-operation status per device album, for the cards' third line. Top-level so
* it can be tested directly: the device list comes from MediaStore and is empty
* in a plain unit test, while this mapping is the whole wiring under test.
*/
internal fun albumSyncProgressByDevice(
devices: List<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_TRANSFERRED_PREFIX = "transferred_"
const val TAG_ORPHANED_PREFIX = "orphaned_"
const val TAG_DEVICE_LOST_COPY = "device_lost_copy"
const val TAG_DEVICE_SETTINGS = "device_album_settings"
const val TAG_MODE_PREFIX = "sync_mode_"
const val TAG_TARGET_PREFIX = "sync_target_"
@@ -131,6 +132,7 @@ fun DeviceAlbumScreen(
val viewModel: DeviceAlbumViewModel = viewModel(factory = factory)
val state by viewModel.state.collectAsStateWithLifecycle()
val role by viewModel.role.collectAsStateWithLifecycle()
val notice by viewModel.notice.collectAsStateWithLifecycle()
val prefs by viewModel.preferences.collectAsStateWithLifecycle()
val meta by viewModel.albumMeta.collectAsStateWithLifecycle()
var viewerIndex by remember { mutableStateOf<Int?>(null) }
@@ -242,6 +244,8 @@ fun DeviceAlbumScreen(
role = role,
localName = localName,
localCoverUrl = localCoverUrl,
notice = notice,
onDismissNotice = viewModel::dismissNotice,
onBack = onBack,
onMode = viewModel::setMode,
onTarget = viewModel::setTarget,
@@ -289,6 +293,8 @@ fun DeviceAlbumContent(
role: String = "",
localName: String? = null,
localCoverUrl: String? = null,
notice: String? = null,
onDismissNotice: () -> Unit = {},
onRenameAlbum: (String) -> Unit = {},
onSetCover: (String?) -> Unit = {},
onResetCover: () -> Unit = {},
@@ -380,6 +386,16 @@ fun DeviceAlbumContent(
)
}
notice?.let { message ->
AlertDialog(
onDismissRequest = onDismissNotice,
text = { Text(message) },
confirmButton = {
TextButton(onClick = onDismissNotice) { Text("Понятно") }
},
)
}
val device = DeviceAlbum(
id = state.name,
name = state.name.ifBlank { "Альбом телефона" },
@@ -448,6 +464,9 @@ fun DeviceAlbumContent(
},
) {
Column(modifier = Modifier.fillMaxSize()) {
if (state.orphanedMediaIds.isNotEmpty()) {
LostCopyBanner(count = state.orphanedMediaIds.size)
}
if (state.items.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("В альбоме нет медиа")
@@ -775,6 +794,22 @@ fun TargetPickerDialog(
)
}
/** Shown when some of the album's photos lost their server copy. */
@Composable
private fun LostCopyBanner(count: Int) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth().testTag(TAG_DEVICE_LOST_COPY),
) {
Text(
text = "Нет серверной копии: $count. Превью сохранены и не удаляются автоматически.",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
/** Confirms a mode change and explains what the mode does. */
@Composable
fun ModeConfirmDialog(
@@ -9,6 +9,7 @@ import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.data.mapper.toDomain
import com.galo.android.data.network.dto.PhotoInfoPrefsDto
import com.galo.android.data.sync.RestoreManager
import com.galo.android.data.sync.RetargetResult
import com.galo.android.domain.model.Album
import com.galo.android.domain.model.DeviceAlbumMeta
import com.galo.android.domain.model.DeviceMedia
@@ -63,6 +64,15 @@ class DeviceAlbumViewModel(
/** Saved photo-attribute preferences (visibility + labels). */
val preferences: StateFlow<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. */
val albumMeta: StateFlow<DeviceAlbumMeta?> = container.syncRepository.observeAlbumMeta()
.map { it[deviceAlbumId] }
@@ -291,14 +301,24 @@ class DeviceAlbumViewModel(
fun setTarget(serverAlbumId: String) {
viewModelScope.launch {
container.syncRepository.link(
deviceAlbumId = deviceAlbumId,
deviceAlbumName = local.value.name,
serverAlbumId = serverAlbumId,
mode = pendingMode.value ?: state.value.mode,
)
refreshRole()
reconcileNow()
val mode = pendingMode.value ?: state.value.mode
when (
val result = container.syncCoordinator.retarget(
deviceAlbumId = deviceAlbumId,
deviceAlbumName = local.value.name,
newServerAlbumId = serverAlbumId,
mode = mode,
)
) {
is RetargetResult.Linked -> {
refreshRole()
if (result.strandedCopies > 0) {
_notice.value = "Фото без серверной копии: ${result.strandedCopies}."
}
}
is RetargetResult.Refused -> _notice.value = result.reason
}
}
}
@@ -309,14 +329,18 @@ class DeviceAlbumViewModel(
viewModelScope.launch {
val album = runCatching { container.api.createAlbum(title) }.getOrNull() ?: return@launch
runCatching { container.albumRepository.refresh() }
container.syncRepository.link(
deviceAlbumId = deviceAlbumId,
deviceAlbumName = local.value.name,
serverAlbumId = album.id,
mode = pendingMode.value ?: state.value.mode,
)
refreshRole()
reconcileNow()
val mode = pendingMode.value ?: state.value.mode
when (
val result = container.syncCoordinator.retarget(
deviceAlbumId = deviceAlbumId,
deviceAlbumName = local.value.name,
newServerAlbumId = album.id,
mode = mode,
)
) {
is RetargetResult.Linked -> refreshRole()
is RetargetResult.Refused -> _notice.value = result.reason
}
}
}
@@ -332,14 +356,24 @@ class DeviceAlbumViewModel(
*/
fun freeSpace(onNeedsConsent: (IntentSender) -> Unit) {
viewModelScope.launch {
// Free only what the target album confirms it holds; on any doubt,
// nothing is freed so no original is replaced without a server copy.
val targetAlbumId = container.syncRepository.currentLink(deviceAlbumId)
?.serverAlbumId
?.takeIf { it.isNotBlank() }
val live = targetAlbumId
?.let { runCatching { container.syncRemote.serverChecksums(it) }.getOrNull() }
?.values
?.toSet()
?: emptySet()
val assets = container.syncRepository.assets(deviceAlbumId)
.filter { it.state == LocalAssetState.AWAITING_FREE.name }
val writeUris = mutableListOf<Uri>()
val deleteUris = mutableListOf<Uri>()
for (asset in assets) {
val result = runCatching { container.offloadManager.offload(asset) }.getOrNull() ?: continue
val result = runCatching { container.offloadManager.offload(asset, live) }.getOrNull() ?: continue
when (result) {
OffloadResult.Done, OffloadResult.Failed -> Unit
OffloadResult.Done, OffloadResult.Failed, OffloadResult.NotOnServer -> Unit
is OffloadResult.NeedsWriteConsent -> writeUris += result.uris
is OffloadResult.NeedsDeleteConsent -> deleteUris += result.uris
}
@@ -68,4 +68,54 @@ class SyncRepositoryTest {
// Provenance survives, and no server photo is touched by unlink.
assertEquals("p1", db.syncDao().getAsset("b1/1")?.serverPhotoId)
}
@Test
fun `uploaded counts are per album and skip assets without a server photo`() = runBlocking {
db.syncDao().upsertAssets(
listOf(
asset("b1/1", "b1", "p1"),
asset("b1/2", "b1", null),
asset("b2/1", "b2", "p2"),
),
)
val counts = repo.observeUploadedCounts().first()
assertEquals(mapOf("b1" to 1, "b2" to 1), counts)
assertEquals(0, counts["b3"] ?: 0)
}
@Test
fun `missing copy counts track only orphaned assets`() = runBlocking {
db.syncDao().upsertAssets(
listOf(
asset("b1/1", "b1", "p1", LocalAssetState.ORPHANED.name),
asset("b1/2", "b1", "p2", LocalAssetState.PREVIEW.name),
asset("b2/1", "b2", "p3", LocalAssetState.ORPHANED.name),
),
)
assertEquals(mapOf("b1" to 1, "b2" to 1), repo.observeMissingCopyCounts().first())
}
private fun asset(
key: String,
deviceAlbumId: String,
serverPhotoId: String?,
state: String = LocalAssetState.UPLOADED.name,
) =
DeviceAssetEntity(
assetKey = key,
deviceAlbumId = deviceAlbumId,
mediaStoreId = 0L,
uri = "u",
displayName = "a.jpg",
mimeType = "image/jpeg",
size = 0L,
dateModified = 0L,
contentHash = null,
serverPhotoId = serverPhotoId,
uploadedAt = null,
state = state,
)
}
@@ -38,26 +38,37 @@ class OffloadManagerTest {
@Test
fun `offload of an unreadable asset returns a result instead of throwing`() = runBlocking {
val asset = DeviceAssetEntity(
assetKey = "b/1",
deviceAlbumId = "b",
mediaStoreId = 1L,
uri = "content://does/not/exist",
displayName = "n.jpg",
mimeType = "image/jpeg",
size = 0L,
dateModified = 0L,
contentHash = null,
serverPhotoId = null,
uploadedAt = null,
state = LocalAssetState.UPLOADED.name,
)
val asset = asset(serverPhotoId = "p1")
val result = manager.offload(asset)
val result = manager.offload(asset, setOf("p1"))
assertTrue(
"expected Failed or a consent request, got $result",
result == OffloadResult.Failed || result is OffloadResult.NeedsWriteConsent,
)
}
@Test
fun `no confirmed copy leaves the original untouched`() = runBlocking {
val asset = asset(serverPhotoId = "p1")
val result = manager.offload(asset, emptySet())
assertTrue("expected NotOnServer, got $result", result == OffloadResult.NotOnServer)
}
private fun asset(serverPhotoId: String?) = DeviceAssetEntity(
assetKey = "b/1",
deviceAlbumId = "b",
mediaStoreId = 1L,
uri = "content://does/not/exist",
displayName = "n.jpg",
mimeType = "image/jpeg",
size = 0L,
dateModified = 0L,
contentHash = null,
serverPhotoId = serverPhotoId,
uploadedAt = null,
state = LocalAssetState.UPLOADED.name,
)
}
@@ -32,10 +32,13 @@ class SyncCoordinatorTest {
val deletes = mutableListOf<String>()
val moves = mutableListOf<Pair<List<String>, String>>()
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> {
checksumCalls++
return serverMap
if (serverAlbumId in failChecksumsFor) throw RuntimeException("album unavailable")
return serverMapByAlbum[serverAlbumId] ?: serverMap
}
override suspend fun renamePhoto(photoId: String, newName: String) {
@@ -77,7 +80,13 @@ class SyncCoordinatorTest {
private class FakeOffloader : AssetOffloader {
val offloaded = mutableListOf<String>()
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
return result
}
@@ -192,6 +201,7 @@ class SyncCoordinatorTest {
@Test
fun `a rename is propagated`() = runBlocking {
link(SyncMode.COPY)
remote.serverMap = mapOf("h" to "p1")
provider.items = listOf(asset("b1/1", name = "b.jpg", hash = "h"))
db.syncDao().upsertAssets(
listOf(
@@ -401,6 +411,112 @@ class SyncCoordinatorTest {
assertEquals(listOf("b1/1"), offloader.offloaded)
}
@Test
fun `a stale recorded copy is not offloaded but re-uploaded`() = runBlocking {
link(SyncMode.MOVE)
db.syncDao().upsertAssets(
listOf(
DeviceAssetEntity(
"b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L,
"h", "p1", 1L, LocalAssetState.UPLOADED.name,
),
),
)
provider.items = listOf(asset("b1/1", hash = "h"))
coordinator.reconcile("b1")
assertEquals(1, queue.enqueued.size)
assertTrue("no confirmed copy means no offload", offloader.offloaded.isEmpty())
}
@Test
fun `changing the target carries uploaded copies over`() = runBlocking {
link(SyncMode.MOVE)
remote.serverMapByAlbum["srv1"] = mapOf("h" to "p1")
remote.serverMapByAlbum["srv2"] = mapOf("h" to "p1")
db.syncDao().upsertAssets(
listOf(
DeviceAssetEntity(
"b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L,
"h", "p1", 1L, LocalAssetState.PREVIEW.name,
),
),
)
provider.items = listOf(asset("b1/1", hash = "preview"))
val result = coordinator.retarget("b1", "Camera", "srv2", SyncMode.MOVE)
assertEquals(RetargetResult.Linked(0), result)
assertEquals(listOf(listOf("p1") to "srv2"), remote.moves)
assertEquals("srv2", db.syncDao().getSyncedAlbum("b1")?.serverAlbumId)
}
@Test
fun `a target change is refused when the previous album is unavailable`() = runBlocking {
link(SyncMode.MOVE)
remote.failChecksumsFor = setOf("srv1")
db.syncDao().upsertAssets(
listOf(
DeviceAssetEntity(
"b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L,
"h", "p1", 1L, LocalAssetState.PREVIEW.name,
),
),
)
val result = coordinator.retarget("b1", "Camera", "srv2", SyncMode.MOVE)
assertTrue(result is RetargetResult.Refused)
assertEquals("srv1", db.syncDao().getSyncedAlbum("b1")?.serverAlbumId)
assertTrue(remote.moves.isEmpty())
}
@Test
fun `a target change is refused when the copies do not arrive`() = runBlocking {
link(SyncMode.MOVE)
remote.serverMapByAlbum["srv1"] = mapOf("h" to "p1")
db.syncDao().upsertAssets(
listOf(
DeviceAssetEntity(
"b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L,
"h", "p1", 1L, LocalAssetState.PREVIEW.name,
),
),
)
provider.items = listOf(asset("b1/1", hash = "preview"))
val result = coordinator.retarget("b1", "Camera", "srv2", SyncMode.MOVE)
assertTrue(result is RetargetResult.Refused)
assertEquals("srv1", db.syncDao().getSyncedAlbum("b1")?.serverAlbumId)
}
@Test
fun `a target change reports copies that could not be carried`() = runBlocking {
link(SyncMode.MOVE)
remote.serverMapByAlbum["srv1"] = mapOf("h" to "p1")
remote.serverMapByAlbum["srv2"] = mapOf("h" to "p1")
db.syncDao().upsertAssets(
listOf(
DeviceAssetEntity(
"b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L,
"h", "p1", 1L, LocalAssetState.PREVIEW.name,
),
DeviceAssetEntity(
"b1/2", "b1", 2L, "u", "b.jpg", "image/jpeg", 1L, 1L,
"h2", "p9", 1L, LocalAssetState.ORPHANED.name,
),
),
)
provider.items = listOf(asset("b1/1", hash = "preview"), asset("b1/2", hash = "preview2"))
val result = coordinator.retarget("b1", "Camera", "srv2", SyncMode.MOVE)
assertEquals(RetargetResult.Linked(strandedCopies = 1), result)
assertEquals(listOf(listOf("p1") to "srv2"), remote.moves)
}
@Test
fun `move delegates to the server`() = runBlocking {
coordinator.moveToServerAlbum(listOf("p1", "p2"), "srv2")
@@ -50,7 +50,7 @@ class SyncPlannerTest {
val actions = SyncPlanner.plan(
local = emptyList(),
index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg")),
serverChecksums = emptyMap(),
serverChecksums = mapOf("h1" to "p1"),
hashesPresentElsewhere = emptySet(),
)
assertEquals(listOf(SyncAction.DeleteOnServer("p1", "b1/1")), actions)
@@ -84,7 +84,7 @@ class SyncPlannerTest {
val actions = SyncPlanner.plan(
local = listOf(asset),
index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg")),
serverChecksums = emptyMap(),
serverChecksums = mapOf("h1" to "p1"),
hashesPresentElsewhere = emptySet(),
)
assertEquals(listOf(SyncAction.RenameOnServer("p1", "b.jpg")), actions)
@@ -152,7 +152,7 @@ class SyncPlannerTest {
val actions = SyncPlanner.plan(
local = listOf(asset),
index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg")),
serverChecksums = emptyMap(),
serverChecksums = mapOf("h1" to "p1"),
hashesPresentElsewhere = emptySet(),
)
assertEquals(
@@ -160,4 +160,28 @@ class SyncPlannerTest {
actions,
)
}
@Test
fun `a stale recorded photo id with the original local is uploaded again`() {
val asset = local("b1/1", hash = "h1")
val actions = SyncPlanner.plan(
local = listOf(asset),
index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg")),
serverChecksums = emptyMap(),
hashesPresentElsewhere = emptySet(),
)
assertEquals(listOf(SyncAction.Upload(asset)), actions)
}
@Test
fun `a stale recorded photo id whose content is in the target is linked`() {
val asset = local("b1/1", hash = "h1")
val actions = SyncPlanner.plan(
local = listOf(asset),
index = listOf(IndexedAsset("b1/1", "p1", "h1", "a.jpg")),
serverChecksums = mapOf("h1" to "p9"),
hashesPresentElsewhere = emptySet(),
)
assertEquals(listOf(SyncAction.Link(asset, "p9")), actions)
}
}
@@ -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.DeviceAlbum
import com.galo.android.domain.model.SyncMode
import com.galo.android.domain.sync.AlbumSyncProgress
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -81,6 +82,33 @@ class AlbumListBuilderTest {
assertEquals(SyncMode.COPY, deviceItem.mode)
}
@Test
fun `a device card carries its sync status and server cards carry none`() {
val status = AlbumSyncProgress.resolve(SyncMode.COPY, emptyList(), uploaded = 1, total = 3)!!
val items = AlbumListBuilder.build(
albums = listOf(album("s1", "Отпуск"), album("s2", "Работа")),
links = listOf(AlbumLink("b1", "Camera", "s1", SyncMode.COPY)),
devices = listOf(device("b1", "Camera")),
progressByDevice = mapOf("b1" to status),
)
val deviceItem = items.filterIsInstance<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:
* 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.DeviceAlbum
import com.galo.android.domain.model.SyncMode
import com.galo.android.domain.model.SyncUpload
import com.galo.android.domain.model.UploadStatus
import com.galo.android.domain.sync.AlbumSyncProgress
import com.galo.android.ui.common.TAG_USER_AVATAR_IMAGE
import com.galo.android.ui.common.TAG_USER_AVATAR_PLACEHOLDER
import com.galo.android.ui.theme.GaloTheme
@@ -90,6 +93,8 @@ class AlbumsContentTest {
compose.onNodeWithText("4 фото").assertIsDisplayed()
compose.onNodeWithTag(TAG_ALBUM_STORAGE_CLOUD_PREFIX + "a1", useUnmergedTree = true).assertExists()
compose.onNodeWithTag(TAG_ALBUM_STORAGE_DEVICE_PREFIX + "a1", useUnmergedTree = true).assertDoesNotExist()
// A server album has no synchronization mode or operation to report.
compose.onNodeWithTag(TAG_ALBUM_STATUS_PREFIX + "a1", useUnmergedTree = true).assertDoesNotExist()
}
@Test
@@ -126,6 +131,118 @@ class AlbumsContentTest {
compose.onNodeWithTag(TAG_ALBUM_STORAGE_DEVICE_PREFIX + "b1", useUnmergedTree = true).assertExists()
compose.onNodeWithTag(TAG_ALBUM_STORAGE_CLOUD_PREFIX + "b1", useUnmergedTree = true).assertDoesNotExist()
compose.onNodeWithText("→ Отпуск").assertDoesNotExist()
// Sync is off, so the card has no third line.
compose.onNodeWithTag(TAG_ALBUM_STATUS_PREFIX + "b1", useUnmergedTree = true).assertDoesNotExist()
}
@Test
fun `move shows the memory card, the cloud and its mode label`() {
val status = AlbumSyncProgress.resolve(SyncMode.MOVE, emptyList(), uploaded = 0, total = 5)!!
val items = AlbumListBuilder.build(
albums = listOf(album("a1", "Отпуск")),
links = listOf(AlbumLink("b1", "Camera", "a1", SyncMode.MOVE)),
devices = listOf(device("b1", "Camera", 5)),
progressByDevice = mapOf("b1" to status),
)
compose.setContent {
screen(AlbumsViewModel.UiState(items = items, deviceAccess = true))
}
compose.onNodeWithTag(TAG_ALBUM_STORAGE_DEVICE_PREFIX + "b1", useUnmergedTree = true).assertExists()
compose.onNodeWithTag(TAG_ALBUM_STORAGE_CLOUD_PREFIX + "b1", useUnmergedTree = true).assertExists()
compose.onNodeWithText("Перенос").assertIsDisplayed()
}
@Test
fun `copies without an operation show only the mode label`() {
val status = AlbumSyncProgress.resolve(SyncMode.COPY, emptyList(), uploaded = 2, total = 5)!!
val items = AlbumListBuilder.build(
albums = listOf(album("a1", "Отпуск")),
links = listOf(AlbumLink("b1", "Camera", "a1", SyncMode.COPY)),
devices = listOf(device("b1", "Camera", 5)),
progressByDevice = mapOf("b1" to status),
)
compose.setContent {
screen(AlbumsViewModel.UiState(items = items, deviceAccess = true))
}
compose.onNodeWithTag(TAG_ALBUM_STATUS_PREFIX + "b1", useUnmergedTree = true).assertExists()
compose.onNodeWithText("Копии").assertIsDisplayed()
}
@Test
fun `a running copy shows the operation percentage`() {
val status = AlbumSyncProgress.resolve(
SyncMode.COPY,
listOf(SyncUpload("b1", UploadStatus.RUNNING, progress = 50, wifiOnly = false)),
uploaded = 1,
total = 4,
)!!
val items = AlbumListBuilder.build(
albums = listOf(album("a1", "Отпуск")),
links = listOf(AlbumLink("b1", "Camera", "a1", SyncMode.COPY)),
devices = listOf(device("b1", "Camera", 4)),
progressByDevice = mapOf("b1" to status),
)
compose.setContent {
screen(AlbumsViewModel.UiState(items = items, deviceAccess = true))
}
compose.onNodeWithTag(TAG_ALBUM_STATUS_PREFIX + "b1", useUnmergedTree = true).assertExists()
// (1 uploaded + 0.5 running) / 4 = 37%
compose.onNodeWithText("Копии 37%").assertIsDisplayed()
}
@Test
fun `a running move shows the upload percentage`() {
val status = AlbumSyncProgress.resolve(
SyncMode.MOVE,
listOf(SyncUpload("b1", UploadStatus.RUNNING, progress = 50, wifiOnly = false)),
uploaded = 1,
total = 4,
)!!
val items = AlbumListBuilder.build(
albums = listOf(album("a1", "Отпуск")),
links = listOf(AlbumLink("b1", "Camera", "a1", SyncMode.MOVE)),
devices = listOf(device("b1", "Camera", 4)),
progressByDevice = mapOf("b1" to status),
)
compose.setContent {
screen(AlbumsViewModel.UiState(items = items, deviceAccess = true))
}
compose.onNodeWithText("Перенос 37%").assertIsDisplayed()
}
@Test
fun `a failed copy shows the error state`() {
val status = AlbumSyncProgress.resolve(
SyncMode.COPY,
listOf(SyncUpload("b1", UploadStatus.ERROR, progress = 0, wifiOnly = false)),
uploaded = 1,
total = 4,
)!!
val items = AlbumListBuilder.build(
albums = listOf(album("a1", "Отпуск")),
links = listOf(AlbumLink("b1", "Camera", "a1", SyncMode.COPY)),
devices = listOf(device("b1", "Camera", 4)),
progressByDevice = mapOf("b1" to status),
)
compose.setContent {
screen(AlbumsViewModel.UiState(items = items, deviceAccess = true))
}
compose.onNodeWithText("Копии — ошибка").assertIsDisplayed()
}
@Test
fun `a device album with lost copies warns on its card`() {
val status = AlbumSyncProgress.resolve(SyncMode.COPY, emptyList(), uploaded = 1, total = 4, missingCopies = 2)!!
val items = AlbumListBuilder.build(
albums = listOf(album("a1", "Отпуск")),
links = listOf(AlbumLink("b1", "Camera", "a1", SyncMode.COPY)),
devices = listOf(device("b1", "Camera", 4)),
progressByDevice = mapOf("b1" to status),
)
compose.setContent {
screen(AlbumsViewModel.UiState(items = items, deviceAccess = true))
}
compose.onNodeWithText("Копии — потеряна копия: 2").assertIsDisplayed()
}
@Test
@@ -9,6 +9,10 @@ import com.galo.android.data.repo.LiveAlbumSource
import com.galo.android.di.AppContainer
import com.galo.android.domain.model.AlbumSort
import com.galo.android.domain.model.AlbumSortChoice
import com.galo.android.domain.model.DeviceAlbum
import com.galo.android.domain.model.SyncMode
import com.galo.android.domain.model.SyncUpload
import com.galo.android.domain.model.UploadStatus
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
@@ -20,6 +24,7 @@ import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -113,6 +118,65 @@ class AlbumsViewModelTest {
}
}
@Test
fun `the album status combines the queue with the provenance count`() {
val device = DeviceAlbum(
id = "b1",
name = "Camera",
itemCount = 4,
coverUri = null,
relativePath = "DCIM/Camera/",
)
val progress = albumSyncProgressByDevice(
devices = listOf(device),
modeByDevice = mapOf("b1" to SyncMode.COPY),
uploads = listOf(SyncUpload("b1", UploadStatus.RUNNING, progress = 50, wifiOnly = false)),
uploadedCounts = mapOf("b1" to 1),
).getValue("b1")
// (1 uploaded + 0.5 running) / 4 = 37%
assertEquals(37, progress.percent)
assertEquals("Копии 37%", progress.cardText())
}
@Test
fun `a lost server copy is reflected in the album status`() {
val device = DeviceAlbum(
id = "b1",
name = "Camera",
itemCount = 4,
coverUri = null,
relativePath = "DCIM/Camera/",
)
val progress = albumSyncProgressByDevice(
devices = listOf(device),
modeByDevice = mapOf("b1" to SyncMode.COPY),
uploads = emptyList(),
uploadedCounts = mapOf("b1" to 1),
missingCounts = mapOf("b1" to 2),
).getValue("b1")
assertEquals(2, progress.missingCopies)
assertEquals("Копии — потеряна копия: 2", progress.cardText())
}
@Test
fun `an album with sync off has no status`() {
val device = DeviceAlbum(
id = "b1",
name = "Camera",
itemCount = 4,
coverUri = null,
relativePath = "DCIM/Camera/",
)
val progress = albumSyncProgressByDevice(listOf(device), emptyMap(), emptyList(), emptyMap())
assertTrue(progress.isEmpty())
}
/** Waits for the server block to settle on [expected], then returns what it saw. */
private suspend fun awaitServerIds(viewModel: AlbumsViewModel, expected: List<String>): List<String> {
val deadline = System.currentTimeMillis() + 10_000
@@ -96,6 +96,8 @@ class DeviceAlbumContentTest {
onDeleteAlbum: () -> Unit = {},
onRenameAlbum: (String) -> Unit = {},
onSetCover: (String?) -> Unit = {},
notice: String? = null,
onDismissNotice: () -> Unit = {},
) {
GaloTheme(dynamicColor = false) {
DeviceAlbumContent(
@@ -112,6 +114,8 @@ class DeviceAlbumContentTest {
onDeleteAlbum = onDeleteAlbum,
onRenameAlbum = onRenameAlbum,
onSetCover = onSetCover,
notice = notice,
onDismissNotice = onDismissNotice,
)
}
}
@@ -340,6 +344,45 @@ class DeviceAlbumContentTest {
compose.onNodeWithTag(TAG_TRANSFERRED_PREFIX + 1, useUnmergedTree = true).assertDoesNotExist()
}
@Test
fun `a lost server copy shows a warning banner`() {
compose.setContent {
content(
DeviceAlbumViewModel.UiState(
name = "Camera",
items = listOf(media(1), media(2)),
orphanedMediaIds = setOf(1L),
),
)
}
compose.onNodeWithTag(TAG_DEVICE_LOST_COPY).assertIsDisplayed()
compose.onNodeWithText("Нет серверной копии: 1. Превью сохранены и не удаляются автоматически.")
.assertIsDisplayed()
}
@Test
fun `no banner without lost copies`() {
compose.setContent {
content(DeviceAlbumViewModel.UiState(name = "Camera", items = listOf(media(1))))
}
compose.onNodeWithTag(TAG_DEVICE_LOST_COPY).assertDoesNotExist()
}
@Test
fun `a notice is shown and dismissed`() {
var dismissed = false
compose.setContent {
content(
DeviceAlbumViewModel.UiState(name = "Camera", items = listOf(media(1))),
notice = "Не удалось перенести фото в новый альбом.",
onDismissNotice = { dismissed = true },
)
}
compose.onNodeWithText("Не удалось перенести фото в новый альбом.").assertIsDisplayed()
compose.onNodeWithText("Понятно").performClick()
assertTrue(dismissed)
}
@Test
fun `settings panel offers the three sync modes`() {
compose.setContent { settings(DeviceAlbumViewModel.UiState(name = "Camera")) }