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?
}
/** 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. */
data class RestoreReport(
val restored: Int,
@@ -31,7 +39,7 @@ class RestoreManager(
private val context: Context,
private val dao: SyncDao,
private val fetcher: OriginalFetcher,
) {
) : AlbumRestorer {
fun availableBytes(): Long = runCatching { context.cacheDir.usableSpace }.getOrDefault(0L)
@@ -50,28 +58,36 @@ 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 bytes = 0L
var needsConsent = 0
for (asset in assets) {
val photoId = asset.serverPhotoId ?: continue
val data = runCatching { fetcher.fetch(photoId) }.getOrNull() ?: continue
if (data.isEmpty()) continue
if (!canRestore(data.size.toLong(), availableBytes())) {
return@withContext RestoreReport(restored, bytes, needsConsent, outOfSpace = true)
assets.forEachIndexed { index, asset ->
val data = asset.serverPhotoId?.let { photoId ->
runCatching { fetcher.fetch(photoId) }.getOrNull()
}
try {
writeInPlace(Uri.parse(asset.uri), data)
dao.markState(asset.assetKey, LocalAssetState.LOCAL.name)
restored++
bytes += data.size
} catch (e: SecurityException) {
needsConsent++
} catch (e: Exception) {
// Leave the asset as is and continue with the rest.
if (data != null && data.isNotEmpty()) {
if (!canRestore(data.size.toLong(), availableBytes())) {
return@withContext RestoreReport(restored, bytes, needsConsent, outOfSpace = true)
}
try {
writeInPlace(Uri.parse(asset.uri), data)
dao.markState(asset.assetKey, LocalAssetState.LOCAL.name)
restored++
bytes += data.size
} catch (e: SecurityException) {
needsConsent++
} catch (e: Exception) {
// 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)
}
@@ -15,7 +15,7 @@ import com.galo.android.domain.sync.GallerySyncPolicy
class SyncCheck(
private val repository: SyncRepository,
private val recovery: SyncRecovery,
private val restoreManager: RestoreManager,
private val restoreCoordinator: RestoreCoordinator,
private val coordinator: SyncCoordinator,
) {
@@ -35,7 +35,7 @@ class SyncCheck(
hasServerPhoto = entity.serverPhotoId != null,
) == 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).
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.OffloadManager
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.SyncCheck
import com.galo.android.data.sync.SyncCoordinator
@@ -115,10 +116,13 @@ class AppContainer(context: Context) {
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)
/** 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() {
scope.launch {
@@ -20,14 +20,20 @@ data class AlbumSyncProgress(
val percent: Int?,
/** Photos whose server copy is gone; the most severe state and shown first. */
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. */
fun cardText(): String = when {
missingCopies > 0 -> "${modeLabel()} — потеряна копия: $missingCopies"
restorePercent != null -> "${modeLabel()} — восстановление $restorePercent%"
running && percent != null -> "${modeLabel()} $percent%"
failed -> "${modeLabel()} — ошибка"
waitingForWifi -> "${modeLabel()} — ждёт Wi-Fi"
@@ -55,6 +61,7 @@ data class AlbumSyncProgress(
uploaded: Int,
total: Int,
missingCopies: Int = 0,
restorePercent: Int? = null,
): AlbumSyncProgress? {
if (mode == SyncMode.DISABLED) return null
@@ -81,6 +88,7 @@ data class AlbumSyncProgress(
failed = failed,
percent = percent,
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.SyncUpload
import com.galo.android.domain.sync.AlbumSyncProgress
import com.galo.android.domain.sync.RestoreProgress
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -64,6 +65,7 @@ class AlbumsViewModel(
val uploads: List<SyncUpload>,
val uploadedCounts: Map<String, Int>,
val missingCounts: Map<String, Int>,
val restores: Map<String, RestoreProgress>,
)
private val deviceInput: Flow<DeviceInput> = combine(
@@ -74,9 +76,18 @@ class AlbumsViewModel(
combine(
container.syncRepository.observeUploadedCounts(),
container.syncRepository.observeMissingCopyCounts(),
) { uploaded, missing -> uploaded to missing },
container.restoreCoordinator.progress,
) { uploaded, missing, restores -> Triple(uploaded, missing, restores) },
) { 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(
@@ -114,6 +125,7 @@ class AlbumsViewModel(
uploads = device.uploads,
uploadedCounts = device.uploadedCounts,
missingCounts = device.missingCounts,
restoreByDevice = device.restores,
),
)
@@ -193,6 +205,7 @@ internal fun albumSyncProgressByDevice(
uploads: List<SyncUpload>,
uploadedCounts: Map<String, Int>,
missingCounts: Map<String, Int> = emptyMap(),
restoreByDevice: Map<String, RestoreProgress> = emptyMap(),
): Map<String, AlbumSyncProgress> {
val uploadsByDevice = uploads.groupBy { it.deviceAlbumId }
return devices.mapNotNull { device ->
@@ -202,6 +215,7 @@ internal fun albumSyncProgressByDevice(
uploaded = uploadedCounts[device.id] ?: 0,
total = device.itemCount,
missingCopies = missingCounts[device.id] ?: 0,
restorePercent = restoreByDevice[device.id]?.percent,
)?.let { device.id to it }
}.toMap()
}
@@ -37,6 +37,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
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.Photo
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.common.GaloAsyncImage
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_ORPHANED_PREFIX = "orphaned_"
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_MODE_PREFIX = "sync_mode_"
const val TAG_TARGET_PREFIX = "sync_target_"
@@ -467,6 +470,7 @@ fun DeviceAlbumContent(
if (state.orphanedMediaIds.isNotEmpty()) {
LostCopyBanner(count = state.orphanedMediaIds.size)
}
state.restore?.let { RestoreRow(progress = it) }
if (state.items.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
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. */
@Composable
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.Photo
import com.galo.android.domain.model.SyncMode
import com.galo.android.domain.sync.RestoreProgress
import com.galo.android.data.sync.OffloadResult
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -43,6 +44,8 @@ class DeviceAlbumViewModel(
val serverPhotoByMediaId: Map<Long, String> = emptyMap(),
val transferredMediaIds: 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(
@@ -78,13 +81,19 @@ class DeviceAlbumViewModel(
.map { it[deviceAlbumId] }
.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(
container.albumRepository.observeAlbums(),
container.syncRepository.observeLinks(),
container.syncRepository.observeAssets(deviceAlbumId),
assetsAndRestore,
local,
pendingMode,
) { albums, links, assets, l, pending ->
) { albums, links, assetsState, l, pending ->
val (assets, restore) = assetsState
val link = links.firstOrNull { it.deviceAlbumId == deviceAlbumId }
UiState(
name = l.name,
@@ -97,6 +106,7 @@ class DeviceAlbumViewModel(
serverPhotoByMediaId = serverOriginalsByMediaId(assets),
transferredMediaIds = serverOriginalsByMediaId(assets).keys,
orphanedMediaIds = orphanedMediaIds(assets),
restore = restore,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState())
@@ -388,7 +398,9 @@ class DeviceAlbumViewModel(
fun restoreOriginals() {
viewModelScope.launch {
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
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.domain.model.LocalAssetState
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
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 {
private fun asset(
@@ -59,4 +69,28 @@ class RestoreManagerTest {
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())
}
@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
fun `move uploads show their own percentage`() {
val progress = AlbumSyncProgress.resolve(
@@ -230,6 +230,22 @@ class AlbumsContentTest {
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
fun `a device album with lost copies warns on its card`() {
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.SyncUpload
import com.galo.android.domain.model.UploadStatus
import com.galo.android.domain.sync.RestoreProgress
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
@@ -162,6 +163,27 @@ class AlbumsViewModelTest {
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
fun `an album with sync off has no status`() {
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.MediaKind
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_FIELD
import com.galo.android.ui.panel.TAG_ALBUM_PANEL
@@ -344,6 +345,29 @@ class DeviceAlbumContentTest {
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
fun `a lost server copy shows a warning banner`() {
compose.setContent {