feat(android): обработка потерянных серверных копий разгруженных превью
В режиме переноса, если серверное фото исчезает из целевого альбома, разгруженное превью помечается состоянием ORPHANED: в сетке показывается перечёркнутое облако, элемент можно удалить с устройства, а вернувшийся оригинал заливается заново. Детекция — в reconcile по живым фото альбома, возврат серверного фото снимает пометку.
This commit is contained in:
@@ -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 &&
|
||||
|
||||
+19
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user