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