feat(android): обработка потерянных серверных копий разгруженных превью

В режиме переноса, если серверное фото исчезает из целевого альбома,
разгруженное превью помечается состоянием ORPHANED: в сетке показывается
перечёркнутое облако, элемент можно удалить с устройства, а вернувшийся
оригинал заливается заново. Детекция — в reconcile по живым фото альбома,
 возврат серверного фото снимает пометку.
This commit is contained in:
iu
2026-10-16 14:12:55 +03:00
parent 863038b5a0
commit 3ff8f80eed
10 changed files with 216 additions and 27 deletions
@@ -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<Uri>): Int =
uris.sumOf { uri -> runCatching { context.contentResolver.delete(uri, null, null) }.getOrDefault(0) }
private fun allMedia(): List<DeviceMedia> =
query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaKind.IMAGE) +
query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, MediaKind.VIDEO)
@@ -28,6 +28,9 @@ class SyncRepository(
/** Device albums that have a local asset index (possibly partial/unlinked). */
suspend fun indexedAlbumIds(): List<String> = 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<android.net.Uri>) = 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<android.net.Uri> =
deviceSource.items(albumId).map { android.net.Uri.parse(it.uri) }
@@ -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<DeviceAssetEntity>,
livePhotoIds: Set<String>,
): List<DeviceAssetEntity> = 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
@@ -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 &&
@@ -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)
}
}
@@ -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,
@@ -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)
@@ -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<Photo?>(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,24 +606,45 @@ private fun DeviceMediaCell(media: DeviceMedia, transferred: Boolean, onClick: (
)
}
if (transferred) {
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_TRANSFERRED_PREFIX + media.id),
.testTag(tag),
) {
Icon(
imageVector = Icons.Filled.Cloud,
contentDescription = "Перенесено на сервер",
imageVector = icon,
contentDescription = description,
tint = Color.White,
modifier = Modifier.padding(3.dp).size(16.dp),
)
}
}
}
}
@Composable
fun DeviceAlbumSettings(
@@ -41,6 +41,7 @@ class DeviceAlbumViewModel(
val previewCount: Int = 0,
val serverPhotoByMediaId: Map<Long, String> = emptyMap(),
val transferredMediaIds: Set<Long> = emptySet(),
val orphanedMediaIds: Set<Long> = 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<String, Long>? = 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<Long, String> = 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<DeviceAssetEntity>): Set<Long> = assets
.filter { it.state == LocalAssetState.ORPHANED.name }
.map { it.mediaStoreId }
.toSet()
@@ -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)
}
}
}