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

Commit acc477c4 authored by Caitlin Shkuratov's avatar Caitlin Shkuratov
Browse files

[SB][Notifs] Hide status bar notif chip if the app is open.

If the app that posted the promoted notification is visible, we don't
want the status bar chip to also show in case the information in the
chip is out-of-sync with the information in the app (e.g. a timer is
slightly off).

OngoingCallController already does this using a UidObserver that it
manually registers and unregisters. This CL does things a little
differently:
1) Use a new ActivityManager API instead of an IActivityManagerAPI
2) Create a repository responsible for listening for UID updates
3) Use `conflatedCallbackFlow` inside the repository which should handle
   the registering and unregistering automatically
4) Have SingleNotificationChipInteractor use that repository to create
   the flow

Bug: 364653005
Flag: com.android.systemui.status_bar_notification_chips
Test: Start a promoted ongoing notification, then open the app
associated with the notification -> verify status bar chip disappears.
Close the app -> verify status bar chip reappears.
Test: atest SingleNotificationChipInteractorTest

Change-Id: I3012b6a99e723e7e0f0f921fbb8bde2aabb5857e
parent 7a32c912
Loading
Loading
Loading
Loading
+143 −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.activity.data.repository

import android.app.ActivityManager
import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND
import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_GONE
import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_TOP_SLEEPING
import android.app.activityManager
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.collectLastValue
import com.android.systemui.kosmos.runTest
import com.android.systemui.kosmos.useUnconfinedTestDispatcher
import com.android.systemui.log.core.Logger
import com.android.systemui.log.logcatLogBuffer
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever

@SmallTest
@RunWith(AndroidJUnit4::class)
class ActivityManagerRepositoryTest : SysuiTestCase() {
    private val kosmos = testKosmos().useUnconfinedTestDispatcher()
    private val logger = Logger(logcatLogBuffer("ActivityManagerRepositoryTest"), "tag")

    private val Kosmos.underTest by Kosmos.Fixture { realActivityManagerRepository }

    @Test
    fun createIsAppVisibleFlow_fetchesInitialValue_true() =
        kosmos.runTest {
            whenever(activityManager.getUidImportance(THIS_UID)).thenReturn(IMPORTANCE_FOREGROUND)

            val latest by
                collectLastValue(underTest.createIsAppVisibleFlow(THIS_UID, logger, LOG_TAG))

            assertThat(latest).isTrue()
        }

    @Test
    fun createIsAppVisibleFlow_fetchesInitialValue_false() =
        kosmos.runTest {
            whenever(activityManager.getUidImportance(THIS_UID)).thenReturn(IMPORTANCE_GONE)

            val latest by
                collectLastValue(underTest.createIsAppVisibleFlow(THIS_UID, logger, LOG_TAG))

            assertThat(latest).isFalse()
        }

    @Test
    fun createIsAppVisibleFlow_getsImportanceUpdates() =
        kosmos.runTest {
            val latest by
                collectLastValue(underTest.createIsAppVisibleFlow(THIS_UID, logger, LOG_TAG))

            val listenerCaptor = argumentCaptor<ActivityManager.OnUidImportanceListener>()
            verify(activityManager).addOnUidImportanceListener(listenerCaptor.capture(), any())
            val listener = listenerCaptor.firstValue

            listener.onUidImportance(THIS_UID, IMPORTANCE_GONE)
            assertThat(latest).isFalse()

            listener.onUidImportance(THIS_UID, IMPORTANCE_FOREGROUND)
            assertThat(latest).isTrue()

            listener.onUidImportance(THIS_UID, IMPORTANCE_TOP_SLEEPING)
            assertThat(latest).isFalse()
        }

    @Test
    fun createIsAppVisibleFlow_ignoresUpdatesForOtherUids() =
        kosmos.runTest {
            val latest by
                collectLastValue(underTest.createIsAppVisibleFlow(THIS_UID, logger, LOG_TAG))

            val listenerCaptor = argumentCaptor<ActivityManager.OnUidImportanceListener>()
            verify(activityManager).addOnUidImportanceListener(listenerCaptor.capture(), any())
            val listener = listenerCaptor.firstValue

            listener.onUidImportance(THIS_UID, IMPORTANCE_GONE)
            assertThat(latest).isFalse()

            // WHEN another UID becomes foreground
            listener.onUidImportance(THIS_UID + 2, IMPORTANCE_FOREGROUND)

            // THEN this UID still stays not visible
            assertThat(latest).isFalse()
        }

    @Test
    fun createIsAppVisibleFlow_securityExceptionOnUidRegistration_ok() =
        kosmos.runTest {
            whenever(activityManager.getUidImportance(THIS_UID)).thenReturn(IMPORTANCE_GONE)
            whenever(activityManager.addOnUidImportanceListener(any(), any()))
                .thenThrow(SecurityException())

            val latest by
                collectLastValue(underTest.createIsAppVisibleFlow(THIS_UID, logger, LOG_TAG))

            // Verify no crash, and we get a value emitted
            assertThat(latest).isFalse()
        }

    /** Regression test for b/216248574. */
    @Test
    fun createIsAppVisibleFlow_getUidImportanceThrowsException_ok() =
        kosmos.runTest {
            whenever(activityManager.getUidImportance(any())).thenThrow(SecurityException())

            val latest by
                collectLastValue(underTest.createIsAppVisibleFlow(THIS_UID, logger, LOG_TAG))

            // Verify no crash, and we get a value emitted
            assertThat(latest).isFalse()
        }

    companion object {
        private const val THIS_UID = 558
        private const val LOG_TAG = "LogTag"
    }
}
+97 −14
Original line number Diff line number Diff line
@@ -19,11 +19,12 @@ package com.android.systemui.statusbar.chips.notification.domain.interactor
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.activity.data.repository.activityManagerRepository
import com.android.systemui.activity.data.repository.fake
import com.android.systemui.kosmos.collectLastValue
import com.android.systemui.kosmos.runTest
import com.android.systemui.kosmos.useUnconfinedTestDispatcher
import com.android.systemui.statusbar.StatusBarIconView
import com.android.systemui.statusbar.chips.statusBarChipsLogger
import com.android.systemui.statusbar.notification.data.model.activeNotificationModel
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
@@ -35,7 +36,7 @@ import org.mockito.kotlin.mock
@RunWith(AndroidJUnit4::class)
class SingleNotificationChipInteractorTest : SysuiTestCase() {
    private val kosmos = testKosmos().useUnconfinedTestDispatcher()
    val logger = kosmos.statusBarChipsLogger
    val factory = kosmos.singleNotificationChipInteractorFactory

    @Test
    fun notificationChip_startsWithStartingModel() =
@@ -43,7 +44,7 @@ class SingleNotificationChipInteractorTest : SysuiTestCase() {
            val icon = mock<StatusBarIconView>()
            val startingNotif = activeNotificationModel(key = "notif1", statusBarChipIcon = icon)

            val underTest = SingleNotificationChipInteractor(startingNotif, logger)
            val underTest = factory.create(startingNotif)

            val latest by collectLastValue(underTest.notificationChip)

@@ -56,9 +57,8 @@ class SingleNotificationChipInteractorTest : SysuiTestCase() {
        kosmos.runTest {
            val originalIconView = mock<StatusBarIconView>()
            val underTest =
                SingleNotificationChipInteractor(
                    activeNotificationModel(key = "notif1", statusBarChipIcon = originalIconView),
                    logger,
                factory.create(
                    activeNotificationModel(key = "notif1", statusBarChipIcon = originalIconView)
                )

            val latest by collectLastValue(underTest.notificationChip)
@@ -77,9 +77,8 @@ class SingleNotificationChipInteractorTest : SysuiTestCase() {
        kosmos.runTest {
            val originalIconView = mock<StatusBarIconView>()
            val underTest =
                SingleNotificationChipInteractor(
                    activeNotificationModel(key = "notif1", statusBarChipIcon = originalIconView),
                    logger,
                factory.create(
                    activeNotificationModel(key = "notif1", statusBarChipIcon = originalIconView)
                )

            val latest by collectLastValue(underTest.notificationChip)
@@ -97,10 +96,7 @@ class SingleNotificationChipInteractorTest : SysuiTestCase() {
    fun notificationChip_missingStatusBarIconChipView_inConstructor_emitsNull() =
        kosmos.runTest {
            val underTest =
                SingleNotificationChipInteractor(
                    activeNotificationModel(key = "notif1", statusBarChipIcon = null),
                    logger,
                )
                factory.create(activeNotificationModel(key = "notif1", statusBarChipIcon = null))

            val latest by collectLastValue(underTest.notificationChip)

@@ -111,7 +107,7 @@ class SingleNotificationChipInteractorTest : SysuiTestCase() {
    fun notificationChip_missingStatusBarIconChipView_inSet_emitsNull() =
        kosmos.runTest {
            val startingNotif = activeNotificationModel(key = "notif1", statusBarChipIcon = mock())
            val underTest = SingleNotificationChipInteractor(startingNotif, logger)
            val underTest = factory.create(startingNotif)
            val latest by collectLastValue(underTest.notificationChip)
            assertThat(latest).isNotNull()

@@ -121,4 +117,91 @@ class SingleNotificationChipInteractorTest : SysuiTestCase() {

            assertThat(latest).isNull()
        }

    @Test
    fun notificationChip_appIsVisibleOnCreation_emitsNull() =
        kosmos.runTest {
            activityManagerRepository.fake.startingIsAppVisibleValue = true

            val underTest =
                factory.create(
                    activeNotificationModel(key = "notif", uid = UID, statusBarChipIcon = mock())
                )

            val latest by collectLastValue(underTest.notificationChip)

            assertThat(latest).isNull()
        }

    @Test
    fun notificationChip_appNotVisibleOnCreation_emitsValue() =
        kosmos.runTest {
            activityManagerRepository.fake.startingIsAppVisibleValue = false

            val underTest =
                factory.create(
                    activeNotificationModel(key = "notif", uid = UID, statusBarChipIcon = mock())
                )

            val latest by collectLastValue(underTest.notificationChip)

            assertThat(latest).isNotNull()
        }

    @Test
    fun notificationChip_hidesWhenAppIsVisible() =
        kosmos.runTest {
            val underTest =
                factory.create(
                    activeNotificationModel(key = "notif", uid = UID, statusBarChipIcon = mock())
                )

            val latest by collectLastValue(underTest.notificationChip)

            activityManagerRepository.fake.setIsAppVisible(UID, false)
            assertThat(latest).isNotNull()

            activityManagerRepository.fake.setIsAppVisible(UID, true)
            assertThat(latest).isNull()

            activityManagerRepository.fake.setIsAppVisible(UID, false)
            assertThat(latest).isNotNull()
        }

    // Note: This test is theoretically impossible because the notification key should contain the
    // UID, so if the UID changes then the key would also change and a new interactor would be
    // created. But, test it just in case.
    @Test
    fun notificationChip_updatedUid_rechecksAppVisibility_oldObserverUnregistered() =
        kosmos.runTest {
            activityManagerRepository.fake.startingIsAppVisibleValue = false

            val hiddenUid = 100
            val shownUid = 101

            val underTest =
                factory.create(
                    activeNotificationModel(
                        key = "notif",
                        uid = hiddenUid,
                        statusBarChipIcon = mock(),
                    )
                )
            val latest by collectLastValue(underTest.notificationChip)
            assertThat(latest).isNotNull()

            // WHEN the notif gets a new UID that starts as visible
            activityManagerRepository.fake.startingIsAppVisibleValue = true
            underTest.setNotification(
                activeNotificationModel(key = "notif", uid = shownUid, statusBarChipIcon = mock())
            )

            // THEN we re-fetch the app visibility state with the new UID, and since that UID is
            // visible, we hide the chip
            assertThat(latest).isNull()
        }

    companion object {
        private const val UID = 885
    }
}
+30 −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.activity

import com.android.systemui.activity.data.repository.ActivityManagerRepository
import com.android.systemui.activity.data.repository.ActivityManagerRepositoryImpl
import com.android.systemui.dagger.SysUISingleton
import dagger.Binds
import dagger.Module

@Module
interface ActivityManagerModule {
    @Binds
    @SysUISingleton
    fun activityManagerRepository(impl: ActivityManagerRepositoryImpl): ActivityManagerRepository
}
+118 −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.activity.data.repository

import android.app.ActivityManager
import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.log.core.Logger
import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart

/** Repository for interfacing with [ActivityManager]. */
interface ActivityManagerRepository {
    /**
     * Given a UID, creates a flow that emits true when the process with the given UID is visible to
     * the user and false otherwise.
     *
     * @param identifyingLogTag a tag identifying who created this flow, used for logging.
     */
    fun createIsAppVisibleFlow(
        creationUid: Int,
        logger: Logger,
        identifyingLogTag: String,
    ): Flow<Boolean>
}

@SysUISingleton
class ActivityManagerRepositoryImpl
@Inject
constructor(
    @Background private val backgroundContext: CoroutineContext,
    private val activityManager: ActivityManager,
) : ActivityManagerRepository {
    override fun createIsAppVisibleFlow(
        creationUid: Int,
        logger: Logger,
        identifyingLogTag: String,
    ): Flow<Boolean> {
        return conflatedCallbackFlow {
                val listener =
                    object : ActivityManager.OnUidImportanceListener {
                        override fun onUidImportance(uid: Int, importance: Int) {
                            if (uid != creationUid) {
                                return
                            }
                            val isAppVisible = isAppVisibleToUser(importance)
                            logger.d({
                                "$str1: #onUidImportance. importance=$int1, isAppVisible=$bool1"
                            }) {
                                str1 = identifyingLogTag
                                int1 = importance
                                bool1 = isAppVisible
                            }
                            trySend(isAppVisible)
                        }
                    }
                try {
                    // TODO(b/286258140): Replace this with the #addOnUidImportanceListener
                    //  overload that filters to certain UIDs.
                    activityManager.addOnUidImportanceListener(listener, IMPORTANCE_CUTPOINT)
                } catch (e: SecurityException) {
                    logger.e({ "$str1: Security exception on #addOnUidImportanceListener" }, e) {
                        str1 = identifyingLogTag
                    }
                }

                awaitClose { activityManager.removeOnUidImportanceListener(listener) }
            }
            .distinctUntilChanged()
            .onStart {
                try {
                    val isVisibleOnStart =
                        isAppVisibleToUser(activityManager.getUidImportance(creationUid))
                    logger.d({ "$str1: Starting UID observation. isAppVisible=$bool1" }) {
                        str1 = identifyingLogTag
                        bool1 = isVisibleOnStart
                    }
                    emit(isVisibleOnStart)
                } catch (e: SecurityException) {
                    logger.e({ "$str1: Security exception on #getUidImportance" }, e) {
                        str1 = identifyingLogTag
                    }
                    emit(false)
                }
            }
            .flowOn(backgroundContext)
    }

    /** Returns true if the given [importance] represents an app that's visible to the user. */
    private fun isAppVisibleToUser(importance: Int): Boolean {
        return importance <= IMPORTANCE_CUTPOINT
    }

    companion object {
        private const val IMPORTANCE_CUTPOINT = IMPORTANCE_FOREGROUND
    }
}
+2 −0
Original line number Diff line number Diff line
@@ -32,6 +32,7 @@ import com.android.systemui.BootCompleteCacheImpl;
import com.android.systemui.CameraProtectionModule;
import com.android.systemui.CoreStartable;
import com.android.systemui.SystemUISecondaryUserService;
import com.android.systemui.activity.ActivityManagerModule;
import com.android.systemui.ambient.dagger.AmbientModule;
import com.android.systemui.appops.dagger.AppOpsModule;
import com.android.systemui.assist.AssistModule;
@@ -198,6 +199,7 @@ import javax.inject.Named;
 * may not appreciate that.
 */
@Module(includes = {
        ActivityManagerModule.class,
        AmbientModule.class,
        AppOpsModule.class,
        AssistModule.class,
Loading