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

Commit 243ed588 authored by Beth Thibodeau's avatar Beth Thibodeau
Browse files

Allow inactive recommendation card and set timeout

When the flag is enabled, the recommendation card can be inactive,
rather than being removed immediately once it is swiped away. If the
smartspace update is marked as "PERIODIC_TRIGGER", it will not make the
carousel active. The card can still be removed manually with the long
press menu. In addition recommendation cards will now be removed automatically
when its expiry time is reached rather than waiting for an empty update.

Smartspace updates not marked as periodic will have the same behavior as
before (make carousel active, showing recent media first if it exists)

Test: atest com.android.systemui.media.controls
Test: manual with test build + smartspace flags
Bug: 264690420
Change-Id: I78d365a2df8b7654041bd425745343232cd19bc4
parent 5c906402
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -364,6 +364,9 @@ object Flags {
    // TODO(b/267007629): Tracking Bug
    val MEDIA_RESUME_PROGRESS = unreleasedFlag(915, "media_resume_progress")

    // TODO(b/267166152) : Tracking Bug
    val MEDIA_RETAIN_RECOMMENDATIONS = unreleasedFlag(916, "media_retain_recommendations")

    // 1000 - dock
    val SIMULATE_DOCK_THROUGH_CHARGING = releasedFlag(1000, "simulate_dock_through_charging")

+11 −2
Original line number Diff line number Diff line
@@ -41,10 +41,12 @@ data class SmartspaceMediaData(
    val recommendations: List<SmartspaceAction>,
    /** Intent for the user's initiated dismissal. */
    val dismissIntent: Intent?,
    /** The timestamp in milliseconds that headphone is connected. */
    /** The timestamp in milliseconds that the card was generated */
    val headphoneConnectionTimeMillis: Long,
    /** Instance ID for [MediaUiEventLogger] */
    val instanceId: InstanceId
    val instanceId: InstanceId,
    /** The timestamp in milliseconds indicating when the card should be removed */
    val expiryTimeMs: Long,
) {
    /**
     * Indicates if all the data is valid.
@@ -86,5 +88,12 @@ data class SmartspaceMediaData(
    }
}

/** Key for extras [SmartspaceMediaData.cardAction] indicating why the card was sent */
const val EXTRA_KEY_TRIGGER_SOURCE = "MEDIA_RECOMMENDATION_TRIGGER_SOURCE"
/** Value for [EXTRA_KEY_TRIGGER_SOURCE] when the card is sent on headphone connection */
const val EXTRA_VALUE_TRIGGER_HEADPHONE = "HEADPHONE_CONNECTION"
/** Value for key [EXTRA_KEY_TRIGGER_SOURCE] when the card is sent as a regular update */
const val EXTRA_VALUE_TRIGGER_PERIODIC = "PERIODIC_TRIGGER"

const val NUM_REQUIRED_RECOMMENDATIONS = 3
private val TAG = SmartspaceMediaData::class.simpleName!!
+32 −15
Original line number Diff line number Diff line
@@ -24,6 +24,7 @@ import com.android.systemui.broadcast.BroadcastSender
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.media.controls.models.player.MediaData
import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaData
import com.android.systemui.media.controls.util.MediaFlags
import com.android.systemui.media.controls.util.MediaUiEventLogger
import com.android.systemui.settings.UserTracker
import com.android.systemui.statusbar.NotificationLockscreenUserManager
@@ -66,7 +67,8 @@ constructor(
    private val lockscreenUserManager: NotificationLockscreenUserManager,
    @Main private val executor: Executor,
    private val systemClock: SystemClock,
    private val logger: MediaUiEventLogger
    private val logger: MediaUiEventLogger,
    private val mediaFlags: MediaFlags,
) : MediaDataManager.Listener {
    private val _listeners: MutableSet<MediaDataManager.Listener> = mutableSetOf()
    internal val listeners: Set<MediaDataManager.Listener>
@@ -121,7 +123,9 @@ constructor(
        data: SmartspaceMediaData,
        shouldPrioritize: Boolean
    ) {
        if (!data.isActive) {
        // With persistent recommendation card, we could get a background update while inactive
        // Otherwise, consider it an invalid update
        if (!data.isActive && !mediaFlags.isPersistentSsCardEnabled()) {
            Log.d(TAG, "Inactive recommendation data. Skip triggering.")
            return
        }
@@ -141,7 +145,7 @@ constructor(
            }
        }

        val shouldReactivate = !hasActiveMedia() && hasAnyMedia()
        val shouldReactivate = !hasActiveMedia() && hasAnyMedia() && data.isActive

        if (timeSinceActive < smartspaceMaxAgeMillis) {
            // It could happen there are existing active media resume cards, then we don't need to
@@ -169,7 +173,7 @@ constructor(
                    )
                }
            }
        } else {
        } else if (data.isActive) {
            // Mark to prioritize Smartspace card if no recent media.
            shouldPrioritizeMutable = true
        }
@@ -252,7 +256,7 @@ constructor(
            if (dismissIntent == null) {
                Log.w(
                    TAG,
                    "Cannot create dismiss action click action: " + "extras missing dismiss_intent."
                    "Cannot create dismiss action click action: extras missing dismiss_intent."
                )
            } else if (
                dismissIntent.getComponent() != null &&
@@ -264,17 +268,23 @@ constructor(
            } else {
                broadcastSender.sendBroadcast(dismissIntent)
            }

            if (mediaFlags.isPersistentSsCardEnabled()) {
                smartspaceMediaData = smartspaceMediaData.copy(isActive = false)
                mediaDataManager.setRecommendationInactive(smartspaceMediaData.targetId)
            } else {
                smartspaceMediaData =
                    EMPTY_SMARTSPACE_MEDIA_DATA.copy(
                        targetId = smartspaceMediaData.targetId,
                    instanceId = smartspaceMediaData.instanceId
                        instanceId = smartspaceMediaData.instanceId,
                    )
                mediaDataManager.dismissSmartspaceRecommendation(
                    smartspaceMediaData.targetId,
                delay = 0L
                    delay = 0L,
                )
            }
        }
    }

    /** Are there any active media entries, including the recommendation? */
    fun hasActiveMediaOrRecommendation() =
@@ -283,8 +293,15 @@ constructor(
                (smartspaceMediaData.isValid() || reactivatedKey != null))

    /** Are there any media entries we should display? */
    fun hasAnyMediaOrRecommendation() =
        userEntries.isNotEmpty() || (smartspaceMediaData.isActive && smartspaceMediaData.isValid())
    fun hasAnyMediaOrRecommendation(): Boolean {
        val hasSmartspace =
            if (mediaFlags.isPersistentSsCardEnabled()) {
                smartspaceMediaData.isValid()
            } else {
                smartspaceMediaData.isActive && smartspaceMediaData.isValid()
            }
        return userEntries.isNotEmpty() || hasSmartspace
    }

    /** Are there any media notifications active (excluding the recommendation)? */
    fun hasActiveMedia() = userEntries.any { it.value.active }
+64 −19
Original line number Diff line number Diff line
@@ -49,7 +49,6 @@ import android.support.v4.media.MediaMetadataCompat
import android.text.TextUtils
import android.util.Log
import androidx.media.utils.MediaConstants
import com.android.internal.annotations.VisibleForTesting
import com.android.internal.logging.InstanceId
import com.android.systemui.Dumpable
import com.android.systemui.R
@@ -63,6 +62,8 @@ import com.android.systemui.media.controls.models.player.MediaButton
import com.android.systemui.media.controls.models.player.MediaData
import com.android.systemui.media.controls.models.player.MediaDeviceData
import com.android.systemui.media.controls.models.player.MediaViewHolder
import com.android.systemui.media.controls.models.recommendation.EXTRA_KEY_TRIGGER_SOURCE
import com.android.systemui.media.controls.models.recommendation.EXTRA_VALUE_TRIGGER_PERIODIC
import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaData
import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaDataProvider
import com.android.systemui.media.controls.resume.MediaResumeListener
@@ -119,7 +120,6 @@ private val LOADING =
        appUid = Process.INVALID_UID
    )

@VisibleForTesting
internal val EMPTY_SMARTSPACE_MEDIA_DATA =
    SmartspaceMediaData(
        targetId = "INVALID",
@@ -129,7 +129,8 @@ internal val EMPTY_SMARTSPACE_MEDIA_DATA =
        recommendations = emptyList(),
        dismissIntent = null,
        headphoneConnectionTimeMillis = 0,
        instanceId = InstanceId.fakeInstanceId(-1)
        instanceId = InstanceId.fakeInstanceId(-1),
        expiryTimeMs = 0,
    )

fun isMediaNotification(sbn: StatusBarNotification): Boolean {
@@ -548,6 +549,11 @@ class MediaDataManager(
            if (DEBUG) Log.d(TAG, "Updating $key timedOut: $timedOut")
            onMediaDataLoaded(key, key, it)
        }

        if (key == smartspaceMediaData.targetId) {
            if (DEBUG) Log.d(TAG, "smartspace card expired")
            dismissSmartspaceRecommendation(key, delay = 0L)
        }
    }

    /** Called when the player's [PlaybackState] has been updated with new actions and/or state */
@@ -605,8 +611,8 @@ class MediaDataManager(
    }

    /**
     * Called whenever the recommendation has been expired, or swiped from QQS. This will make the
     * recommendation view to not be shown anymore during this headphone connection session.
     * Called whenever the recommendation has been expired or removed by the user. This will remove
     * the recommendation card entirely from the carousel.
     */
    fun dismissSmartspaceRecommendation(key: String, delay: Long) {
        if (smartspaceMediaData.targetId != key || !smartspaceMediaData.isValid()) {
@@ -628,6 +634,23 @@ class MediaDataManager(
        )
    }

    /** Called when the recommendation card should no longer be visible in QQS or lockscreen */
    fun setRecommendationInactive(key: String) {
        if (!mediaFlags.isPersistentSsCardEnabled()) {
            Log.e(TAG, "Only persistent recommendation can be inactive!")
            return
        }
        if (DEBUG) Log.d(TAG, "Setting smartspace recommendation inactive")

        if (smartspaceMediaData.targetId != key || !smartspaceMediaData.isValid()) {
            // If this doesn't match, or we've already invalidated the data, no action needed
            return
        }

        smartspaceMediaData = smartspaceMediaData.copy(isActive = false)
        notifySmartspaceMediaDataLoaded(smartspaceMediaData.targetId, smartspaceMediaData)
    }

    private fun loadMediaDataInBgForResumption(
        userId: Int,
        desc: MediaDescription,
@@ -1265,12 +1288,25 @@ class MediaDataManager(
                if (DEBUG) {
                    Log.d(TAG, "Set Smartspace media to be inactive for the data update")
                }
                if (mediaFlags.isPersistentSsCardEnabled()) {
                    // Smartspace uses this signal to hide the card (e.g. when it expires or user
                    // disconnects headphones), so treat as setting inactive when flag is on
                    smartspaceMediaData = smartspaceMediaData.copy(isActive = false)
                    notifySmartspaceMediaDataLoaded(
                        smartspaceMediaData.targetId,
                        smartspaceMediaData,
                    )
                } else {
                    smartspaceMediaData =
                        EMPTY_SMARTSPACE_MEDIA_DATA.copy(
                            targetId = smartspaceMediaData.targetId,
                        instanceId = smartspaceMediaData.instanceId
                            instanceId = smartspaceMediaData.instanceId,
                        )
                    notifySmartspaceMediaDataRemoved(
                        smartspaceMediaData.targetId,
                        immediately = false,
                    )
                notifySmartspaceMediaDataRemoved(smartspaceMediaData.targetId, immediately = false)
                }
            }
            1 -> {
                val newMediaTarget = mediaTargets.get(0)
@@ -1279,7 +1315,7 @@ class MediaDataManager(
                    return
                }
                if (DEBUG) Log.d(TAG, "Forwarding Smartspace media update.")
                smartspaceMediaData = toSmartspaceMediaData(newMediaTarget, isActive = true)
                smartspaceMediaData = toSmartspaceMediaData(newMediaTarget)
                notifySmartspaceMediaDataLoaded(smartspaceMediaData.targetId, smartspaceMediaData)
            }
            else -> {
@@ -1288,7 +1324,7 @@ class MediaDataManager(
                Log.wtf(TAG, "More than 1 Smartspace Media Update. Resetting the status...")
                notifySmartspaceMediaDataRemoved(
                    smartspaceMediaData.targetId,
                    false /* immediately */
                    immediately = false,
                )
                smartspaceMediaData = EMPTY_SMARTSPACE_MEDIA_DATA
            }
@@ -1494,21 +1530,28 @@ class MediaDataManager(
    }

    /**
     * Converts the pass-in SmartspaceTarget to SmartspaceMediaData with the pass-in active status.
     * Converts the pass-in SmartspaceTarget to SmartspaceMediaData
     *
     * @return An empty SmartspaceMediaData with the valid target Id is returned if the
     * SmartspaceTarget's data is invalid.
     */
    private fun toSmartspaceMediaData(
        target: SmartspaceTarget,
        isActive: Boolean
    ): SmartspaceMediaData {
    private fun toSmartspaceMediaData(target: SmartspaceTarget): SmartspaceMediaData {
        var dismissIntent: Intent? = null
        if (target.baseAction != null && target.baseAction.extras != null) {
            dismissIntent =
                target.baseAction.extras.getParcelable(EXTRAS_SMARTSPACE_DISMISS_INTENT_KEY)
                    as Intent?
        }

        val isActive =
            when {
                !mediaFlags.isPersistentSsCardEnabled() -> true
                target.baseAction == null -> true
                else ->
                    target.baseAction.extras.getString(EXTRA_KEY_TRIGGER_SOURCE) !=
                        EXTRA_VALUE_TRIGGER_PERIODIC
            }

        packageName(target)?.let {
            return SmartspaceMediaData(
                targetId = target.smartspaceTargetId,
@@ -1518,7 +1561,8 @@ class MediaDataManager(
                recommendations = target.iconGrid,
                dismissIntent = dismissIntent,
                headphoneConnectionTimeMillis = target.creationTimeMillis,
                instanceId = logger.getNewInstanceId()
                instanceId = logger.getNewInstanceId(),
                expiryTimeMs = target.expiryTimeMillis,
            )
        }
        return EMPTY_SMARTSPACE_MEDIA_DATA.copy(
@@ -1526,7 +1570,8 @@ class MediaDataManager(
            isActive = isActive,
            dismissIntent = dismissIntent,
            headphoneConnectionTimeMillis = target.creationTimeMillis,
            instanceId = logger.getNewInstanceId()
            instanceId = logger.getNewInstanceId(),
            expiryTimeMs = target.expiryTimeMillis,
        )
    }

+88 −1
Original line number Diff line number Diff line
@@ -23,7 +23,9 @@ import com.android.internal.annotations.VisibleForTesting
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.media.controls.models.player.MediaData
import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaData
import com.android.systemui.media.controls.util.MediaControllerFactory
import com.android.systemui.media.controls.util.MediaFlags
import com.android.systemui.plugins.statusbar.StatusBarStateController
import com.android.systemui.statusbar.NotificationMediaManager.isPlayingState
import com.android.systemui.statusbar.SysuiStatusBarStateController
@@ -49,10 +51,12 @@ constructor(
    @Main private val mainExecutor: DelayableExecutor,
    private val logger: MediaTimeoutLogger,
    statusBarStateController: SysuiStatusBarStateController,
    private val systemClock: SystemClock
    private val systemClock: SystemClock,
    private val mediaFlags: MediaFlags,
) : MediaDataManager.Listener {

    private val mediaListeners: MutableMap<String, PlaybackStateListener> = mutableMapOf()
    private val recommendationListeners: MutableMap<String, RecommendationListener> = mutableMapOf()

    /**
     * Callback representing that a media object is now expired:
@@ -93,6 +97,16 @@ constructor(
                                listener.doTimeout()
                            }
                        }

                        recommendationListeners.forEach { (key, listener) ->
                            if (
                                listener.cancellation != null &&
                                    listener.expiration <= systemClock.currentTimeMillis()
                            ) {
                                logger.logTimeoutCancelled(key, "Timed out while dozing")
                                listener.doTimeout()
                            }
                        }
                    }
                }
            }
@@ -155,6 +169,30 @@ constructor(
        mediaListeners.remove(key)?.destroy()
    }

    override fun onSmartspaceMediaDataLoaded(
        key: String,
        data: SmartspaceMediaData,
        shouldPrioritize: Boolean
    ) {
        if (!mediaFlags.isPersistentSsCardEnabled()) return

        // First check if we already have a listener
        recommendationListeners.get(key)?.let {
            if (!it.destroyed) {
                it.recommendationData = data
                return
            }
        }

        // Otherwise, create a new one
        recommendationListeners[key] = RecommendationListener(key, data)
    }

    override fun onSmartspaceMediaDataRemoved(key: String, immediately: Boolean) {
        if (!mediaFlags.isPersistentSsCardEnabled()) return
        recommendationListeners.remove(key)?.destroy()
    }

    fun isTimedOut(key: String): Boolean {
        return mediaListeners[key]?.timedOut ?: false
    }
@@ -335,4 +373,53 @@ constructor(
        }
        return true
    }

    /** Listens to changes in recommendation card data and schedules a timeout for its expiration */
    private inner class RecommendationListener(var key: String, data: SmartspaceMediaData) {
        private var timedOut = false
        var destroyed = false
        var expiration = Long.MAX_VALUE
            private set
        var cancellation: Runnable? = null
            private set

        var recommendationData: SmartspaceMediaData = data
            set(value) {
                destroyed = false
                field = value
                processUpdate()
            }

        init {
            recommendationData = data
        }

        fun destroy() {
            cancellation?.run()
            cancellation = null
            destroyed = true
        }

        private fun processUpdate() {
            if (recommendationData.expiryTimeMs != expiration) {
                // The expiry time changed - cancel and reschedule
                val timeout =
                    recommendationData.expiryTimeMs -
                        recommendationData.headphoneConnectionTimeMillis
                logger.logRecommendationTimeoutScheduled(key, timeout)
                cancellation?.run()
                cancellation = mainExecutor.executeDelayed({ doTimeout() }, timeout)
                expiration = recommendationData.expiryTimeMs
            }
        }

        fun doTimeout() {
            cancellation?.run()
            cancellation = null
            logger.logTimeout(key)
            timedOut = true
            expiration = Long.MAX_VALUE
            timeoutCallback(key, timedOut)
        }
    }
}
Loading