diff --git a/android/app/src/main/java/com/galo/android/data/sync/RestoreCoordinator.kt b/android/app/src/main/java/com/galo/android/data/sync/RestoreCoordinator.kt new file mode 100644 index 0000000..c50f6a3 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/data/sync/RestoreCoordinator.kt @@ -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>(emptyMap()) + + /** Progress keyed by device album id; an album is present only while restoring. */ + val progress: StateFlow> = _progress.asStateFlow() + + private val gate = Mutex() + private val busy = mutableSetOf() + + /** Starts restoring in the application scope; does nothing when there is nothing to do. */ + fun start(deviceAlbumId: String, targets: List) { + 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) { + 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 } + } + } +} diff --git a/android/app/src/main/java/com/galo/android/data/sync/RestoreManager.kt b/android/app/src/main/java/com/galo/android/data/sync/RestoreManager.kt index 415d90a..9f58b31 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/RestoreManager.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/RestoreManager.kt @@ -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, + 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): RestoreReport = withContext(Dispatchers.IO) { + override suspend fun restore( + assets: List, + 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) } diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncCheck.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncCheck.kt index c65c3bb..ddbdc08 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/SyncCheck.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncCheck.kt @@ -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) } diff --git a/android/app/src/main/java/com/galo/android/di/AppContainer.kt b/android/app/src/main/java/com/galo/android/di/AppContainer.kt index cd39a0e..a88beaf 100644 --- a/android/app/src/main/java/com/galo/android/di/AppContainer.kt +++ b/android/app/src/main/java/com/galo/android/di/AppContainer.kt @@ -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 { diff --git a/android/app/src/main/java/com/galo/android/domain/sync/AlbumSyncProgress.kt b/android/app/src/main/java/com/galo/android/domain/sync/AlbumSyncProgress.kt index 1513e94..e18c97d 100644 --- a/android/app/src/main/java/com/galo/android/domain/sync/AlbumSyncProgress.kt +++ b/android/app/src/main/java/com/galo/android/domain/sync/AlbumSyncProgress.kt @@ -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, ) } } diff --git a/android/app/src/main/java/com/galo/android/domain/sync/RestoreProgress.kt b/android/app/src/main/java/com/galo/android/domain/sync/RestoreProgress.kt new file mode 100644 index 0000000..01d04ee --- /dev/null +++ b/android/app/src/main/java/com/galo/android/domain/sync/RestoreProgress.kt @@ -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 +} diff --git a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt index 9e028cf..51457c5 100644 --- a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt +++ b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt @@ -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, val uploadedCounts: Map, val missingCounts: Map, + val restores: Map, ) private val deviceInput: Flow = 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 = 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, uploadedCounts: Map, missingCounts: Map = emptyMap(), + restoreByDevice: Map = emptyMap(), ): Map { 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() } diff --git a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt index d9bda3e..f4ed8ad 100644 --- a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt +++ b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumScreen.kt @@ -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) { diff --git a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt index 2b08098..b3f379e 100644 --- a/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt +++ b/android/app/src/main/java/com/galo/android/ui/devicealbum/DeviceAlbumViewModel.kt @@ -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 = emptyMap(), val transferredMediaIds: Set = emptySet(), val orphanedMediaIds: Set = 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 = 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) } } diff --git a/android/app/src/test/java/com/galo/android/data/sync/RestoreCoordinatorTest.kt b/android/app/src/test/java/com/galo/android/data/sync/RestoreCoordinatorTest.kt new file mode 100644 index 0000000..9832167 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/data/sync/RestoreCoordinatorTest.kt @@ -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, + 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>() + lateinit var coordinator: RestoreCoordinator + val restorer = object : AlbumRestorer { + override suspend fun restore( + assets: List, + 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() + val targets = listOf(asset("b1/1")) + val restorer = object : AlbumRestorer { + var calls = 0 + override suspend fun restore( + assets: List, + 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()) + } +} diff --git a/android/app/src/test/java/com/galo/android/data/sync/RestoreManagerTest.kt b/android/app/src/test/java/com/galo/android/data/sync/RestoreManagerTest.kt index cece30e..f908c26 100644 --- a/android/app/src/test/java/com/galo/android/data/sync/RestoreManagerTest.kt +++ b/android/app/src/test/java/com/galo/android/data/sync/RestoreManagerTest.kt @@ -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() + val db = Room.inMemoryDatabaseBuilder(context, GaloDatabase::class.java) + .allowMainThreadQueries() + .build() + try { + val manager = RestoreManager(context, db.syncDao(), FakeFetcher(ByteArray(16))) + val progress = mutableListOf>() + + 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 + } } diff --git a/android/app/src/test/java/com/galo/android/domain/sync/AlbumSyncProgressTest.kt b/android/app/src/test/java/com/galo/android/domain/sync/AlbumSyncProgressTest.kt index 346ccf5..191b47b 100644 --- a/android/app/src/test/java/com/galo/android/domain/sync/AlbumSyncProgressTest.kt +++ b/android/app/src/test/java/com/galo/android/domain/sync/AlbumSyncProgressTest.kt @@ -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( diff --git a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt index cba77bd..9775d1b 100644 --- a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt @@ -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)!! diff --git a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsViewModelTest.kt b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsViewModelTest.kt index 34c4eb0..eb24772 100644 --- a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsViewModelTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsViewModelTest.kt @@ -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( diff --git a/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumContentTest.kt b/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumContentTest.kt index b9468bd..9bba437 100644 --- a/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumContentTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumContentTest.kt @@ -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 { diff --git a/openspec/changes/android-album-restore-progress/.openspec.yaml b/openspec/changes/android-album-restore-progress/.openspec.yaml new file mode 100644 index 0000000..0cb4765 --- /dev/null +++ b/openspec/changes/android-album-restore-progress/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-16 diff --git a/openspec/changes/android-album-restore-progress/design.md b/openspec/changes/android-album-restore-progress/design.md new file mode 100644 index 0000000..4e2aa6a --- /dev/null +++ b/openspec/changes/android-album-restore-progress/design.md @@ -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 + +- Нужно ли отдельное сообщение при остановке восстановления из-за нехватки места. diff --git a/openspec/changes/android-album-restore-progress/proposal.md b/openspec/changes/android-album-restore-progress/proposal.md new file mode 100644 index 0000000..477fdc0 --- /dev/null +++ b/openspec/changes/android-album-restore-progress/proposal.md @@ -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 и бэкенд:** не затрагиваются. +- **Веб-клиент:** не затрагивается. +- **Не затрагивается:** восстановление потерянных копий из корзины сервера. diff --git a/openspec/changes/android-album-restore-progress/specs/android-client/spec.md b/openspec/changes/android-album-restore-progress/specs/android-client/spec.md new file mode 100644 index 0000000..b8279b9 --- /dev/null +++ b/openspec/changes/android-album-restore-progress/specs/android-client/spec.md @@ -0,0 +1,24 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: Прогресс восстановления оригиналов + +Пока идёт восстановление оригиналов, карточка альбома устройства SHALL показывать ход в +третьей строке («Копии — восстановление <процент>%»), а экран альбома устройства SHALL +показывать ход отдельной строкой. По завершении ход SHALL исчезать. + +#### Scenario: Карточка в списке + +- **WHEN** для альбома устройства идёт восстановление оригиналов +- **THEN** карточка показывает «<метка режима> — восстановление <процент>%» + +#### Scenario: Экран альбома устройства + +- **WHEN** для альбома устройства идёт восстановление оригиналов +- **THEN** экран альбома показывает ход восстановления + +#### Scenario: Завершение + +- **WHEN** восстановление оригиналов завершилось +- **THEN** ход больше не показывается, а карточка возвращается к метке режима diff --git a/openspec/changes/android-album-restore-progress/specs/phone-album-sync/spec.md b/openspec/changes/android-album-restore-progress/specs/phone-album-sync/spec.md new file mode 100644 index 0000000..219a08e --- /dev/null +++ b/openspec/changes/android-album-restore-progress/specs/phone-album-sync/spec.md @@ -0,0 +1,24 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: Ход и устойчивость восстановления оригиналов + +При возврате оригиналов на устройство приложение SHALL выполнять операцию до конца +независимо от того, остаётся ли открыт экран альбома устройства, и SHALL сообщать ход: +сколько элементов восстановлено из скольких намечено. + +#### Scenario: Уход с экрана + +- **WHEN** восстановление оригиналов запущено и пользователь покидает экран альбома устройства +- **THEN** восстановление продолжается и завершается + +#### Scenario: Ход операции + +- **WHEN** восстановление оригиналов идёт +- **THEN** приложение знает и сообщает, сколько элементов уже восстановлено из намеченных + +#### Scenario: Нечего восстанавливать + +- **WHEN** среди элементов альбома нет превью с серверной копией +- **THEN** операция не запускается и ход не показывается diff --git a/openspec/changes/android-album-restore-progress/tasks.md b/openspec/changes/android-album-restore-progress/tasks.md new file mode 100644 index 0000000..3f6b1c7 --- /dev/null +++ b/openspec/changes/android-album-restore-progress/tasks.md @@ -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 + +- Заархивировать изменение после прохождения ревью проекта. +- Проверить результат архивации.