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

Commit a57a70b9 authored by Michael Mikhail's avatar Michael Mikhail Committed by Android (Google) Code Review
Browse files

Merge changes from topic "media-control-view-model" into main

* changes:
  Add media control view model.
  Add media view-model classes
parents 988b8246 69ef80b3
Loading
Loading
Loading
Loading
+135 −11
Original line number Diff line number Diff line
@@ -16,10 +16,17 @@

package com.android.systemui.media.controls.domain.interactor

import android.app.PendingIntent
import android.os.Bundle
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.internal.logging.InstanceId
import com.android.systemui.SysuiTestCase
import com.android.systemui.activityIntentHelper
import com.android.systemui.animation.ActivityTransitionAnimator
import com.android.systemui.animation.DialogTransitionAnimator
import com.android.systemui.animation.Expandable
import com.android.systemui.bluetooth.mockBroadcastDialogController
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.kosmos.testScope
import com.android.systemui.media.controls.domain.pipeline.MediaDataFilterImpl
@@ -28,13 +35,22 @@ import com.android.systemui.media.controls.domain.pipeline.interactor.mediaContr
import com.android.systemui.media.controls.domain.pipeline.mediaDataFilter
import com.android.systemui.media.controls.shared.model.MediaData
import com.android.systemui.media.controls.util.mediaInstanceId
import com.android.systemui.media.mediaOutputDialogManager
import com.android.systemui.mockActivityIntentHelper
import com.android.systemui.plugins.activityStarter
import com.android.systemui.statusbar.notificationLockscreenUserManager
import com.android.systemui.statusbar.policy.keyguardStateController
import com.android.systemui.testKosmos
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.eq
import com.android.systemui.util.mockito.mock
import com.android.systemui.util.mockito.whenever
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.never
import org.mockito.Mockito.verify

@SmallTest
@RunWith(AndroidJUnit4::class)
@@ -44,10 +60,16 @@ class MediaControlInteractorTest : SysuiTestCase() {
    private val testScope = kosmos.testScope

    private val mediaDataFilter: MediaDataFilterImpl = kosmos.mediaDataFilter
    private val activityStarter = kosmos.activityStarter
    private val keyguardStateController = kosmos.keyguardStateController
    private val instanceId: InstanceId = kosmos.mediaInstanceId
    private val notificationLockscreenUserManager = kosmos.notificationLockscreenUserManager

    private val underTest: MediaControlInteractor = kosmos.mediaControlInteractor
    private val underTest: MediaControlInteractor =
        with(kosmos) {
            activityIntentHelper = mockActivityIntentHelper
            kosmos.mediaControlInteractor
        }

    @Test
    fun onMediaDataUpdated() =
@@ -55,39 +77,141 @@ class MediaControlInteractorTest : SysuiTestCase() {
            whenever(notificationLockscreenUserManager.isCurrentProfile(USER_ID)).thenReturn(true)
            whenever(notificationLockscreenUserManager.isProfileAvailable(USER_ID)).thenReturn(true)
            val controlModel by collectLastValue(underTest.mediaControl)
            var mediaData =
                MediaData(userId = USER_ID, instanceId = instanceId, artist = SESSION_ARTIST)
            var mediaData = MediaData(userId = USER_ID, instanceId = instanceId, artist = ARTIST)

            mediaDataFilter.onMediaDataLoaded(KEY, KEY, mediaData)

            assertThat(controlModel?.instanceId).isEqualTo(instanceId)
            assertThat(controlModel?.artistName).isEqualTo(SESSION_ARTIST)
            assertThat(controlModel?.artistName).isEqualTo(ARTIST)

            mediaData =
                MediaData(userId = USER_ID, instanceId = instanceId, artist = SESSION_ARTIST_2)
            mediaData = MediaData(userId = USER_ID, instanceId = instanceId, artist = ARTIST_2)

            mediaDataFilter.onMediaDataLoaded(KEY, KEY, mediaData)

            assertThat(controlModel?.instanceId).isEqualTo(instanceId)
            assertThat(controlModel?.artistName).isEqualTo(SESSION_ARTIST_2)
            assertThat(controlModel?.artistName).isEqualTo(ARTIST_2)

            mediaData =
                MediaData(
                    userId = USER_ID,
                    instanceId = InstanceId.fakeInstanceId(2),
                    artist = SESSION_ARTIST
                    artist = ARTIST
                )

            mediaDataFilter.onMediaDataLoaded(KEY, KEY, mediaData)

            assertThat(controlModel?.instanceId).isNotEqualTo(mediaData.instanceId)
            assertThat(controlModel?.artistName).isEqualTo(SESSION_ARTIST_2)
            assertThat(controlModel?.artistName).isEqualTo(ARTIST_2)
        }

    @Test
    fun startSettings() {
        underTest.startSettings()

        verify(activityStarter).startActivity(any(), eq(true))
    }

    @Test
    fun startClickIntent_showOverLockscreen() {
        whenever(keyguardStateController.isShowing).thenReturn(true)
        whenever(kosmos.activityIntentHelper.wouldPendingShowOverLockscreen(any(), any()))
            .thenReturn(true)

        val clickIntent = mock<PendingIntent> { whenever(isActivity).thenReturn(true) }
        val expandable = mock<Expandable>()

        underTest.startClickIntent(expandable, clickIntent)

        verify(clickIntent).send(any<Bundle>())
    }

    @Test
    fun startClickIntent_hideOverLockscreen() {
        whenever(keyguardStateController.isShowing).thenReturn(false)

        val clickIntent = mock<PendingIntent> { whenever(isActivity).thenReturn(true) }
        val expandable = mock<Expandable>()
        val activityController = mock<ActivityTransitionAnimator.Controller>()
        whenever(expandable.activityTransitionController(any())).thenReturn(activityController)

        underTest.startClickIntent(expandable, clickIntent)

        verify(activityStarter)
            .postStartActivityDismissingKeyguard(eq(clickIntent), eq(activityController))
    }

    @Test
    fun startDeviceIntent_showOverLockscreen() {
        whenever(keyguardStateController.isShowing).thenReturn(true)
        whenever(kosmos.activityIntentHelper.wouldPendingShowOverLockscreen(any(), any()))
            .thenReturn(true)

        val deviceIntent = mock<PendingIntent> { whenever(isActivity).thenReturn(true) }

        underTest.startDeviceIntent(deviceIntent)

        verify(deviceIntent).send(any<Bundle>())
    }

    @Test
    fun startDeviceIntent_intentNotActivity() {
        whenever(keyguardStateController.isShowing).thenReturn(true)
        whenever(kosmos.activityIntentHelper.wouldPendingShowOverLockscreen(any(), any()))
            .thenReturn(true)

        val deviceIntent = mock<PendingIntent> { whenever(isActivity).thenReturn(false) }

        underTest.startDeviceIntent(deviceIntent)

        verify(deviceIntent, never()).send(any<Bundle>())
    }

    @Test
    fun startDeviceIntent_hideOverLockscreen() {
        whenever(keyguardStateController.isShowing).thenReturn(false)

        val deviceIntent = mock<PendingIntent> { whenever(isActivity).thenReturn(true) }

        underTest.startDeviceIntent(deviceIntent)

        verify(activityStarter).postStartActivityDismissingKeyguard(eq(deviceIntent))
    }

    @Test
    fun startMediaOutputDialog() {
        val expandable = mock<Expandable>()
        val dialogTransitionController = mock<DialogTransitionAnimator.Controller>()
        whenever(expandable.dialogTransitionController(any()))
            .thenReturn(dialogTransitionController)

        underTest.startMediaOutputDialog(expandable, PACKAGE_NAME)

        verify(kosmos.mediaOutputDialogManager)
            .createAndShowWithController(eq(PACKAGE_NAME), eq(true), eq(dialogTransitionController))
    }

    @Test
    fun startBroadcastDialog() {
        val expandable = mock<Expandable>()
        val dialogTransitionController = mock<DialogTransitionAnimator.Controller>()
        whenever(expandable.dialogTransitionController()).thenReturn(dialogTransitionController)

        underTest.startBroadcastDialog(expandable, APP_NAME, PACKAGE_NAME)

        verify(kosmos.mockBroadcastDialogController)
            .createBroadcastDialogWithController(
                eq(APP_NAME),
                eq(PACKAGE_NAME),
                eq(dialogTransitionController)
            )
    }

    companion object {
        private const val USER_ID = 0
        private const val KEY = "key"
        private const val SESSION_ARTIST = "artist"
        private const val SESSION_ARTIST_2 = "artist2"
        private const val PACKAGE_NAME = "com.example.app"
        private const val APP_NAME = "app"
        private const val ARTIST = "artist"
        private const val ARTIST_2 = "artist2"
    }
}
+135 −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.media.controls.ui.viewmodel

import android.R
import android.content.packageManager
import android.content.pm.ApplicationInfo
import android.media.MediaMetadata
import android.media.session.MediaSession
import android.media.session.PlaybackState
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.internal.logging.InstanceId
import com.android.systemui.SysuiTestCase
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.kosmos.testScope
import com.android.systemui.media.controls.domain.pipeline.MediaDataFilterImpl
import com.android.systemui.media.controls.domain.pipeline.mediaDataFilter
import com.android.systemui.media.controls.shared.model.MediaData
import com.android.systemui.media.controls.shared.model.MediaDeviceData
import com.android.systemui.media.controls.util.mediaInstanceId
import com.android.systemui.statusbar.notificationLockscreenUserManager
import com.android.systemui.testKosmos
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.eq
import com.android.systemui.util.mockito.whenever
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers
import org.mockito.Mockito

@SmallTest
@RunWith(AndroidJUnit4::class)
class MediaControlViewModelTest : SysuiTestCase() {

    private val kosmos = testKosmos()
    private val testScope = kosmos.testScope

    private val mediaDataFilter: MediaDataFilterImpl = kosmos.mediaDataFilter
    private val notificationLockscreenUserManager = kosmos.notificationLockscreenUserManager
    private val packageManager = kosmos.packageManager
    private val drawable = context.getDrawable(R.drawable.ic_media_play)
    private val instanceId: InstanceId = kosmos.mediaInstanceId

    private val underTest: MediaControlViewModel = kosmos.mediaControlViewModel

    @Test
    fun addMediaControl_mediaControlViewModelIsLoaded() =
        testScope.runTest {
            whenever(packageManager.getApplicationIcon(Mockito.anyString())).thenReturn(drawable)
            whenever(packageManager.getApplicationIcon(any(ApplicationInfo::class.java)))
                .thenReturn(drawable)
            whenever(packageManager.getApplicationInfo(eq(PACKAGE_NAME), ArgumentMatchers.anyInt()))
                .thenReturn(ApplicationInfo())
            whenever(packageManager.getApplicationLabel(any())).thenReturn(PACKAGE_NAME)
            whenever(notificationLockscreenUserManager.isCurrentProfile(USER_ID)).thenReturn(true)
            whenever(notificationLockscreenUserManager.isProfileAvailable(USER_ID)).thenReturn(true)
            val playerModel by collectLastValue(underTest.player)

            context.setMockPackageManager(packageManager)

            val mediaData = initMediaData()

            mediaDataFilter.onMediaDataLoaded(KEY, KEY, mediaData)

            assertThat(playerModel).isNotNull()
            assertThat(playerModel?.titleName).isEqualTo(TITLE)
            assertThat(playerModel?.artistName).isEqualTo(ARTIST)
            assertThat(playerModel?.gutsMenu).isNotNull()
            assertThat(playerModel?.outputSwitcher).isNotNull()
            assertThat(playerModel?.actionButtons).isNotNull()
            assertThat(playerModel?.playTurbulenceNoise).isFalse()
        }

    private fun initMediaData(): MediaData {
        val device = MediaDeviceData(true, null, DEVICE_NAME, null, showBroadcastButton = true)

        // Create media session
        val metadataBuilder =
            MediaMetadata.Builder().apply {
                putString(MediaMetadata.METADATA_KEY_ARTIST, SESSION_ARTIST)
                putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_TITLE)
            }
        val playbackBuilder =
            PlaybackState.Builder().apply {
                setState(PlaybackState.STATE_PAUSED, 6000L, 1f)
                setActions(PlaybackState.ACTION_PLAY)
            }
        val session =
            MediaSession(context, SESSION_KEY).apply {
                setMetadata(metadataBuilder.build())
                setPlaybackState(playbackBuilder.build())
            }
        session.isActive = true

        return MediaData(
            userId = USER_ID,
            artist = ARTIST,
            song = TITLE,
            packageName = PACKAGE,
            token = session.sessionToken,
            device = device,
            instanceId = instanceId
        )
    }

    companion object {
        private const val USER_ID = 0
        private const val KEY = "key"
        private const val PACKAGE_NAME = "com.example.app"
        private const val PACKAGE = "PKG"
        private const val ARTIST = "ARTIST"
        private const val TITLE = "TITLE"
        private const val DEVICE_NAME = "DEVICE_NAME"
        private const val SESSION_KEY = "SESSION_KEY"
        private const val SESSION_ARTIST = "SESSION_ARTIST"
        private const val SESSION_TITLE = "SESSION_TITLE"
    }
}
+18 −0
Original line number Diff line number Diff line
@@ -56,4 +56,22 @@ public class BroadcastDialogController {
            broadcastDialog.show();
        }
    }

    /** Creates a [BroadcastDialog] for the user to switch broadcast or change the output device
     *
     * @param currentBroadcastAppName Indicates the APP name currently broadcasting
     * @param outputPkgName Indicates the output media package name to be switched
     * @param controller Indicates the dialog controller of the source view.
     */
    public void createBroadcastDialogWithController(
            String currentBroadcastAppName, String outputPkgName,
            DialogTransitionAnimator.Controller controller) {
        SystemUIDialog broadcastDialog = mBroadcastDialogFactory.create(
                currentBroadcastAppName, outputPkgName).createDialog();
        if (controller != null) {
            mDialogTransitionAnimator.show(broadcastDialog, controller);
        } else {
            broadcastDialog.show();
        }
    }
}
+13 −2
Original line number Diff line number Diff line
@@ -590,7 +590,7 @@ class MediaDataProcessor(
    }

    /** Dismiss a media entry. Returns false if the key was not found. */
    fun dismissMediaData(key: String, delay: Long): Boolean {
    fun dismissMediaData(key: String, delayMs: Long): Boolean {
        val existed = mediaDataRepository.mediaEntries.value[key] != null
        backgroundExecutor.execute {
            mediaDataRepository.mediaEntries.value[key]?.let { mediaData ->
@@ -602,10 +602,21 @@ class MediaDataProcessor(
                }
            }
        }
        foregroundExecutor.executeDelayed({ removeEntry(key) }, delay)
        foregroundExecutor.executeDelayed({ removeEntry(key) }, delayMs)
        return existed
    }

    /** Dismiss a media entry. Returns false if the corresponding key was not found. */
    fun dismissMediaData(instanceId: InstanceId, delayMs: Long): Boolean {
        val mediaEntries = mediaDataRepository.mediaEntries.value
        val filteredEntries = mediaEntries.filter { (_, data) -> data.instanceId == instanceId }
        return if (filteredEntries.isNotEmpty()) {
            dismissMediaData(filteredEntries.keys.first(), delayMs)
        } else {
            false
        }
    }

    /**
     * Called whenever the recommendation has been expired or removed by the user. This will remove
     * the recommendation card entirely from the carousel.
+131 −3
Original line number Diff line number Diff line
@@ -16,20 +16,49 @@

package com.android.systemui.media.controls.domain.pipeline.interactor

import android.app.ActivityOptions
import android.app.BroadcastOptions
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.session.MediaController
import android.media.session.MediaSession
import android.media.session.PlaybackState
import android.provider.Settings
import android.util.Log
import com.android.internal.jank.Cuj
import com.android.internal.logging.InstanceId
import com.android.systemui.ActivityIntentHelper
import com.android.systemui.animation.DialogCuj
import com.android.systemui.animation.DialogTransitionAnimator
import com.android.systemui.animation.Expandable
import com.android.systemui.bluetooth.BroadcastDialogController
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.media.controls.data.repository.MediaFilterRepository
import com.android.systemui.media.controls.domain.pipeline.MediaDataProcessor
import com.android.systemui.media.controls.shared.model.MediaControlModel
import com.android.systemui.media.controls.shared.model.MediaData
import com.android.systemui.media.dialog.MediaOutputDialogManager
import com.android.systemui.plugins.ActivityStarter
import com.android.systemui.statusbar.NotificationLockscreenUserManager
import com.android.systemui.statusbar.policy.KeyguardStateController
import com.android.systemui.util.kotlin.pairwiseBy
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map

/** Encapsulates business logic for single media control. */
class MediaControlInteractor(
    instanceId: InstanceId,
    @Application applicationContext: Context,
    private val instanceId: InstanceId,
    repository: MediaFilterRepository,
    private val mediaDataProcessor: MediaDataProcessor,
    private val keyguardStateController: KeyguardStateController,
    private val activityStarter: ActivityStarter,
    private val activityIntentHelper: ActivityIntentHelper,
    private val lockscreenUserManager: NotificationLockscreenUserManager,
    private val mediaOutputDialogManager: MediaOutputDialogManager,
    private val broadcastDialogController: BroadcastDialogController,
) {

    val mediaControl: Flow<MediaControlModel?> =
@@ -37,8 +66,32 @@ class MediaControlInteractor(
            .map { entries -> entries[instanceId]?.let { toMediaControlModel(it) } }
            .distinctUntilChanged()

    fun removeMediaControl(key: String, delayMs: Long): Boolean {
        return mediaDataProcessor.dismissMediaData(key, delayMs)
    val isStartedPlaying: Flow<Boolean> =
        mediaControl
            .map { mediaControl ->
                mediaControl?.token?.let { token ->
                    MediaController(applicationContext, token).playbackState?.let {
                        it.state == PlaybackState.STATE_PLAYING
                    }
                }
                    ?: false
            }
            .pairwiseBy(initialValue = false) { wasPlaying, isPlaying -> !wasPlaying && isPlaying }
            .distinctUntilChanged()

    fun removeMediaControl(
        token: MediaSession.Token?,
        instanceId: InstanceId,
        delayMs: Long
    ): Boolean {
        val dismissed = mediaDataProcessor.dismissMediaData(instanceId, delayMs)
        if (!dismissed) {
            Log.w(
                TAG,
                "Manager failed to dismiss media of instanceId=$instanceId, Token uid=${token?.uid}"
            )
        }
        return dismissed
    }

    private fun toMediaControlModel(data: MediaData): MediaControlModel {
@@ -53,14 +106,89 @@ class MediaControlInteractor(
                appName = app,
                songName = song,
                artistName = artist,
                showExplicit = isExplicit,
                artwork = artwork,
                deviceData = device,
                semanticActionButtons = semanticActions,
                notificationActionButtons = actions,
                actionsToShowInCollapsed = actionsToShowInCompact,
                isDismissible = isClearable,
                isResume = resumption,
                resumeProgress = resumeProgress,
            )
        }
    }

    fun startSettings() {
        activityStarter.startActivity(SETTINGS_INTENT, /* dismissShade= */ true)
    }

    fun startClickIntent(expandable: Expandable, clickIntent: PendingIntent) {
        if (!launchOverLockscreen(clickIntent)) {
            activityStarter.postStartActivityDismissingKeyguard(
                clickIntent,
                expandable.activityTransitionController(Cuj.CUJ_SHADE_APP_LAUNCH_FROM_MEDIA_PLAYER)
            )
        }
    }

    fun startDeviceIntent(deviceIntent: PendingIntent) {
        if (deviceIntent.isActivity) {
            if (!launchOverLockscreen(deviceIntent)) {
                activityStarter.postStartActivityDismissingKeyguard(deviceIntent)
            }
        } else {
            Log.w(TAG, "Device pending intent of instanceId=$instanceId is not an activity.")
        }
    }

    private fun launchOverLockscreen(pendingIntent: PendingIntent): Boolean {
        val showOverLockscreen =
            keyguardStateController.isShowing &&
                activityIntentHelper.wouldPendingShowOverLockscreen(
                    pendingIntent,
                    lockscreenUserManager.currentUserId
                )
        if (showOverLockscreen) {
            try {
                val options = BroadcastOptions.makeBasic()
                options.isInteractive = true
                options.pendingIntentBackgroundActivityStartMode =
                    ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
                pendingIntent.send(options.toBundle())
            } catch (e: PendingIntent.CanceledException) {
                Log.e(TAG, "pending intent of $instanceId was canceled")
            }
            return true
        }
        return false
    }

    fun startMediaOutputDialog(expandable: Expandable, packageName: String) {
        mediaOutputDialogManager.createAndShowWithController(
            packageName,
            true,
            expandable.dialogController()
        )
    }

    fun startBroadcastDialog(expandable: Expandable, broadcastApp: String, packageName: String) {
        broadcastDialogController.createBroadcastDialogWithController(
            broadcastApp,
            packageName,
            expandable.dialogTransitionController()
        )
    }

    private fun Expandable.dialogController(): DialogTransitionAnimator.Controller? {
        return dialogTransitionController(
            cuj =
                DialogCuj(Cuj.CUJ_SHADE_DIALOG_OPEN, MediaOutputDialogManager.INTERACTION_JANK_TAG)
        )
    }

    companion object {
        private const val TAG = "MediaControlInteractor"
        private val SETTINGS_INTENT = Intent(Settings.ACTION_MEDIA_CONTROLS_SETTINGS)
    }
}
Loading