feat(android): показывать ход восстановления оригиналов
Возврат из переноса в копии скачивает оригиналы с сервера, но ход нигде не отражался: карточка умела показывать только загрузку, а восстановление шло в области экрана и обрывалось при уходе с него. Добавлен RestoreCoordinator: выполняет восстановление в области приложения (уход с экрана больше не прерывает), ведёт ход «альбом -> восстановлено из всего» и защищает от одновременного запуска по одному альбому. RestoreManager сообщает ход по каждому элементу и вынесен за интерфейс AlbumRestorer; SyncCheck ходит через координатора и, как и раньше, дожидается завершения. Карточка альбома показывает «Копии — восстановление N%» (пульсирует), экран альбома устройства — строку «Восстановление оригиналов: X из Y» с полосой. Изменение OpenSpec: android-album-restore-progress.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
- Заархивировать изменение после прохождения ревью проекта.
|
||||
- Проверить результат архивации.
|
||||
Reference in New Issue
Block a user