From 3ff8f80eed22e55223a6599367fe6fe6164c8ea4 Mon Sep 17 00:00:00 2001 From: Iu Date: Fri, 16 Oct 2026 14:12:55 +0300 Subject: [PATCH] =?UTF-8?q?feat(android):=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=B0=20=D0=BF=D0=BE=D1=82=D0=B5=D1=80=D1=8F?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B9=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D0=B3=D1=80=D1=83=D0=B6=D0=B5=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D0=BF=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В режиме переноса, если серверное фото исчезает из целевого альбома, разгруженное превью помечается состоянием ORPHANED: в сетке показывается перечёркнутое облако, элемент можно удалить с устройства, а вернувшийся оригинал заливается заново. Детекция — в reconcile по живым фото альбома, возврат серверного фото снимает пометку. --- .../data/media/MediaStoreDeviceSource.kt | 7 ++ .../galo/android/data/repo/SyncRepository.kt | 14 +++ .../galo/android/data/sync/SyncCoordinator.kt | 33 ++++++- .../com/galo/android/data/sync/SyncPlan.kt | 14 +++ .../domain/attributes/PhotoAttributeSource.kt | 27 ++++-- .../com/galo/android/domain/model/Sync.kt | 3 + .../android/domain/sync/GallerySyncPolicy.kt | 5 ++ .../ui/devicealbum/DeviceAlbumScreen.kt | 85 +++++++++++++++---- .../ui/devicealbum/DeviceAlbumViewModel.kt | 52 ++++++++++++ .../android/ui/photo/PhotoAttributesSheet.kt | 3 +- 10 files changed, 216 insertions(+), 27 deletions(-) diff --git a/android/app/src/main/java/com/galo/android/data/media/MediaStoreDeviceSource.kt b/android/app/src/main/java/com/galo/android/data/media/MediaStoreDeviceSource.kt index 1188332..c4cc2e7 100644 --- a/android/app/src/main/java/com/galo/android/data/media/MediaStoreDeviceSource.kt +++ b/android/app/src/main/java/com/galo/android/data/media/MediaStoreDeviceSource.kt @@ -112,6 +112,13 @@ class MediaStoreDeviceSource(private val context: Context) { null } + /** + * Deletes media directly through MediaStore, used for media the app owns or + * after a delete consent has been granted. @return how many items were removed. + */ + fun delete(uris: List): Int = + uris.sumOf { uri -> runCatching { context.contentResolver.delete(uri, null, null) }.getOrDefault(0) } + private fun allMedia(): List = query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaKind.IMAGE) + query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, MediaKind.VIDEO) diff --git a/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt b/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt index 2d46f9c..9f9295e 100644 --- a/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt +++ b/android/app/src/main/java/com/galo/android/data/repo/SyncRepository.kt @@ -28,6 +28,9 @@ class SyncRepository( /** Device albums that have a local asset index (possibly partial/unlinked). */ suspend fun indexedAlbumIds(): List = dao.indexedAlbumIds() + /** Drops one asset's index row (e.g. after its preview was removed from the device). */ + suspend fun deleteAsset(assetKey: String) = dao.deleteAsset(assetKey) + suspend fun getAsset(assetKey: String): DeviceAssetEntity? = dao.getAsset(assetKey) suspend fun currentLink(deviceAlbumId: String): SyncedAlbumEntity? = @@ -106,6 +109,17 @@ class SyncRepository( /** System consent to delete the given device media (see [MediaStoreDeviceSource.deleteConsent]). */ fun deleteConsent(uris: List) = deviceSource.deleteConsent(uris) + /** + * Deletes a device media item and drops its index row. Used for a lost preview + * once its removal is permitted (the app owns it, or consent was granted). + * @return true when the media is gone from MediaStore. + */ + suspend fun removeDeviceAsset(assetKey: String, uri: android.net.Uri): Boolean { + val removed = deviceSource.delete(listOf(uri)) > 0 + if (removed) dao.deleteAsset(assetKey) + return removed + } + fun deviceItemUris(albumId: String): List = deviceSource.items(albumId).map { android.net.Uri.parse(it.uri) } diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt index edbc6ab..93c3951 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncCoordinator.kt @@ -35,7 +35,9 @@ class SyncCoordinator( val serverChecksums = remote.serverChecksums(link.serverAlbumId) val livePhotoIds = serverChecksums.values.toSet() val assets = local.assets(deviceAlbumId) - val index = dao.getAssets(deviceAlbumId).map { + // Reflect lost/returned server copies before planning, so an offloaded + // preview whose photo is gone is seen as ORPHANED and handled as such. + val index = reflectLostCopies(dao.getAssets(deviceAlbumId), livePhotoIds).map { IndexedAsset(it.assetKey, it.serverPhotoId, it.contentHash, it.displayName, it.state) } val hashesOutside = local.hashesOutside(deviceAlbumId) @@ -62,6 +64,32 @@ class SyncCoordinator( return any } + /** + * Reflects the loss or return of the server copy behind an offloaded preview: + * a preview whose server photo is gone becomes ORPHANED, and one whose photo is + * back becomes PREVIEW again. Returns the updated index rows for planning. + */ + private suspend fun reflectLostCopies( + entities: List, + livePhotoIds: Set, + ): List = entities.map { entity -> + val photoId = entity.serverPhotoId ?: return@map entity + val live = photoId in livePhotoIds + when { + entity.state == LocalAssetState.PREVIEW.name && !live -> { + dao.markState(entity.assetKey, LocalAssetState.ORPHANED.name) + entity.copy(state = LocalAssetState.ORPHANED.name) + } + + entity.state == LocalAssetState.ORPHANED.name && live -> { + dao.markState(entity.assetKey, LocalAssetState.PREVIEW.name) + entity.copy(state = LocalAssetState.PREVIEW.name) + } + + else -> entity + } + } + /** * Offloads assets that are already on the server but still have their original * locally (mode C). Retries assets left as "awaiting free" from a previous pass, @@ -74,8 +102,9 @@ class SyncCoordinator( if (entity.assetKey !in localKeys) continue // Anything linked to a server photo that still has its original on the // device (uploaded, awaiting free, or otherwise) is offloaded; only the - // already-offloaded preview/server-only states are skipped. + // already-offloaded preview/lost/server-only states are skipped. if (entity.state == LocalAssetState.PREVIEW.name || + entity.state == LocalAssetState.ORPHANED.name || entity.state == LocalAssetState.SERVER_ONLY.name ) { continue diff --git a/android/app/src/main/java/com/galo/android/data/sync/SyncPlan.kt b/android/app/src/main/java/com/galo/android/data/sync/SyncPlan.kt index ab31b4a..be9e11f 100644 --- a/android/app/src/main/java/com/galo/android/data/sync/SyncPlan.kt +++ b/android/app/src/main/java/com/galo/android/data/sync/SyncPlan.kt @@ -58,6 +58,20 @@ object SyncPlanner { // An offloaded asset keeps only a preview locally, so its content hash // no longer matches the original: it must not be re-uploaded. if (indexed?.state == LocalAssetState.PREVIEW.name) continue + if (indexed?.state == LocalAssetState.ORPHANED.name) { + // The server copy is gone. The offloaded preview must not be + // re-uploaded, but the original coming back to the device is a new + // upload (deduplicated against the server as usual). + val originalHash = indexed.contentHash + if (originalHash != null && asset.contentHash == originalHash) { + if (serverChecksums.containsKey(originalHash)) { + actions += SyncAction.Link(asset, serverChecksums.getValue(originalHash)) + } else { + actions += SyncAction.Upload(asset) + } + } + continue + } when { indexed?.serverPhotoId != null -> { val changed = asset.contentHash != null && diff --git a/android/app/src/main/java/com/galo/android/domain/attributes/PhotoAttributeSource.kt b/android/app/src/main/java/com/galo/android/domain/attributes/PhotoAttributeSource.kt index 03ec848..56a399d 100644 --- a/android/app/src/main/java/com/galo/android/domain/attributes/PhotoAttributeSource.kt +++ b/android/app/src/main/java/com/galo/android/domain/attributes/PhotoAttributeSource.kt @@ -12,17 +12,30 @@ sealed interface PhotoAttributeSource { /** Server photo id backing the sheet. */ data class Server(val photoId: String) : PhotoAttributeSource + /** + * A device media item whose server copy is gone from the target album: the + * sheet offers only local actions (removing the item from the device). + */ + data class Orphaned(val media: DeviceMedia) : PhotoAttributeSource + /** A device media item with no matching server photo. */ data class Local(val media: DeviceMedia) : PhotoAttributeSource } object PhotoAttributeResolver { - /** [serverPhotoId] is the photo this device media maps to, when known. */ - fun resolve(media: DeviceMedia, serverPhotoId: String?): PhotoAttributeSource = - if (serverPhotoId.isNullOrBlank()) { - PhotoAttributeSource.Local(media) - } else { - PhotoAttributeSource.Server(serverPhotoId) - } + /** + * [serverPhotoId] is the photo this device media maps to, when known; + * [orphaned] marks a media item whose server copy is gone, which takes + * precedence over a stale mapping. + */ + fun resolve( + media: DeviceMedia, + serverPhotoId: String?, + orphaned: Boolean = false, + ): PhotoAttributeSource = when { + orphaned -> PhotoAttributeSource.Orphaned(media) + serverPhotoId.isNullOrBlank() -> PhotoAttributeSource.Local(media) + else -> PhotoAttributeSource.Server(serverPhotoId) + } } diff --git a/android/app/src/main/java/com/galo/android/domain/model/Sync.kt b/android/app/src/main/java/com/galo/android/domain/model/Sync.kt index 7f836b2..8881549 100644 --- a/android/app/src/main/java/com/galo/android/domain/model/Sync.kt +++ b/android/app/src/main/java/com/galo/android/domain/model/Sync.kt @@ -33,6 +33,9 @@ enum class LocalAssetState { /** Only a preview is kept locally; the original lives on the server (mode C). */ PREVIEW, + /** Only a preview is kept locally, but the server copy is gone from the target album. */ + ORPHANED, + /** Known on the server, but the original never came to this device. */ SERVER_ONLY, diff --git a/android/app/src/main/java/com/galo/android/domain/sync/GallerySyncPolicy.kt b/android/app/src/main/java/com/galo/android/domain/sync/GallerySyncPolicy.kt index 289f08b..0987f24 100644 --- a/android/app/src/main/java/com/galo/android/domain/sync/GallerySyncPolicy.kt +++ b/android/app/src/main/java/com/galo/android/domain/sync/GallerySyncPolicy.kt @@ -21,6 +21,11 @@ object GallerySyncPolicy { LocalAssetState.PREVIEW -> if (mode == SyncMode.MOVE) LocalFix.NONE else LocalFix.RESTORE + // Only a preview locally and the server copy is gone: nothing to restore + // (the server has no bytes) and nothing to upload (a preview is not the + // original), so leave the element untouched. + LocalAssetState.ORPHANED -> LocalFix.NONE + // Original still local, already on the server: offload in move, keep otherwise. LocalAssetState.UPLOADED -> offloadIfMove(mode, hasServerPhoto) 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 ab7425e..dae81aa 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 @@ -8,6 +8,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -28,6 +29,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.CloudOff import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.ExperimentalMaterial3Api @@ -91,6 +93,7 @@ import kotlinx.coroutines.launch const val TAG_DEVICE_GRID = "device_album_grid" const val TAG_DEVICE_ITEM_PREFIX = "device_item_" const val TAG_TRANSFERRED_PREFIX = "transferred_" +const val TAG_ORPHANED_PREFIX = "orphaned_" const val TAG_DEVICE_SETTINGS = "device_album_settings" const val TAG_MODE_PREFIX = "sync_mode_" const val TAG_TARGET_PREFIX = "sync_target_" @@ -148,6 +151,11 @@ fun DeviceAlbumScreen( ) { viewModel.refreshAfterDelete() } + val orphanDeleteLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartIntentSenderForResult(), + ) { + viewModel.refreshAfterOrphanDelete() + } LifecycleResumeEffect(Unit) { allFilesGranted = AllFilesAccess.isGranted() onPauseOrDispose { } @@ -161,7 +169,11 @@ fun DeviceAlbumScreen( onBack = { viewerIndex = null }, serverPhotoByMediaId = state.serverPhotoByMediaId, sheet = { media -> - when (val source = PhotoAttributeResolver.resolve(media, state.serverPhotoByMediaId[media.id])) { + when (val source = PhotoAttributeResolver.resolve( + media, + state.serverPhotoByMediaId[media.id], + orphaned = media.id in state.orphanedMediaIds, + )) { is PhotoAttributeSource.Server -> { var photo by remember(source.photoId) { mutableStateOf(null) } LaunchedEffect(source.photoId) { photo = viewModel.serverPhoto(source.photoId) } @@ -196,6 +208,18 @@ fun DeviceAlbumScreen( } } + is PhotoAttributeSource.Orphaned -> { + PhotoAttributesSheet( + model = localPhotoSheetModel(media), + onDelete = { + viewModel.deleteOrphanedFromDevice(media.id) { sender -> + orphanDeleteLauncher.launch(IntentSenderRequest.Builder(sender).build()) + } + }, + deleteLabel = "Удалить с устройства", + ) + } + is PhotoAttributeSource.Local -> { PhotoAttributesSheet( model = localPhotoSheetModel(media), @@ -440,6 +464,7 @@ fun DeviceAlbumContent( DeviceMediaCell( media = media, transferred = media.id in state.transferredMediaIds, + orphaned = media.id in state.orphanedMediaIds, onClick = { onOpen(index) }, ) } @@ -555,7 +580,12 @@ fun LocalCoverPickerDialog( } @Composable -private fun DeviceMediaCell(media: DeviceMedia, transferred: Boolean, onClick: () -> Unit) { +private fun DeviceMediaCell( + media: DeviceMedia, + transferred: Boolean, + orphaned: Boolean = false, + onClick: () -> Unit, +) { Box( modifier = Modifier .aspectRatio(1f) @@ -576,22 +606,43 @@ private fun DeviceMediaCell(media: DeviceMedia, transferred: Boolean, onClick: ( ) } if (transferred) { - Surface( - color = Color.Black.copy(alpha = 0.45f), - shape = MaterialTheme.shapes.small, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(4.dp) - .testTag(TAG_TRANSFERRED_PREFIX + media.id), - ) { - Icon( - imageVector = Icons.Filled.Cloud, - contentDescription = "Перенесено на сервер", - tint = Color.White, - modifier = Modifier.padding(3.dp).size(16.dp), - ) - } + CloudBadge( + icon = Icons.Filled.Cloud, + description = "Перенесено на сервер", + tag = TAG_TRANSFERRED_PREFIX + media.id, + ) } + if (orphaned) { + CloudBadge( + icon = Icons.Filled.CloudOff, + description = "Серверной копии больше нет", + tag = TAG_ORPHANED_PREFIX + media.id, + ) + } + } +} + +/** The small top-end cloud badge shown over a device media cell. */ +@Composable +private fun BoxScope.CloudBadge( + icon: androidx.compose.ui.graphics.vector.ImageVector, + description: String, + tag: String, +) { + Surface( + color = Color.Black.copy(alpha = 0.45f), + shape = MaterialTheme.shapes.small, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .testTag(tag), + ) { + Icon( + imageVector = icon, + contentDescription = description, + tint = Color.White, + modifier = Modifier.padding(3.dp).size(16.dp), + ) } } 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 5e728df..83484a5 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 @@ -41,6 +41,7 @@ class DeviceAlbumViewModel( val previewCount: Int = 0, val serverPhotoByMediaId: Map = emptyMap(), val transferredMediaIds: Set = emptySet(), + val orphanedMediaIds: Set = emptySet(), ) private data class Local( @@ -85,6 +86,7 @@ class DeviceAlbumViewModel( previewCount = assets.count { it.state == LocalAssetState.PREVIEW.name }, serverPhotoByMediaId = serverOriginalsByMediaId(assets), transferredMediaIds = serverOriginalsByMediaId(assets).keys, + orphanedMediaIds = orphanedMediaIds(assets), ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState()) @@ -356,6 +358,46 @@ class DeviceAlbumViewModel( } } + private var pendingOrphanDelete: Pair? = null + + /** + * Removes a lost-server-copy preview from the device. The preview is not owned + * by the app, so the system delete consent is surfaced through [onConsent]; the + * index row is dropped once the file is gone. The server is never contacted — + * its copy is already gone. + */ + fun deleteOrphanedFromDevice(mediaStoreId: Long, onConsent: (IntentSender) -> Unit) { + viewModelScope.launch { + val asset = container.syncRepository.assets(deviceAlbumId) + .firstOrNull { it.mediaStoreId == mediaStoreId } ?: return@launch + val uri = Uri.parse(asset.uri) + val sender = container.syncRepository.deleteConsent(listOf(uri)) + if (sender != null) { + pendingOrphanDelete = asset.assetKey to mediaStoreId + onConsent(sender) + } else { + // No consent needed (older platforms or owned media): delete now. + container.syncRepository.removeDeviceAsset(asset.assetKey, uri) + load() + } + } + } + + /** Re-reads the album after the delete consent for a lost preview. */ + fun refreshAfterOrphanDelete() { + val pending = pendingOrphanDelete ?: return + viewModelScope.launch { finishOrphanDelete(pending.first, pending.second) } + } + + private suspend fun finishOrphanDelete(assetKey: String, mediaStoreId: Long) { + pendingOrphanDelete = null + val stillOnDevice = runCatching { container.syncRepository.deviceItems(deviceAlbumId) } + .getOrDefault(emptyList()) + .any { it.id == mediaStoreId } + if (!stillOnDevice) container.syncRepository.deleteAsset(assetKey) + load() + } + /** Unlinks the album. Server photos are never deleted by this action. */ fun unlink() { viewModelScope.launch { container.syncRepository.unlink(deviceAlbumId) } @@ -394,3 +436,13 @@ internal fun serverOriginalsByMediaId( ): Map = assets .filter { it.state == LocalAssetState.PREVIEW.name && it.serverPhotoId != null } .associate { it.mediaStoreId to it.serverPhotoId!! } + +/** + * Device media ids whose server copy is gone from the target album (an offloaded + * preview that lost its link). Such items keep their local preview but are shown + * with a crossed-out cloud and can be removed from the device. + */ +internal fun orphanedMediaIds(assets: List): Set = assets + .filter { it.state == LocalAssetState.ORPHANED.name } + .map { it.mediaStoreId } + .toSet() diff --git a/android/app/src/main/java/com/galo/android/ui/photo/PhotoAttributesSheet.kt b/android/app/src/main/java/com/galo/android/ui/photo/PhotoAttributesSheet.kt index 7687fd9..d613e98 100644 --- a/android/app/src/main/java/com/galo/android/ui/photo/PhotoAttributesSheet.kt +++ b/android/app/src/main/java/com/galo/android/ui/photo/PhotoAttributesSheet.kt @@ -74,6 +74,7 @@ fun PhotoAttributesSheet( onSetCover: (() -> Unit)? = null, onDelete: (() -> Unit)? = null, onShare: (() -> Unit)? = null, + deleteLabel: String = "Удалить", onOpenAttributeSettings: (() -> Unit)? = null, onAddTag: ((String) -> Unit)? = null, onRemoveTag: ((String) -> Unit)? = null, @@ -192,7 +193,7 @@ fun PhotoAttributesSheet( verticalAlignment = Alignment.CenterVertically, ) { Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(20.dp)) - Text("Удалить", color = MaterialTheme.colorScheme.error) + Text(deleteLabel, color = MaterialTheme.colorScheme.error) } } }