@@ -10,6 +10,7 @@ import android.webkit.MimeTypeMap
1010import androidx.core.content.FileProvider
1111import androidx.core.net.toUri
1212import dagger.hilt.android.qualifiers.ApplicationContext
13+ import dev.anilbeesetti.nextplayer.core.common.Logger
1314import dev.anilbeesetti.nextplayer.core.common.Utils
1415import dev.anilbeesetti.nextplayer.core.data.mappers.toAudioStreamInfo
1516import dev.anilbeesetti.nextplayer.core.data.mappers.toSubtitleStreamInfo
@@ -21,12 +22,19 @@ import dev.anilbeesetti.nextplayer.core.model.MediaInfo
2122import dev.anilbeesetti.nextplayer.core.model.Video
2223import io.github.anilbeesetti.nextlib.mediainfo.MediaInfoBuilder
2324import java.io.File
25+ import java.util.UUID
2426import javax.inject.Inject
2527import javax.inject.Singleton
28+ import kotlinx.coroutines.CancellationException
2629import kotlinx.coroutines.Dispatchers
30+ import kotlinx.coroutines.NonCancellable
2731import kotlinx.coroutines.flow.Flow
32+ import kotlinx.coroutines.flow.MutableStateFlow
33+ import kotlinx.coroutines.flow.combine
2834import 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
3038import 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. */
0 commit comments