Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 4bbd3a44 authored by Ben Reich's avatar Ben Reich
Browse files

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
parent 011c3c4e
Loading
Loading
Loading
Loading
+42 −4
Original line number Diff line number Diff line
@@ -59,30 +59,68 @@
            android:name="com.google.android.backup.api_key"
            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>
+170 −0
Original line number Diff line number Diff line
/*
 * 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
}
+205 −0
Original line number Diff line number Diff line
/*
 * 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"
                )
            }
        }
    }
}