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