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.compose.runtime.getValue 33 import androidx.compose.runtime.setValue 34 import androidx.core.os.bundleOf 35 import androidx.lifecycle.ViewModel 36 import androidx.lifecycle.viewModelScope 37 import com.android.photopicker.core.selection.Selection 38 import com.android.photopicker.core.selection.SelectionModifiedResult.FAILURE_SELECTION_LIMIT_EXCEEDED 39 import com.android.photopicker.core.user.UserMonitor 40 import com.android.photopicker.data.model.Media 41 import dagger.hilt.android.lifecycle.HiltViewModel 42 import javax.inject.Inject 43 import kotlinx.coroutines.CoroutineScope 44 import kotlinx.coroutines.flow.Flow 45 import kotlinx.coroutines.flow.MutableSharedFlow 46 import kotlinx.coroutines.flow.MutableStateFlow 47 import kotlinx.coroutines.flow.filter 48 import kotlinx.coroutines.flow.update 49 import kotlinx.coroutines.launch 50 51 /** 52 * The view model for the Preview routes. 53 * 54 * This view model manages snapshots of the session's selection so that items can observe a slice of 55 * state rather than the mutable selection state. 56 * 57 * Additionally, [RemoteSurfaceController] are created and held for re-use in the scope of this view 58 * model. The view model handles the [ICloudMediaSurfaceStateChangedCallback] for each controller, 59 * and stores the information for the UI to obtain via exported flows. 60 */ 61 @HiltViewModel 62 class PreviewViewModel 63 @Inject 64 constructor( 65 private val scopeOverride: CoroutineScope?, 66 private val selection: Selection<Media>, 67 private val userMonitor: UserMonitor, 68 ) : ViewModel() { 69 70 companion object { 71 val TAG: String = PreviewFeature.TAG 72 73 // These are the authority strings for [CloudMediaProvider]-s for local on device files. 74 private val PHOTOPICKER_PROVIDER_AUTHORITY = "com.android.providers.media.photopicker" 75 private val REMOTE_PREVIEW_PROVIDER_AUTHORITY = 76 "com.android.providers.media.remote_video_preview" 77 } 78 79 // Check if a scope override was injected before using the default [viewModelScope] 80 private val scope: CoroutineScope = 81 if (scopeOverride == null) { 82 this.viewModelScope 83 } else { 84 scopeOverride 85 } 86 87 /** 88 * A flow which exposes a snapshot of the selection. Initially this is an empty set and will not 89 * automatically update with the current selection, snapshots must be explicitly requested. 90 */ 91 val selectionSnapshot = MutableStateFlow<Set<Media>>(emptySet()) 92 93 /** Trigger a new snapshot of the selection. */ takeNewSelectionSnapshotnull94 fun takeNewSelectionSnapshot() { 95 scope.launch { selectionSnapshot.update { selection.snapshot() } } 96 } 97 98 /** 99 * Toggle the media item into the current session's selection. 100 * 101 * @param media 102 */ toggleInSelectionnull103 fun toggleInSelection( 104 media: Media, 105 onSelectionLimitExceeded: () -> Unit, 106 ) { 107 scope.launch { 108 val result = selection.toggle(item = media) 109 if (result == FAILURE_SELECTION_LIMIT_EXCEEDED) { 110 onSelectionLimitExceeded() 111 } 112 } 113 } 114 115 /** 116 * Holds any cached [RemotePreviewControllerInfo] to avoid re-creating 117 * [RemoteSurfaceController]-s that already exist during a preview session. 118 */ 119 val controllers: HashMap<String, RemotePreviewControllerInfo> = HashMap() 120 121 /** 122 * A flow that all [ICloudMediaSurfaceStateChangedCallback] push their [setPlaybackState] 123 * updates to. This flow is later filtered to a specific (authority + surfaceId) pairing for 124 * providing the playback state updates to the UI composables to collect. 125 * 126 * A shared flow is used here to ensure that all emissions are delivered since a StateFlow will 127 * conflate deliveries to slow receivers (sometimes the UI is slow to pull emissions) to this 128 * flow since they happen in quick succession, and this will avoid dropping any. 129 * 130 * See [getPlaybackInfoForPlayer] where this flow is filtered. 131 */ 132 private val _playbackInfo = MutableSharedFlow<PlaybackInfo>() 133 134 /** 135 * Creates a [Flow<PlaybackInfo>] for the provided player configuration. This just siphons the 136 * larger [playbackInfo] flow that all of the [ICloudMediaSurfaceStateChangedCallback]-s push 137 * their updates to. 138 * 139 * The larger flow is filtered for updates related to the requested video session. (surfaceId + 140 * authority) 141 */ getPlaybackInfoForPlayernull142 fun getPlaybackInfoForPlayer(surfaceId: Int, video: Media.Video): Flow<PlaybackInfo> { 143 return _playbackInfo.filter { it.surfaceId == surfaceId && it.authority == video.authority } 144 } 145 146 /** @return the active user's [ContentResolver]. */ getContentResolverForCurrentUsernull147 fun getContentResolverForCurrentUser(): ContentResolver { 148 return userMonitor.userStatus.value.activeContentResolver 149 } 150 151 /** 152 * Obtains an instance of [RemoteSurfaceController] for the requested authority. Attempts to 153 * re-use any controllers that have previously been fetched, and additionally, generates a 154 * [RemotePreviewControllerInfo] for the requested authority and holds it in [controllers] for 155 * future re-use. 156 * 157 * @return A [RemoteSurfaceController] for [authority] 158 */ getControllerForAuthoritynull159 fun getControllerForAuthority( 160 authority: String, 161 ): RemoteSurfaceController { 162 163 if (controllers.containsKey(authority)) { 164 Log.d(TAG, "Existing controller found, re-using for $authority") 165 return controllers.getValue(authority).controller 166 } 167 168 Log.d(TAG, "Creating controller for authority: $authority") 169 170 val callback = buildSurfaceStateChangedCallback(authority) 171 172 // For local photos which use the PhotopickerProvider, the remote video preview 173 // functionality is actually delegated to the mediaprovider:Photopicker process 174 // and is run out of the RemoteVideoPreviewProvider, so for the purposes of 175 // acquiring a [ContentProviderClient], use a different authority. 176 val clientAuthority = 177 when (authority) { 178 PHOTOPICKER_PROVIDER_AUTHORITY -> REMOTE_PREVIEW_PROVIDER_AUTHORITY 179 else -> authority 180 } 181 182 // Acquire a [ContentProviderClient] that can be retained as long as the [PreviewViewModel] 183 // is active. This creates a binding between the current process that is running Photopicker 184 // and the remote process that is rendering video and prevents the remote process from being 185 // killed by the OS. This client is held onto until the [PreviewViewModel] is cleared when 186 // the Preview route is navigated away from. (The PreviewViewModel is bound to the 187 // navigation backStackEntry). 188 val remoteClient = 189 getContentResolverForCurrentUser().acquireContentProviderClient(clientAuthority) 190 // TODO: b/323833427 Navigate back to the main grid when a controller cannot be obtained. 191 checkNotNull(remoteClient) { "Unable to get a client for $clientAuthority" } 192 193 // Don't reuse the remote client from above since it may not be the right provider for 194 // local files. Instead, assemble a new URI, and call the correct provider via 195 // [ContentResolver#call] 196 val uri: Uri = 197 Uri.Builder() 198 .apply { 199 scheme(ContentResolver.SCHEME_CONTENT) 200 authority(authority) 201 } 202 .build() 203 204 val extras = 205 bundleOf( 206 EXTRA_LOOPING_PLAYBACK_ENABLED to true, 207 EXTRA_SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED to true, 208 EXTRA_SURFACE_STATE_CALLBACK to callback 209 ) 210 211 val controllerBundle: Bundle? = 212 getContentResolverForCurrentUser() 213 .call( 214 /*uri=*/ uri, 215 /*method=*/ METHOD_CREATE_SURFACE_CONTROLLER, 216 /*arg=*/ null, 217 /*extras=*/ extras, 218 ) 219 checkNotNull(controllerBundle) { "No controller was returned for RemoteVideoPreview" } 220 221 val binder = controllerBundle.getBinder(EXTRA_SURFACE_CONTROLLER) 222 223 // Produce the [RemotePreviewControllerInfo] and save it for future re-use. 224 val controllerInfo = 225 RemotePreviewControllerInfo( 226 authority = authority, 227 client = remoteClient, 228 controller = 229 RemoteSurfaceController(ICloudMediaSurfaceController.Stub.asInterface(binder)), 230 ) 231 controllers.put(authority, controllerInfo) 232 233 return controllerInfo.controller 234 } 235 236 /** 237 * When this ViewModel is cleared, close any held [ContentProviderClient]s that are retained for 238 * video rendering. 239 */ onClearednull240 override fun onCleared() { 241 // When the view model is cleared then it is safe to assume the preview route is no longer 242 // active, and any [ContentProviderClient] that are being held to support remote video 243 // preview can now be closed. 244 for ((_, controllerInfo) in controllers) { 245 246 try { 247 controllerInfo.controller.onDestroy() 248 } catch (e: RemoteException) { 249 Log.d(TAG, "Failed to destroy surface controller.", e) 250 } 251 252 controllerInfo.client.close() 253 } 254 } 255 256 /** 257 * Constructs a [ICloudMediaSurfaceStateChangedCallback] for the provided authority. 258 * 259 * @param authority The authority this callback will assign to its PlaybackInfo emissions. 260 * @return A [ICloudMediaSurfaceStateChangedCallback] bound to the provided authority. 261 */ buildSurfaceStateChangedCallbacknull262 private fun buildSurfaceStateChangedCallback( 263 authority: String 264 ): ICloudMediaSurfaceStateChangedCallback.Stub { 265 return object : ICloudMediaSurfaceStateChangedCallback.Stub() { 266 override fun setPlaybackState( 267 surfaceId: Int, 268 playbackState: Int, 269 playbackStateInfo: Bundle? 270 ) { 271 scope.launch { 272 _playbackInfo.emit( 273 PlaybackInfo( 274 state = PlaybackState.fromStateInt(playbackState), 275 surfaceId = surfaceId, 276 authority = authority, 277 playbackStateInfo = playbackStateInfo, 278 ) 279 ) 280 } 281 } 282 } 283 } 284 } 285