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
}