Skip to content

Commit 2726d64

Browse files
authored
Fix vault data loss when hiding videos (#1834)
2 parents 5480b71 + 14044dc commit 2726d64

8 files changed

Lines changed: 1251 additions & 31 deletions

File tree

core/data/src/androidTest/java/dev/anilbeesetti/nextplayer/core/data/repository/LocalVaultRepositoryTest.kt

Lines changed: 478 additions & 0 deletions
Large diffs are not rendered by default.

core/data/src/main/java/dev/anilbeesetti/nextplayer/core/data/repository/LocalVaultRepository.kt

Lines changed: 166 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import android.webkit.MimeTypeMap
1010
import androidx.core.content.FileProvider
1111
import androidx.core.net.toUri
1212
import dagger.hilt.android.qualifiers.ApplicationContext
13+
import dev.anilbeesetti.nextplayer.core.common.Logger
1314
import dev.anilbeesetti.nextplayer.core.common.Utils
1415
import dev.anilbeesetti.nextplayer.core.data.mappers.toAudioStreamInfo
1516
import dev.anilbeesetti.nextplayer.core.data.mappers.toSubtitleStreamInfo
@@ -21,12 +22,19 @@ import dev.anilbeesetti.nextplayer.core.model.MediaInfo
2122
import dev.anilbeesetti.nextplayer.core.model.Video
2223
import io.github.anilbeesetti.nextlib.mediainfo.MediaInfoBuilder
2324
import java.io.File
25+
import java.util.UUID
2426
import javax.inject.Inject
2527
import javax.inject.Singleton
28+
import kotlinx.coroutines.CancellationException
2629
import kotlinx.coroutines.Dispatchers
30+
import kotlinx.coroutines.NonCancellable
2731
import kotlinx.coroutines.flow.Flow
32+
import kotlinx.coroutines.flow.MutableStateFlow
33+
import kotlinx.coroutines.flow.combine
2834
import kotlinx.coroutines.flow.first
29-
import kotlinx.coroutines.flow.map
35+
import kotlinx.coroutines.flow.update
36+
import kotlinx.coroutines.sync.Mutex
37+
import kotlinx.coroutines.sync.withLock
3038
import kotlinx.coroutines.withContext
3139

3240
@Singleton
@@ -36,46 +44,181 @@ class LocalVaultRepository @Inject constructor(
3644
@ApplicationContext private val context: Context,
3745
) : VaultRepository {
3846

47+
private val vaultMutationMutex = Mutex()
48+
private val pendingVaultPaths = MutableStateFlow<Set<String>>(emptySet())
49+
3950
private val vaultDir: File by lazy {
4051
val base = context.getExternalFilesDir(null) ?: context.filesDir
4152
File(base, VAULT_DIR_NAME).apply { if (!exists()) mkdirs() }
4253
}
4354

4455
override fun observeHiddenVideos(): Flow<List<Video>> {
45-
return hiddenVideoDao.getAll().map { entities -> entities.map { it.toVideo() } }
56+
return combine(hiddenVideoDao.getAll(), pendingVaultPaths) { entities, pendingPaths ->
57+
entities
58+
.filterNot { it.vaultPath in pendingPaths }
59+
.filter { File(it.vaultPath).exists() }
60+
.map { it.toVideo() }
61+
}
62+
}
63+
64+
override suspend fun hideVideos(videos: List<Video>) = vaultMutationMutex.withLock {
65+
hideVideosLocked(videos)
4666
}
4767

48-
override suspend fun hideVideos(videos: List<Video>) {
49-
val movedFiles = mediaOperationsService.moveMedia(
50-
uris = videos.map { it.uriString.toUri() },
51-
targetDir = vaultDir,
68+
private suspend fun hideVideosLocked(videos: List<Video>) {
69+
val reservations = reserveVideos(videos)
70+
if (reservations.isEmpty()) return
71+
72+
try {
73+
val moveOutcome = moveReservedVideos(reservations)
74+
reconcileReservations(reservations, moveOutcome)
75+
moveOutcome.rethrowCancellation()
76+
} finally {
77+
revealReservations(reservations.map { it.destination.absolutePath })
78+
}
79+
}
80+
81+
private suspend fun reserveVideos(videos: List<Video>): List<HideReservation> {
82+
val attemptedVaultPaths = mutableListOf<String>()
83+
return try {
84+
videos.mapNotNull { video ->
85+
reserveVideo(video, attemptedVaultPaths)
86+
}
87+
} catch (e: Exception) {
88+
cleanUpReservations(attemptedVaultPaths)
89+
throw e
90+
}
91+
}
92+
93+
private suspend fun reserveVideo(
94+
video: Video,
95+
attemptedVaultPaths: MutableList<String>,
96+
): HideReservation? {
97+
val destination = createVaultDestination(video.nameWithExtension)
98+
attemptedVaultPaths += destination.absolutePath
99+
val sourceUri = video.uriString.toUri()
100+
val entity = video.toHiddenVideoEntity(destination)
101+
pendingVaultPaths.update { it + destination.absolutePath }
102+
val rowId = try {
103+
hiddenVideoDao.insert(entity)
104+
} catch (e: CancellationException) {
105+
throw e
106+
} catch (e: Exception) {
107+
cleanUpReservations(listOf(destination.absolutePath))
108+
return null
109+
}
110+
return HideReservation(
111+
rowId = rowId,
112+
sourceUri = sourceUri,
113+
destination = destination,
114+
)
115+
}
116+
117+
private fun Video.toHiddenVideoEntity(destination: File): HiddenVideoEntity {
118+
return HiddenVideoEntity(
119+
vaultPath = destination.absolutePath,
120+
originalPath = path,
121+
displayName = nameWithExtension,
122+
duration = duration,
123+
size = size,
124+
width = width,
125+
height = height,
126+
hiddenAt = System.currentTimeMillis(),
52127
)
128+
}
129+
130+
private data class HideReservation(
131+
val rowId: Long,
132+
val sourceUri: Uri,
133+
val destination: File,
134+
)
53135

54-
videos.forEach { video ->
55-
val vaultFile = movedFiles[video.uriString.toUri()] ?: return@forEach
56-
hiddenVideoDao.insert(
57-
HiddenVideoEntity(
58-
vaultPath = vaultFile.absolutePath,
59-
originalPath = video.path,
60-
displayName = video.nameWithExtension,
61-
duration = video.duration,
62-
size = video.size,
63-
width = video.width,
64-
height = video.height,
65-
hiddenAt = System.currentTimeMillis(),
136+
private sealed interface MoveOutcome {
137+
data class Completed(val movedFiles: Map<Uri, File?>) : MoveOutcome
138+
data object Failed : MoveOutcome
139+
data class Cancelled(val exception: CancellationException) : MoveOutcome
140+
}
141+
142+
private suspend fun moveReservedVideos(
143+
reservations: List<HideReservation>,
144+
): MoveOutcome {
145+
return try {
146+
MoveOutcome.Completed(
147+
mediaOperationsService.moveMedia(
148+
reservations.associate { it.sourceUri to it.destination },
66149
),
67150
)
151+
} catch (e: CancellationException) {
152+
MoveOutcome.Cancelled(e)
153+
} catch (e: Exception) {
154+
MoveOutcome.Failed
68155
}
69156
}
70157

71-
override suspend fun unhideVideos(videos: List<Video>): UnhideResult {
158+
private fun createVaultDestination(displayName: String): File {
159+
val extension = File(displayName).extension.takeIf { it.isNotBlank() }
160+
val suffix = extension?.let { ".$it" }.orEmpty()
161+
return generateSequence { File(vaultDir, "${UUID.randomUUID()}$suffix") }
162+
.first { !it.exists() }
163+
}
164+
165+
private suspend fun reconcileReservations(
166+
reservations: List<HideReservation>,
167+
moveOutcome: MoveOutcome,
168+
) {
169+
val failedRowIds = reservations.mapNotNull { reservation ->
170+
reservation.rowId.takeUnless { moveOutcome.wasCommitted(reservation) }
171+
}
172+
if (failedRowIds.isEmpty()) return
173+
withContext(NonCancellable) {
174+
runCatching { hiddenVideoDao.deleteByIds(failedRowIds) }
175+
.onFailure { logCleanupFailure("delete failed hide reservations", it) }
176+
}
177+
}
178+
179+
private fun MoveOutcome.wasCommitted(reservation: HideReservation): Boolean {
180+
return when (this) {
181+
is MoveOutcome.Completed -> {
182+
movedFiles[reservation.sourceUri] == reservation.destination &&
183+
reservation.destination.exists()
184+
}
185+
MoveOutcome.Failed, is MoveOutcome.Cancelled -> reservation.destination.exists()
186+
}
187+
}
188+
189+
private fun MoveOutcome.rethrowCancellation() {
190+
if (this is MoveOutcome.Cancelled) throw exception
191+
}
192+
193+
private suspend fun deleteReservationsByVaultPath(vaultPaths: List<String>) {
194+
if (vaultPaths.isEmpty()) return
195+
withContext(NonCancellable) {
196+
runCatching { hiddenVideoDao.deleteByVaultPaths(vaultPaths) }
197+
.onFailure { logCleanupFailure("delete reservations by vault path", it) }
198+
}
199+
}
200+
201+
private suspend fun cleanUpReservations(vaultPaths: List<String>) {
202+
deleteReservationsByVaultPath(vaultPaths)
203+
revealReservations(vaultPaths)
204+
}
205+
206+
private fun revealReservations(vaultPaths: List<String>) {
207+
pendingVaultPaths.update { it - vaultPaths.toSet() }
208+
}
209+
210+
private fun logCleanupFailure(operation: String, throwable: Throwable) {
211+
Logger.logError(TAG, "Failed to $operation: ${throwable.stackTraceToString()}")
212+
}
213+
214+
override suspend fun unhideVideos(videos: List<Video>): UnhideResult = vaultMutationMutex.withLock {
72215
val restored = withContext(Dispatchers.IO) {
73216
entitiesFor(videos).mapNotNull { entity ->
74217
restoreToMediaStore(entity)?.let { relocated -> entity.id to relocated }
75218
}
76219
}
77220
if (restored.isNotEmpty()) hiddenVideoDao.deleteByIds(restored.map { it.first })
78-
return UnhideResult(relocatedCount = restored.count { (_, relocated) -> relocated })
221+
UnhideResult(relocatedCount = restored.count { (_, relocated) -> relocated })
79222
}
80223

81224
/**
@@ -167,9 +310,9 @@ class LocalVaultRepository @Inject constructor(
167310
return parent.removePrefix(root).trim('/').takeIf { it.isNotEmpty() }?.let { "$it/" }
168311
}
169312

170-
override suspend fun deleteHiddenVideos(videos: List<Video>) {
313+
override suspend fun deleteHiddenVideos(videos: List<Video>): Unit = vaultMutationMutex.withLock {
171314
val entities = entitiesFor(videos)
172-
if (entities.isEmpty()) return
315+
if (entities.isEmpty()) return@withLock
173316
entities.forEach { File(it.vaultPath).delete() }
174317
hiddenVideoDao.deleteByIds(entities.map { it.id })
175318
}
@@ -217,6 +360,7 @@ class LocalVaultRepository @Inject constructor(
217360
}
218361

219362
companion object {
363+
private const val TAG = "LocalVaultRepository"
220364
private const val VAULT_DIR_NAME = "vault"
221365

222366
/** Fallback folder for videos whose original location MediaStore won't accept. */

core/database/src/main/java/dev/anilbeesetti/nextplayer/core/database/dao/HiddenVideoDao.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.Flow
1010
interface HiddenVideoDao {
1111

1212
@Insert
13-
suspend fun insert(hiddenVideo: HiddenVideoEntity)
13+
suspend fun insert(hiddenVideo: HiddenVideoEntity): Long
1414

1515
@Query("SELECT * FROM hidden_video ORDER BY hidden_at DESC")
1616
fun getAll(): Flow<List<HiddenVideoEntity>>
@@ -20,4 +20,7 @@ interface HiddenVideoDao {
2020

2121
@Query("DELETE FROM hidden_video WHERE id IN (:ids)")
2222
suspend fun deleteByIds(ids: List<Long>)
23+
24+
@Query("DELETE FROM hidden_video WHERE vault_path IN (:vaultPaths)")
25+
suspend fun deleteByVaultPaths(vaultPaths: List<String>)
2326
}

0 commit comments

Comments
 (0)