diff options
4 files changed, 492 insertions, 41 deletions
diff --git a/core/java/android/service/controls/ControlsProviderService.java b/core/java/android/service/controls/ControlsProviderService.java index 47b16a351806..d2a4ae282061 100644 --- a/core/java/android/service/controls/ControlsProviderService.java +++ b/core/java/android/service/controls/ControlsProviderService.java @@ -55,6 +55,20 @@ public abstract class ControlsProviderService extends Service { "android.service.controls.ControlsProviderService"; /** + * Manifest metadata to show a custom embedded activity as part of device controls. + * + * The value of this metadata must be the {@link ComponentName} as a string of an activity in + * the same package that will be launched as part of a TaskView. + * + * The activity must be exported, enabled and protected by + * {@link Manifest.permission.BIND_CONTROLS}. + * + * @hide + */ + public static final String META_DATA_PANEL_ACTIVITY = + "android.service.controls.META_DATA_PANEL_ACTIVITY"; + + /** * @hide */ @SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION) diff --git a/packages/SystemUI/src/com/android/systemui/controls/ControlsServiceInfo.kt b/packages/SystemUI/src/com/android/systemui/controls/ControlsServiceInfo.kt index 588ef5c4e68f..4dfcd6398a4d 100644 --- a/packages/SystemUI/src/com/android/systemui/controls/ControlsServiceInfo.kt +++ b/packages/SystemUI/src/com/android/systemui/controls/ControlsServiceInfo.kt @@ -16,16 +16,120 @@ package com.android.systemui.controls +import android.Manifest +import android.content.ComponentName import android.content.Context +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.PackageManager +import android.content.pm.PackageManager.MATCH_DIRECT_BOOT_AWARE +import android.content.pm.PackageManager.MATCH_DIRECT_BOOT_UNAWARE +import android.content.pm.ResolveInfo import android.content.pm.ServiceInfo +import android.os.UserHandle +import android.service.controls.ControlsProviderService +import androidx.annotation.WorkerThread import com.android.settingslib.applications.DefaultAppInfo +import java.util.Objects class ControlsServiceInfo( - context: Context, + private val context: Context, val serviceInfo: ServiceInfo ) : DefaultAppInfo( context, context.packageManager, context.userId, serviceInfo.componentName -)
\ No newline at end of file +) { + private val _panelActivity: ComponentName? + + init { + val metadata = serviceInfo.metaData + ?.getString(ControlsProviderService.META_DATA_PANEL_ACTIVITY) ?: "" + val unflatenned = ComponentName.unflattenFromString(metadata) + if (unflatenned != null && unflatenned.packageName == componentName.packageName) { + _panelActivity = unflatenned + } else { + _panelActivity = null + } + } + + /** + * Component name of an activity that will be shown embedded in the device controls space + * instead of using the controls rendered by SystemUI. + * + * The activity must be in the same package, exported, enabled and protected by the + * [Manifest.permission.BIND_CONTROLS] permission. + */ + var panelActivity: ComponentName? = null + private set + + private var resolved: Boolean = false + + @WorkerThread + fun resolvePanelActivity() { + if (resolved) return + resolved = true + panelActivity = _panelActivity?.let { + val resolveInfos = mPm.queryIntentActivitiesAsUser( + Intent().setComponent(it), + PackageManager.ResolveInfoFlags.of( + MATCH_DIRECT_BOOT_AWARE.toLong() or + MATCH_DIRECT_BOOT_UNAWARE.toLong() + ), + UserHandle.of(userId) + ) + if (resolveInfos.isNotEmpty() && verifyResolveInfo(resolveInfos[0])) { + it + } else { + null + } + } + } + + /** + * Verifies that the panel activity is enabled, exported and protected by the correct + * permission. This last check is to prevent apps from forgetting to protect the activity, as + * they won't be able to see the panel until they do. + */ + @WorkerThread + private fun verifyResolveInfo(resolveInfo: ResolveInfo): Boolean { + return resolveInfo.activityInfo?.let { + it.permission == Manifest.permission.BIND_CONTROLS && + it.exported && isComponentActuallyEnabled(it) + } ?: false + } + + @WorkerThread + private fun isComponentActuallyEnabled(activityInfo: ActivityInfo): Boolean { + return when (mPm.getComponentEnabledSetting(activityInfo.componentName)) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> true + PackageManager.COMPONENT_ENABLED_STATE_DISABLED -> false + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -> activityInfo.enabled + else -> false + } + } + + override fun equals(other: Any?): Boolean { + return other is ControlsServiceInfo && + userId == other.userId && + componentName == other.componentName && + panelActivity == other.panelActivity + } + + override fun hashCode(): Int { + return Objects.hash(userId, componentName, panelActivity) + } + + fun copy(): ControlsServiceInfo { + return ControlsServiceInfo(context, serviceInfo).also { + it.panelActivity = this.panelActivity + } + } + + override fun toString(): String { + return """ + ControlsServiceInfo(serviceInfo=$serviceInfo, panelActivity=$panelActivity, resolved=$resolved) + """.trimIndent() + } +}
\ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/controls/management/ControlsListingControllerImpl.kt b/packages/SystemUI/src/com/android/systemui/controls/management/ControlsListingControllerImpl.kt index 2d76ff2774d6..115edd115ffe 100644 --- a/packages/SystemUI/src/com/android/systemui/controls/management/ControlsListingControllerImpl.kt +++ b/packages/SystemUI/src/com/android/systemui/controls/management/ControlsListingControllerImpl.kt @@ -18,17 +18,23 @@ package com.android.systemui.controls.management import android.content.ComponentName import android.content.Context -import android.content.pm.ServiceInfo import android.os.UserHandle import android.service.controls.ControlsProviderService import android.util.Log import com.android.internal.annotations.VisibleForTesting import com.android.settingslib.applications.ServiceListing import com.android.settingslib.widget.CandidateInfo +import com.android.systemui.Dumpable import com.android.systemui.controls.ControlsServiceInfo import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.dump.DumpManager +import com.android.systemui.flags.FeatureFlags +import com.android.systemui.flags.Flags import com.android.systemui.settings.UserTracker +import com.android.systemui.util.asIndenting +import com.android.systemui.util.indentIfPossible +import java.io.PrintWriter import java.util.concurrent.Executor import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject @@ -57,16 +63,19 @@ class ControlsListingControllerImpl @VisibleForTesting constructor( private val context: Context, @Background private val backgroundExecutor: Executor, private val serviceListingBuilder: (Context) -> ServiceListing, - userTracker: UserTracker -) : ControlsListingController { + private val userTracker: UserTracker, + dumpManager: DumpManager, + featureFlags: FeatureFlags +) : ControlsListingController, Dumpable { @Inject - constructor(context: Context, executor: Executor, userTracker: UserTracker): this( - context, - executor, - ::createServiceListing, - userTracker - ) + constructor( + context: Context, + @Background executor: Executor, + userTracker: UserTracker, + dumpManager: DumpManager, + featureFlags: FeatureFlags + ) : this(context, executor, ::createServiceListing, userTracker, dumpManager, featureFlags) private var serviceListing = serviceListingBuilder(context) // All operations in background thread @@ -76,27 +85,25 @@ class ControlsListingControllerImpl @VisibleForTesting constructor( private const val TAG = "ControlsListingControllerImpl" } - private var availableComponents = emptySet<ComponentName>() - private var availableServices = emptyList<ServiceInfo>() + private var availableServices = emptyList<ControlsServiceInfo>() private var userChangeInProgress = AtomicInteger(0) override var currentUserId = userTracker.userId private set private val serviceListingCallback = ServiceListing.Callback { - val newServices = it.toList() - val newComponents = - newServices.mapTo(mutableSetOf<ComponentName>(), { s -> s.getComponentName() }) - backgroundExecutor.execute { if (userChangeInProgress.get() > 0) return@execute - if (!newComponents.equals(availableComponents)) { - Log.d(TAG, "ServiceConfig reloaded, count: ${newComponents.size}") - availableComponents = newComponents + Log.d(TAG, "ServiceConfig reloaded, count: ${it.size}") + val newServices = it.map { ControlsServiceInfo(userTracker.userContext, it) } + if (featureFlags.isEnabled(Flags.USE_APP_PANELS)) { + newServices.forEach(ControlsServiceInfo::resolvePanelActivity) + } + + if (newServices != availableServices) { availableServices = newServices - val currentServices = getCurrentServices() callbacks.forEach { - it.onServicesUpdated(currentServices) + it.onServicesUpdated(getCurrentServices()) } } } @@ -104,6 +111,7 @@ class ControlsListingControllerImpl @VisibleForTesting constructor( init { Log.d(TAG, "Initializing") + dumpManager.registerDumpable(TAG, this) serviceListing.addCallback(serviceListingCallback) serviceListing.setListening(true) serviceListing.reload() @@ -165,7 +173,7 @@ class ControlsListingControllerImpl @VisibleForTesting constructor( * [ControlsProviderService] */ override fun getCurrentServices(): List<ControlsServiceInfo> = - availableServices.map { ControlsServiceInfo(context, it) } + availableServices.map(ControlsServiceInfo::copy) /** * Get the localized label for the component. @@ -174,7 +182,15 @@ class ControlsListingControllerImpl @VisibleForTesting constructor( * @return a label as returned by [CandidateInfo.loadLabel] or `null`. */ override fun getAppLabel(name: ComponentName): CharSequence? { - return getCurrentServices().firstOrNull { it.componentName == name } + return availableServices.firstOrNull { it.componentName == name } ?.loadLabel() } + + override fun dump(writer: PrintWriter, args: Array<out String>) { + writer.println("ControlsListingController:") + writer.asIndenting().indentIfPossible { + println("Callbacks: $callbacks") + println("Services: ${getCurrentServices()}") + } + } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/controls/management/ControlsListingControllerImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/controls/management/ControlsListingControllerImplTest.kt index db41d8d37a43..dedc7239bf85 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/controls/management/ControlsListingControllerImplTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/controls/management/ControlsListingControllerImplTest.kt @@ -16,27 +16,42 @@ package com.android.systemui.controls.management +import android.Manifest import android.content.ComponentName import android.content.Context import android.content.ContextWrapper +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo import android.content.pm.ServiceInfo +import android.os.Bundle import android.os.UserHandle +import android.service.controls.ControlsProviderService import android.testing.AndroidTestingRunner import androidx.test.filters.SmallTest import com.android.settingslib.applications.ServiceListing import com.android.systemui.SysuiTestCase import com.android.systemui.controls.ControlsServiceInfo +import com.android.systemui.dump.DumpManager +import com.android.systemui.flags.FeatureFlags +import com.android.systemui.flags.Flags.USE_APP_PANELS import com.android.systemui.settings.UserTracker import com.android.systemui.util.concurrency.FakeExecutor +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.argThat +import com.android.systemui.util.mockito.capture +import com.android.systemui.util.mockito.eq import com.android.systemui.util.time.FakeSystemClock import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatcher import org.mockito.Mock -import org.mockito.Mockito import org.mockito.Mockito.`when` import org.mockito.Mockito.inOrder import org.mockito.Mockito.mock @@ -51,10 +66,8 @@ import java.util.concurrent.Executor class ControlsListingControllerImplTest : SysuiTestCase() { companion object { - private const val TEST_LABEL = "TEST_LABEL" - private const val TEST_PERMISSION = "permission" - fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture() - fun <T> any(): T = Mockito.any<T>() + private const val FLAGS = PackageManager.MATCH_DIRECT_BOOT_AWARE.toLong() or + PackageManager.MATCH_DIRECT_BOOT_UNAWARE.toLong() } @Mock @@ -63,15 +76,17 @@ class ControlsListingControllerImplTest : SysuiTestCase() { private lateinit var mockCallback: ControlsListingController.ControlsListingCallback @Mock private lateinit var mockCallbackOther: ControlsListingController.ControlsListingCallback - @Mock - private lateinit var serviceInfo: ServiceInfo - @Mock - private lateinit var serviceInfo2: ServiceInfo @Mock(stubOnly = true) private lateinit var userTracker: UserTracker + @Mock(stubOnly = true) + private lateinit var dumpManager: DumpManager + @Mock + private lateinit var packageManager: PackageManager + @Mock + private lateinit var featureFlags: FeatureFlags - private var componentName = ComponentName("pkg1", "class1") - private var componentName2 = ComponentName("pkg2", "class2") + private var componentName = ComponentName("pkg", "class1") + private var activityName = ComponentName("pkg", "activity") private val executor = FakeExecutor(FakeSystemClock()) @@ -87,9 +102,15 @@ class ControlsListingControllerImplTest : SysuiTestCase() { fun setUp() { MockitoAnnotations.initMocks(this) - `when`(serviceInfo.componentName).thenReturn(componentName) - `when`(serviceInfo2.componentName).thenReturn(componentName2) `when`(userTracker.userId).thenReturn(user) + `when`(userTracker.userContext).thenReturn(context) + // Return disabled by default + `when`(packageManager.getComponentEnabledSetting(any())) + .thenReturn(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + mContext.setMockPackageManager(packageManager) + + // Return true by default, we'll test the false path + `when`(featureFlags.isEnabled(USE_APP_PANELS)).thenReturn(true) val wrapper = object : ContextWrapper(mContext) { override fun createContextAsUser(user: UserHandle, flags: Int): Context { @@ -97,7 +118,14 @@ class ControlsListingControllerImplTest : SysuiTestCase() { } } - controller = ControlsListingControllerImpl(wrapper, executor, { mockSL }, userTracker) + controller = ControlsListingControllerImpl( + wrapper, + executor, + { mockSL }, + userTracker, + dumpManager, + featureFlags + ) verify(mockSL).addCallback(capture(serviceListingCallbackCaptor)) } @@ -123,9 +151,16 @@ class ControlsListingControllerImplTest : SysuiTestCase() { Unit } `when`(mockServiceListing.reload()).then { - callback?.onServicesReloaded(listOf(serviceInfo)) + callback?.onServicesReloaded(listOf(ServiceInfo(componentName))) } - ControlsListingControllerImpl(mContext, exec, { mockServiceListing }, userTracker) + ControlsListingControllerImpl( + mContext, + exec, + { mockServiceListing }, + userTracker, + dumpManager, + featureFlags + ) } @Test @@ -148,7 +183,7 @@ class ControlsListingControllerImplTest : SysuiTestCase() { @Test fun testCallbackGetsList() { - val list = listOf(serviceInfo) + val list = listOf(ServiceInfo(componentName)) controller.addCallback(mockCallback) controller.addCallback(mockCallbackOther) @@ -188,6 +223,8 @@ class ControlsListingControllerImplTest : SysuiTestCase() { @Test fun testChangeUserSendsCorrectServiceUpdate() { + val serviceInfo = ServiceInfo(componentName) + val list = listOf(serviceInfo) controller.addCallback(mockCallback) @@ -223,4 +260,284 @@ class ControlsListingControllerImplTest : SysuiTestCase() { verify(mockCallback).onServicesUpdated(capture(captor)) assertEquals(0, captor.value.size) } + + @Test + fun test_nullPanelActivity() { + val list = listOf(ServiceInfo(componentName)) + serviceListingCallbackCaptor.value.onServicesReloaded(list) + + executor.runAllReady() + + assertNull(controller.getCurrentServices()[0].panelActivity) + } + + @Test + fun testNoActivity_nullPanel() { + val serviceInfo = ServiceInfo( + componentName, + activityName + ) + + val list = listOf(serviceInfo) + serviceListingCallbackCaptor.value.onServicesReloaded(list) + + executor.runAllReady() + + assertNull(controller.getCurrentServices()[0].panelActivity) + } + + @Test + fun testActivityWithoutPermission_nullPanel() { + val serviceInfo = ServiceInfo( + componentName, + activityName + ) + + setUpQueryResult(listOf(ActivityInfo(activityName))) + + val list = listOf(serviceInfo) + serviceListingCallbackCaptor.value.onServicesReloaded(list) + + executor.runAllReady() + + assertNull(controller.getCurrentServices()[0].panelActivity) + } + + @Test + fun testActivityPermissionNotExported_nullPanel() { + val serviceInfo = ServiceInfo( + componentName, + activityName + ) + + setUpQueryResult(listOf( + ActivityInfo(activityName, permission = Manifest.permission.BIND_CONTROLS) + )) + + val list = listOf(serviceInfo) + serviceListingCallbackCaptor.value.onServicesReloaded(list) + + executor.runAllReady() + + assertNull(controller.getCurrentServices()[0].panelActivity) + } + + @Test + fun testActivityDisabled_nullPanel() { + val serviceInfo = ServiceInfo( + componentName, + activityName + ) + + setUpQueryResult(listOf( + ActivityInfo( + activityName, + exported = true, + permission = Manifest.permission.BIND_CONTROLS + ) + )) + + val list = listOf(serviceInfo) + serviceListingCallbackCaptor.value.onServicesReloaded(list) + + executor.runAllReady() + + assertNull(controller.getCurrentServices()[0].panelActivity) + } + + @Test + fun testActivityEnabled_correctPanel() { + val serviceInfo = ServiceInfo( + componentName, + activityName + ) + + `when`(packageManager.getComponentEnabledSetting(eq(activityName))) + .thenReturn(PackageManager.COMPONENT_ENABLED_STATE_ENABLED) + + setUpQueryResult(listOf( + ActivityInfo( + activityName, + exported = true, + permission = Manifest.permission.BIND_CONTROLS + ) + )) + + val list = listOf(serviceInfo) + serviceListingCallbackCaptor.value.onServicesReloaded(list) + + executor.runAllReady() + + assertEquals(activityName, controller.getCurrentServices()[0].panelActivity) + } + + @Test + fun testActivityDefaultEnabled_correctPanel() { + val serviceInfo = ServiceInfo( + componentName, + activityName + ) + + `when`(packageManager.getComponentEnabledSetting(eq(activityName))) + .thenReturn(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT) + + setUpQueryResult(listOf( + ActivityInfo( + activityName, + enabled = true, + exported = true, + permission = Manifest.permission.BIND_CONTROLS + ) + )) + + val list = listOf(serviceInfo) + serviceListingCallbackCaptor.value.onServicesReloaded(list) + + executor.runAllReady() + + assertEquals(activityName, controller.getCurrentServices()[0].panelActivity) + } + + @Test + fun testActivityDefaultDisabled_nullPanel() { + val serviceInfo = ServiceInfo( + componentName, + activityName + ) + + `when`(packageManager.getComponentEnabledSetting(eq(activityName))) + .thenReturn(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT) + + setUpQueryResult(listOf( + ActivityInfo( + activityName, + enabled = false, + exported = true, + permission = Manifest.permission.BIND_CONTROLS + ) + )) + + val list = listOf(serviceInfo) + serviceListingCallbackCaptor.value.onServicesReloaded(list) + + executor.runAllReady() + + assertNull(controller.getCurrentServices()[0].panelActivity) + } + + @Test + fun testActivityDefaultEnabled_flagDisabled_nullPanel() { + `when`(featureFlags.isEnabled(USE_APP_PANELS)).thenReturn(false) + val serviceInfo = ServiceInfo( + componentName, + activityName, + ) + + `when`(packageManager.getComponentEnabledSetting(eq(activityName))) + .thenReturn(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT) + + setUpQueryResult(listOf( + ActivityInfo( + activityName, + enabled = true, + exported = true, + permission = Manifest.permission.BIND_CONTROLS + ) + )) + + val list = listOf(serviceInfo) + serviceListingCallbackCaptor.value.onServicesReloaded(list) + + executor.runAllReady() + + assertNull(controller.getCurrentServices()[0].panelActivity) + } + + @Test + fun testActivityDifferentPackage_nullPanel() { + val serviceInfo = ServiceInfo( + componentName, + ComponentName("other_package", "cls") + ) + + `when`(packageManager.getComponentEnabledSetting(eq(activityName))) + .thenReturn(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT) + + setUpQueryResult(listOf( + ActivityInfo( + activityName, + enabled = true, + exported = true, + permission = Manifest.permission.BIND_CONTROLS + ) + )) + + val list = listOf(serviceInfo) + serviceListingCallbackCaptor.value.onServicesReloaded(list) + + executor.runAllReady() + + assertNull(controller.getCurrentServices()[0].panelActivity) + } + + private fun ServiceInfo( + componentName: ComponentName, + panelActivityComponentName: ComponentName? = null + ): ServiceInfo { + return ServiceInfo().apply { + packageName = componentName.packageName + name = componentName.className + panelActivityComponentName?.let { + metaData = Bundle().apply { + putString( + ControlsProviderService.META_DATA_PANEL_ACTIVITY, + it.flattenToShortString() + ) + } + } + } + } + + private fun ActivityInfo( + componentName: ComponentName, + exported: Boolean = false, + enabled: Boolean = true, + permission: String? = null + ): ActivityInfo { + return ActivityInfo().apply { + packageName = componentName.packageName + name = componentName.className + this.permission = permission + this.exported = exported + this.enabled = enabled + } + } + + private fun setUpQueryResult(infos: List<ActivityInfo>) { + `when`( + packageManager.queryIntentActivitiesAsUser( + argThat(IntentMatcher(activityName)), + argThat(FlagsMatcher(FLAGS)), + eq(UserHandle.of(user)) + ) + ).thenReturn(infos.map { + ResolveInfo().apply { activityInfo = it } + }) + } + + private class IntentMatcher( + private val componentName: ComponentName + ) : ArgumentMatcher<Intent> { + override fun matches(argument: Intent?): Boolean { + return argument?.component == componentName + } + } + + private class FlagsMatcher( + private val flags: Long + ) : ArgumentMatcher<PackageManager.ResolveInfoFlags> { + override fun matches(argument: PackageManager.ResolveInfoFlags?): Boolean { + return flags == argument?.value + } + } } |