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

Commit 817196af authored by Lucas Silva's avatar Lucas Silva
Browse files

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
parent f61a9476
Loading
Loading
Loading
Loading
+6 −0
Original line number Diff line number Diff line
@@ -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>
+236 −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.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)
    }
}
+9 −0
Original line number Diff line number Diff line
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);
}
+7 −0
Original line number Diff line number Diff line
package com.android.systemui.dreams.homecontrols.shared;

import android.content.ComponentName;

oneway interface IOnControlsSettingsChangeListener {
    void onControlsSettingsChanged(in ComponentName panelComponent, boolean allowTrivialControlsOnLockscreen);
}
+155 −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.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
    }
}
Loading