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
18 
19 import android.content.ClipData
20 import android.content.ComponentName
21 import android.content.Intent
22 import android.net.Uri
23 import android.os.Bundle
24 import android.os.UserHandle
25 import android.provider.MediaStore
26 import android.util.Log
27 import androidx.activity.ComponentActivity
28 import androidx.activity.compose.setContent
29 import androidx.activity.enableEdgeToEdge
30 import androidx.compose.runtime.CompositionLocalProvider
31 import androidx.compose.runtime.getValue
32 import androidx.lifecycle.Lifecycle
33 import androidx.lifecycle.compose.collectAsStateWithLifecycle
34 import androidx.lifecycle.flowWithLifecycle
35 import androidx.lifecycle.lifecycleScope
36 import com.android.photopicker.core.Background
37 import com.android.photopicker.core.PhotopickerAppWithBottomSheet
38 import com.android.photopicker.core.configuration.ConfigurationManager
39 import com.android.photopicker.core.configuration.IllegalIntentExtraException
40 import com.android.photopicker.core.configuration.LocalPhotopickerConfiguration
41 import com.android.photopicker.core.events.Event
42 import com.android.photopicker.core.events.Events
43 import com.android.photopicker.core.events.LocalEvents
44 import com.android.photopicker.core.features.FeatureManager
45 import com.android.photopicker.core.features.LocalFeatureManager
46 import com.android.photopicker.core.selection.LocalSelection
47 import com.android.photopicker.core.selection.Selection
48 import com.android.photopicker.core.theme.PhotopickerTheme
49 import com.android.photopicker.data.model.Media
50 import com.android.photopicker.extensions.canHandleGetContentIntentMimeTypes
51 import com.android.photopicker.features.cloudmedia.CloudMediaFeature
52 import dagger.Lazy
53 import dagger.hilt.android.AndroidEntryPoint
54 import dagger.hilt.android.scopes.ActivityRetainedScoped
55 import javax.inject.Inject
56 import kotlinx.coroutines.CompletableDeferred
57 import kotlinx.coroutines.CoroutineDispatcher
58 import kotlinx.coroutines.flow.MutableSharedFlow
59 import kotlinx.coroutines.flow.first
60 import kotlinx.coroutines.launch
61 import kotlinx.coroutines.withContext
62 
63 /**
64  * This is the main entrypoint into the Android Photopicker.
65  *
66  * This class is responsible for bootstrapping the launched activity, session related dependencies,
67  * and providing the compose ui entrypoint in [[PhotopickerApp]] with everything it needs.
68  */
69 @AndroidEntryPoint(ComponentActivity::class)
70 class MainActivity : Hilt_MainActivity() {
71 
72     @Inject @ActivityRetainedScoped lateinit var configurationManager: ConfigurationManager
73     @Inject @ActivityRetainedScoped lateinit var processOwnerUserHandle: UserHandle
74     @Inject @ActivityRetainedScoped lateinit var selection: Lazy<Selection<Media>>
75     // This needs to be injected lazily, to defer initialization until the action can be set
76     // on the ConfigurationManager.
77     @Inject @ActivityRetainedScoped lateinit var featureManager: Lazy<FeatureManager>
78     @Inject @Background lateinit var background: CoroutineDispatcher
79 
80     // Events requires the feature manager, so initialize this lazily until the action is set.
81     @Inject lateinit var events: Lazy<Events>
82 
83     companion object {
84         val TAG: String = "Photopicker"
85     }
86 
87     /**
88      * A flow used to trigger the preloader. When media is ready to be preloaded it should be
89      * provided to the preloader by emitting into this flow.
90      *
91      * The main activity should create a new [_preloadDeferred] before emitting, and then monitor
92      * that deferred to obtain the result of the preload operation that this flow will trigger.
93      */
94     val preloadMedia: MutableSharedFlow<Set<Media>> = MutableSharedFlow()
95 
96     /**
97      * A deferred which tracks the current state of any preload operation requested by the main
98      * activity.
99      */
100     private var _preloadDeferred: CompletableDeferred<Boolean> = CompletableDeferred()
101 
102     /**
103      * Public access to the deferred, behind a getter. (To ensure any access to this property always
104      * obtains the latest value)
105      */
106     public val preloadDeferred: CompletableDeferred<Boolean>
107         get() {
108             return _preloadDeferred
109         }
110 
111     override fun onCreate(savedInstanceState: Bundle?) {
112         super.onCreate(savedInstanceState)
113 
114         // [ACTION_GET_CONTENT]: Check to see if Photopicker should handle this session, or if the
115         // user should instead be referred to [com.android.documentsui]. This is necessary because
116         // Photopicker has a higher priority for "image/*" and "video/*" mimetypes that DocumentsUi.
117         // An unfortunate side effect is that a mimetype of "*/*" also matches Photopicker's
118         // intent-filter, and in that case, the user is not in a pure media selection mode, so refer
119         // the user to DocumentsUi to handle all file types.
120         if (shouldRerouteGetContentRequest()) {
121             referToDocumentsUi()
122         }
123 
124         enableEdgeToEdge()
125 
126         // Set the action before allowing FeatureManager to be initialized, so that it receives
127         // the correct config with this activity's action.
128         try {
129             getIntent()?.let { configurationManager.setIntent(it) }
130         } catch (exception: IllegalIntentExtraException) {
131             // If the incoming intent contains intent extras that are not supported in the current
132             // configuration, then cancel the activity and close.
133             Log.e(TAG, "Unable to start Photopicker with illegal configuration", exception)
134             setResult(RESULT_CANCELED)
135             finish()
136         }
137 
138         // Begin listening for events before starting the UI.
139         listenForEvents()
140 
141         /*
142          * In single select sessions, the activity needs to end after a media object is selected,
143          * so register a listener to the selection so the activity can handle calling
144          * [onMediaSelectionConfirmed] itself.
145          *
146          * For multi-select, the activity has to wait for onMediaSelectionConfirmed to be called
147          * by the selection bar click handler, or for the [Event.MediaSelectionConfirmed], in
148          * the event the user ends the session from the [PreviewFeature]
149          */
150         listenForSelectionIfSingleSelect()
151 
152         setContent {
153             val photopickerConfiguration by
154                 configurationManager.configuration.collectAsStateWithLifecycle()
155             // Provide values to the entire compose stack.
156             CompositionLocalProvider(
157                 LocalFeatureManager provides featureManager.get(),
158                 LocalPhotopickerConfiguration provides photopickerConfiguration,
159                 LocalSelection provides selection.get(),
160                 LocalEvents provides events.get(),
161             ) {
162                 PhotopickerTheme(intent = photopickerConfiguration.intent) {
163                     PhotopickerAppWithBottomSheet(
164                         onDismissRequest = ::finish,
165                         onMediaSelectionConfirmed = {
166                             lifecycleScope.launch {
167                                 // Move the work off the UI dispatcher.
168                                 withContext(background) { onMediaSelectionConfirmed() }
169                             }
170                         },
171                         preloadMedia = preloadMedia,
172                         obtainPreloaderDeferred = { preloadDeferred }
173                     )
174                 }
175             }
176         }
177     }
178 
179     /**
180      * A collector that starts when Photopicker is running in single-select mode. This collector
181      * will trigger [onMediaSelectionConfirmed] when the first (and only) item is selected.
182      */
183     private fun listenForSelectionIfSingleSelect() {
184 
185         // Only set up a collector if the selection limit is 1, otherwise the [SelectionBarFeature]
186         // will be enabled for the user to confirm the selection.
187         if (configurationManager.configuration.value.selectionLimit == 1) {
188             lifecycleScope.launch {
189                 withContext(background) {
190                     selection.get().flow.collect {
191                         if (it.size == 1) {
192                             onMediaSelectionConfirmed()
193                         }
194                     }
195                 }
196             }
197         }
198     }
199 
200     /** Setup an [Event] listener for the [MainActivity] to monitor the event bus. */
201     private fun listenForEvents() {
202         lifecycleScope.launch {
203             events.get().flow.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collect { event
204                 ->
205                 when (event) {
206 
207                     /**
208                      * [MediaSelectionConfirmed] will be dispatched in response to the user
209                      * confirming their selection of Media in the UI.
210                      */
211                     is Event.MediaSelectionConfirmed -> onMediaSelectionConfirmed()
212                     else -> {}
213                 }
214             }
215         }
216     }
217 
218     /**
219      * Entrypoint for confirming the set of selected media and preparing the media for the calling
220      * application.
221      *
222      * This should be called when the user has confirmed their selection, and would like to exit
223      * photopicker and grant access to the media to the calling application.
224      *
225      * This will result in access being issued to the calling app if the media can be successfully
226      * prepared.
227      */
228     private suspend fun onMediaSelectionConfirmed() {
229 
230         val snapshot = selection.get().snapshot()
231         // Determine if any preload of the selected media needs to happen, and
232         // await the result of the preloader before proceeding.
233         if (featureManager.get().isFeatureEnabled(CloudMediaFeature::class.java)) {
234 
235             // Create a new [CompletableDeferred] that represents the result of this
236             // preload operation
237             _preloadDeferred = CompletableDeferred()
238             preloadMedia.emit(snapshot)
239 
240             // Await a response from the deferred before proceeding.
241             // This will suspend until the response is available.
242             val preloadSuccessful = _preloadDeferred.await()
243 
244             // The preload failed, so the activity cannot be completed.
245             if (!preloadSuccessful) {
246                 return
247             }
248         }
249         val deselectionSnapshot = selection.get().getDeselection().toHashSet()
250         onMediaSelectionReady(snapshot, deselectionSnapshot)
251     }
252 
253     /**
254      * This will end the activity.
255      *
256      * This method should be called when the user has confirmed their selection of media and would
257      * like to exit the Photopicker. All Media preloading should be completed before this method is
258      * invoked. This method will then arrange for the correct data to be returned based on the
259      * configuration Photopicker is running under.
260      *
261      * When this method is complete, the Photopicker session will end.
262      *
263      * @param selection The prepared media that is ready to be returned to the caller.
264      * @see [setResultForApp] for modes where the Photopicker returns media directly to the caller
265      * @see [issueGrantsForApp] for permission mode grant writing in MediaProvider
266      */
267     private suspend fun onMediaSelectionReady(selection: Set<Media>, deselection: Set<Media>) {
268 
269         val configuration = configurationManager.configuration.first()
270 
271         when (configuration.action) {
272             MediaStore.ACTION_PICK_IMAGES,
273             Intent.ACTION_GET_CONTENT ->
274                 setResultForApp(selection, canSelectMultiple = configuration.selectionLimit > 1)
275             MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP -> {
276                 val uid =
277                     configuration.intent?.getExtras()?.getInt(Intent.EXTRA_UID)
278                         // If the permission controller did not provide a uid, there is no way to
279                         // continue.
280                         ?: throw IllegalStateException(
281                             "Expected a uid to provided by PermissionController."
282                         )
283                 updateGrantsForApp(selection, deselection, uid)
284             }
285             else -> {}
286         }
287 
288         finish()
289     }
290 
291     /**
292      * The selection must be returned to the calling app via [setResult] and [ClipData]. When the
293      * [MainActivity] is ending, this is part of the sequence of events to close the picker and
294      * provide the selected media uris to the caller.
295      *
296      * This work runs on the @Background [CoroutineDispatcher] to avoid any UI disruption.
297      *
298      * @param selection the prepared media that can be safely returned to the app.
299      * @param canSelectMultiple whether photopicker is in multi-select mode.
300      */
301     private suspend fun setResultForApp(selection: Set<Media>, canSelectMultiple: Boolean) {
302 
303         if (selection.size < 1) return
304 
305         val resultData = Intent()
306 
307         val uris: MutableList<Uri> = selection.map { it.mediaUri }.toMutableList()
308 
309         if (!canSelectMultiple) {
310             // For Single selection set the Uri on the intent directly.
311             resultData.setData(uris.removeFirst())
312         } else if (uris.isNotEmpty()) {
313             // For multi-selection, returned data needs to be attached via [ClipData]
314             val clipData =
315                 ClipData(
316                     /* label= */ null,
317                     /* mimeTypes= */ selection.map { it.mimeType }.distinct().toTypedArray(),
318                     /* item= */ ClipData.Item(uris.removeFirst())
319                 )
320 
321             // If there are any remaining items in the list, attach those as additional
322             // [ClipData.Item]
323             for (uri in uris) {
324                 clipData.addItem(ClipData.Item(uri))
325             }
326             resultData.setClipData(clipData)
327         } else {
328             // The selection is empty, and there is no data to return to the caller.
329             setResult(RESULT_CANCELED)
330             return
331         }
332 
333         resultData.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
334         resultData.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
335 
336         setResult(RESULT_OK, resultData)
337     }
338 
339     /**
340      * When Photopicker is in permission mode, the PermissionController is the calling application,
341      * and rather than returning a list of media uris to the caller, instead MediaGrants must be
342      * generated for the app uid provided by the PermissionController. (Which in this context is the
343      * app that has invoked the permission controller, and thus caused PermissionController to open
344      * photopicker).
345      *
346      * In addition to this, the preGranted items that are now de-selected by the user, the app
347      * should no longer hold MediaGrants for them. This method takes care of revoking these grants.
348      *
349      * This is part of the sequence of ending a Photopicker Session, and is done in place of
350      * returning data to the caller.
351      *
352      * @param selection The prepared media that is ready to be returned to the caller.
353      * @param deselection The media for which the read grants should be revoked.
354      * @param uid The uid of the calling application to issue media grants for.
355      */
356     private suspend fun updateGrantsForApp(
357         selection: Set<Media>,
358         deselection: Set<Media>,
359         uid: Int
360     ) {
361         // Adding grants for items selected by the user.
362         val uris: List<Uri> = selection.map { it.mediaUri }
363         MediaStore.grantMediaReadForPackage(getApplicationContext(), uid, uris)
364 
365         // Removing grants for preGranted items that have now been de-selected by the user.
366         val urisForItemsToBeRevoked = deselection.map { it.mediaUri }
367         MediaStore.revokeMediaReadForPackages(getApplicationContext(), uid, urisForItemsToBeRevoked)
368 
369         // No need to send any data back to the PermissionController, just send an OK signal
370         // back to indicate the MediaGrants are available.
371         setResult(RESULT_OK)
372     }
373 
374     /**
375      * This will end the activity. Refer the current session to [com.android.documentsui]
376      *
377      * Note: Complete any pending logging or work before calling this method as this will end the
378      * process immediately.
379      */
380     private fun referToDocumentsUi() {
381         // The incoming intent is not changed in any way when redirecting to DocumentsUi.
382         // The calling app launched [ACTION_GET_CONTENT] probably without knowing it would first
383         // come to Photopicker, so if Photopicker isn't going to handle the intent, just pass it
384         // along unmodified.
385         @Suppress("UnsafeIntentLaunch") val intent = getIntent()
386         intent?.apply {
387             addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT)
388             addFlags(Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP)
389             setComponent(getDocumentssUiComponentName())
390         }
391         startActivityAsUser(intent, processOwnerUserHandle)
392         finish()
393     }
394 
395     /**
396      * Determines if this session should end and the user should be redirected to
397      * [com.android.documentsUi]. The evaluates the incoming [Intent] to see if Photopicker is
398      * running in [ACTION_GET_CONTENT], and if the mimetypes requested can be correctly handled by
399      * Photopicker. If the activity is not running in [ACTION_GET_CONTENT] this will always return
400      * false.
401      *
402      * A notable exception would be if Photopicker was started by DocumentsUi rather than the
403      * original app, in which case this method will return [false].
404      *
405      * @return true if the activity is running [ACTION_GET_CONTENT] and Photopicker shouldn't handle
406      *   the session.
407      */
408     private fun shouldRerouteGetContentRequest(): Boolean {
409         val intent = getIntent()
410 
411         return when {
412             Intent.ACTION_GET_CONTENT != intent.getAction() -> false
413 
414             // GET_CONTENT for all (media and non-media) files opens DocumentsUi, but it still shows
415             // "Photo Picker app option. When the user clicks on "Photo Picker", the same intent
416             // which includes filters to show non-media files as well is forwarded to PhotoPicker.
417             // Make sure Photo Picker is opened when the intent is explicitly forwarded by
418             // documentsUi
419             isIntentReferredByDocumentsUi(getReferrer()) -> false
420 
421             // Ensure Photopicker can handle the specified MIME types.
422             intent.canHandleGetContentIntentMimeTypes() -> false
423             else -> true
424         }
425     }
426 
427     /**
428      * Resolves a [ComponentName] for DocumentsUi via [Intent.ACTION_OPEN_DOCUMENT]
429      *
430      * ACTION_OPEN_DOCUMENT is used to find DocumentsUi's component due to DocumentsUi being the
431      * default handler.
432      *
433      * @return the [ComponentName] for DocumentsUi's picker activity.
434      */
435     private fun getDocumentssUiComponentName(): ComponentName? {
436 
437         val intent =
438             Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
439                 addCategory(Intent.CATEGORY_OPENABLE)
440                 setType("*/*")
441             }
442 
443         val componentName = intent.resolveActivity(getPackageManager())
444         return componentName
445     }
446 
447     /**
448      * Determines if the referrer uri came from [com.android.documentsui]
449      *
450      * @return true if the referrer [Uri] is from DocumentsUi.
451      */
452     private fun isIntentReferredByDocumentsUi(referrer: Uri?): Boolean {
453         return referrer?.getHost() == getDocumentssUiComponentName()?.getPackageName()
454     }
455 }
456