summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Ben Reich <benreich@google.com> 2025-02-05 17:08:33 +1100
committer Ben Reich <benreich@google.com> 2025-02-12 19:49:19 +1100
commit4bbd3a44260476fac59bbe9b3509db0a2bf12d2d (patch)
tree7f73edacd7605bcc3da860ca44041b773485063d
parent011c3c4ea2b8fc2df83a65fdfe45c25ec22be2db (diff)
Introduce TrampolineActivity to route requests to Photopicker
Currently DocumentsUI has a priority of 100 for the ACTION_GET_CONTENT intent. When the new Photopicker was released, it took over the GET_CONTENT intent and routes requests that it can't handle back to DocumentsUI. On some devices that have DocsUI disabled, Photopicker ends up in a loop trying to launch DocsUI. To avoid this, perform the test in DocumentsUI. This is also a precursor for some follow up work which is going to migrate DocsUI to pop up via a bottom sheet instead of taking over the entire window. On devices with WINDOW_MANAGEMENT enabled the experience of full window takeover is jarring and the bottom sheet is a more idiomatic UX. Bug: 377771195 Flag: EXEMPT resource file change Test: atest com.android.documentsui.TrampolineActivityTest Change-Id: I9212b3cc52e5dc0b92543061f2a384dacc9c7257
-rw-r--r--AndroidManifest.xml46
-rw-r--r--src/com/android/documentsui/picker/TrampolineActivity.kt170
-rw-r--r--tests/functional/com/android/documentsui/TrampolineActivityTest.kt205
3 files changed, 417 insertions, 4 deletions
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 257faebf8..1fa1ac3b6 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -60,29 +60,67 @@
android:value="AEdPqrEAAAAInBA8ued0O_ZyYUsVhwinUF-x50NIe9K0GzBW4A" />
<activity
+ android:name=".picker.TrampolineActivity"
+ android:exported="true"
+ android:theme="@android:style/Theme.NoDisplay"
+ android:featureFlag="com.android.documentsui.flags.redirect_get_content"
+ android:visibleToInstantApps="true">
+ <intent-filter android:priority="120">
+ <action android:name="android.intent.action.OPEN_DOCUMENT" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.OPENABLE" />
+ <data android:mimeType="*/*" />
+ </intent-filter>
+ <intent-filter android:priority="120">
+ <action android:name="android.intent.action.CREATE_DOCUMENT" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.OPENABLE" />
+ <data android:mimeType="*/*" />
+ </intent-filter>
+ <intent-filter android:priority="120">
+ <action android:name="android.intent.action.GET_CONTENT" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.OPENABLE" />
+ <data android:mimeType="*/*" />
+ </intent-filter>
+ <intent-filter android:priority="120">
+ <action android:name="android.intent.action.OPEN_DOCUMENT_TREE" />
+ <category android:name="android.intent.category.DEFAULT" />
+ </intent-filter>
+ </activity>
+
+ <activity
android:name=".picker.PickActivity"
android:exported="true"
android:theme="@style/LauncherTheme"
android:visibleToInstantApps="true">
- <intent-filter android:priority="100">
+ <intent-filter
+ android:featureFlag="!com.android.documentsui.flags.redirect_get_content"
+ android:priority="100">
<action android:name="android.intent.action.OPEN_DOCUMENT" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="*/*" />
</intent-filter>
- <intent-filter android:priority="100">
+ <intent-filter
+ android:featureFlag="!com.android.documentsui.flags.redirect_get_content"
+ android:priority="100">
<action android:name="android.intent.action.CREATE_DOCUMENT" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="*/*" />
</intent-filter>
- <intent-filter android:priority="100">
+ <intent-filter
+ android:featureFlag="!com.android.documentsui.flags.redirect_get_content"
+ android:priority="100">
<action android:name="android.intent.action.GET_CONTENT" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="*/*" />
</intent-filter>
- <intent-filter android:priority="100">
+ <intent-filter
+ android:featureFlag="!com.android.documentsui.flags.redirect_get_content"
+ android:priority="100">
<action android:name="android.intent.action.OPEN_DOCUMENT_TREE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
diff --git a/src/com/android/documentsui/picker/TrampolineActivity.kt b/src/com/android/documentsui/picker/TrampolineActivity.kt
new file mode 100644
index 000000000..6cb7d37a1
--- /dev/null
+++ b/src/com/android/documentsui/picker/TrampolineActivity.kt
@@ -0,0 +1,170 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.documentsui.picker
+
+import android.content.ComponentName
+import android.content.Intent
+import android.content.Intent.ACTION_GET_CONTENT
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import android.os.ext.SdkExtensions
+import android.provider.MediaStore.ACTION_PICK_IMAGES
+import androidx.appcompat.app.AppCompatActivity
+
+/**
+ * DocumentsUI PickActivity currently defers picking of media mime types to the Photopicker. This
+ * activity trampolines the intent to either Photopicker or to the PickActivity depending on whether
+ * there are non-media mime types to handle.
+ */
+class TrampolineActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceBundle: Bundle?) {
+ super.onCreate(savedInstanceBundle)
+
+ // This activity should not be present in the back stack nor should handle any of the
+ // corresponding results when picking items.
+ intent?.apply {
+ addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT)
+ addFlags(Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP)
+ }
+
+ // In the event there is no photopicker returned, just refer to DocumentsUI.
+ val photopickerComponentName = getPhotopickerComponentName(intent.type)
+ if (photopickerComponentName == null) {
+ forwardIntentToDocumentsUI()
+ return
+ }
+
+ // The Photopicker has an entry point to take them back to DocumentsUI. In the event the
+ // user originated from Photopicker, we don't want to send them back.
+ val referredFromPhotopicker = referrer?.host == photopickerComponentName.packageName
+ if (referredFromPhotopicker || !shouldForwardIntentToPhotopicker(intent)) {
+ forwardIntentToDocumentsUI()
+ return
+ }
+
+ // Forward intent to Photopicker.
+ intent.setComponent(photopickerComponentName)
+ startActivity(intent)
+ finish()
+ }
+
+ private fun forwardIntentToDocumentsUI() {
+ intent.setClass(applicationContext, PickActivity::class.java)
+ startActivity(intent)
+ finish()
+ }
+
+ private fun getPhotopickerComponentName(type: String?): ComponentName? {
+ // Intent.ACTION_PICK_IMAGES is only available from SdkExtensions v2 onwards. Prior to that
+ // the Photopicker was not available, so in those cases should always send to DocumentsUI.
+ if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 2) {
+ return null
+ }
+
+ // Attempt to resolve the `ACTION_PICK_IMAGES` intent to get the Photopicker package.
+ // On T+ devices this is is a standalone package, whilst prior to T it is part of the
+ // MediaProvider module.
+ val pickImagesIntent = Intent(
+ ACTION_PICK_IMAGES
+ ).apply { addCategory(Intent.CATEGORY_DEFAULT) }
+ val photopickerComponentName: ComponentName? = pickImagesIntent.resolveActivity(
+ packageManager
+ )
+
+ // For certain devices the activity that handles ACTION_GET_CONTENT can be disabled (when
+ // the ACTION_PICK_IMAGES is enabled) so double check by explicitly checking the
+ // ACTION_GET_CONTENT activity on the same activity that handles ACTION_PICK_IMAGES.
+ val photopickerGetContentIntent = Intent(ACTION_GET_CONTENT).apply {
+ setType(type)
+ setPackage(photopickerComponentName?.packageName)
+ }
+ val photopickerGetContentComponent: ComponentName? =
+ photopickerGetContentIntent.resolveActivity(packageManager)
+
+ // Ensure the `ACTION_GET_CONTENT` activity is enabled.
+ if (!isComponentEnabled(photopickerGetContentComponent)) {
+ return null
+ }
+
+ return photopickerGetContentComponent
+ }
+
+ private fun isComponentEnabled(componentName: ComponentName?): Boolean {
+ if (componentName == null) {
+ return false
+ }
+
+ return when (packageManager.getComponentEnabledSetting(componentName)) {
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> true
+ PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -> {
+ // DEFAULT is a state that essentially defers to the state defined in the
+ // AndroidManifest which can be either enabled or disabled.
+ packageManager.getPackageInfo(
+ componentName.packageName,
+ PackageManager.GET_ACTIVITIES
+ )?.let { packageInfo: PackageInfo ->
+ if (packageInfo.activities == null) {
+ return false
+ }
+ for (val info in packageInfo.activities) {
+ if (info.name == componentName.className) {
+ return info.enabled
+ }
+ }
+ }
+ return false
+ }
+
+ // Everything else is considered disabled.
+ else -> false
+ }
+ }
+}
+
+fun shouldForwardIntentToPhotopicker(intent: Intent): Boolean {
+ if (intent.action != ACTION_GET_CONTENT || !isMediaMimeType(intent.type)) {
+ return false
+ }
+
+ // Intent has type ACTION_GET_CONTENT and is either image/* or video/* with no
+ // additional mime types.
+ if (!intent.hasExtra(Intent.EXTRA_MIME_TYPES)) {
+ return true
+ }
+
+ val extraMimeTypes = intent.getStringArrayExtra(Intent.EXTRA_MIME_TYPES)
+ extraMimeTypes?.let {
+ if (it.size == 0) {
+ return false
+ }
+
+ for (mimeType in it) {
+ if (!isMediaMimeType(mimeType)) {
+ return false
+ }
+ }
+ } ?: return false
+
+ return true
+}
+
+fun isMediaMimeType(mimeType: String?): Boolean {
+ return mimeType?.let { mimeType ->
+ mimeType.startsWith("image/") || mimeType.startsWith("video/")
+ } == true
+}
diff --git a/tests/functional/com/android/documentsui/TrampolineActivityTest.kt b/tests/functional/com/android/documentsui/TrampolineActivityTest.kt
new file mode 100644
index 000000000..0507cefee
--- /dev/null
+++ b/tests/functional/com/android/documentsui/TrampolineActivityTest.kt
@@ -0,0 +1,205 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.documentsui
+
+import android.content.Intent
+import android.os.Build.VERSION_CODES
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.Until
+import com.android.documentsui.picker.TrampolineActivity
+import java.util.regex.Pattern
+import org.junit.Assert.assertNotNull
+import org.junit.Before
+import org.junit.BeforeClass
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import org.junit.runners.Suite
+import org.junit.runners.Suite.SuiteClasses
+
+@SmallTest
+@RunWith(Suite::class)
+@SuiteClasses(
+ TrampolineActivityTest.ShouldLaunchCorrectPackageTest::class,
+ TrampolineActivityTest.RedirectTest::class
+)
+class TrampolineActivityTest() {
+ companion object {
+ const val UI_TIMEOUT = 5000L
+ val PHOTOPICKER_PACKAGE_REGEX: Pattern = Pattern.compile(".*photopicker.*")
+ val DOCUMENTSUI_PACKAGE_REGEX: Pattern = Pattern.compile(".*documentsui.*")
+
+ private var device: UiDevice? = null
+
+ @BeforeClass
+ @JvmStatic
+ fun setUp() {
+ device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+ }
+ }
+
+ @RunWith(Parameterized::class)
+ class ShouldLaunchCorrectPackageTest {
+ enum class AppType {
+ PHOTOPICKER,
+ DOCUMENTSUI,
+ }
+
+ data class GetContentIntentData(
+ val mimeType: String,
+ val expectedApp: AppType,
+ val extraMimeTypes: Array<String>? = null,
+ ) {
+ override fun toString(): String {
+ if (extraMimeTypes != null) {
+ return "${mimeType}_${extraMimeTypes.joinToString("_")}"
+ }
+ return mimeType
+ }
+ }
+
+ companion object {
+ @Parameterized.Parameters(name = "{0}")
+ @JvmStatic
+ fun parameters() =
+ listOf(
+ GetContentIntentData(
+ mimeType = "*/*",
+ expectedApp = AppType.DOCUMENTSUI,
+ ),
+ GetContentIntentData(
+ mimeType = "image/*",
+ expectedApp = AppType.PHOTOPICKER,
+ ),
+ GetContentIntentData(
+ mimeType = "video/*",
+ expectedApp = AppType.PHOTOPICKER,
+ ),
+ GetContentIntentData(
+ mimeType = "image/*",
+ extraMimeTypes = arrayOf("video/*"),
+ expectedApp = AppType.PHOTOPICKER,
+ ),
+ GetContentIntentData(
+ mimeType = "video/*",
+ extraMimeTypes = arrayOf("image/*"),
+ expectedApp = AppType.PHOTOPICKER,
+ ),
+ GetContentIntentData(
+ mimeType = "video/*",
+ extraMimeTypes = arrayOf("text/*"),
+ expectedApp = AppType.DOCUMENTSUI,
+ ),
+ GetContentIntentData(
+ mimeType = "video/*",
+ extraMimeTypes = arrayOf("image/*", "text/*"),
+ expectedApp = AppType.DOCUMENTSUI,
+ ),
+ GetContentIntentData(
+ mimeType = "*/*",
+ extraMimeTypes = arrayOf("image/*", "video/*"),
+ expectedApp = AppType.DOCUMENTSUI,
+ ),
+ GetContentIntentData(
+ mimeType = "image/*",
+ extraMimeTypes = arrayOf(),
+ expectedApp = AppType.DOCUMENTSUI,
+ )
+ )
+ }
+
+ @Parameterized.Parameter(0)
+ lateinit var testData: GetContentIntentData
+
+ @Before
+ fun setUp() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val intent = Intent(Intent.ACTION_GET_CONTENT)
+ intent.setClass(context, TrampolineActivity::class.java)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ intent.setType(testData.mimeType)
+ testData.extraMimeTypes?.let { intent.putExtra(Intent.EXTRA_MIME_TYPES, it) }
+
+ context.startActivity(intent)
+ }
+
+ @Test
+ fun testCorrectAppIsLaunched() {
+ val bySelector = when (testData.expectedApp) {
+ AppType.PHOTOPICKER -> By.pkg(PHOTOPICKER_PACKAGE_REGEX)
+ else -> By.pkg(DOCUMENTSUI_PACKAGE_REGEX)
+ }
+
+ assertNotNull(device?.wait(Until.findObject(bySelector), UI_TIMEOUT))
+ }
+ }
+
+ @RunWith(AndroidJUnit4::class)
+ class RedirectTest {
+ @Test
+ fun testReferredGetContentFromPhotopickerShouldNotRedirectBack() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val intent = Intent(Intent.ACTION_GET_CONTENT)
+ intent.setClass(context, TrampolineActivity::class.java)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ intent.setType("image/*")
+
+ context.startActivity(intent)
+ val moreButton = device?.wait(Until.findObject(By.desc("More")), UI_TIMEOUT)
+ moreButton?.click()
+
+ val browseButton = device?.wait(Until.findObject(By.textContains("Browse")), UI_TIMEOUT)
+ browseButton?.click()
+
+ assertNotNull(
+ "DocumentsUI has not launched",
+ device?.wait(Until.findObject(By.pkg(DOCUMENTSUI_PACKAGE_REGEX)), UI_TIMEOUT)
+ )
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = VERSION_CODES.S, maxSdkVersion = VERSION_CODES.S_V2)
+ fun testAndroidSWithTakeoverGetContentDisabledShouldNotReferToDocumentsUI() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val intent = Intent(Intent.ACTION_GET_CONTENT)
+ intent.setClass(context, TrampolineActivity::class.java)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ intent.setType("image/*")
+
+ try {
+ // Disable Photopicker from taking over `ACTION_GET_CONTENT`. In this situation, it
+ // should ALWAYS defer to DocumentsUI regardless if the mimetype satisfies the
+ // conditions.
+ device?.executeShellCommand(
+ "device_config put mediaprovider take_over_get_content false"
+ )
+ context.startActivity(intent)
+ assertNotNull(
+ device?.wait(Until.findObject(By.pkg(DOCUMENTSUI_PACKAGE_REGEX)), UI_TIMEOUT)
+ )
+ } finally {
+ device?.executeShellCommand(
+ "device_config delete mediaprovider take_over_get_content"
+ )
+ }
+ }
+ }
+}