feat(android): показывать ход восстановления оригиналов

Возврат из переноса в копии скачивает оригиналы с сервера, но ход нигде не
отражался: карточка умела показывать только загрузку, а восстановление шло в
области экрана и обрывалось при уходе с него.

Добавлен RestoreCoordinator: выполняет восстановление в области приложения
(уход с экрана больше не прерывает), ведёт ход «альбом -> восстановлено из
всего» и защищает от одновременного запуска по одному альбому. RestoreManager
сообщает ход по каждому элементу и вынесен за интерфейс AlbumRestorer; SyncCheck
ходит через координатора и, как и раньше, дожидается завершения.

Карточка альбома показывает «Копии — восстановление N%» (пульсирует), экран
альбома устройства — строку «Восстановление оригиналов: X из Y» с полосой.

Изменение OpenSpec: android-album-restore-progress.
This commit is contained in:
iu
2026-10-16 20:15:05 +03:00
parent 1eb1177273
commit fcc7ac05c1
21 changed files with 593 additions and 27 deletions
@@ -0,0 +1,56 @@
package com.galo.android.data.sync
import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.domain.sync.RestoreProgress
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Runs album restores in the application scope, so leaving the album screen does
* not interrupt them, and exposes per-album progress for the album card and the
* album screen.
*/
class RestoreCoordinator(
private val scope: CoroutineScope,
private val restorer: AlbumRestorer,
) {
private val _progress = MutableStateFlow<Map<String, RestoreProgress>>(emptyMap())
/** Progress keyed by device album id; an album is present only while restoring. */
val progress: StateFlow<Map<String, RestoreProgress>> = _progress.asStateFlow()
private val gate = Mutex()
private val busy = mutableSetOf<String>()
/** Starts restoring in the application scope; does nothing when there is nothing to do. */
fun start(deviceAlbumId: String, targets: List<DeviceAssetEntity>) {
if (targets.isEmpty()) return
scope.launch { run(deviceAlbumId, targets) }
}
/** Restores and reports progress; the caller waits for completion. */
suspend fun run(deviceAlbumId: String, targets: List<DeviceAssetEntity>) {
if (targets.isEmpty()) return
// One restore per album at a time: a screen action and a background check
// must not fight over the same previews.
gate.withLock {
if (!busy.add(deviceAlbumId)) return
}
_progress.update { it + (deviceAlbumId to RestoreProgress(0, targets.size)) }
try {
restorer.restore(targets) { done, total ->
_progress.update { it + (deviceAlbumId to RestoreProgress(done, total)) }
}
} finally {
gate.withLock { busy.remove(deviceAlbumId) }
_progress.update { it - deviceAlbumId }
}
}
}
@@ -14,6 +14,14 @@ interface OriginalFetcher {
suspend fun fetch(photoId: String): ByteArray? suspend fun fetch(photoId: String): ByteArray?
} }
/** Brings originals back onto the device, reporting how far it got. */
interface AlbumRestorer {
suspend fun restore(
assets: List<DeviceAssetEntity>,
onProgress: ((done: Int, total: Int) -> Unit)? = null,
): RestoreReport
}
/** Outcome of restoring originals for a set of offloaded assets. */ /** Outcome of restoring originals for a set of offloaded assets. */
data class RestoreReport( data class RestoreReport(
val restored: Int, val restored: Int,
@@ -31,7 +39,7 @@ class RestoreManager(
private val context: Context, private val context: Context,
private val dao: SyncDao, private val dao: SyncDao,
private val fetcher: OriginalFetcher, private val fetcher: OriginalFetcher,
) { ) : AlbumRestorer {
fun availableBytes(): Long = runCatching { context.cacheDir.usableSpace }.getOrDefault(0L) fun availableBytes(): Long = runCatching { context.cacheDir.usableSpace }.getOrDefault(0L)
@@ -50,15 +58,19 @@ class RestoreManager(
} }
} }
suspend fun restore(assets: List<DeviceAssetEntity>): RestoreReport = withContext(Dispatchers.IO) { override suspend fun restore(
assets: List<DeviceAssetEntity>,
onProgress: ((done: Int, total: Int) -> Unit)?,
): RestoreReport = withContext(Dispatchers.IO) {
var restored = 0 var restored = 0
var bytes = 0L var bytes = 0L
var needsConsent = 0 var needsConsent = 0
for (asset in assets) { assets.forEachIndexed { index, asset ->
val photoId = asset.serverPhotoId ?: continue val data = asset.serverPhotoId?.let { photoId ->
val data = runCatching { fetcher.fetch(photoId) }.getOrNull() ?: continue runCatching { fetcher.fetch(photoId) }.getOrNull()
if (data.isEmpty()) continue }
if (data != null && data.isNotEmpty()) {
if (!canRestore(data.size.toLong(), availableBytes())) { if (!canRestore(data.size.toLong(), availableBytes())) {
return@withContext RestoreReport(restored, bytes, needsConsent, outOfSpace = true) return@withContext RestoreReport(restored, bytes, needsConsent, outOfSpace = true)
} }
@@ -73,6 +85,10 @@ class RestoreManager(
// Leave the asset as is and continue with the rest. // Leave the asset as is and continue with the rest.
} }
} }
// Progress counts every planned item, including the skipped ones, so the
// bar reaches the total even when some originals cannot be fetched.
onProgress?.invoke(index + 1, assets.size)
}
RestoreReport(restored, bytes, needsConsent, outOfSpace = false) RestoreReport(restored, bytes, needsConsent, outOfSpace = false)
} }
@@ -15,7 +15,7 @@ import com.galo.android.domain.sync.GallerySyncPolicy
class SyncCheck( class SyncCheck(
private val repository: SyncRepository, private val repository: SyncRepository,
private val recovery: SyncRecovery, private val recovery: SyncRecovery,
private val restoreManager: RestoreManager, private val restoreCoordinator: RestoreCoordinator,
private val coordinator: SyncCoordinator, private val coordinator: SyncCoordinator,
) { ) {
@@ -35,7 +35,7 @@ class SyncCheck(
hasServerPhoto = entity.serverPhotoId != null, hasServerPhoto = entity.serverPhotoId != null,
) == GallerySyncPolicy.LocalFix.RESTORE ) == GallerySyncPolicy.LocalFix.RESTORE
} }
if (restore.isNotEmpty()) runCatching { restoreManager.restore(restore) } if (restore.isNotEmpty()) runCatching { restoreCoordinator.run(deviceAlbumId, restore) }
// Upload what is missing and offload what is already on the server (move). // Upload what is missing and offload what is already on the server (move).
runCatching { coordinator.reconcile(deviceAlbumId) } runCatching { coordinator.reconcile(deviceAlbumId) }
@@ -28,6 +28,7 @@ import com.galo.android.data.sync.DeviceLocalAssetProvider
import com.galo.android.data.sync.DeviceMediaObserver import com.galo.android.data.sync.DeviceMediaObserver
import com.galo.android.data.sync.OffloadManager import com.galo.android.data.sync.OffloadManager
import com.galo.android.data.sync.RepositorySyncUploadQueue import com.galo.android.data.sync.RepositorySyncUploadQueue
import com.galo.android.data.sync.RestoreCoordinator
import com.galo.android.data.sync.RestoreManager import com.galo.android.data.sync.RestoreManager
import com.galo.android.data.sync.SyncCheck import com.galo.android.data.sync.SyncCheck
import com.galo.android.data.sync.SyncCoordinator import com.galo.android.data.sync.SyncCoordinator
@@ -115,10 +116,13 @@ class AppContainer(context: Context) {
fetcher = ApiOriginalFetcher(api, apiClient), fetcher = ApiOriginalFetcher(api, apiClient),
) )
/** Restores album originals in the application scope and reports their progress. */
val restoreCoordinator = RestoreCoordinator(scope, restoreManager)
val syncRecovery = SyncRecovery(appContext, database.syncDao(), deviceSource) val syncRecovery = SyncRecovery(appContext, database.syncDao(), deviceSource)
/** Unified per-gallery check and fix (startup + album open). */ /** Unified per-gallery check and fix (startup + album open). */
val syncCheck = SyncCheck(syncRepository, syncRecovery, restoreManager, syncCoordinator) val syncCheck = SyncCheck(syncRepository, syncRecovery, restoreCoordinator, syncCoordinator)
fun start() { fun start() {
scope.launch { scope.launch {
@@ -20,14 +20,20 @@ data class AlbumSyncProgress(
val percent: Int?, val percent: Int?,
/** Photos whose server copy is gone; the most severe state and shown first. */ /** Photos whose server copy is gone; the most severe state and shown first. */
val missingCopies: Int = 0, val missingCopies: Int = 0,
/** Share of an ongoing restore of the album's originals, when any. */
val restorePercent: Int? = null,
) { ) {
/** Pulsing accompanies a live percentage, never a waiting, failed or lost state. */ /**
val pulsing: Boolean get() = percent != null && missingCopies == 0 * Pulsing accompanies live work: an upload percentage or a running restore,
* never a waiting, failed or lost state.
*/
val pulsing: Boolean get() = (percent != null || restorePercent != null) && missingCopies == 0
/** The third line of the card: the mode label plus the operation state. */ /** The third line of the card: the mode label plus the operation state. */
fun cardText(): String = when { fun cardText(): String = when {
missingCopies > 0 -> "${modeLabel()} — потеряна копия: $missingCopies" missingCopies > 0 -> "${modeLabel()} — потеряна копия: $missingCopies"
restorePercent != null -> "${modeLabel()} — восстановление $restorePercent%"
running && percent != null -> "${modeLabel()} $percent%" running && percent != null -> "${modeLabel()} $percent%"
failed -> "${modeLabel()} — ошибка" failed -> "${modeLabel()} — ошибка"
waitingForWifi -> "${modeLabel()} — ждёт Wi-Fi" waitingForWifi -> "${modeLabel()} — ждёт Wi-Fi"
@@ -55,6 +61,7 @@ data class AlbumSyncProgress(
uploaded: Int, uploaded: Int,
total: Int, total: Int,
missingCopies: Int = 0, missingCopies: Int = 0,
restorePercent: Int? = null,
): AlbumSyncProgress? { ): AlbumSyncProgress? {
if (mode == SyncMode.DISABLED) return null if (mode == SyncMode.DISABLED) return null
@@ -81,6 +88,7 @@ data class AlbumSyncProgress(
failed = failed, failed = failed,
percent = percent, percent = percent,
missingCopies = missingCopies, missingCopies = missingCopies,
restorePercent = restorePercent,
) )
} }
} }
@@ -0,0 +1,8 @@
package com.galo.android.domain.sync
/** How many of an album's originals have been brought back, out of the planned total. */
data class RestoreProgress(val done: Int, val total: Int) {
/** Share of the restore that is finished; zero when there is nothing planned. */
val percent: Int get() = if (total > 0) (done * 100 / total).coerceIn(0, 100) else 0
}
@@ -12,6 +12,7 @@ import com.galo.android.domain.model.DeviceAlbumMeta
import com.galo.android.domain.model.SyncMode import com.galo.android.domain.model.SyncMode
import com.galo.android.domain.model.SyncUpload import com.galo.android.domain.model.SyncUpload
import com.galo.android.domain.sync.AlbumSyncProgress import com.galo.android.domain.sync.AlbumSyncProgress
import com.galo.android.domain.sync.RestoreProgress
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -64,6 +65,7 @@ class AlbumsViewModel(
val uploads: List<SyncUpload>, val uploads: List<SyncUpload>,
val uploadedCounts: Map<String, Int>, val uploadedCounts: Map<String, Int>,
val missingCounts: Map<String, Int>, val missingCounts: Map<String, Int>,
val restores: Map<String, RestoreProgress>,
) )
private val deviceInput: Flow<DeviceInput> = combine( private val deviceInput: Flow<DeviceInput> = combine(
@@ -74,9 +76,18 @@ class AlbumsViewModel(
combine( combine(
container.syncRepository.observeUploadedCounts(), container.syncRepository.observeUploadedCounts(),
container.syncRepository.observeMissingCopyCounts(), container.syncRepository.observeMissingCopyCounts(),
) { uploaded, missing -> uploaded to missing }, container.restoreCoordinator.progress,
) { uploaded, missing, restores -> Triple(uploaded, missing, restores) },
) { devices, access, meta, uploads, counts -> ) { devices, access, meta, uploads, counts ->
DeviceInput(devices, access, meta, uploads, counts.first, counts.second) DeviceInput(
devices = devices,
access = access,
meta = meta,
uploads = uploads,
uploadedCounts = counts.first,
missingCounts = counts.second,
restores = counts.third,
)
} }
val state: StateFlow<UiState> = combine( val state: StateFlow<UiState> = combine(
@@ -114,6 +125,7 @@ class AlbumsViewModel(
uploads = device.uploads, uploads = device.uploads,
uploadedCounts = device.uploadedCounts, uploadedCounts = device.uploadedCounts,
missingCounts = device.missingCounts, missingCounts = device.missingCounts,
restoreByDevice = device.restores,
), ),
) )
@@ -193,6 +205,7 @@ internal fun albumSyncProgressByDevice(
uploads: List<SyncUpload>, uploads: List<SyncUpload>,
uploadedCounts: Map<String, Int>, uploadedCounts: Map<String, Int>,
missingCounts: Map<String, Int> = emptyMap(), missingCounts: Map<String, Int> = emptyMap(),
restoreByDevice: Map<String, RestoreProgress> = emptyMap(),
): Map<String, AlbumSyncProgress> { ): Map<String, AlbumSyncProgress> {
val uploadsByDevice = uploads.groupBy { it.deviceAlbumId } val uploadsByDevice = uploads.groupBy { it.deviceAlbumId }
return devices.mapNotNull { device -> return devices.mapNotNull { device ->
@@ -202,6 +215,7 @@ internal fun albumSyncProgressByDevice(
uploaded = uploadedCounts[device.id] ?: 0, uploaded = uploadedCounts[device.id] ?: 0,
total = device.itemCount, total = device.itemCount,
missingCopies = missingCounts[device.id] ?: 0, missingCopies = missingCounts[device.id] ?: 0,
restorePercent = restoreByDevice[device.id]?.percent,
)?.let { device.id to it } )?.let { device.id to it }
}.toMap() }.toMap()
} }
@@ -37,6 +37,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalDrawerSheet
@@ -80,6 +81,7 @@ import com.galo.android.domain.model.DeviceMedia
import com.galo.android.domain.model.MediaKind import com.galo.android.domain.model.MediaKind
import com.galo.android.domain.model.Photo import com.galo.android.domain.model.Photo
import com.galo.android.domain.model.SyncMode import com.galo.android.domain.model.SyncMode
import com.galo.android.domain.sync.RestoreProgress
import com.galo.android.ui.album.RenameAlbumDialog import com.galo.android.ui.album.RenameAlbumDialog
import com.galo.android.ui.common.GaloAsyncImage import com.galo.android.ui.common.GaloAsyncImage
import com.galo.android.ui.panel.AlbumAttributesPanel import com.galo.android.ui.panel.AlbumAttributesPanel
@@ -95,6 +97,7 @@ const val TAG_DEVICE_ITEM_PREFIX = "device_item_"
const val TAG_TRANSFERRED_PREFIX = "transferred_" const val TAG_TRANSFERRED_PREFIX = "transferred_"
const val TAG_ORPHANED_PREFIX = "orphaned_" const val TAG_ORPHANED_PREFIX = "orphaned_"
const val TAG_DEVICE_LOST_COPY = "device_lost_copy" const val TAG_DEVICE_LOST_COPY = "device_lost_copy"
const val TAG_DEVICE_RESTORE = "device_restore"
const val TAG_DEVICE_SETTINGS = "device_album_settings" const val TAG_DEVICE_SETTINGS = "device_album_settings"
const val TAG_MODE_PREFIX = "sync_mode_" const val TAG_MODE_PREFIX = "sync_mode_"
const val TAG_TARGET_PREFIX = "sync_target_" const val TAG_TARGET_PREFIX = "sync_target_"
@@ -467,6 +470,7 @@ fun DeviceAlbumContent(
if (state.orphanedMediaIds.isNotEmpty()) { if (state.orphanedMediaIds.isNotEmpty()) {
LostCopyBanner(count = state.orphanedMediaIds.size) LostCopyBanner(count = state.orphanedMediaIds.size)
} }
state.restore?.let { RestoreRow(progress = it) }
if (state.items.isEmpty()) { if (state.items.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("В альбоме нет медиа") Text("В альбоме нет медиа")
@@ -794,6 +798,27 @@ fun TargetPickerDialog(
) )
} }
/** Shows how far the restore of this album's originals has got. */
@Composable
private fun RestoreRow(progress: RestoreProgress) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.testTag(TAG_DEVICE_RESTORE),
) {
Text(
text = "Восстановление оригиналов: ${progress.done} из ${progress.total}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
LinearProgressIndicator(
progress = { progress.percent / 100f },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
)
}
}
/** Shown when some of the album's photos lost their server copy. */ /** Shown when some of the album's photos lost their server copy. */
@Composable @Composable
private fun LostCopyBanner(count: Int) { private fun LostCopyBanner(count: Int) {
@@ -16,6 +16,7 @@ import com.galo.android.domain.model.DeviceMedia
import com.galo.android.domain.model.LocalAssetState import com.galo.android.domain.model.LocalAssetState
import com.galo.android.domain.model.Photo import com.galo.android.domain.model.Photo
import com.galo.android.domain.model.SyncMode import com.galo.android.domain.model.SyncMode
import com.galo.android.domain.sync.RestoreProgress
import com.galo.android.data.sync.OffloadResult import com.galo.android.data.sync.OffloadResult
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -43,6 +44,8 @@ class DeviceAlbumViewModel(
val serverPhotoByMediaId: Map<Long, String> = emptyMap(), val serverPhotoByMediaId: Map<Long, String> = emptyMap(),
val transferredMediaIds: Set<Long> = emptySet(), val transferredMediaIds: Set<Long> = emptySet(),
val orphanedMediaIds: Set<Long> = emptySet(), val orphanedMediaIds: Set<Long> = emptySet(),
/** Progress of an ongoing restore of this album's originals, when any. */
val restore: RestoreProgress? = null,
) )
private data class Local( private data class Local(
@@ -78,13 +81,19 @@ class DeviceAlbumViewModel(
.map { it[deviceAlbumId] } .map { it[deviceAlbumId] }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
private val assetsAndRestore = combine(
container.syncRepository.observeAssets(deviceAlbumId),
container.restoreCoordinator.progress.map { it[deviceAlbumId] },
) { assets, restore -> assets to restore }
val state: StateFlow<UiState> = combine( val state: StateFlow<UiState> = combine(
container.albumRepository.observeAlbums(), container.albumRepository.observeAlbums(),
container.syncRepository.observeLinks(), container.syncRepository.observeLinks(),
container.syncRepository.observeAssets(deviceAlbumId), assetsAndRestore,
local, local,
pendingMode, pendingMode,
) { albums, links, assets, l, pending -> ) { albums, links, assetsState, l, pending ->
val (assets, restore) = assetsState
val link = links.firstOrNull { it.deviceAlbumId == deviceAlbumId } val link = links.firstOrNull { it.deviceAlbumId == deviceAlbumId }
UiState( UiState(
name = l.name, name = l.name,
@@ -97,6 +106,7 @@ class DeviceAlbumViewModel(
serverPhotoByMediaId = serverOriginalsByMediaId(assets), serverPhotoByMediaId = serverOriginalsByMediaId(assets),
transferredMediaIds = serverOriginalsByMediaId(assets).keys, transferredMediaIds = serverOriginalsByMediaId(assets).keys,
orphanedMediaIds = orphanedMediaIds(assets), orphanedMediaIds = orphanedMediaIds(assets),
restore = restore,
) )
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState())
@@ -388,7 +398,9 @@ class DeviceAlbumViewModel(
fun restoreOriginals() { fun restoreOriginals() {
viewModelScope.launch { viewModelScope.launch {
val targets = RestoreManager.restoreTargets(container.syncRepository.assets(deviceAlbumId)) val targets = RestoreManager.restoreTargets(container.syncRepository.assets(deviceAlbumId))
if (targets.isNotEmpty()) container.restoreManager.restore(targets) // The coordinator runs it in the application scope, so leaving the album
// screen does not interrupt the restore.
container.restoreCoordinator.start(deviceAlbumId, targets)
} }
} }
@@ -0,0 +1,107 @@
package com.galo.android.data.sync
import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.domain.model.LocalAssetState
import com.galo.android.domain.sync.RestoreProgress
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class RestoreCoordinatorTest {
private fun asset(key: String) = DeviceAssetEntity(
assetKey = key,
deviceAlbumId = "b1",
mediaStoreId = 1L,
uri = "content://media/external/images/media/1",
displayName = "a.jpg",
mimeType = "image/jpeg",
size = 1L,
dateModified = 1L,
contentHash = "h",
serverPhotoId = "p1",
uploadedAt = 1L,
state = LocalAssetState.PREVIEW.name,
)
private class Report : AlbumRestorer {
var calls = 0
override suspend fun restore(
assets: List<DeviceAssetEntity>,
onProgress: ((Int, Int) -> Unit)?,
): RestoreReport {
calls++
return RestoreReport(0, 0, 0, outOfSpace = false)
}
}
@Test
fun `progress appears while restoring and clears afterwards`() = runTest {
val seen = mutableListOf<Map<String, RestoreProgress>>()
lateinit var coordinator: RestoreCoordinator
val restorer = object : AlbumRestorer {
override suspend fun restore(
assets: List<DeviceAssetEntity>,
onProgress: ((Int, Int) -> Unit)?,
): RestoreReport {
onProgress?.invoke(1, assets.size)
seen += coordinator.progress.value
onProgress?.invoke(assets.size, assets.size)
seen += coordinator.progress.value
return RestoreReport(assets.size, 0, 0, outOfSpace = false)
}
}
coordinator = RestoreCoordinator(this, restorer)
coordinator.run("b1", listOf(asset("b1/1"), asset("b1/2")))
assertEquals(50, seen.first().getValue("b1").percent)
assertEquals(100, seen.last().getValue("b1").percent)
assertTrue("progress is cleared when done", coordinator.progress.value.isEmpty())
}
@Test
fun `nothing is started without targets`() = runTest {
val restorer = Report()
val coordinator = RestoreCoordinator(this, restorer)
coordinator.start("b1", emptyList())
coordinator.run("b1", emptyList())
assertEquals(0, restorer.calls)
assertTrue(coordinator.progress.value.isEmpty())
}
@Test
fun `a second restore of the same album is ignored while one runs`() =
runTest(UnconfinedTestDispatcher()) {
val gate = CompletableDeferred<Unit>()
val targets = listOf(asset("b1/1"))
val restorer = object : AlbumRestorer {
var calls = 0
override suspend fun restore(
assets: List<DeviceAssetEntity>,
onProgress: ((Int, Int) -> Unit)?,
): RestoreReport {
calls++
gate.await()
return RestoreReport(0, 0, 0, outOfSpace = false)
}
}
val coordinator = RestoreCoordinator(this, restorer)
val first = launch { coordinator.run("b1", targets) }
assertEquals(1, restorer.calls)
coordinator.run("b1", targets)
assertEquals("the second run must be ignored", 1, restorer.calls)
gate.complete(Unit)
first.join()
assertTrue(coordinator.progress.value.isEmpty())
}
}
@@ -1,12 +1,22 @@
package com.galo.android.data.sync package com.galo.android.data.sync
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.galo.android.data.db.GaloDatabase
import com.galo.android.data.db.entity.DeviceAssetEntity import com.galo.android.data.db.entity.DeviceAssetEntity
import com.galo.android.domain.model.LocalAssetState import com.galo.android.domain.model.LocalAssetState
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class RestoreManagerTest { class RestoreManagerTest {
private fun asset( private fun asset(
@@ -59,4 +69,28 @@ class RestoreManagerTest {
assertTrue("a lost server copy has nothing to restore", targets.isEmpty()) assertTrue("a lost server copy has nothing to restore", targets.isEmpty())
} }
@Test
fun `progress reaches the total even when a write fails`() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val db = Room.inMemoryDatabaseBuilder(context, GaloDatabase::class.java)
.allowMainThreadQueries()
.build()
try {
val manager = RestoreManager(context, db.syncDao(), FakeFetcher(ByteArray(16)))
val progress = mutableListOf<Pair<Int, Int>>()
manager.restore(listOf(asset("b1/1"), asset("b1/2"))) { done, total ->
progress += done to total
}
assertEquals(listOf(1 to 2, 2 to 2), progress)
} finally {
db.close()
}
}
private class FakeFetcher(private val data: ByteArray) : OriginalFetcher {
override suspend fun fetch(photoId: String): ByteArray = data
}
} }
@@ -150,6 +150,20 @@ class AlbumSyncProgressTest {
assertEquals("Копии — потеряна копия: 2", progress.cardText()) assertEquals("Копии — потеряна копия: 2", progress.cardText())
} }
@Test
fun `a running restore is shown before upload progress`() {
val progress = AlbumSyncProgress.resolve(
SyncMode.COPY,
listOf(upload(RUNNING, progress = 50)),
uploaded = 0,
total = 4,
restorePercent = 40,
)!!
assertTrue(progress.pulsing)
assertEquals("Копии — восстановление 40%", progress.cardText())
}
@Test @Test
fun `move uploads show their own percentage`() { fun `move uploads show their own percentage`() {
val progress = AlbumSyncProgress.resolve( val progress = AlbumSyncProgress.resolve(
@@ -230,6 +230,22 @@ class AlbumsContentTest {
compose.onNodeWithText("Копии — ошибка").assertIsDisplayed() compose.onNodeWithText("Копии — ошибка").assertIsDisplayed()
} }
@Test
fun `a running restore is shown on the card`() {
val status = AlbumSyncProgress
.resolve(SyncMode.COPY, emptyList(), uploaded = 4, total = 4, restorePercent = 40)!!
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("Копии — восстановление 40%").assertIsDisplayed()
}
@Test @Test
fun `a device album with lost copies warns on its card`() { 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 status = AlbumSyncProgress.resolve(SyncMode.COPY, emptyList(), uploaded = 1, total = 4, missingCopies = 2)!!
@@ -13,6 +13,7 @@ import com.galo.android.domain.model.DeviceAlbum
import com.galo.android.domain.model.SyncMode import com.galo.android.domain.model.SyncMode
import com.galo.android.domain.model.SyncUpload import com.galo.android.domain.model.SyncUpload
import com.galo.android.domain.model.UploadStatus import com.galo.android.domain.model.UploadStatus
import com.galo.android.domain.sync.RestoreProgress
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -162,6 +163,27 @@ class AlbumsViewModelTest {
assertEquals("Копии — потеряна копия: 2", progress.cardText()) assertEquals("Копии — потеряна копия: 2", progress.cardText())
} }
@Test
fun `an ongoing restore 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 4),
restoreByDevice = mapOf("b1" to RestoreProgress(done = 1, total = 4)),
).getValue("b1")
assertEquals("Копии — восстановление 25%", progress.cardText())
}
@Test @Test
fun `an album with sync off has no status`() { fun `an album with sync off has no status`() {
val device = DeviceAlbum( val device = DeviceAlbum(
@@ -21,6 +21,7 @@ import com.galo.android.domain.model.Album
import com.galo.android.domain.model.DeviceMedia import com.galo.android.domain.model.DeviceMedia
import com.galo.android.domain.model.MediaKind import com.galo.android.domain.model.MediaKind
import com.galo.android.domain.model.SyncMode import com.galo.android.domain.model.SyncMode
import com.galo.android.domain.sync.RestoreProgress
import com.galo.android.ui.album.TAG_ALBUM_RENAME_CONFIRM import com.galo.android.ui.album.TAG_ALBUM_RENAME_CONFIRM
import com.galo.android.ui.album.TAG_ALBUM_RENAME_FIELD import com.galo.android.ui.album.TAG_ALBUM_RENAME_FIELD
import com.galo.android.ui.panel.TAG_ALBUM_PANEL import com.galo.android.ui.panel.TAG_ALBUM_PANEL
@@ -344,6 +345,29 @@ class DeviceAlbumContentTest {
compose.onNodeWithTag(TAG_TRANSFERRED_PREFIX + 1, useUnmergedTree = true).assertDoesNotExist() compose.onNodeWithTag(TAG_TRANSFERRED_PREFIX + 1, useUnmergedTree = true).assertDoesNotExist()
} }
@Test
fun `an ongoing restore is shown on the album screen`() {
compose.setContent {
content(
DeviceAlbumViewModel.UiState(
name = "Camera",
items = listOf(media(1)),
restore = RestoreProgress(done = 1, total = 4),
),
)
}
compose.onNodeWithTag(TAG_DEVICE_RESTORE).assertIsDisplayed()
compose.onNodeWithText("Восстановление оригиналов: 1 из 4").assertIsDisplayed()
}
@Test
fun `no restore row without a restore`() {
compose.setContent {
content(DeviceAlbumViewModel.UiState(name = "Camera", items = listOf(media(1))))
}
compose.onNodeWithTag(TAG_DEVICE_RESTORE).assertDoesNotExist()
}
@Test @Test
fun `a lost server copy shows a warning banner`() { fun `a lost server copy shows a warning banner`() {
compose.setContent { compose.setContent {
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-16
@@ -0,0 +1,77 @@
# Design
## Context
См. `proposal.md` — Why. Что определяет подход:
- `RestoreManager.restore(assets)` — цикл «получить байты → записать поверх превью → пометить
`LOCAL`», без источника хода; возвращает `RestoreReport`.
- Вызывают его двое: `DeviceAlbumViewModel.restoreOriginals()` (явное действие и подтверждение
выхода из режима C) и `SyncCheck.checkGallery` (неявная починка при открытии альбома).
Оба идут в области вызывающего: у экрана это `viewModelScope`, у фонового воркера — его
собственная область.
- `RestoreManager.restoreTargets` и `GallerySyncPolicy` уже сходятся в одном наборе: превью
(`state == PREVIEW`) с серверной копией.
- Карточка альбома строит третью строку из `AlbumSyncProgress`, куда сейчас попадают только
загрузки (`SyncUpload`) и счётчики индекса.
## Goals / Non-Goals
**Goals:**
- Показывать ход восстановления на карточке и на экране альбома.
- Не обрывать восстановление при уходе с экрана.
- Не менять поведение загрузки, разгрузки и режимов.
**Non-Goals:**
- Восстановление потерянных копий из корзины сервера.
- Возобновление восстановления после смерти процесса (операция идемпотентна и повторится при
следующей проверке синхронизации).
- Прогресс по байтам: считаем элементы, как и в очереди загрузки.
## Decisions
**Единая точка восстановления — `RestoreCoordinator` в области приложения.** Он владеет
картой «альбом → ход», запускает операцию в scope контейнера и защищается от повторного
запуска по одному альбому. Альтернатива «добавить колбэк прямо в менеджер и запускать из
экрана» отклонена: она не решает обрыв при уходе с экрана.
**Два входа: `start` и `run`.** Явное действие пользователя использует `start` (область
приложения, не ждём). `SyncCheck` использует `run` (ждёт завершения), чтобы фоновый воркер,
как и раньше, не отдавал управление до конца восстановления. Оба делят одну защиту и одну
карту хода.
**Прогресс отдаёт сам менеджер.** `restore(assets, onProgress)` вызывает колбэк после каждого
элемента, включая пропуски, чтобы полоса доходила до конца. Ход считает `RestoreCoordinator`,
а не менеджер: менеджер остаётся про байты и файлы.
**Интерфейс ради тестируемости.** Координатор зависит от `AlbumRestorer` (реализация —
`RestoreManager`), чтобы его можно было проверить без MediaStore.
**Ход на карточке — новое состояние третьей строки.** `AlbumSyncProgress` получает
`restorePercent`; приоритет: потерянные копии → восстановление → загрузка → ошибка →
ожидание Wi-Fi → очередь → метка. Пульсация включает и восстановление: это активная работа.
**Уборка хода — по завершении.** Карта чистится в `finally`, поэтому и успех, и ошибка, и
нехватка места убирают строку; незавершённые превью при следующем проходе попробуют снова.
## Risks / Trade-offs
- [Двойной запуск восстановления (экран + фон)] → защита по альбому: второй запуск
игнорируется, пока идёт первый.
- [Прогресс не переживает смерть процесса] → операция идемпотентна: оставшиеся превью
восстанавливаются при следующей проверке; карточка просто теряет строку хода.
- [Нехватка места обрывает восстановление раньше времени] → строка хода исчезает, состояние
остаётся честным (часть превью осталась); отдельного сообщения о нехватке места не
добавляем в этом объёме.
- [Лишние эмиссии Room + flows при каждом элементе] → альбомы небольшие, состояние обновляется
в памяти; реальной нагрузки нет.
## Migration Plan
Схема Room не меняется. Откат — обычная сборка без изменения.
## Open Questions
- Нужно ли отдельное сообщение при остановке восстановления из-за нехватки места.
@@ -0,0 +1,52 @@
# Proposal
## Why
При возврате из режима переноса в копии (`MOVE → COPY`) приложение скачивает оригиналы с
сервера и записывает их поверх превью, но **никак не показывает ход**. Карточка альбома умеет
показывать только загрузку (upload-джобы), которых при этом переходе не возникает, поэтому
третья строка молчит, а операция идёт.
Дополнительно восстановление запускается в области экрана (`viewModelScope`): стоит уйти с
экрана альбома устройства — и оно обрывается на середине, хотя пользователь именно затем и
уходит в список, чтобы посмотреть ход.
(Для альбома с потерянными копиями восстановление и не начнётся: такие элементы в целевой
набор не входят — это отдельный случай, здесь не меняется.)
## What Changes
- Восстановление становится наблюдаемой операцией: приложение ведёт счётчик «готово из
всего» по каждому альбому устройства.
- Восстановление выполняется в области приложения, а не экрана: уход с экрана альбома не
прерывает его.
- Карточка альбома показывает ход: `Копии — восстановление N%` (и пульсирует, пока идёт).
- Экран альбома устройства также показывает ход восстановления.
- **BREAKING**: нет. Загрузка, разгрузка и режимы не меняются; добавляется только видимость
и устойчивость уже существующей операции.
## Capabilities
### New Capabilities
_Нет — поведение относится к существующим способностям._
### Modified Capabilities
- `phone-album-sync`: восстановление оригиналов выполняется до конца независимо от ухода с
экрана и сообщает ход (сколько элементов восстановлено из скольких).
- `android-client`: карточка альбома и экран альбома устройства показывают ход
восстановления оригиналов.
## Impact
- **Код (Android):** `data/sync/RestoreManager` (обратный вызов хода, интерфейс исполнителя),
новый `data/sync/RestoreCoordinator` (область приложения + прогресс по альбомам),
`di/AppContainer` (создание координатора), `data/sync/SyncCheck` (восстановление через
координатора), `ui/devicealbum/DeviceAlbumViewModel` (запуск через координатора, ход в
состоянии) и `DeviceAlbumScreen` (строка хода), `domain/sync/AlbumSyncProgress` и
`ui/albums/AlbumsViewModel`/`AlbumsScreen` (ход на карточке).
- **Схема Room:** без изменений.
- **API и бэкенд:** не затрагиваются.
- **Веб-клиент:** не затрагивается.
- **Не затрагивается:** восстановление потерянных копий из корзины сервера.
@@ -0,0 +1,24 @@
# Spec Delta
## ADDED Requirements
### Requirement: Прогресс восстановления оригиналов
Пока идёт восстановление оригиналов, карточка альбома устройства SHALL показывать ход в
третьей строке («Копии — восстановление <процент>%»), а экран альбома устройства SHALL
показывать ход отдельной строкой. По завершении ход SHALL исчезать.
#### Scenario: Карточка в списке
- **WHEN** для альбома устройства идёт восстановление оригиналов
- **THEN** карточка показывает «<метка режима> — восстановление <процент>%»
#### Scenario: Экран альбома устройства
- **WHEN** для альбома устройства идёт восстановление оригиналов
- **THEN** экран альбома показывает ход восстановления
#### Scenario: Завершение
- **WHEN** восстановление оригиналов завершилось
- **THEN** ход больше не показывается, а карточка возвращается к метке режима
@@ -0,0 +1,24 @@
# Spec Delta
## ADDED Requirements
### Requirement: Ход и устойчивость восстановления оригиналов
При возврате оригиналов на устройство приложение SHALL выполнять операцию до конца
независимо от того, остаётся ли открыт экран альбома устройства, и SHALL сообщать ход:
сколько элементов восстановлено из скольких намечено.
#### Scenario: Уход с экрана
- **WHEN** восстановление оригиналов запущено и пользователь покидает экран альбома устройства
- **THEN** восстановление продолжается и завершается
#### Scenario: Ход операции
- **WHEN** восстановление оригиналов идёт
- **THEN** приложение знает и сообщает, сколько элементов уже восстановлено из намеченных
#### Scenario: Нечего восстанавливать
- **WHEN** среди элементов альбома нет превью с серверной копией
- **THEN** операция не запускается и ход не показывается
@@ -0,0 +1,27 @@
# Tasks
## 1. Ход восстановления в менеджере
- [x] 1.1 Вынести у восстановления интерфейс `AlbumRestorer` и добавить в `RestoreManager.restore` обратный вызов хода (после каждого элемента, включая пропуски); покрыть тестом, что ход доходит до «всего» при успехе и при неудачной записи (`cd android && ./gradlew :app:testDebugUnitTest --tests "*RestoreManagerTest"`).
## 2. Координатор восстановления
- [x] 2.1 Добавить `RestoreCoordinator`: карта «альбом → ход», запуск в области приложения, защита от повторного запуска по одному альбому; покрыть тестом «ход появляется и исчезает», «пустой набор — хода нет», «повторный запуск игнорируется» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*RestoreCoordinatorTest"`).
## 3. Подключение вызывающих
- [x] 3.1 Создать координатора в `AppContainer` и перевести `DeviceAlbumViewModel.restoreOriginals` на `start`, а `SyncCheck` — на ожидаемый `run`; проверить, что `SyncCheck` по-прежнему выполняет восстановление до конца (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncCoordinatorTest" --tests "*DeviceAlbum*"`).
## 4. Ход на карточке и на экране
- [x] 4.1 Добавить ход восстановления в `AlbumSyncProgress` (`restorePercent`) с приоритетом над загрузкой и в пульсацию; покрыть `AlbumSyncProgressTest` и связать с координатором в `AlbumsViewModel` (`cd android && ./gradlew :app:testDebugUnitTest --tests "*AlbumSyncProgressTest" --tests "*AlbumsViewModelTest"`).
- [x] 4.2 Показать ход отдельной строкой на экране альбома устройства и проверить на карточке в списке; покрыть `DeviceAlbumContentTest` и `AlbumsContentTest` (`cd android && ./gradlew :app:testDebugUnitTest --tests "*DeviceAlbumContentTest" --tests "*AlbumsContentTest"`).
## 5. Интеграционная проверка
- [x] 5.1 Прогнать весь Android-набор и собрать debug-APK (`cd android && ./gradlew test :app:assembleDebug`).
## Workflow follow-up
- Заархивировать изменение после прохождения ревью проекта.
- Проверить результат архивации.