Define remote service for home controls
This service will run in the SYSTEM_USER and provide the necessary
information back to the foreground user needed to show the home controls
dream.
Bug: 370691405
Test: atest HomeControlsRemoteServiceBinderTest
Flag: com.android.systemui.home_controls_dream_hsum
Change-Id: I053ac93ced99889a480f3e76e9513a8fdab40b52
diff --git a/packages/SystemUI/AndroidManifest.xml b/packages/SystemUI/AndroidManifest.xml
index 510c9b7..59526e9 100644
--- a/packages/SystemUI/AndroidManifest.xml
+++ b/packages/SystemUI/AndroidManifest.xml
@@ -1139,5 +1139,11 @@
android:name="android.service.dream"
android:resource="@xml/home_controls_dream_metadata" />
</service>
+
+ <service android:name="com.android.systemui.dreams.homecontrols.system.HomeControlsRemoteService"
+ android:singleUser="true"
+ android:exported="false"
+ />
+
</application>
</manifest>
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/dreams/homecontrols/system/HomeControlsRemoteServiceBinderTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/dreams/homecontrols/system/HomeControlsRemoteServiceBinderTest.kt
new file mode 100644
index 0000000..f8a45e8
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/dreams/homecontrols/system/HomeControlsRemoteServiceBinderTest.kt
@@ -0,0 +1,236 @@
+/*
+ * 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.systemui.dreams.homecontrols.system
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.pm.ApplicationInfo
+import android.content.pm.ServiceInfo
+import android.content.pm.UserInfo
+import androidx.lifecycle.testing.TestLifecycleOwner
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.controls.ControlsServiceInfo
+import com.android.systemui.controls.panels.SelectedComponentRepository
+import com.android.systemui.controls.panels.authorizedPanelsRepository
+import com.android.systemui.controls.panels.selectedComponentRepository
+import com.android.systemui.controls.settings.FakeControlsSettingsRepository
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.dreams.homecontrols.shared.IOnControlsSettingsChangeListener
+import com.android.systemui.dreams.homecontrols.system.domain.interactor.controlsComponent
+import com.android.systemui.dreams.homecontrols.system.domain.interactor.controlsListingController
+import com.android.systemui.dreams.homecontrols.system.domain.interactor.homeControlsComponentInteractor
+import com.android.systemui.kosmos.backgroundCoroutineContext
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.log.logcatLogBuffer
+import com.android.systemui.settings.fakeUserTracker
+import com.android.systemui.testKosmos
+import com.android.systemui.user.data.repository.fakeUserRepository
+import com.android.systemui.util.mockito.whenever
+import com.android.systemui.util.mockito.withArgCaptor
+import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow
+import com.google.common.truth.Truth.assertThat
+import java.util.Optional
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class HomeControlsRemoteServiceBinderTest : SysuiTestCase() {
+ private val kosmos = testKosmos()
+ private val testScope = kosmos.testScope
+
+ private val lifecycleOwner = TestLifecycleOwner(coroutineDispatcher = kosmos.testDispatcher)
+ private val fakeControlsSettingsRepository = FakeControlsSettingsRepository()
+
+ private val underTest by lazy {
+ HomeControlsRemoteServiceBinder(
+ kosmos.homeControlsComponentInteractor,
+ fakeControlsSettingsRepository,
+ kosmos.backgroundCoroutineContext,
+ logcatLogBuffer(),
+ lifecycleOwner,
+ )
+ }
+
+ @Before
+ fun setUp() {
+ with(kosmos) {
+ fakeUserRepository.setUserInfos(listOf(PRIMARY_USER))
+ whenever(controlsComponent.getControlsListingController())
+ .thenReturn(Optional.of(controlsListingController))
+ }
+ }
+
+ @Test
+ fun testRegisterSingleListener() =
+ testScope.runTest {
+ setup()
+ val controlsSettings by collectLastValue(addCallback())
+ runServicesUpdate()
+
+ assertThat(controlsSettings)
+ .isEqualTo(
+ CallbackArgs(
+ panelComponent = TEST_COMPONENT,
+ allowTrivialControlsOnLockscreen = false,
+ )
+ )
+ }
+
+ @Test
+ fun testRegisterMultipleListeners() =
+ testScope.runTest {
+ setup()
+ val controlsSettings1 by collectLastValue(addCallback())
+ val controlsSettings2 by collectLastValue(addCallback())
+ runServicesUpdate()
+
+ assertThat(controlsSettings1)
+ .isEqualTo(
+ CallbackArgs(
+ panelComponent = TEST_COMPONENT,
+ allowTrivialControlsOnLockscreen = false,
+ )
+ )
+ assertThat(controlsSettings2)
+ .isEqualTo(
+ CallbackArgs(
+ panelComponent = TEST_COMPONENT,
+ allowTrivialControlsOnLockscreen = false,
+ )
+ )
+ }
+
+ @Test
+ fun testListenerCalledWhenStateChanges() =
+ testScope.runTest {
+ setup()
+ val controlsSettings by collectLastValue(addCallback())
+ runServicesUpdate()
+
+ assertThat(controlsSettings)
+ .isEqualTo(
+ CallbackArgs(
+ panelComponent = TEST_COMPONENT,
+ allowTrivialControlsOnLockscreen = false,
+ )
+ )
+
+ kosmos.authorizedPanelsRepository.removeAuthorizedPanels(setOf(TEST_PACKAGE))
+
+ // Updated with null component now that we are no longer authorized.
+ assertThat(controlsSettings)
+ .isEqualTo(
+ CallbackArgs(panelComponent = null, allowTrivialControlsOnLockscreen = false)
+ )
+ }
+
+ private fun TestScope.runServicesUpdate() {
+ runCurrent()
+ val listings = listOf(ControlsServiceInfo(TEST_COMPONENT, "panel", hasPanel = true))
+ val callback = withArgCaptor {
+ Mockito.verify(kosmos.controlsListingController).addCallback(capture())
+ }
+ callback.onServicesUpdated(listings)
+ runCurrent()
+ }
+
+ private fun addCallback() = conflatedCallbackFlow {
+ val callback =
+ object : IOnControlsSettingsChangeListener.Stub() {
+ override fun onControlsSettingsChanged(
+ panelComponent: ComponentName?,
+ allowTrivialControlsOnLockscreen: Boolean,
+ ) {
+ trySend(CallbackArgs(panelComponent, allowTrivialControlsOnLockscreen))
+ }
+ }
+ underTest.registerListenerForCurrentUser(callback)
+ awaitClose { underTest.unregisterListenerForCurrentUser(callback) }
+ }
+
+ private suspend fun TestScope.setup() {
+ kosmos.fakeUserRepository.setSelectedUserInfo(PRIMARY_USER)
+ kosmos.fakeUserTracker.set(listOf(PRIMARY_USER), 0)
+ kosmos.authorizedPanelsRepository.addAuthorizedPanels(setOf(TEST_PACKAGE))
+ kosmos.selectedComponentRepository.setSelectedComponent(TEST_SELECTED_COMPONENT_PANEL)
+ runCurrent()
+ }
+
+ private data class CallbackArgs(
+ val panelComponent: ComponentName?,
+ val allowTrivialControlsOnLockscreen: Boolean,
+ )
+
+ private fun ControlsServiceInfo(
+ componentName: ComponentName,
+ label: CharSequence,
+ hasPanel: Boolean,
+ ): ControlsServiceInfo {
+ val serviceInfo =
+ ServiceInfo().apply {
+ applicationInfo = ApplicationInfo()
+ packageName = componentName.packageName
+ name = componentName.className
+ }
+ return FakeControlsServiceInfo(context, serviceInfo, label, hasPanel)
+ }
+
+ private class FakeControlsServiceInfo(
+ context: Context,
+ serviceInfo: ServiceInfo,
+ private val label: CharSequence,
+ hasPanel: Boolean,
+ ) : ControlsServiceInfo(context, serviceInfo) {
+
+ init {
+ if (hasPanel) {
+ panelActivity = serviceInfo.componentName
+ }
+ }
+
+ override fun loadLabel(): CharSequence {
+ return label
+ }
+ }
+
+ private companion object {
+ const val PRIMARY_USER_ID = 0
+ val PRIMARY_USER =
+ UserInfo(
+ /* id= */ PRIMARY_USER_ID,
+ /* name= */ "primary user",
+ /* flags= */ UserInfo.FLAG_PRIMARY,
+ )
+
+ private const val TEST_PACKAGE = "pkg"
+ private val TEST_COMPONENT = ComponentName(TEST_PACKAGE, "service")
+ private val TEST_SELECTED_COMPONENT_PANEL =
+ SelectedComponentRepository.SelectedComponent(TEST_PACKAGE, TEST_COMPONENT, true)
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/dreams/homecontrols/shared/IHomeControlsRemoteProxy.aidl b/packages/SystemUI/src/com/android/systemui/dreams/homecontrols/shared/IHomeControlsRemoteProxy.aidl
new file mode 100644
index 0000000..115b62c
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/dreams/homecontrols/shared/IHomeControlsRemoteProxy.aidl
@@ -0,0 +1,9 @@
+package com.android.systemui.dreams.homecontrols.shared;
+
+import android.os.IRemoteCallback;
+import com.android.systemui.dreams.homecontrols.shared.IOnControlsSettingsChangeListener;
+
+oneway interface IHomeControlsRemoteProxy {
+ void registerListenerForCurrentUser(in IOnControlsSettingsChangeListener callback);
+ void unregisterListenerForCurrentUser(in IOnControlsSettingsChangeListener callback);
+}
diff --git a/packages/SystemUI/src/com/android/systemui/dreams/homecontrols/shared/IOnControlsSettingsChangeListener.aidl b/packages/SystemUI/src/com/android/systemui/dreams/homecontrols/shared/IOnControlsSettingsChangeListener.aidl
new file mode 100644
index 0000000..99e5fae
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/dreams/homecontrols/shared/IOnControlsSettingsChangeListener.aidl
@@ -0,0 +1,7 @@
+package com.android.systemui.dreams.homecontrols.shared;
+
+import android.content.ComponentName;
+
+oneway interface IOnControlsSettingsChangeListener {
+ void onControlsSettingsChanged(in ComponentName panelComponent, boolean allowTrivialControlsOnLockscreen);
+}
diff --git a/packages/SystemUI/src/com/android/systemui/dreams/homecontrols/system/HomeControlsRemoteService.kt b/packages/SystemUI/src/com/android/systemui/dreams/homecontrols/system/HomeControlsRemoteService.kt
new file mode 100644
index 0000000..a65d216
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/dreams/homecontrols/system/HomeControlsRemoteService.kt
@@ -0,0 +1,155 @@
+/*
+ * 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.systemui.dreams.homecontrols.system
+
+import android.content.ComponentName
+import android.content.Intent
+import android.os.IBinder
+import android.os.RemoteCallbackList
+import android.os.RemoteException
+import android.util.Log
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleService
+import androidx.lifecycle.lifecycleScope
+import com.android.systemui.controls.settings.ControlsSettingsRepository
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.dreams.DreamLogger
+import com.android.systemui.dreams.homecontrols.shared.IHomeControlsRemoteProxy
+import com.android.systemui.dreams.homecontrols.shared.IOnControlsSettingsChangeListener
+import com.android.systemui.dreams.homecontrols.system.domain.interactor.HomeControlsComponentInteractor
+import com.android.systemui.log.LogBuffer
+import com.android.systemui.log.dagger.DreamLog
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import java.util.concurrent.atomic.AtomicInteger
+import javax.inject.Inject
+import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.launch
+
+/**
+ * Service which exports the current home controls component name, for use in SystemUI processes
+ * running in other users. This service should only run in the system user.
+ */
+class HomeControlsRemoteService
+@Inject
+constructor(binderFactory: HomeControlsRemoteServiceBinder.Factory) : LifecycleService() {
+ val binder by lazy { binderFactory.create(this) }
+
+ override fun onBind(intent: Intent): IBinder? {
+ super.onBind(intent)
+ return binder
+ }
+}
+
+class HomeControlsRemoteServiceBinder
+@AssistedInject
+constructor(
+ private val homeControlsComponentInteractor: HomeControlsComponentInteractor,
+ private val controlsSettingsRepository: ControlsSettingsRepository,
+ @Background private val bgContext: CoroutineContext,
+ @DreamLog logBuffer: LogBuffer,
+ @Assisted lifecycleOwner: LifecycleOwner,
+) : IHomeControlsRemoteProxy.Stub(), LifecycleOwner by lifecycleOwner {
+ private val logger = DreamLogger(logBuffer, TAG)
+ private val callbacks =
+ object : RemoteCallbackList<IOnControlsSettingsChangeListener>() {
+ override fun onCallbackDied(listener: IOnControlsSettingsChangeListener?) {
+ if (callbackCount.decrementAndGet() == 0) {
+ logger.d("Cancelling collection due to callback death")
+ collectionJob?.cancel()
+ collectionJob = null
+ }
+ }
+ }
+ private val callbackCount = AtomicInteger(0)
+ private var collectionJob: Job? = null
+
+ override fun registerListenerForCurrentUser(listener: IOnControlsSettingsChangeListener?) {
+ if (listener == null) return
+ logger.d("Register listener")
+ val registered = callbacks.register(listener)
+ if (registered && callbackCount.getAndIncrement() == 0) {
+ // If the first listener, start the collection job. This will also take
+ // care of notifying the listener of the initial state.
+ logger.d("Starting collection")
+ collectionJob =
+ lifecycleScope.launch(bgContext) {
+ combine(
+ homeControlsComponentInteractor.panelComponent,
+ controlsSettingsRepository.allowActionOnTrivialControlsInLockscreen,
+ ) { panelComponent, allowTrivialControls ->
+ callbacks.notifyAllCallbacks(panelComponent, allowTrivialControls)
+ }
+ .launchIn(this)
+ }
+ } else if (registered) {
+ // If not the first listener, notify the listener of the current value immediately.
+ listener.notify(
+ homeControlsComponentInteractor.panelComponent.value,
+ controlsSettingsRepository.allowActionOnTrivialControlsInLockscreen.value,
+ )
+ }
+ }
+
+ override fun unregisterListenerForCurrentUser(listener: IOnControlsSettingsChangeListener?) {
+ if (listener == null) return
+ logger.d("Unregister listener")
+ if (callbacks.unregister(listener) && callbackCount.decrementAndGet() == 0) {
+ logger.d("Cancelling collection due to unregister")
+ collectionJob?.cancel()
+ collectionJob = null
+ }
+ }
+
+ private companion object {
+ const val TAG = "HomeControlsRemoteServiceBinder"
+ }
+
+ private fun IOnControlsSettingsChangeListener.notify(
+ panelComponent: ComponentName?,
+ allowTrivialControlsOnLockscreen: Boolean,
+ ) {
+ try {
+ onControlsSettingsChanged(panelComponent, allowTrivialControlsOnLockscreen)
+ } catch (e: RemoteException) {
+ Log.e(TAG, "Error notifying callback", e)
+ }
+ }
+
+ private fun RemoteCallbackList<IOnControlsSettingsChangeListener>.notifyAllCallbacks(
+ panelComponent: ComponentName?,
+ allowTrivialControlsOnLockscreen: Boolean,
+ ) {
+ val itemCount = beginBroadcast()
+ try {
+ for (i in 0 until itemCount) {
+ getBroadcastItem(i).notify(panelComponent, allowTrivialControlsOnLockscreen)
+ }
+ } finally {
+ finishBroadcast()
+ }
+ }
+
+ @AssistedFactory
+ interface Factory {
+ fun create(lifecycleOwner: LifecycleOwner): HomeControlsRemoteServiceBinder
+ }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/dreams/homecontrols/service/FakeIHomeControlsRemoteProxyBinder.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/dreams/homecontrols/service/FakeIHomeControlsRemoteProxyBinder.kt
new file mode 100644
index 0000000..f469d74
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/dreams/homecontrols/service/FakeIHomeControlsRemoteProxyBinder.kt
@@ -0,0 +1,40 @@
+/*
+ * 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.systemui.dreams.homecontrols.service
+
+import android.content.ComponentName
+import com.android.systemui.dreams.homecontrols.shared.IHomeControlsRemoteProxy
+import com.android.systemui.dreams.homecontrols.shared.IOnControlsSettingsChangeListener
+
+/** Fake binder implementation of [IHomeControlsRemoteProxy] */
+class FakeIHomeControlsRemoteProxyBinder : IHomeControlsRemoteProxy.Stub() {
+ val callbacks = mutableSetOf<IOnControlsSettingsChangeListener>()
+
+ override fun registerListenerForCurrentUser(callback: IOnControlsSettingsChangeListener) {
+ callbacks.add(callback)
+ }
+
+ override fun unregisterListenerForCurrentUser(callback: IOnControlsSettingsChangeListener) {
+ callbacks.remove(callback)
+ }
+
+ fun notifyCallbacks(component: ComponentName, allowTrivialControlsOnLockscreen: Boolean) {
+ for (callback in callbacks.toSet()) {
+ callback.onControlsSettingsChanged(component, allowTrivialControlsOnLockscreen)
+ }
+ }
+}