1 /*
<lambda>null2  * Copyright 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.photopicker.features.cloudmedia
18 
19 import android.util.Log
20 import androidx.annotation.GuardedBy
21 import androidx.lifecycle.ViewModel
22 import androidx.lifecycle.viewModelScope
23 import com.android.photopicker.core.Background
24 import com.android.photopicker.core.selection.Selection
25 import com.android.photopicker.core.user.UserMonitor
26 import com.android.photopicker.data.model.Media
27 import com.android.photopicker.data.model.MediaSource
28 import dagger.hilt.android.lifecycle.HiltViewModel
29 import java.io.FileNotFoundException
30 import javax.inject.Inject
31 import kotlinx.coroutines.CompletableDeferred
32 import kotlinx.coroutines.CoroutineDispatcher
33 import kotlinx.coroutines.CoroutineScope
34 import kotlinx.coroutines.Job
35 import kotlinx.coroutines.channels.BufferOverflow
36 import kotlinx.coroutines.flow.MutableSharedFlow
37 import kotlinx.coroutines.flow.MutableStateFlow
38 import kotlinx.coroutines.flow.SharingStarted
39 import kotlinx.coroutines.flow.StateFlow
40 import kotlinx.coroutines.flow.distinctUntilChanged
41 import kotlinx.coroutines.flow.map
42 import kotlinx.coroutines.flow.stateIn
43 import kotlinx.coroutines.flow.update
44 import kotlinx.coroutines.launch
45 import kotlinx.coroutines.sync.Mutex
46 import kotlinx.coroutines.sync.withLock
47 
48 /** Enumeration for the LoadState of a given preloaded item. */
49 private enum class LoadResult {
50     COMPLETED,
51     FAILED,
52     QUEUED,
53 }
54 
55 /** Data objects which contain all the UI data to render the various Preloader dialogs. */
56 sealed interface PreloaderDialogData {
57 
58     /**
59      * The loading dialog data
60      *
61      * @param total Total of items to be loaded
62      * @param completed Number of items currently completed
63      */
64     data class PreloaderLoadingDialogData(
65         val total: Int,
66         val completed: Int = 0,
67     ) : PreloaderDialogData
68 
69     /** Empty object for telling the UI to show a generic error dialog */
70     object PreloaderLoadingErrorDialog : PreloaderDialogData
71 }
72 
73 /**
74  * The view model for the [MediaPreloader].
75  *
76  * This is the class responsible for the requests to remote providers to prepare remote media for
77  * local apps. The main preloading operation should only be triggered by the main activity, by
78  * emitting a set of media to preload into the flow provided to the MediaPreloader compose UI via
79  * [LocationParams].
80  *
81  * Additionally, this method exposes the required state data for the UI to draw the correct dialog
82  * overlays as preloading is initiated, is progressing, and resolves with either a failure or a
83  * success.
84  *
85  * This class should not be injected anywhere other than the MediaPreloader's context to attempt to
86  * monitor the state of the ongoing preload.
87  *
88  * When the preload is complete, the [CompletableDeferred] that is passed in the [LocationParams]
89  * will be marked completed, A TRUE value indicates success, and a FALSE value indicates a failure.
90  */
91 @HiltViewModel
92 class MediaPreloaderViewModel
93 @Inject
94 constructor(
95     private val scopeOverride: CoroutineScope?,
96     @Background private val backgroundDispatcher: CoroutineDispatcher,
97     private val selection: Selection<Media>,
98     private val userMonitor: UserMonitor,
99 ) : ViewModel() {
100 
101     companion object {
102         // Ensure only 2 downloads are occurring in parallel.
103         val MAX_CONCURRENT_LOADS = 2
104     }
105 
106     /* Parent job that owns the overall preloader operation & monitor */
107     private var job: Job? = null
108 
109     /*
110      * A heartbeat flow to drive the preload monitor job.
111      * Replay = 1 and DROP_OLDEST due to the fact the heartbeat doesn't contain any useful
112      * data, so as long as something is in the buffer to be collected, there's no need
113      * for duplicate emissions.
114      */
115     private val heartbeat: MutableSharedFlow<Unit> =
116         MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
117 
118     // Protect [remoteItems] with a Mutex since multiple coroutines are reading/writing it.
119     private val mutex = Mutex()
120     // A list of remote items to be loaded, and their current [LoadResult].
121     // NOTE: This should always be accessed after acquiring the [Mutex] to ensure data
122     // accuracy during concurrency.
123     @GuardedBy("mutex") private val remoteItems = mutableMapOf<Media, LoadResult>()
124 
125     // Check if a scope override was injected before using the default [viewModelScope]
126     private val scope: CoroutineScope =
127         if (scopeOverride == null) {
128             this.viewModelScope
129         } else {
130             scopeOverride
131         }
132 
133     /* Flow for monitoring the activeContentResolver:
134      *   - map to get rid of other [UserStatus] fields this does not care about
135      *   - distinctUntilChanged to only emit when the resolver actually changes, since
136      *     UserStatus might be updated if other profiles turn on and off
137      */
138     private val _contentResolver =
<lambda>null139         userMonitor.userStatus.map { it.activeContentResolver }.distinctUntilChanged()
140 
141     /** Flow that can push new data into the preloader's dialogs. */
142     private val _dialogData = MutableStateFlow<PreloaderDialogData?>(null)
143 
144     /** Public flow for the compose ui to collect. */
145     val dialogData: StateFlow<PreloaderDialogData?> =
146         _dialogData.stateIn(
147             scope,
148             SharingStarted.WhileSubscribed(),
149             initialValue = _dialogData.value
150         )
151 
152     init {
153 
154         // If the active user's resolver changes, cancel any pending preload work.
<lambda>null155         scope.launch {
156             _contentResolver.collect {
157                 // Action is only required if there's currently a job running.
158                 job?.let {
159                     Log.d(CloudMediaFeature.TAG, "User was changed, abandoning preloads")
160                     it.cancel()
161                     hideAllDialogs()
162                 }
163             }
164         }
165     }
166 
167     /**
168      * Entrypoint of the selected media preload operation.
169      *
170      * This is triggered when the preloadMedia flow from compose receives a new Set<Media> to
171      * preload.
172      *
173      * Once the new set of media is received from its source, the compose UI will call startPreload
174      * to begin the preload of the set.
175      *
176      * This operation will enqueue work to load any [DataSource.REMOTE] files that are present in
177      * the current selection to ensure they are downloaded / prepared by the remote provider. This
178      * has the benefit of ensuring that the files can be immediately opened by the App that started
179      * Photopicker without having to deal with awaiting any remote procedures to bring the remote
180      * file down to the device.
181      *
182      * This method will run a parent [CoroutineScope] (see [job] in this class), which will
183      * subsequently schedule child jobs for each remote item in the selection. The [Background]
184      * [CoroutineDispatcher] is used for this operation, however the parallel execution is limited
185      * to [MAX_CONCURRENT_LOADS] to avoid over-stressing the remote providers and saturating the
186      * available network bandwidth.
187      *
188      * @param selection The set of media to preload
189      * @param deferred A [CompletableDeferred] that can be used to signal when the preload operation
190      *   is complete. TRUE represents success, FALSE represents failure.
191      * @see [LocationParams.WithMediaPreloader] for the data that is passed to the UI to attach the
192      *   preloader.
193      */
startPreloadnull194     suspend fun startPreload(selection: Set<Media>, deferred: CompletableDeferred<Boolean>) {
195 
196         mutex.withLock {
197 
198             // Begin by clearing any prior state.
199             remoteItems.clear()
200 
201             for (item in selection.filter { it.mediaSource == MediaSource.REMOTE }) {
202                 remoteItems.put(item, LoadResult.QUEUED)
203             }
204         }
205 
206         // End early if there are not any [DataSource.REMOTE] items in the current selection.
207         if (remoteItems.isEmpty()) {
208             Log.i(CloudMediaFeature.TAG, "Preload not required, no remote items.")
209             deferred.complete(true)
210             return
211         }
212 
213         Log.i(
214             CloudMediaFeature.TAG,
215             "SelectionMediaBeginPreload operation was requested. " +
216                 "Total remote items: ${remoteItems.size}"
217         )
218         // Update the UI so the Loading dialog can be displayed with the initial loading data.
219         _dialogData.update {
220             PreloaderDialogData.PreloaderLoadingDialogData(
221                 total = selection.size,
222                 // Local items are automatically "completed" as there is nothing to preload.
223                 completed = (selection.size - remoteItems.size),
224             )
225         }
226 
227         // All preloading work must be a child of this job, a reference of the job is saved
228         // so that if the User requests cancellation the child jobs receive the cancellation as
229         // well.
230         job =
231             scope.launch(backgroundDispatcher) {
232                 // Enqueue a job to monitor the ongoing operation. This job is crucially also a
233                 // child of the main preloading job, so it will be canceled anytime loading is
234                 // canceled.
235                 launch { monitorPreloadOperation(deferred) }
236 
237                 // Start a parallelism constrained child job to actually handle the loads to
238                 // enforce that the device bandwidth doesn't become over saturated by trying
239                 // to load too many files at once.
240                 launch(
241                     @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
242                     backgroundDispatcher.limitedParallelism(MAX_CONCURRENT_LOADS)
243                 ) {
244                     // This is the main preloader job coroutine, enqueue other work here, but
245                     // don't run any heavy / blocking work, as it will prevent the loading
246                     // from starting.
247                     for (item in remoteItems.keys) {
248                         launch { preloadMediaItem(item, deferred) }
249                     }
250                 }
251             }
252     }
253 
254     /**
255      * Entrypoint for preloading a single [Media] item.
256      *
257      * This begins preparing the file by requesting the file from the current user's
258      * [ContentResolver], and updates the dialog data and remote items statuses when a load is
259      * successful.
260      *
261      * If a file cannot be opened or the ContentResolver throws a [FileNotFoundException], the item
262      * is marked as failed.
263      *
264      * @param item The item to load from the [ContentResolver].
265      * @param deferred The overall deferred for the preload operation which is used to see if the
266      *   preload has been canceled already)
267      */
preloadMediaItemnull268     private suspend fun preloadMediaItem(item: Media, deferred: CompletableDeferred<Boolean>) {
269         Log.v(CloudMediaFeature.TAG, "Beginning preload of: $item")
270         try {
271             if (!deferred.isCompleted) {
272                 userMonitor.userStatus.value.activeContentResolver
273                     .openAssetFileDescriptor(item.mediaUri, "r")
274                     .use {
275 
276                         // Mark the item as complete in the result status.
277                         mutex.withLock { remoteItems.set(item, LoadResult.COMPLETED) }
278 
279                         // Update the [PreloaderDialogData] flow an increment the
280                         // completed operations by one so the UI updates.
281                         _dialogData.update {
282                             when (it) {
283                                 is PreloaderDialogData.PreloaderLoadingDialogData ->
284                                     it.copy(completed = it.completed + 1)
285                                 else -> it
286                             }
287                         }
288                         Log.v(CloudMediaFeature.TAG, "Preload successful: $item")
289                     }
290                 // Emit a new monitor heartbeat so the preload can continue or finish.
291                 heartbeat.emit(Unit)
292             }
293         } catch (e: FileNotFoundException) {
294             Log.e(CloudMediaFeature.TAG, "Error while preloading $item", e)
295 
296             // Only need to take action if the deferred is already not marked as completed,
297             // another load job may have already failed.
298             if (!deferred.isCompleted) {
299                 Log.d(
300                     CloudMediaFeature.TAG,
301                     "Failure detected, cancelling the rest of the preload operation."
302                 )
303                 // Mark the item as failed in the result status.
304                 mutex.withLock { remoteItems.set(item, LoadResult.FAILED) }
305                 // Emit a new heartbeat so the monitor will react to this failure.
306                 heartbeat.emit(Unit)
307             }
308         }
309     }
310 
311     /**
312      * Suspended function that monitors [remoteItems] preloading and takes an action when all items
313      * are [LoadResult.COMPLETED] or a [LoadResult.FAILURE] is found in [remoteItems].
314      *
315      * When all remoteItems are [LoadResult.COMPLETED] -> mark the [CompletableDeferred] that
316      * represents this preload operation as completed(TRUE) to signal the preload was successful.
317      *
318      * When one of the remoteItems returns [LoadResult.FAILED] any pending preloads are cancelled,
319      * and the parent job is also canceled. The failed item(s) will be removed from the current
320      * selection, and the deferred will be completed(FALSE) to signal the preload has failed.
321      *
322      * This method will run a new check for every heartbeat, and does not observe the [remoteItems]
323      * data structure directly. As such, it's important that any status changes in the state of
324      * loading trigger an update of heartbeat for the collector in this method to execute.
325      *
326      * @param deferred the status of the overall preload operation. TRUE signals a successful
327      *   preload, and FALSE a failure.
328      */
monitorPreloadOperationnull329     private suspend fun monitorPreloadOperation(deferred: CompletableDeferred<Boolean>) {
330 
331         heartbeat.collect {
332 
333             // Outcomes, another possibility is neither is met, and the load should continue until
334             // the next result.
335             var loadFailed = false
336             var loadCompleted = false
337 
338             // Fetch the current results with the mutex, but don't hold the mutex longer than
339             // needed.
340             mutex.withLock {
341 
342                 // The load is failed if any single item fails to load.
343                 loadFailed = remoteItems.any { (_, loadResult) -> loadResult == LoadResult.FAILED }
344 
345                 // The load is complete if all items are completed successfully.
346                 loadCompleted =
347                     remoteItems.all { (_, loadResult) -> loadResult == LoadResult.COMPLETED }
348             }
349 
350             // Outcomes, if none of these branches are yet met, the load will continue, and this
351             // block will run on the next known result.
352             when {
353                 loadFailed -> {
354                     // Remove any failed items from the selection
355                     selection.removeAll(
356                         remoteItems
357                             .filter { (_, loadResult) -> loadResult == LoadResult.FAILED }
358                             .keys
359                     )
360                     // Now that a failure has been detected, update the [PreloaderDialogData]
361                     // so the UI will show the loading error dialog.
362                     _dialogData.update { PreloaderDialogData.PreloaderLoadingErrorDialog }
363 
364                     // Since something has failed, mark the overall preload operation as failed.
365                     deferred.complete(false)
366                 }
367                 loadCompleted -> {
368                     // If all of the remote items have completed successfully, the preload operation
369                     // is complete, deferred can be marked as complete(true) to instruct the
370                     // application to send the selected Media to the caller.
371                     Log.d(CloudMediaFeature.TAG, "Preload operation was successful.")
372                     deferred.complete(true)
373                 }
374             }
375 
376             // If the load has a result, clean up the active running job.
377             if (loadFailed || loadCompleted) {
378                 job?.cancel()
379                 // Drop any pending heartbeats as the monitor job is being shutdown.
380                 @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
381                 heartbeat.resetReplayCache()
382             }
383         }
384     }
385 
386     /**
387      * Cancels any pending preload operation by canceling the parent job.
388      *
389      * This method is safe to call if no preload is currently active, it will have no effect.
390      *
391      * NOTE: This does not cancel any file open calls that have already started, but will prevent
392      * any additional file open calls from being started.
393      */
cancelPreloadnull394     fun cancelPreload() {
395         job?.let {
396             it.cancel()
397             Log.i(CloudMediaFeature.TAG, "Preload operation was cancelled.")
398         }
399 
400         // Drop any pending heartbeats as the monitor job is being shutdown.
401         @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) heartbeat.resetReplayCache()
402     }
403 
404     /**
405      * Forces the [PreloaderDialogData] flows back to their initialization state so that any dialog
406      * currently being shown will be hidden.
407      *
408      * NOTE: This does not cancel a preload operation, so future progress may show a dialog.
409      */
hideAllDialogsnull410     fun hideAllDialogs() {
411         _dialogData.update { null }
412     }
413 }
414