• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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