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 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> = private fun allMedia(): List<DeviceMedia> =
query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaKind.IMAGE) + query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaKind.IMAGE) +
query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, MediaKind.VIDEO) 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). */ /** Device albums that have a local asset index (possibly partial/unlinked). */
suspend fun indexedAlbumIds(): List<String> = dao.indexedAlbumIds() 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 getAsset(assetKey: String): DeviceAssetEntity? = dao.getAsset(assetKey)
suspend fun currentLink(deviceAlbumId: String): SyncedAlbumEntity? = suspend fun currentLink(deviceAlbumId: String): SyncedAlbumEntity? =
@@ -106,6 +109,17 @@ class SyncRepository(
/** System consent to delete the given device media (see [MediaStoreDeviceSource.deleteConsent]). */ /** System consent to delete the given device media (see [MediaStoreDeviceSource.deleteConsent]). */
fun deleteConsent(uris: List<android.net.Uri>) = deviceSource.deleteConsent(uris) 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> = fun deviceItemUris(albumId: String): List<android.net.Uri> =
deviceSource.items(albumId).map { android.net.Uri.parse(it.uri) } deviceSource.items(albumId).map { android.net.Uri.parse(it.uri) }
@@ -35,7 +35,9 @@ class SyncCoordinator(
val serverChecksums = remote.serverChecksums(link.serverAlbumId) val serverChecksums = remote.serverChecksums(link.serverAlbumId)
val livePhotoIds = serverChecksums.values.toSet() val livePhotoIds = serverChecksums.values.toSet()
val assets = local.assets(deviceAlbumId) 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) IndexedAsset(it.assetKey, it.serverPhotoId, it.contentHash, it.displayName, it.state)
} }
val hashesOutside = local.hashesOutside(deviceAlbumId) val hashesOutside = local.hashesOutside(deviceAlbumId)
@@ -62,6 +64,32 @@ class SyncCoordinator(
return any 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 * 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, * 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 if (entity.assetKey !in localKeys) continue
// Anything linked to a server photo that still has its original on the // Anything linked to a server photo that still has its original on the
// device (uploaded, awaiting free, or otherwise) is offloaded; only 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 || if (entity.state == LocalAssetState.PREVIEW.name ||
entity.state == LocalAssetState.ORPHANED.name ||
entity.state == LocalAssetState.SERVER_ONLY.name entity.state == LocalAssetState.SERVER_ONLY.name
) { ) {
continue continue
@@ -58,6 +58,20 @@ object SyncPlanner {
// An offloaded asset keeps only a preview locally, so its content hash // An offloaded asset keeps only a preview locally, so its content hash
// no longer matches the original: it must not be re-uploaded. // no longer matches the original: it must not be re-uploaded.
if (indexed?.state == LocalAssetState.PREVIEW.name) continue 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 { when {
indexed?.serverPhotoId != null -> { indexed?.serverPhotoId != null -> {
val changed = asset.contentHash != null && val changed = asset.contentHash != null &&
@@ -12,17 +12,30 @@ sealed interface PhotoAttributeSource {
/** Server photo id backing the sheet. */ /** Server photo id backing the sheet. */
data class Server(val photoId: String) : PhotoAttributeSource 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. */ /** A device media item with no matching server photo. */
data class Local(val media: DeviceMedia) : PhotoAttributeSource data class Local(val media: DeviceMedia) : PhotoAttributeSource
} }
object PhotoAttributeResolver { object PhotoAttributeResolver {
/** [serverPhotoId] is the photo this device media maps to, when known. */ /**
fun resolve(media: DeviceMedia, serverPhotoId: String?): PhotoAttributeSource = * [serverPhotoId] is the photo this device media maps to, when known;
if (serverPhotoId.isNullOrBlank()) { * [orphaned] marks a media item whose server copy is gone, which takes
PhotoAttributeSource.Local(media) * precedence over a stale mapping.
} else { */
PhotoAttributeSource.Server(serverPhotoId) 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). */ /** Only a preview is kept locally; the original lives on the server (mode C). */
PREVIEW, 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. */ /** Known on the server, but the original never came to this device. */
SERVER_ONLY, SERVER_ONLY,
@@ -21,6 +21,11 @@ object GallerySyncPolicy {
LocalAssetState.PREVIEW -> LocalAssetState.PREVIEW ->
if (mode == SyncMode.MOVE) LocalFix.NONE else LocalFix.RESTORE 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. // Original still local, already on the server: offload in move, keep otherwise.
LocalAssetState.UPLOADED -> offloadIfMove(mode, hasServerPhoto) LocalAssetState.UPLOADED -> offloadIfMove(mode, hasServerPhoto)
@@ -8,6 +8,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Cloud 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.PlayArrow
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api 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_GRID = "device_album_grid"
const val TAG_DEVICE_ITEM_PREFIX = "device_item_" const val TAG_DEVICE_ITEM_PREFIX = "device_item_"
const val TAG_TRANSFERRED_PREFIX = "transferred_" const val TAG_TRANSFERRED_PREFIX = "transferred_"
const val TAG_ORPHANED_PREFIX = "orphaned_"
const val TAG_DEVICE_SETTINGS = "device_album_settings" const val TAG_DEVICE_SETTINGS = "device_album_settings"
const val TAG_MODE_PREFIX = "sync_mode_" const val TAG_MODE_PREFIX = "sync_mode_"
const val TAG_TARGET_PREFIX = "sync_target_" const val TAG_TARGET_PREFIX = "sync_target_"
@@ -148,6 +151,11 @@ fun DeviceAlbumScreen(
) { ) {
viewModel.refreshAfterDelete() viewModel.refreshAfterDelete()
} }
val orphanDeleteLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartIntentSenderForResult(),
) {
viewModel.refreshAfterOrphanDelete()
}
LifecycleResumeEffect(Unit) { LifecycleResumeEffect(Unit) {
allFilesGranted = AllFilesAccess.isGranted() allFilesGranted = AllFilesAccess.isGranted()
onPauseOrDispose { } onPauseOrDispose { }
@@ -161,7 +169,11 @@ fun DeviceAlbumScreen(
onBack = { viewerIndex = null }, onBack = { viewerIndex = null },
serverPhotoByMediaId = state.serverPhotoByMediaId, serverPhotoByMediaId = state.serverPhotoByMediaId,
sheet = { media -> 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 -> { is PhotoAttributeSource.Server -> {
var photo by remember(source.photoId) { mutableStateOf<Photo?>(null) } var photo by remember(source.photoId) { mutableStateOf<Photo?>(null) }
LaunchedEffect(source.photoId) { photo = viewModel.serverPhoto(source.photoId) } 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 -> { is PhotoAttributeSource.Local -> {
PhotoAttributesSheet( PhotoAttributesSheet(
model = localPhotoSheetModel(media), model = localPhotoSheetModel(media),
@@ -440,6 +464,7 @@ fun DeviceAlbumContent(
DeviceMediaCell( DeviceMediaCell(
media = media, media = media,
transferred = media.id in state.transferredMediaIds, transferred = media.id in state.transferredMediaIds,
orphaned = media.id in state.orphanedMediaIds,
onClick = { onOpen(index) }, onClick = { onOpen(index) },
) )
} }
@@ -555,7 +580,12 @@ fun LocalCoverPickerDialog(
} }
@Composable @Composable
private fun DeviceMediaCell(media: DeviceMedia, transferred: Boolean, onClick: () -> Unit) { private fun DeviceMediaCell(
media: DeviceMedia,
transferred: Boolean,
orphaned: Boolean = false,
onClick: () -> Unit,
) {
Box( Box(
modifier = Modifier modifier = Modifier
.aspectRatio(1f) .aspectRatio(1f)
@@ -576,22 +606,43 @@ private fun DeviceMediaCell(media: DeviceMedia, transferred: Boolean, onClick: (
) )
} }
if (transferred) { if (transferred) {
Surface( CloudBadge(
color = Color.Black.copy(alpha = 0.45f), icon = Icons.Filled.Cloud,
shape = MaterialTheme.shapes.small, description = "Перенесено на сервер",
modifier = Modifier tag = TAG_TRANSFERRED_PREFIX + media.id,
.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),
)
}
} }
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),
)
} }
} }
@@ -41,6 +41,7 @@ class DeviceAlbumViewModel(
val previewCount: Int = 0, val previewCount: Int = 0,
val serverPhotoByMediaId: Map<Long, String> = emptyMap(), val serverPhotoByMediaId: Map<Long, String> = emptyMap(),
val transferredMediaIds: Set<Long> = emptySet(), val transferredMediaIds: Set<Long> = emptySet(),
val orphanedMediaIds: Set<Long> = emptySet(),
) )
private data class Local( private data class Local(
@@ -85,6 +86,7 @@ class DeviceAlbumViewModel(
previewCount = assets.count { it.state == LocalAssetState.PREVIEW.name }, previewCount = assets.count { it.state == LocalAssetState.PREVIEW.name },
serverPhotoByMediaId = serverOriginalsByMediaId(assets), serverPhotoByMediaId = serverOriginalsByMediaId(assets),
transferredMediaIds = serverOriginalsByMediaId(assets).keys, transferredMediaIds = serverOriginalsByMediaId(assets).keys,
orphanedMediaIds = orphanedMediaIds(assets),
) )
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState()) }.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. */ /** Unlinks the album. Server photos are never deleted by this action. */
fun unlink() { fun unlink() {
viewModelScope.launch { container.syncRepository.unlink(deviceAlbumId) } viewModelScope.launch { container.syncRepository.unlink(deviceAlbumId) }
@@ -394,3 +436,13 @@ internal fun serverOriginalsByMediaId(
): Map<Long, String> = assets ): Map<Long, String> = assets
.filter { it.state == LocalAssetState.PREVIEW.name && it.serverPhotoId != null } .filter { it.state == LocalAssetState.PREVIEW.name && it.serverPhotoId != null }
.associate { it.mediaStoreId to it.serverPhotoId!! } .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, onSetCover: (() -> Unit)? = null,
onDelete: (() -> Unit)? = null, onDelete: (() -> Unit)? = null,
onShare: (() -> Unit)? = null, onShare: (() -> Unit)? = null,
deleteLabel: String = "Удалить",
onOpenAttributeSettings: (() -> Unit)? = null, onOpenAttributeSettings: (() -> Unit)? = null,
onAddTag: ((String) -> Unit)? = null, onAddTag: ((String) -> Unit)? = null,
onRemoveTag: ((String) -> Unit)? = null, onRemoveTag: ((String) -> Unit)? = null,
@@ -192,7 +193,7 @@ fun PhotoAttributesSheet(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(20.dp)) Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(20.dp))
Text("Удалить", color = MaterialTheme.colorScheme.error) Text(deleteLabel, color = MaterialTheme.colorScheme.error)
} }
} }
} }