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

Commit cd3c0edf authored by Jeff DeCew's avatar Jeff DeCew
Browse files

Split the minimalism prototype into an alternate coordinator

Bug: 330387368
Flag: com.android.systemui.notification_minimalism_prototype
Test: atest SystemUITests
Change-Id: Ic3218a760c931370d3698a4e17e4bde9ee4f1bf4
parent 08763676
Loading
Loading
Loading
Loading
+423 −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.statusbar.notification.collection.coordinator

import android.annotation.SuppressLint
import android.app.NotificationManager
import android.os.UserHandle
import android.provider.Settings
import androidx.annotation.VisibleForTesting
import com.android.systemui.Dumpable
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.dump.DumpManager
import com.android.systemui.keyguard.data.repository.KeyguardRepository
import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
import com.android.systemui.keyguard.shared.model.KeyguardState
import com.android.systemui.plugins.statusbar.StatusBarStateController
import com.android.systemui.scene.shared.model.Scenes
import com.android.systemui.statusbar.StatusBarState
import com.android.systemui.statusbar.expansionChanges
import com.android.systemui.statusbar.notification.collection.GroupEntry
import com.android.systemui.statusbar.notification.collection.ListEntry
import com.android.systemui.statusbar.notification.collection.NotifPipeline
import com.android.systemui.statusbar.notification.collection.NotificationEntry
import com.android.systemui.statusbar.notification.collection.coordinator.dagger.CoordinatorScope
import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifPromoter
import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifSectioner
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener
import com.android.systemui.statusbar.notification.domain.interactor.SeenNotificationsInteractor
import com.android.systemui.statusbar.notification.shared.NotificationMinimalismPrototype
import com.android.systemui.statusbar.notification.stack.BUCKET_TOP_ONGOING
import com.android.systemui.statusbar.notification.stack.BUCKET_TOP_UNSEEN
import com.android.systemui.statusbar.policy.HeadsUpManager
import com.android.systemui.statusbar.policy.headsUpEvents
import com.android.systemui.util.asIndenting
import com.android.systemui.util.printCollection
import com.android.systemui.util.settings.SecureSettings
import com.android.systemui.util.settings.SettingsProxyExt.observerFlow
import java.io.PrintWriter
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield

/**
 * If the setting is enabled, this will track seen notifications and ensure that they only show in
 * the shelf on the lockscreen.
 *
 * This class is a replacement of the [OriginalUnseenKeyguardCoordinator].
 */
@CoordinatorScope
@SuppressLint("SharedFlowCreation")
class LockScreenMinimalismCoordinator
@Inject
constructor(
    @Background private val bgDispatcher: CoroutineDispatcher,
    private val dumpManager: DumpManager,
    private val headsUpManager: HeadsUpManager,
    private val keyguardRepository: KeyguardRepository,
    private val keyguardTransitionInteractor: KeyguardTransitionInteractor,
    private val logger: KeyguardCoordinatorLogger,
    @Application private val scope: CoroutineScope,
    private val secureSettings: SecureSettings,
    private val seenNotificationsInteractor: SeenNotificationsInteractor,
    private val statusBarStateController: StatusBarStateController,
) : Coordinator, Dumpable {

    private val unseenNotifications = mutableSetOf<NotificationEntry>()
    private val unseenEntryAdded = MutableSharedFlow<NotificationEntry>(extraBufferCapacity = 1)
    private val unseenEntryRemoved = MutableSharedFlow<NotificationEntry>(extraBufferCapacity = 1)
    private var unseenFilterEnabled = false

    override fun attach(pipeline: NotifPipeline) {
        if (NotificationMinimalismPrototype.isUnexpectedlyInLegacyMode()) {
            return
        }
        pipeline.addPromoter(unseenNotifPromoter)
        pipeline.addOnBeforeTransformGroupsListener(::pickOutTopUnseenNotifs)
        pipeline.addCollectionListener(collectionListener)
        scope.launch { trackUnseenFilterSettingChanges() }
        dumpManager.registerDumpable(this)
    }

    private suspend fun trackSeenNotifications() {
        // Whether or not keyguard is visible (or occluded).
        @Suppress("DEPRECATION")
        val isKeyguardPresentFlow: Flow<Boolean> =
            keyguardTransitionInteractor
                .transitionValue(
                    scene = Scenes.Gone,
                    stateWithoutSceneContainer = KeyguardState.GONE,
                )
                .map { it == 0f }
                .distinctUntilChanged()
                .onEach { trackingUnseen -> logger.logTrackingUnseen(trackingUnseen) }

        // Separately track seen notifications while the device is locked, applying once the device
        // is unlocked.
        val notificationsSeenWhileLocked = mutableSetOf<NotificationEntry>()

        // Use [collectLatest] to cancel any running jobs when [trackingUnseen] changes.
        isKeyguardPresentFlow.collectLatest { isKeyguardPresent: Boolean ->
            if (isKeyguardPresent) {
                // Keyguard is not gone, notifications need to be visible for a certain threshold
                // before being marked as seen
                trackSeenNotificationsWhileLocked(notificationsSeenWhileLocked)
            } else {
                // Mark all seen-while-locked notifications as seen for real.
                if (notificationsSeenWhileLocked.isNotEmpty()) {
                    unseenNotifications.removeAll(notificationsSeenWhileLocked)
                    logger.logAllMarkedSeenOnUnlock(
                        seenCount = notificationsSeenWhileLocked.size,
                        remainingUnseenCount = unseenNotifications.size
                    )
                    notificationsSeenWhileLocked.clear()
                }
                unseenNotifPromoter.invalidateList("keyguard no longer showing")
                // Keyguard is gone, notifications can be immediately marked as seen when they
                // become visible.
                trackSeenNotificationsWhileUnlocked()
            }
        }
    }

    /**
     * Keep [notificationsSeenWhileLocked] updated to represent which notifications have actually
     * been "seen" while the device is on the keyguard.
     */
    private suspend fun trackSeenNotificationsWhileLocked(
        notificationsSeenWhileLocked: MutableSet<NotificationEntry>,
    ) = coroutineScope {
        // Remove removed notifications from the set
        launch {
            unseenEntryRemoved.collect { entry ->
                if (notificationsSeenWhileLocked.remove(entry)) {
                    logger.logRemoveSeenOnLockscreen(entry)
                }
            }
        }
        // Use collectLatest so that the timeout delay is cancelled if the device enters doze, and
        // is restarted when doze ends.
        keyguardRepository.isDozing.collectLatest { isDozing ->
            if (!isDozing) {
                trackSeenNotificationsWhileLockedAndNotDozing(notificationsSeenWhileLocked)
            }
        }
    }

    /**
     * Keep [notificationsSeenWhileLocked] updated to represent which notifications have actually
     * been "seen" while the device is on the keyguard and not dozing. Any new and existing unseen
     * notifications are not marked as seen until they are visible for the [SEEN_TIMEOUT] duration.
     */
    private suspend fun trackSeenNotificationsWhileLockedAndNotDozing(
        notificationsSeenWhileLocked: MutableSet<NotificationEntry>
    ) = coroutineScope {
        // All child tracking jobs will be cancelled automatically when this is cancelled.
        val trackingJobsByEntry = mutableMapOf<NotificationEntry, Job>()

        /**
         * Wait for the user to spend enough time on the lock screen before removing notification
         * from unseen set upon unlock.
         */
        suspend fun trackSeenDurationThreshold(entry: NotificationEntry) {
            if (notificationsSeenWhileLocked.remove(entry)) {
                logger.logResetSeenOnLockscreen(entry)
            }
            delay(SEEN_TIMEOUT)
            notificationsSeenWhileLocked.add(entry)
            trackingJobsByEntry.remove(entry)
            logger.logSeenOnLockscreen(entry)
        }

        /** Stop any unseen tracking when a notification is removed. */
        suspend fun stopTrackingRemovedNotifs(): Nothing =
            unseenEntryRemoved.collect { entry ->
                trackingJobsByEntry.remove(entry)?.let {
                    it.cancel()
                    logger.logStopTrackingLockscreenSeenDuration(entry)
                }
            }

        /** Start tracking new notifications when they are posted. */
        suspend fun trackNewUnseenNotifs(): Nothing = coroutineScope {
            unseenEntryAdded.collect { entry ->
                logger.logTrackingLockscreenSeenDuration(entry)
                // If this is an update, reset the tracking.
                trackingJobsByEntry[entry]?.let {
                    it.cancel()
                    logger.logResetSeenOnLockscreen(entry)
                }
                trackingJobsByEntry[entry] = launch { trackSeenDurationThreshold(entry) }
            }
        }

        // Start tracking for all notifications that are currently unseen.
        logger.logTrackingLockscreenSeenDuration(unseenNotifications)
        unseenNotifications.forEach { entry ->
            trackingJobsByEntry[entry] = launch { trackSeenDurationThreshold(entry) }
        }

        launch { trackNewUnseenNotifs() }
        launch { stopTrackingRemovedNotifs() }
    }

    // Track "seen" notifications, marking them as such when either shade is expanded or the
    // notification becomes heads up.
    private suspend fun trackSeenNotificationsWhileUnlocked() {
        coroutineScope {
            launch { clearUnseenNotificationsWhenShadeIsExpanded() }
            launch { markHeadsUpNotificationsAsSeen() }
        }
    }

    private suspend fun clearUnseenNotificationsWhenShadeIsExpanded() {
        statusBarStateController.expansionChanges.collectLatest { isExpanded ->
            // Give keyguard events time to propagate, in case this expansion is part of the
            // keyguard transition and not the user expanding the shade
            yield()
            if (isExpanded) {
                logger.logShadeExpanded()
                unseenNotifications.clear()
            }
        }
    }

    private suspend fun markHeadsUpNotificationsAsSeen() {
        headsUpManager.allEntries
            .filter { it.isRowPinned }
            .forEach { unseenNotifications.remove(it) }
        headsUpManager.headsUpEvents.collect { (entry, isHun) ->
            if (isHun) {
                logger.logUnseenHun(entry.key)
                unseenNotifications.remove(entry)
            }
        }
    }

    private fun unseenFeatureEnabled(): Flow<Boolean> {
        // TODO(b/330387368): create LOCK_SCREEN_NOTIFICATION_MINIMALISM setting to use here?
        //  Or should we actually just repurpose using the existing setting?
        if (NotificationMinimalismPrototype.isEnabled) {
            return flowOf(true)
        }
        return secureSettings
            // emit whenever the setting has changed
            .observerFlow(
                UserHandle.USER_ALL,
                Settings.Secure.LOCK_SCREEN_SHOW_ONLY_UNSEEN_NOTIFICATIONS,
            )
            // perform a query immediately
            .onStart { emit(Unit) }
            // for each change, lookup the new value
            .map {
                secureSettings.getIntForUser(
                    name = Settings.Secure.LOCK_SCREEN_SHOW_ONLY_UNSEEN_NOTIFICATIONS,
                    def = 0,
                    userHandle = UserHandle.USER_CURRENT,
                ) == 1
            }
            // don't emit anything if nothing has changed
            .distinctUntilChanged()
            // perform lookups on the bg thread pool
            .flowOn(bgDispatcher)
            // only track the most recent emission, if events are happening faster than they can be
            // consumed
            .conflate()
    }

    private suspend fun trackUnseenFilterSettingChanges() {
        unseenFeatureEnabled().collectLatest { setting ->
            // update local field and invalidate if necessary
            if (setting != unseenFilterEnabled) {
                unseenFilterEnabled = setting
                unseenNotifPromoter.invalidateList("unseen setting changed")
            }
            // if the setting is enabled, then start tracking and filtering unseen notifications
            if (setting) {
                trackSeenNotifications()
            }
        }
    }

    private val collectionListener =
        object : NotifCollectionListener {
            override fun onEntryAdded(entry: NotificationEntry) {
                if (
                    keyguardRepository.isKeyguardShowing() || !statusBarStateController.isExpanded
                ) {
                    logger.logUnseenAdded(entry.key)
                    unseenNotifications.add(entry)
                    unseenEntryAdded.tryEmit(entry)
                }
            }

            override fun onEntryUpdated(entry: NotificationEntry) {
                if (
                    keyguardRepository.isKeyguardShowing() || !statusBarStateController.isExpanded
                ) {
                    logger.logUnseenUpdated(entry.key)
                    unseenNotifications.add(entry)
                    unseenEntryAdded.tryEmit(entry)
                }
            }

            override fun onEntryRemoved(entry: NotificationEntry, reason: Int) {
                if (unseenNotifications.remove(entry)) {
                    logger.logUnseenRemoved(entry.key)
                    unseenEntryRemoved.tryEmit(entry)
                }
            }
        }

    private fun pickOutTopUnseenNotifs(list: List<ListEntry>) {
        if (NotificationMinimalismPrototype.isUnexpectedlyInLegacyMode()) return
        // Only ever elevate a top unseen notification on keyguard, not even locked shade
        if (statusBarStateController.state != StatusBarState.KEYGUARD) {
            seenNotificationsInteractor.setTopOngoingNotification(null)
            seenNotificationsInteractor.setTopUnseenNotification(null)
            return
        }
        // On keyguard pick the top-ranked unseen or ongoing notification to elevate
        val nonSummaryEntries: Sequence<NotificationEntry> =
            list
                .asSequence()
                .flatMap {
                    when (it) {
                        is NotificationEntry -> listOfNotNull(it)
                        is GroupEntry -> it.children
                        else -> error("unhandled type of $it")
                    }
                }
                .filter { it.importance >= NotificationManager.IMPORTANCE_DEFAULT }
        seenNotificationsInteractor.setTopOngoingNotification(
            nonSummaryEntries
                .filter { ColorizedFgsCoordinator.isRichOngoing(it) }
                .minByOrNull { it.ranking.rank }
        )
        seenNotificationsInteractor.setTopUnseenNotification(
            nonSummaryEntries
                .filter { !ColorizedFgsCoordinator.isRichOngoing(it) && it in unseenNotifications }
                .minByOrNull { it.ranking.rank }
        )
    }

    @VisibleForTesting
    val unseenNotifPromoter =
        object : NotifPromoter(TAG) {
            override fun shouldPromoteToTopLevel(child: NotificationEntry): Boolean =
                if (NotificationMinimalismPrototype.isUnexpectedlyInLegacyMode()) false
                else if (!NotificationMinimalismPrototype.ungroupTopUnseen) false
                else
                    seenNotificationsInteractor.isTopOngoingNotification(child) ||
                        seenNotificationsInteractor.isTopUnseenNotification(child)
        }

    val topOngoingSectioner =
        object : NotifSectioner("TopOngoing", BUCKET_TOP_ONGOING) {
            override fun isInSection(entry: ListEntry): Boolean {
                if (NotificationMinimalismPrototype.isUnexpectedlyInLegacyMode()) return false
                return entry.anyEntry { notificationEntry ->
                    seenNotificationsInteractor.isTopOngoingNotification(notificationEntry)
                }
            }
        }

    val topUnseenSectioner =
        object : NotifSectioner("TopUnseen", BUCKET_TOP_UNSEEN) {
            override fun isInSection(entry: ListEntry): Boolean {
                if (NotificationMinimalismPrototype.isUnexpectedlyInLegacyMode()) return false
                return entry.anyEntry { notificationEntry ->
                    seenNotificationsInteractor.isTopUnseenNotification(notificationEntry)
                }
            }
        }

    private fun ListEntry.anyEntry(predicate: (NotificationEntry?) -> Boolean) =
        when {
            predicate(representativeEntry) -> true
            this !is GroupEntry -> false
            else -> children.any(predicate)
        }

    override fun dump(pw: PrintWriter, args: Array<out String>) =
        with(pw.asIndenting()) {
            seenNotificationsInteractor.dump(this)
            printCollection("unseen notifications", unseenNotifications) { println(it.key) }
        }

    companion object {
        private const val TAG = "LockScreenMinimalismCoordinator"
        private val SEEN_TIMEOUT = 5.seconds
    }
}
+8 −3
Original line number Diff line number Diff line
@@ -47,6 +47,7 @@ constructor(
    hideNotifsForOtherUsersCoordinator: HideNotifsForOtherUsersCoordinator,
    keyguardCoordinator: KeyguardCoordinator,
    unseenKeyguardCoordinator: OriginalUnseenKeyguardCoordinator,
    lockScreenMinimalismCoordinator: LockScreenMinimalismCoordinator,
    rankingCoordinator: RankingCoordinator,
    colorizedFgsCoordinator: ColorizedFgsCoordinator,
    deviceProvisionedCoordinator: DeviceProvisionedCoordinator,
@@ -87,7 +88,11 @@ constructor(
        mCoordinators.add(hideLocallyDismissedNotifsCoordinator)
        mCoordinators.add(hideNotifsForOtherUsersCoordinator)
        mCoordinators.add(keyguardCoordinator)
        if (NotificationMinimalismPrototype.isEnabled) {
            mCoordinators.add(lockScreenMinimalismCoordinator)
        } else {
            mCoordinators.add(unseenKeyguardCoordinator)
        }
        mCoordinators.add(rankingCoordinator)
        mCoordinators.add(colorizedFgsCoordinator)
        mCoordinators.add(deviceProvisionedCoordinator)
@@ -121,11 +126,11 @@ constructor(

        // Manually add Ordered Sections
        if (NotificationMinimalismPrototype.isEnabled) {
            mOrderedSections.add(unseenKeyguardCoordinator.topOngoingSectioner) // Top Ongoing
            mOrderedSections.add(lockScreenMinimalismCoordinator.topOngoingSectioner) // Top Ongoing
        }
        mOrderedSections.add(headsUpCoordinator.sectioner) // HeadsUp
        if (NotificationMinimalismPrototype.isEnabled) {
            mOrderedSections.add(unseenKeyguardCoordinator.topUnseenSectioner) // Top Unseen
            mOrderedSections.add(lockScreenMinimalismCoordinator.topUnseenSectioner) // Top Unseen
        }
        mOrderedSections.add(colorizedFgsCoordinator.sectioner) // ForegroundService
        if (PriorityPeopleSection.isEnabled) {
+13 −106

File changed.

Preview size limit exceeded, changes collapsed.

+20 −0
Original line number Diff line number Diff line
@@ -16,10 +16,12 @@

package com.android.systemui.statusbar.notification.domain.interactor

import android.util.IndentingPrintWriter
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.statusbar.notification.collection.NotificationEntry
import com.android.systemui.statusbar.notification.data.repository.ActiveNotificationListRepository
import com.android.systemui.statusbar.notification.shared.NotificationMinimalismPrototype
import com.android.systemui.util.printSection
import javax.inject.Inject
import kotlinx.coroutines.flow.StateFlow

@@ -61,4 +63,22 @@ constructor(
    fun isTopUnseenNotification(entry: NotificationEntry?): Boolean =
        if (NotificationMinimalismPrototype.isUnexpectedlyInLegacyMode()) false
        else entry != null && notificationListRepository.topUnseenNotificationKey.value == entry.key

    fun dump(pw: IndentingPrintWriter) =
        with(pw) {
            printSection("SeenNotificationsInteractor") {
                print(
                    "hasFilteredOutSeenNotifications",
                    notificationListRepository.hasFilteredOutSeenNotifications.value
                )
                print(
                    "topOngoingNotificationKey",
                    notificationListRepository.topOngoingNotificationKey.value
                )
                print(
                    "topUnseenNotificationKey",
                    notificationListRepository.topUnseenNotificationKey.value
                )
            }
        }
}