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