diff options
author | 2025-02-05 17:08:33 +1100 | |
---|---|---|
committer | 2025-02-12 19:49:19 +1100 | |
commit | 4bbd3a44260476fac59bbe9b3509db0a2bf12d2d (patch) | |
tree | 7f73edacd7605bcc3da860ca44041b773485063d | |
parent | 011c3c4ea2b8fc2df83a65fdfe45c25ec22be2db (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.xml | 46 | ||||
-rw-r--r-- | src/com/android/documentsui/picker/TrampolineActivity.kt | 170 | ||||
-rw-r--r-- | tests/functional/com/android/documentsui/TrampolineActivityTest.kt | 205 |
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" + ) + } + } + } +} |