• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.preparemedia
18 
19 import android.content.Context
20 import android.os.Bundle
21 import android.util.Log
22 import androidx.annotation.GuardedBy
23 import androidx.annotation.VisibleForTesting
24 import androidx.lifecycle.ViewModel
25 import androidx.lifecycle.viewModelScope
26 import com.android.photopicker.core.Background
27 import com.android.photopicker.core.configuration.ConfigurationManager
28 import com.android.photopicker.core.events.Event
29 import com.android.photopicker.core.events.Events
30 import com.android.photopicker.core.events.Telemetry
31 import com.android.photopicker.core.features.FeatureToken
32 import com.android.photopicker.core.selection.Selection
33 import com.android.photopicker.core.user.UserMonitor
34 import com.android.photopicker.data.model.Media
35 import com.android.photopicker.data.model.MediaSource
36 import com.android.photopicker.features.preparemedia.PrepareMediaResult.PrepareMediaFailed
37 import com.android.photopicker.features.preparemedia.PrepareMediaResult.PreparedMedia
38 import com.android.photopicker.features.preparemedia.Transcoder.Companion.toTranscodedUri
39 import dagger.hilt.android.lifecycle.HiltViewModel
40 import java.io.FileNotFoundException
41 import javax.inject.Inject
42 import kotlinx.coroutines.CompletableDeferred
43 import kotlinx.coroutines.CoroutineDispatcher
44 import kotlinx.coroutines.CoroutineScope
45 import kotlinx.coroutines.ExperimentalCoroutinesApi
46 import kotlinx.coroutines.Job
47 import kotlinx.coroutines.channels.BufferOverflow
48 import kotlinx.coroutines.flow.MutableSharedFlow
49 import kotlinx.coroutines.flow.MutableStateFlow
50 import kotlinx.coroutines.flow.SharingStarted
51 import kotlinx.coroutines.flow.StateFlow
52 import kotlinx.coroutines.flow.distinctUntilChanged
53 import kotlinx.coroutines.flow.map
54 import kotlinx.coroutines.flow.stateIn
55 import kotlinx.coroutines.flow.update
56 import kotlinx.coroutines.launch
57 import kotlinx.coroutines.sync.Mutex
58 import kotlinx.coroutines.sync.withLock
59 
60 /** Enumeration for the LoadStatus of a given preloaded item. */
61 private enum class LoadStatus {
62     COMPLETED,
63     FAILED,
64     QUEUED,
65 }
66 
67 /** Enumeration for the TranscodeStatus of a given media item. */
68 private enum class TranscodeStatus {
69     NOT_APPLIED,
70     SUCCEED,
71     FAILED,
72     QUEUED,
73 }
74 
75 /** Data class for the prepare status of a given media item. */
76 private data class PrepareStatus(val loadStatus: LoadStatus, val transcodeStatus: TranscodeStatus) {
77     val isPreloadCompleted = loadStatus == LoadStatus.COMPLETED
78     val isTranscodeCompleted =
79         transcodeStatus == TranscodeStatus.SUCCEED || transcodeStatus == TranscodeStatus.NOT_APPLIED
80 
81     val isCompleted = isPreloadCompleted && isTranscodeCompleted
82     val isFailed = loadStatus == LoadStatus.FAILED || transcodeStatus == TranscodeStatus.FAILED
83 }
84 
85 /** Data objects which contain all the UI data to render the various Preparer dialogs. */
86 sealed interface PreparerDialogData {
87 
88     /**
89      * The preparing dialog data.
90      *
91      * @param total Total of items to be prepared
92      * @param completed Number of items currently completed
93      */
94     data class PreparingDialogData(val total: Int, val completed: Int = 0) : PreparerDialogData
95 
96     /** Empty object for telling the UI to show a generic error dialog */
97     object PreparingErrorDialog : PreparerDialogData
98 }
99 
100 /**
101  * The view model for the [MediaPreparer].
102  *
103  * This is the class responsible for preparing files before providing URI, e.g. request remote
104  * providers to prepare remote media for local apps or pre-transcode video for incompatible apps.
105  * The main preparing operation should only be triggered by the main activity, by emitting a set of
106  * media to prepare into the flow provided to the MediaPreparer compose UI via [LocationParams].
107  *
108  * Additionally, this method exposes the required state data for the UI to draw the correct dialog
109  * overlays as preparing is initiated, is progressing, and resolves with either a failure or a
110  * success.
111  *
112  * This class should not be injected anywhere other than the MediaPreparer's context to attempt to
113  * monitor the state of the ongoing prepare.
114  *
115  * When the prepare is complete, the [CompletableDeferred] that is passed in the [LocationParams]
116  * will be marked completed, A TRUE value indicates success, and a FALSE value indicates a failure.
117  */
118 @HiltViewModel
119 class MediaPreparerViewModel
120 @Inject
121 constructor(
122     private val scopeOverride: CoroutineScope?,
123     @Background private val backgroundDispatcher: CoroutineDispatcher,
124     private val selection: Selection<Media>,
125     private val userMonitor: UserMonitor,
126     private val configurationManager: ConfigurationManager,
127     private val events: Events,
128 ) : ViewModel() {
129 
130     companion object {
131         private const val EXTRA_URI = "uri"
132         private const val PICKER_TRANSCODE_CALL = "picker_transcode"
133         @VisibleForTesting const val PICKER_TRANSCODE_RESULT = "picker_transcode_result"
134 
135         // Ensure only 2 downloads are occurring in parallel.
136         val MAX_CONCURRENT_LOADS = 2
137     }
138 
139     /* Parent job that owns the overall preparer operation & monitor */
140     private var job: Job? = null
141 
142     /*
143      * A heartbeat flow to drive the prepare monitor job.
144      * Replay = 1 and DROP_OLDEST due to the fact the heartbeat doesn't contain any useful
145      * data, so as long as something is in the buffer to be collected, there's no need
146      * for duplicate emissions.
147      */
148     private val heartbeat: MutableSharedFlow<Unit> =
149         MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
150 
151     // Protect [preparingItems] with a Mutex since multiple coroutines are reading/writing it.
152     private val mutex = Mutex()
153 
154     // A map that tracks the p [PrepareStatus] of media items.
155     // NOTE: This should always be accessed after acquiring the [Mutex] to ensure data
156     // accuracy during concurrency.
157     @GuardedBy("mutex") private val preparingItems = mutableMapOf<Media, PrepareStatus>()
158 
159     /*
160      * A flow to drive the media transcoding job.
161      * Replay = selectionLimit so that any place that emits to this flow, won't suspend.
162      * (Each media is expected to be emitted only once, so each media should only be emitted when
163      *  it is ready for transcoding)
164      */
165     private val itemsToTranscode: MutableSharedFlow<Media> =
166         MutableSharedFlow(
167             replay = configurationManager.configuration.value.selectionLimit,
168             onBufferOverflow = BufferOverflow.SUSPEND,
169         )
170 
171     // Transcoder that help media transcode process.
172     @VisibleForTesting var transcoder: Transcoder = TranscoderImpl()
173 
174     // Check if a scope override was injected before using the default [viewModelScope]
175     private val scope: CoroutineScope =
176         if (scopeOverride == null) {
177             this.viewModelScope
178         } else {
179             scopeOverride
180         }
181 
182     /* Flow for monitoring the activeContentResolver:
183      *   - map to get rid of other [UserStatus] fields this does not care about
184      *   - distinctUntilChanged to only emit when the resolver actually changes, since
185      *     UserStatus might be updated if other profiles turn on and off
186      */
187     private val _contentResolver =
<lambda>null188         userMonitor.userStatus.map { it.activeContentResolver }.distinctUntilChanged()
189 
190     /** Flow that can push new data into the preparer's dialogs. */
191     private val _dialogData = MutableStateFlow<PreparerDialogData?>(null)
192 
193     /** Public flow for the compose ui to collect. */
194     val dialogData: StateFlow<PreparerDialogData?> =
195         _dialogData.stateIn(
196             scope,
197             SharingStarted.WhileSubscribed(),
198             initialValue = _dialogData.value,
199         )
200 
201     init {
202 
203         // If the active user's resolver changes, cancel any pending prepare work.
<lambda>null204         scope.launch {
205             _contentResolver.collect {
206                 // Action is only required if there's currently a job running.
207                 job?.let {
208                     Log.d(PrepareMediaFeature.TAG, "User was changed, abandoning prepares")
209                     it.cancel()
210                     hideAllDialogs()
211                 }
212             }
213         }
214     }
215 
216     /**
217      * Entrypoint of the selected media prepare operation.
218      *
219      * This is triggered when the prepareMedia flow from compose receives a new Set<Media> to
220      * prepare.
221      *
222      * Once the new set of media is received from its source, the compose UI will call startPrepare
223      * to begin the prepare of the set.
224      *
225      * This operation will enqueue work to prepare any media files that are present in the current
226      * selection to ensure they are downloaded by the remote provider or transcoded to a compatible
227      * format. This has the benefit of ensuring that the files can be immediately opened by the App
228      * that started Photopicker without having to deal with awaiting any remote procedures to bring
229      * the remote file down to the device.
230      *
231      * This method will run a parent [CoroutineScope] (see [job] in this class), which will
232      * subsequently schedule child jobs for each media item in the selection. For remote media
233      * preloading, the [Background] [CoroutineDispatcher] is used for this operation, however the
234      * parallel execution is limited to [MAX_CONCURRENT_LOADS] to avoid over-stressing the remote
235      * providers and saturating the available network bandwidth. For media transcoding, the
236      * execution is running sequentially, and if a media item requires remote preloading before
237      * transcoding, subsequent items are processed first to maximize efficiency.
238      *
239      * @param selection The set of media to prepare.
240      * @param deferred A [CompletableDeferred] that can be used to signal when the prepare operation
241      *   is complete. TRUE represents success, FALSE represents failure.
242      * @param context The current context.
243      * @see [LocationParams.WithMediaPreparer] for the data that is passed to the UI to attach the
244      *   preparer.
245      */
startPreparenull246     suspend fun startPrepare(
247         selection: Set<Media>,
248         deferred: CompletableDeferred<PrepareMediaResult>,
249         context: Context,
250     ) {
251         initialMediaPreparation(selection)
252 
253         val countPrepareRequired: Int =
254             mutex.withLock { preparingItems.filter { (_, status) -> !status.isCompleted }.size }
255 
256         // End early if there are not any items need to be prepared.
257         if (countPrepareRequired == 0) {
258             Log.i(PrepareMediaFeature.TAG, "Prepare not required, no remote or incompatible items.")
259             deferred.complete(PreparedMedia(preparedMedia = getPreparedMedia()))
260             return
261         }
262 
263         Log.i(
264             PrepareMediaFeature.TAG,
265             "SelectionMediaBeginPrepare operation was requested. " +
266                 "Total items to prepare: $countPrepareRequired",
267         )
268 
269         // Update the UI so the Preparing dialog can be displayed with the initial preparing data.
270         _dialogData.update {
271             PreparerDialogData.PreparingDialogData(
272                 total = selection.size,
273                 completed = (selection.size - countPrepareRequired),
274             )
275         }
276 
277         // All preparing work must be a child of this job, a reference of the job is saved
278         // so that if the User requests cancellation the child jobs receive the cancellation as
279         // well.
280         job =
281             scope.launch(backgroundDispatcher) {
282                 // Enqueue a job to monitor the ongoing operation. This job is crucially also a
283                 // child of the main preloading job, so it will be canceled anytime loading is
284                 // canceled.
285                 launch { monitorPrepareOperation(deferred) }
286 
287                 // Start a parallelism constrained child job to actually handle the loads to
288                 // enforce that the device bandwidth doesn't become over saturated by trying
289                 // to load too many files at once.
290                 launch(
291                     @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
292                     backgroundDispatcher.limitedParallelism(MAX_CONCURRENT_LOADS)
293                 ) {
294                     // This is the main preloading job coroutine, enqueue other work here, but
295                     // don't run any heavy / blocking work, as it will prevent the loading
296                     // from starting.
297                     val remoteItems =
298                         mutex.withLock {
299                             preparingItems.entries
300                                 .toList()
301                                 .filter { it.value.loadStatus == LoadStatus.QUEUED }
302                                 .map { it.key }
303                         }
304                     for (item in remoteItems) {
305                         launch { preloadMediaItem(item, deferred) }
306                     }
307                 }
308 
309                 // Start a child job to wait and send the readied media items to transcode.
310                 launch {
311                     itemsToTranscode.collect { item ->
312                         try {
313                             // The transcoding process is only started when an item's transcoding
314                             // is not completed (currently only video can be transcoded) and
315                             // does not require loading.
316                             val prepareStatus = mutex.withLock { preparingItems.getValue(item) }
317                             if (
318                                 !prepareStatus.isTranscodeCompleted &&
319                                     prepareStatus.isPreloadCompleted
320                             ) {
321                                 transcodeMediaItem(item, deferred, context)
322                             }
323                         } catch (e: NoSuchElementException) {
324                             // Should not go here.
325                             Log.e(
326                                 PrepareMediaFeature.TAG,
327                                 "Expected media object was not in the status map",
328                                 e,
329                             )
330                         }
331                     }
332                 }
333             }
334     }
335 
336     /**
337      * Initializes required objects for media prepare processing.
338      *
339      * @param selection The set of media to be prepared.
340      */
initialMediaPreparationnull341     private suspend fun initialMediaPreparation(selection: Set<Media>) {
342         val config = configurationManager.configuration.value
343         val mediaCapabilities = config.callingPackageMediaCapabilities
344         val isTranscodingEnabled = config.flags.PICKER_TRANSCODING_ENABLED
345 
346         // Initial preparation states.
347         mutex.withLock {
348             // Begin by clearing any prior state.
349             preparingItems.clear()
350 
351             for (item in selection) {
352                 // Check if media need to be preloaded.
353                 val loadStatus =
354                     if (item.mediaSource == MediaSource.REMOTE) {
355                         LoadStatus.QUEUED
356                     } else {
357                         LoadStatus.COMPLETED
358                     }
359 
360                 // Check if media need to be transcoded.
361                 val transcodeStatus =
362                     if (isTranscodingEnabled && mediaCapabilities != null && item is Media.Video) {
363                         TranscodeStatus.QUEUED
364                     } else {
365                         TranscodeStatus.NOT_APPLIED
366                     }
367 
368                 val prepareStatus = PrepareStatus(loadStatus, transcodeStatus)
369                 preparingItems.put(item, prepareStatus)
370 
371                 // Queue readied media items for transcoding. Items that have not been preloaded
372                 // will be queued after loading.
373                 if (!prepareStatus.isTranscodeCompleted && prepareStatus.isPreloadCompleted) {
374                     itemsToTranscode.emit(item)
375                 }
376             }
377         }
378     }
379 
380     /**
381      * Entrypoint for preloading a single [Media] item.
382      *
383      * This begins preparing the file by requesting the file from the current user's
384      * [ContentResolver], and updates the dialog data and remote items statuses when a load is
385      * successful.
386      *
387      * If a file cannot be opened or the ContentResolver throws a [FileNotFoundException], the item
388      * is marked as failed.
389      *
390      * @param item The item to load from the [ContentResolver].
391      * @param deferred The overall deferred for the preload operation which is used to see if the
392      *   preload has been canceled already)
393      */
preloadMediaItemnull394     private suspend fun preloadMediaItem(
395         item: Media,
396         deferred: CompletableDeferred<PrepareMediaResult>,
397     ) {
398         Log.v(PrepareMediaFeature.TAG, "Beginning preload of: $item")
399         try {
400             if (!deferred.isCompleted) {
401                 userMonitor.userStatus.value.activeContentResolver
402                     .openAssetFileDescriptor(item.mediaUri, "r")
403                     ?.close()
404 
405                 // Mark the item as complete in the result status.
406                 updatePrepareStatus(item, loadStatus = LoadStatus.COMPLETED)
407                 Log.v(PrepareMediaFeature.TAG, "Preload successful: $item")
408 
409                 // Pass the loaded item for transcoding. The transcoding status does not to check
410                 // here, since it will be examined before passing to "transcodeMediaItem" to start
411                 // the transcoding process.
412                 itemsToTranscode.emit(item)
413 
414                 // Emit a new monitor heartbeat so the prepare can continue or finish.
415                 heartbeat.emit(Unit)
416             }
417         } catch (e: FileNotFoundException) {
418             Log.e(PrepareMediaFeature.TAG, "Error while preloading $item", e)
419 
420             // Only need to take action if the deferred is already not marked as completed,
421             // another prepare job may have already failed.
422             if (!deferred.isCompleted) {
423                 Log.d(
424                     PrepareMediaFeature.TAG,
425                     "Failure detected, cancelling the rest of the preload operation.",
426                 )
427                 // Log failure of media items preloading
428                 scope.launch {
429                     val configuration = configurationManager.configuration.value
430                     events.dispatch(
431                         Event.LogPhotopickerUIEvent(
432                             FeatureToken.CORE.token,
433                             configuration.sessionId,
434                             configuration.callingPackageUid ?: -1,
435                             Telemetry.UiEvent.PICKER_PRELOADING_FAILED,
436                         )
437                     )
438                 }
439                 // Mark the item as failed in the result status.
440                 updatePrepareStatus(item, loadStatus = LoadStatus.FAILED)
441                 // Emit a new heartbeat so the monitor will react to this failure.
442                 heartbeat.emit(Unit)
443             }
444         }
445     }
446 
447     /**
448      * Entrypoint for transcoding a single [Media.Video] item.
449      *
450      * This begins transcoding the file by triggering the call method of the current user's
451      * [ContentResolver], and updates the dialog data and incompatible items statuses when a
452      * transcode is successful.
453      *
454      * If a file cannot be opened or the ContentResolver throws a [FileNotFoundException], the item
455      * is marked as failed.
456      *
457      * @param item The item to transcode from the [ContentResolver].
458      * @param deferred The overall deferred for the transcode operation which is used to see if the
459      *   transcode has been canceled already).
460      * @param context The current context.
461      */
transcodeMediaItemnull462     private suspend fun transcodeMediaItem(
463         item: Media,
464         deferred: CompletableDeferred<PrepareMediaResult>,
465         context: Context,
466     ) {
467         Log.v(PrepareMediaFeature.TAG, "Beginning transcode of: $item")
468         if (!deferred.isCompleted) {
469             if (item is Media.Video) {
470                 val contentResolver = userMonitor.userStatus.value.activeContentResolver
471                 val mediaCapabilities =
472                     configurationManager.configuration.value.callingPackageMediaCapabilities
473 
474                 // Trigger transcoding.
475                 val transcodeStatus =
476                     if (transcoder.isTranscodeRequired(context, mediaCapabilities, item)) {
477                         val transcodingVideoInfo: Transcoder.VideoInfo? =
478                             transcoder.getTranscodingVideoInfo()
479                         scope.launch {
480                             val configuration = configurationManager.configuration.value
481                             if (transcodingVideoInfo != null) {
482                                 events.dispatch(
483                                     Event.ReportTranscodingVideoDetails(
484                                         dispatcherToken = FeatureToken.CORE.token,
485                                         sessionId = configuration.sessionId,
486                                         duration = transcodingVideoInfo.duration,
487                                         colorTransfer = transcodingVideoInfo.colorTransfer,
488                                         colorStandard = transcodingVideoInfo.colorStandard,
489                                         mimeType = transcodingVideoInfo.mimeType,
490                                     )
491                                 )
492                             }
493                             events.dispatch(
494                                 Event.LogPhotopickerUIEvent(
495                                     FeatureToken.CORE.token,
496                                     configuration.sessionId,
497                                     configuration.callingPackageUid ?: -1,
498                                     Telemetry.UiEvent.PICKER_TRANSCODING_START,
499                                 )
500                             )
501                         }
502                         val uri = item.mediaUri
503                         val resultBundle =
504                             contentResolver.call(
505                                 uri,
506                                 PICKER_TRANSCODE_CALL,
507                                 null,
508                                 Bundle().apply { putParcelable(EXTRA_URI, uri) },
509                             )
510 
511                         if (resultBundle?.getBoolean(PICKER_TRANSCODE_RESULT, false) == true) {
512                             Log.v(PrepareMediaFeature.TAG, "Transcode successful: $item")
513                             scope.launch {
514                                 val configuration = configurationManager.configuration.value
515                                 events.dispatch(
516                                     Event.LogPhotopickerUIEvent(
517                                         FeatureToken.CORE.token,
518                                         configuration.sessionId,
519                                         configuration.callingPackageUid ?: -1,
520                                         Telemetry.UiEvent.PICKER_TRANSCODING_SUCCESS,
521                                     )
522                                 )
523                             }
524                             TranscodeStatus.SUCCEED
525                         } else {
526                             Log.w(PrepareMediaFeature.TAG, "Not able to transcode: $item")
527                             scope.launch {
528                                 val configuration = configurationManager.configuration.value
529                                 events.dispatch(
530                                     Event.LogPhotopickerUIEvent(
531                                         FeatureToken.CORE.token,
532                                         configuration.sessionId,
533                                         configuration.callingPackageUid ?: -1,
534                                         Telemetry.UiEvent.PICKER_TRANSCODING_FAILED,
535                                     )
536                                 )
537                             }
538                             TranscodeStatus.NOT_APPLIED
539                         }
540                     } else {
541                         Log.v(PrepareMediaFeature.TAG, "No need to transcode: $item")
542                         TranscodeStatus.NOT_APPLIED
543                     }
544 
545                 // Mark the item as complete in the result status.
546                 updatePrepareStatus(item, transcodeStatus = transcodeStatus)
547             } else {
548                 // Should not go here. Currently, only video can be transcoded.
549                 Log.e(PrepareMediaFeature.TAG, "Expected media object was not a video")
550 
551                 // Mark the item as failed in the result status.
552                 updatePrepareStatus(item, transcodeStatus = TranscodeStatus.FAILED)
553             }
554 
555             // Emit a new monitor heartbeat so the prepare can continue or finish.
556             heartbeat.emit(Unit)
557         }
558     }
559 
560     /**
561      * Updates the preparation status for the given media.
562      *
563      * @param item The media whose status to update.
564      * @param loadStatus The new load status. If null, the existing status is preserved.
565      * @param transcodeStatus The new transcode status. If null, the existing status is preserved.
566      */
updatePrepareStatusnull567     private suspend fun updatePrepareStatus(
568         item: Media,
569         loadStatus: LoadStatus? = null,
570         transcodeStatus: TranscodeStatus? = null,
571     ) {
572         val oldStatus: PrepareStatus
573         val newStatus: PrepareStatus
574 
575         mutex.withLock {
576             oldStatus =
577                 try {
578                     preparingItems.getValue(item)
579                 } catch (e: NoSuchElementException) {
580                     Log.e(
581                         PrepareMediaFeature.TAG,
582                         "Failed to update preparing status, item not in the map",
583                         e,
584                     )
585                     return
586                 }
587             newStatus =
588                 oldStatus.copy(
589                     loadStatus = loadStatus ?: oldStatus.loadStatus,
590                     transcodeStatus = transcodeStatus ?: oldStatus.transcodeStatus,
591                 )
592             preparingItems.put(item, newStatus)
593         }
594 
595         if (!oldStatus.isCompleted && newStatus.isCompleted) {
596             increaseCompletionOnUI()
597         }
598     }
599 
600     /** Update the [PreparerDialogData] flow an increment the completed operations by one on UI. */
increaseCompletionOnUInull601     private fun increaseCompletionOnUI() {
602         _dialogData.update {
603             when (it) {
604                 is PreparerDialogData.PreparingDialogData -> it.copy(completed = it.completed + 1)
605                 else -> it
606             }
607         }
608     }
609 
610     /**
611      * Suspended function that monitors media preparing and takes an action when [PrepareStatus] of
612      * all items are completed or a failure is found in [preparingItems].
613      *
614      * When all preparingItems are completed -> mark the [CompletableDeferred] that represents this
615      * prepare operation as completed(TRUE) to signal the prepare was successful.
616      *
617      * When one of the preparingItems is failed any pending prepares are cancelled, and the parent
618      * job is also canceled. The failed item(s) will be removed from the current selection, and the
619      * deferred will be completed(FALSE) to signal the prepare has failed.
620      *
621      * This method will run a new check for every heartbeat, and does not observe the
622      * [preparingItems] data structure directly. As such, it's important that any status changes in
623      * the state of preparing trigger an update of heartbeat for the collector in this method to
624      * execute.
625      *
626      * @param deferred the status of the overall prepare operation. TRUE signals a successful
627      *   prepare, and FALSE a failure.
628      */
629     @OptIn(ExperimentalCoroutinesApi::class)
monitorPrepareOperationnull630     private suspend fun monitorPrepareOperation(deferred: CompletableDeferred<PrepareMediaResult>) {
631 
632         heartbeat.collect {
633 
634             // Outcomes, another possibility is neither is met, and the prepare should continue
635             // until the next result.
636             var prepareFailed = false
637             var prepareCompleted = false
638 
639             // Fetch the current results with the mutex, but don't hold the mutex longer than
640             // needed.
641             mutex.withLock {
642 
643                 // The prepare is failed if any single item fails to prepare.
644                 prepareFailed = preparingItems.any { (_, status) -> status.isFailed }
645 
646                 // The prepare is complete if all items are completed successfully.
647                 prepareCompleted = preparingItems.all { (_, status) -> status.isCompleted }
648             }
649 
650             // Outcomes, if none of these branches are yet met, the prepare will continue, and this
651             // block will run on the next known result.
652             when {
653                 prepareFailed -> {
654                     // Remove any failed items from the selection
655                     selection.removeAll(
656                         preparingItems.filter { (_, status) -> status.isFailed }.keys
657                     )
658                     // Now that a failure has been detected, update the [PreparerDialogData]
659                     // so the UI will show the preparing error dialog.
660                     _dialogData.update { PreparerDialogData.PreparingErrorDialog }
661 
662                     // Since something has failed, mark the overall prepare operation as failed.
663                     deferred.complete(PrepareMediaFailed)
664                 }
665                 prepareCompleted -> {
666                     // If all of the remote items have completed successfully and the videos have
667                     // been transcoded to the compatible formats, the prepare operation is
668                     // complete, deferred can be marked as complete(true) to instruct the
669                     // application to send the selected Media to the caller.
670                     Log.d(PrepareMediaFeature.TAG, "Prepare operation was successful.")
671                     deferred.complete(PreparedMedia(preparedMedia = getPreparedMedia()))
672 
673                     // Dispatch UI event to mark the end of preparing of media items
674                     scope.launch {
675                         val configuration = configurationManager.configuration.value
676                         events.dispatch(
677                             Event.LogPhotopickerUIEvent(
678                                 FeatureToken.CORE.token,
679                                 configuration.sessionId,
680                                 configuration.callingPackageUid ?: -1,
681                                 Telemetry.UiEvent.PICKER_PRELOADING_FINISHED,
682                             )
683                         )
684                     }
685                 }
686             }
687 
688             // If the prepare has a result, clean up the active running job.
689             if (prepareFailed || prepareCompleted) {
690                 job?.cancel()
691                 // Drop any pending heartbeats or transcoding items as the preparer job is being
692                 // shutdown.
693                 heartbeat.resetReplayCache()
694                 itemsToTranscode.resetReplayCache()
695             }
696         }
697     }
698 
699     /**
700      * Gets the set of media that have been prepared.
701      *
702      * Note that if the media is transcoded, its media URI will be updated to the transcoded URI .
703      *
704      * @return The set of media.
705      */
getPreparedMedianull706     private suspend fun getPreparedMedia(): Set<Media> {
707         return mutex.withLock {
708             preparingItems
709                 .asSequence()
710                 .map { (media, status) ->
711                     if (status.transcodeStatus == TranscodeStatus.SUCCEED) {
712                         if (media is Media.Video) {
713                             // Replace media uri with transcoded uri if the media is transcoded.
714                             return@map media.copy(mediaUri = toTranscodedUri(media.mediaUri))
715                         } else {
716                             // Should not go here. Currently, only video can be transcoded.
717                             Log.e(PrepareMediaFeature.TAG, "Expected media object was not a video")
718                         }
719                     }
720                     media
721                 }
722                 .toSet()
723         }
724     }
725 
726     /**
727      * Cancels any pending prepare operation by canceling the parent job.
728      *
729      * This method is safe to call if no prepare is currently active, it will have no effect.
730      *
731      * NOTE: This does not cancel any file open calls that have already started, but will prevent
732      * any additional file open calls from being started.
733      *
734      * @param deferred The [CompletableDeferred] for the job to cancel, if one exists.
735      */
736     @OptIn(ExperimentalCoroutinesApi::class)
cancelPreparenull737     fun cancelPrepare(deferred: CompletableDeferred<PrepareMediaResult>? = null) {
738         job?.let {
739             it.cancel()
740             Log.i(PrepareMediaFeature.TAG, "Prepare operation was cancelled.")
741             // Dispatch an event to log cancellation of media items preparing
742             scope.launch {
743                 val configuration = configurationManager.configuration.value
744                 events.dispatch(
745                     Event.LogPhotopickerUIEvent(
746                         FeatureToken.CORE.token,
747                         configuration.sessionId,
748                         configuration.callingPackageUid ?: -1,
749                         Telemetry.UiEvent.PICKER_PRELOADING_CANCELLED,
750                     )
751                 )
752             }
753         }
754 
755         // In the event of single selection mode, the selection needs to be cleared.
756         if (configurationManager.configuration.value.selectionLimit == 1) {
757             scope.launch { selection.clear() }
758         }
759 
760         // If a deferred was passed, mark it as failed.
761         deferred?.complete(PrepareMediaFailed)
762 
763         // Drop any pending heartbeats or transcoding items as the preparer job is being shutdown.
764         heartbeat.resetReplayCache()
765         itemsToTranscode.resetReplayCache()
766     }
767 
768     /**
769      * Forces the [PreparerDialogData] flows back to their initialization state so that any dialog
770      * currently being shown will be hidden.
771      *
772      * NOTE: This does not cancel a prepare operation, so future progress may show a dialog.
773      */
hideAllDialogsnull774     fun hideAllDialogs() {
775         _dialogData.update { null }
776     }
777 }
778