1 /* 2 * 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.preview 18 19 import android.content.ContentProviderClient 20 import android.content.ContentResolver 21 import android.net.Uri 22 import android.os.Bundle 23 import android.os.RemoteException 24 import android.provider.CloudMediaProviderContract.EXTRA_LOOPING_PLAYBACK_ENABLED 25 import android.provider.CloudMediaProviderContract.EXTRA_SURFACE_CONTROLLER 26 import android.provider.CloudMediaProviderContract.EXTRA_SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED 27 import android.provider.CloudMediaProviderContract.EXTRA_SURFACE_STATE_CALLBACK 28 import android.provider.CloudMediaProviderContract.METHOD_CREATE_SURFACE_CONTROLLER 29 import android.provider.ICloudMediaSurfaceController 30 import android.provider.ICloudMediaSurfaceStateChangedCallback 31 import android.util.Log 32 import androidx.core.os.bundleOf 33 import androidx.lifecycle.ViewModel 34 import androidx.lifecycle.viewModelScope 35 import androidx.paging.Pager 36 import androidx.paging.PagingConfig 37 import androidx.paging.PagingData 38 import androidx.paging.cachedIn 39 import com.android.photopicker.core.configuration.ConfigurationManager 40 import com.android.photopicker.core.configuration.PhotopickerConfiguration 41 import com.android.photopicker.core.events.Event 42 import com.android.photopicker.core.events.Events 43 import com.android.photopicker.core.events.Telemetry 44 import com.android.photopicker.core.features.FeatureToken 45 import com.android.photopicker.core.selection.GrantsAwareSelectionImpl 46 import com.android.photopicker.core.selection.Selection 47 import com.android.photopicker.core.selection.SelectionModifiedResult.FAILURE_SELECTION_LIMIT_EXCEEDED 48 import com.android.photopicker.core.selection.SelectionStrategy 49 import com.android.photopicker.core.user.UserMonitor 50 import com.android.photopicker.data.DataService 51 import com.android.photopicker.data.model.Media 52 import dagger.hilt.android.lifecycle.HiltViewModel 53 import javax.inject.Inject 54 import kotlinx.coroutines.CoroutineScope 55 import kotlinx.coroutines.flow.Flow 56 import kotlinx.coroutines.flow.MutableSharedFlow 57 import kotlinx.coroutines.flow.MutableStateFlow 58 import kotlinx.coroutines.flow.filter 59 import kotlinx.coroutines.flow.flowOf 60 import kotlinx.coroutines.flow.update 61 import kotlinx.coroutines.launch 62 63 /** 64 * The view model for the Preview routes. 65 * 66 * This view model manages snapshots of the session's selection so that items can observe a slice of 67 * state rather than the mutable selection state. 68 * 69 * Additionally, [RemoteSurfaceController] are created and held for re-use in the scope of this view 70 * model. The view model handles the [ICloudMediaSurfaceStateChangedCallback] for each controller, 71 * and stores the information for the UI to obtain via exported flows. 72 */ 73 @HiltViewModel 74 class PreviewViewModel 75 @Inject 76 constructor( 77 private val scopeOverride: CoroutineScope?, 78 private val selection: Selection<Media>, 79 private val userMonitor: UserMonitor, 80 private val dataService: DataService, 81 private val events: Events, 82 private val configManager: ConfigurationManager, 83 ) : ViewModel() { 84 85 companion object { 86 val TAG: String = PreviewFeature.TAG 87 88 // These are the authority strings for [CloudMediaProvider]-s for local on device files. 89 private val PHOTOPICKER_PROVIDER_AUTHORITY = "com.android.providers.media.photopicker" 90 private val REMOTE_PREVIEW_PROVIDER_AUTHORITY = 91 "com.android.providers.media.remote_video_preview" 92 } 93 94 // Request Media in batches of 10 items 95 private val PREVIEW_PAGER_PAGE_SIZE = 10 96 97 // Keep up to 5 pages loaded in memory before unloading pages. 98 private val PREVIEW_PAGER_MAX_ITEMS_IN_MEMORY = PREVIEW_PAGER_PAGE_SIZE * 5 99 100 // Check if a scope override was injected before using the default [viewModelScope] 101 private val scope: CoroutineScope = 102 if (scopeOverride == null) { 103 this.viewModelScope 104 } else { 105 scopeOverride 106 } 107 108 /** 109 * A flow which exposes a snapshot of the selection. Initially this is an empty set and will not 110 * automatically update with the current selection, snapshots must be explicitly requested. 111 */ 112 val selectionSnapshot = MutableStateFlow<Set<Media>>(emptySet()) 113 114 val deselectionSnapshot = MutableStateFlow<Set<Media>>(emptySet()) 115 116 /** Trigger a new snapshot of the selection. */ takeNewSelectionSnapshotnull117 fun takeNewSelectionSnapshot() { 118 scope.launch { 119 selectionSnapshot.update { selection.snapshot() } 120 deselectionSnapshot.update { selection.getDeselection().toHashSet() } 121 } 122 } 123 124 /** 125 * Toggle the media item into the current session's selection. 126 * 127 * @param media 128 */ toggleInSelectionnull129 fun toggleInSelection( 130 media: Media, 131 onSelectionLimitExceeded: () -> Unit, 132 ) { 133 scope.launch { 134 val result = selection.toggle(item = media) 135 if (result == FAILURE_SELECTION_LIMIT_EXCEEDED) { 136 onSelectionLimitExceeded() 137 } 138 } 139 } 140 toggleInSelectionnull141 fun toggleInSelection( 142 media: Collection<Media>, 143 onSelectionLimitExceeded: () -> Unit, 144 ) { 145 scope.launch { 146 val result = selection.toggleAll(media) 147 if (result == FAILURE_SELECTION_LIMIT_EXCEEDED) { 148 onSelectionLimitExceeded() 149 } 150 } 151 } 152 153 /** 154 * Provides a flow containing paging data for items that needs to be displayed on the preview 155 * view. 156 * 157 * It takes into account pre-grants, selections and de-selections. 158 */ getPreviewMediaIncludingPreGrantedItemsnull159 fun getPreviewMediaIncludingPreGrantedItems( 160 selectionSet: Set<Media>, 161 photopickerConfiguration: PhotopickerConfiguration, 162 isSingleItemPreview: Boolean = false, 163 ): Flow<PagingData<Media>> { 164 val flow = 165 if (isSingleItemPreview) flowOf(PagingData.from(selectionSet.toList())) 166 else { 167 when (SelectionStrategy.determineSelectionStrategy(photopickerConfiguration)) { 168 SelectionStrategy.DEFAULT -> flowOf(PagingData.from(selectionSet.toList())) 169 SelectionStrategy.GRANTS_AWARE_SELECTION -> { 170 val deselectAllEnabled = 171 if (selection is GrantsAwareSelectionImpl) { 172 selection.isDeSelectAllEnabled 173 } else { 174 false 175 } 176 if (deselectAllEnabled) { 177 flowOf(PagingData.from(selectionSet.toList())) 178 } else { 179 val pager = 180 Pager( 181 PagingConfig( 182 pageSize = PREVIEW_PAGER_PAGE_SIZE, 183 maxSize = PREVIEW_PAGER_MAX_ITEMS_IN_MEMORY 184 ) 185 ) { 186 dataService.previewMediaPagingSource( 187 selectionSnapshot.value, 188 deselectionSnapshot.value 189 ) 190 } 191 pager.flow 192 } 193 } 194 } 195 } 196 197 /** Export the data from the pager and prepare it for use in the [Preview] */ 198 val data = flow.cachedIn(scope) 199 return data 200 } 201 202 /** 203 * Holds any cached [RemotePreviewControllerInfo] to avoid re-creating 204 * [RemoteSurfaceController]-s that already exist during a preview session. 205 */ 206 val controllers: HashMap<String, RemotePreviewControllerInfo> = HashMap() 207 208 /** 209 * A flow that all [ICloudMediaSurfaceStateChangedCallback] push their [setPlaybackState] 210 * updates to. This flow is later filtered to a specific (authority + surfaceId) pairing for 211 * providing the playback state updates to the UI composables to collect. 212 * 213 * A shared flow is used here to ensure that all emissions are delivered since a StateFlow will 214 * conflate deliveries to slow receivers (sometimes the UI is slow to pull emissions) to this 215 * flow since they happen in quick succession, and this will avoid dropping any. 216 * 217 * See [getPlaybackInfoForPlayer] where this flow is filtered. 218 */ 219 private val _playbackInfo = MutableSharedFlow<PlaybackInfo>() 220 221 /** 222 * Creates a [Flow<PlaybackInfo>] for the provided player configuration. This just siphons the 223 * larger [playbackInfo] flow that all of the [ICloudMediaSurfaceStateChangedCallback]-s push 224 * their updates to. 225 * 226 * The larger flow is filtered for updates related to the requested video session. (surfaceId + 227 * authority) 228 */ getPlaybackInfoForPlayernull229 fun getPlaybackInfoForPlayer(surfaceId: Int, video: Media.Video): Flow<PlaybackInfo> { 230 return _playbackInfo.filter { it.surfaceId == surfaceId && it.authority == video.authority } 231 } 232 233 /** @return the active user's [ContentResolver]. */ getContentResolverForCurrentUsernull234 fun getContentResolverForCurrentUser(): ContentResolver { 235 return userMonitor.userStatus.value.activeContentResolver 236 } 237 238 /** 239 * Obtains an instance of [RemoteSurfaceController] for the requested authority. Attempts to 240 * re-use any controllers that have previously been fetched, and additionally, generates a 241 * [RemotePreviewControllerInfo] for the requested authority and holds it in [controllers] for 242 * future re-use. 243 * 244 * @return A [RemoteSurfaceController] for [authority] 245 */ getControllerForAuthoritynull246 fun getControllerForAuthority( 247 authority: String, 248 ): RemoteSurfaceController { 249 250 if (controllers.containsKey(authority)) { 251 Log.d(TAG, "Existing controller found, re-using for $authority") 252 return controllers.getValue(authority).controller 253 } 254 255 Log.d(TAG, "Creating controller for authority: $authority") 256 257 val callback = buildSurfaceStateChangedCallback(authority) 258 259 // For local photos which use the PhotopickerProvider, the remote video preview 260 // functionality is actually delegated to the mediaprovider:Photopicker process 261 // and is run out of the RemoteVideoPreviewProvider, so for the purposes of 262 // acquiring a [ContentProviderClient], use a different authority. 263 val clientAuthority = 264 when (authority) { 265 PHOTOPICKER_PROVIDER_AUTHORITY -> REMOTE_PREVIEW_PROVIDER_AUTHORITY 266 else -> authority 267 } 268 269 // Acquire a [ContentProviderClient] that can be retained as long as the [PreviewViewModel] 270 // is active. This creates a binding between the current process that is running Photopicker 271 // and the remote process that is rendering video and prevents the remote process from being 272 // killed by the OS. This client is held onto until the [PreviewViewModel] is cleared when 273 // the Preview route is navigated away from. (The PreviewViewModel is bound to the 274 // navigation backStackEntry). 275 val remoteClient = 276 getContentResolverForCurrentUser().acquireContentProviderClient(clientAuthority) 277 // TODO: b/323833427 Navigate back to the main grid when a controller cannot be obtained. 278 checkNotNull(remoteClient) { "Unable to get a client for $clientAuthority" } 279 280 // Don't reuse the remote client from above since it may not be the right provider for 281 // local files. Instead, assemble a new URI, and call the correct provider via 282 // [ContentResolver#call] 283 val uri: Uri = 284 Uri.Builder() 285 .apply { 286 scheme(ContentResolver.SCHEME_CONTENT) 287 authority(authority) 288 } 289 .build() 290 291 val extras = 292 bundleOf( 293 EXTRA_LOOPING_PLAYBACK_ENABLED to true, 294 EXTRA_SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED to true, 295 EXTRA_SURFACE_STATE_CALLBACK to callback 296 ) 297 298 val controllerBundle: Bundle? = 299 getContentResolverForCurrentUser() 300 .call( 301 /*uri=*/ uri, 302 /*method=*/ METHOD_CREATE_SURFACE_CONTROLLER, 303 /*arg=*/ null, 304 /*extras=*/ extras, 305 ) 306 checkNotNull(controllerBundle) { "No controller was returned for RemoteVideoPreview" } 307 308 val binder = controllerBundle.getBinder(EXTRA_SURFACE_CONTROLLER) 309 310 val configuration = configManager.configuration.value 311 // UI event to mark the start of surface controller creation 312 scope.launch { 313 events.dispatch( 314 Event.LogPhotopickerUIEvent( 315 FeatureToken.PREVIEW.token, 316 configuration.sessionId, 317 configuration.callingPackageUid ?: -1, 318 Telemetry.UiEvent.CREATE_SURFACE_CONTROLLER_START 319 ) 320 ) 321 } 322 323 // Produce the [RemotePreviewControllerInfo] and save it for future re-use. 324 val controllerInfo = 325 RemotePreviewControllerInfo( 326 authority = authority, 327 client = remoteClient, 328 controller = 329 RemoteSurfaceController(ICloudMediaSurfaceController.Stub.asInterface(binder)), 330 ) 331 controllers.put(authority, controllerInfo) 332 333 return controllerInfo.controller 334 } 335 336 /** 337 * When this ViewModel is cleared, close any held [ContentProviderClient]s that are retained for 338 * video rendering. 339 */ onClearednull340 override fun onCleared() { 341 // When the view model is cleared then it is safe to assume the preview route is no longer 342 // active, and any [ContentProviderClient] that are being held to support remote video 343 // preview can now be closed. 344 for ((_, controllerInfo) in controllers) { 345 346 try { 347 controllerInfo.controller.onDestroy() 348 val configuration = configManager.configuration.value 349 // UI event to mark the end of surface controller creation 350 scope.launch { 351 events.dispatch( 352 Event.LogPhotopickerUIEvent( 353 FeatureToken.PREVIEW.token, 354 configuration.sessionId, 355 configuration.callingPackageUid ?: -1, 356 Telemetry.UiEvent.CREATE_SURFACE_CONTROLLER_END 357 ) 358 ) 359 } 360 } catch (e: RemoteException) { 361 Log.d(TAG, "Failed to destroy surface controller.", e) 362 } 363 364 controllerInfo.client.close() 365 } 366 } 367 368 /** 369 * Constructs a [ICloudMediaSurfaceStateChangedCallback] for the provided authority. 370 * 371 * @param authority The authority this callback will assign to its PlaybackInfo emissions. 372 * @return A [ICloudMediaSurfaceStateChangedCallback] bound to the provided authority. 373 */ buildSurfaceStateChangedCallbacknull374 private fun buildSurfaceStateChangedCallback( 375 authority: String 376 ): ICloudMediaSurfaceStateChangedCallback.Stub { 377 return object : ICloudMediaSurfaceStateChangedCallback.Stub() { 378 override fun setPlaybackState( 379 surfaceId: Int, 380 playbackState: Int, 381 playbackStateInfo: Bundle? 382 ) { 383 scope.launch { 384 _playbackInfo.emit( 385 PlaybackInfo( 386 state = PlaybackState.fromStateInt(playbackState), 387 surfaceId = surfaceId, 388 authority = authority, 389 playbackStateInfo = playbackStateInfo, 390 ) 391 ) 392 } 393 } 394 } 395 } 396 } 397