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

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

Merge "Add media recommendations view-model" into main

parents ff5c5dd9 a1bb8269
Loading
Loading
Loading
Loading
+67 −2
Original line number Diff line number Diff line
@@ -17,10 +17,16 @@
package com.android.systemui.media.controls.domain.interactor

import android.R
import android.content.ComponentName
import android.content.Intent
import android.content.applicationContext
import android.graphics.drawable.Icon
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.animation.Expandable
import com.android.systemui.broadcast.broadcastSender
import com.android.systemui.broadcast.mockBroadcastSender
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.flags.Flags
import com.android.systemui.flags.fakeFeatureFlagsClassic
@@ -28,25 +34,36 @@ import com.android.systemui.kosmos.testScope
import com.android.systemui.media.controls.MediaTestHelper
import com.android.systemui.media.controls.domain.pipeline.MediaDataFilterImpl
import com.android.systemui.media.controls.domain.pipeline.interactor.MediaRecommendationsInteractor
import com.android.systemui.media.controls.domain.pipeline.interactor.MediaRecommendationsInteractor.Companion.EXPORTED_SMARTSPACE_TRAMPOLINE_ACTIVITY_NAME
import com.android.systemui.media.controls.domain.pipeline.interactor.mediaRecommendationsInteractor
import com.android.systemui.media.controls.domain.pipeline.mediaDataFilter
import com.android.systemui.media.controls.shared.model.MediaRecModel
import com.android.systemui.media.controls.shared.model.MediaRecommendationsModel
import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
import com.android.systemui.plugins.activityStarter
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.doNothing
import org.mockito.Mockito.spy
import org.mockito.Mockito.verify

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

    private val kosmos = testKosmos()
    private val spyContext = spy(context)
    private val kosmos = testKosmos().apply { applicationContext = spyContext }
    private val testScope = kosmos.testScope

    private val mediaDataFilter: MediaDataFilterImpl = kosmos.mediaDataFilter
    private val activityStarter = kosmos.activityStarter
    private val icon: Icon = Icon.createWithResource(context, R.drawable.ic_media_play)
    private val smartspaceMediaData: SmartspaceMediaData =
        SmartspaceMediaData(
@@ -56,7 +73,11 @@ class MediaRecommendationsInteractorTest : SysuiTestCase() {
            recommendations = MediaTestHelper.getValidRecommendationList(icon),
        )

    private val underTest: MediaRecommendationsInteractor = kosmos.mediaRecommendationsInteractor
    private val underTest: MediaRecommendationsInteractor =
        with(kosmos) {
            broadcastSender = mockBroadcastSender
            kosmos.mediaRecommendationsInteractor
        }

    @Test
    fun addRecommendation_smartspaceMediaDataUpdate() =
@@ -111,6 +132,50 @@ class MediaRecommendationsInteractorTest : SysuiTestCase() {
            assertThat(recommendations?.mediaRecs?.isEmpty()).isTrue()
        }

    @Test
    fun removeRecommendation_noTrampolineActivity() {
        val intent = Intent()

        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)

        underTest.removeMediaRecommendations(KEY_MEDIA_SMARTSPACE, intent, 0)

        verify(kosmos.mockBroadcastSender).sendBroadcast(eq(intent))
    }

    @Test
    fun removeRecommendation_usingTrampolineActivity() {
        doNothing().whenever(spyContext).startActivity(any())
        val intent = Intent()

        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        intent.component = ComponentName(PACKAGE_NAME, EXPORTED_SMARTSPACE_TRAMPOLINE_ACTIVITY_NAME)

        underTest.removeMediaRecommendations(KEY_MEDIA_SMARTSPACE, intent, 0)

        verify(spyContext).startActivity(eq(intent))
    }

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

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

    @Test
    fun startClickIntent() {
        doNothing().whenever(spyContext).startActivity(any())
        val intent = Intent()
        val expandable = mock<Expandable>()

        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)

        underTest.startClickIntent(expandable, intent)

        verify(spyContext).startActivity(eq(intent))
    }

    companion object {
        private const val KEY_MEDIA_SMARTSPACE = "MEDIA_SMARTSPACE_ID"
        private const val PACKAGE_NAME = "com.example.app"
+88 −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.graphics.drawable.Icon
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.kosmos.testScope
import com.android.systemui.media.controls.MediaTestHelper
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.SmartspaceMediaData
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 MediaRecommendationsViewModelTest : SysuiTestCase() {

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

    private val mediaDataFilter: MediaDataFilterImpl = kosmos.mediaDataFilter
    private val packageManager = kosmos.packageManager
    private val icon: Icon = Icon.createWithResource(context, R.drawable.ic_media_play)
    private val drawable = context.getDrawable(R.drawable.ic_media_play)
    private val smartspaceMediaData: SmartspaceMediaData =
        SmartspaceMediaData(
            targetId = KEY_MEDIA_SMARTSPACE,
            isActive = true,
            packageName = PACKAGE_NAME,
            recommendations = MediaTestHelper.getValidRecommendationList(icon),
        )

    private val underTest: MediaRecommendationsViewModel = kosmos.mediaRecommendationsViewModel

    @Test
    fun loadRecommendations_recsCardViewModelIsLoaded() =
        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)
            val recsCardViewModel by collectLastValue(underTest.mediaRecsCard)

            context.setMockPackageManager(packageManager)

            mediaDataFilter.onSmartspaceMediaDataLoaded(KEY_MEDIA_SMARTSPACE, smartspaceMediaData)

            assertThat(recsCardViewModel).isNotNull()
            assertThat(recsCardViewModel?.mediaRecs?.size)
                .isEqualTo(smartspaceMediaData.recommendations.size)
        }

    companion object {
        private const val KEY_MEDIA_SMARTSPACE = "MEDIA_SMARTSPACE_ID"
        private const val PACKAGE_NAME = "com.example.app"
    }
}
+74 −1
Original line number Diff line number Diff line
@@ -17,6 +17,13 @@
package com.android.systemui.media.controls.domain.pipeline.interactor

import android.content.Context
import android.content.Intent
import android.provider.Settings
import android.util.Log
import androidx.annotation.VisibleForTesting
import com.android.internal.jank.InteractionJankMonitor
import com.android.systemui.animation.Expandable
import com.android.systemui.broadcast.BroadcastSender
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.media.controls.data.repository.MediaFilterRepository
@@ -24,6 +31,8 @@ import com.android.systemui.media.controls.domain.pipeline.MediaDataProcessor
import com.android.systemui.media.controls.shared.model.MediaRecModel
import com.android.systemui.media.controls.shared.model.MediaRecommendationsModel
import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
import com.android.systemui.plugins.ActivityStarter
import java.net.URISyntaxException
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
@@ -42,6 +51,8 @@ constructor(
    @Application private val applicationContext: Context,
    repository: MediaFilterRepository,
    private val mediaDataProcessor: MediaDataProcessor,
    private val broadcastSender: BroadcastSender,
    private val activityStarter: ActivityStarter,
) {

    val recommendations: Flow<MediaRecommendationsModel> =
@@ -54,8 +65,53 @@ constructor(
            .distinctUntilChanged()
            .stateIn(applicationScope, SharingStarted.WhileSubscribed(), false)

    fun removeMediaRecommendations(key: String, delayMs: Long) {
    fun removeMediaRecommendations(key: String, dismissIntent: Intent?, delayMs: Long) {
        mediaDataProcessor.dismissSmartspaceRecommendation(key, delayMs)
        if (dismissIntent == null) {
            Log.w(TAG, "Cannot create dismiss action click action: extras missing dismiss_intent.")
            return
        }

        val className = dismissIntent.component?.className
        if (className == EXPORTED_SMARTSPACE_TRAMPOLINE_ACTIVITY_NAME) {
            // Dismiss the card Smartspace data through Smartspace trampoline activity.
            applicationContext.startActivity(dismissIntent)
        } else {
            broadcastSender.sendBroadcast(dismissIntent)
        }
    }

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

    fun startClickIntent(expandable: Expandable, intent: Intent) {
        if (shouldActivityOpenInForeground(intent)) {
            // Request to unlock the device if the activity needs to be opened in foreground.
            activityStarter.postStartActivityDismissingKeyguard(
                intent,
                0 /* delay */,
                expandable.activityTransitionController(
                    InteractionJankMonitor.CUJ_SHADE_APP_LAUNCH_FROM_MEDIA_PLAYER
                )
            )
        } else {
            // Otherwise, open the activity in background directly.
            applicationContext.startActivity(intent)
        }
    }

    /** Returns if the action will open the activity in foreground. */
    private fun shouldActivityOpenInForeground(intent: Intent): Boolean {
        val intentString = intent.extras?.getString(EXTRAS_SMARTSPACE_INTENT) ?: return false
        try {
            val wrapperIntent = Intent.parseUri(intentString, Intent.URI_INTENT_SCHEME)
            return wrapperIntent.getBooleanExtra(KEY_SMARTSPACE_OPEN_IN_FOREGROUND, false)
        } catch (e: URISyntaxException) {
            Log.wtf(TAG, "Failed to create intent from URI: $intentString")
            e.printStackTrace()
        }
        return false
    }

    private fun toRecommendationsModel(data: SmartspaceMediaData): MediaRecommendationsModel {
@@ -76,4 +132,21 @@ constructor(
            )
        }
    }

    companion object {

        private const val TAG = "MediaRecommendationsInteractor"

        // TODO (b/237284176) : move AGSA reference out.
        private const val EXTRAS_SMARTSPACE_INTENT =
            "com.google.android.apps.gsa.smartspace.extra.SMARTSPACE_INTENT"
        @VisibleForTesting
        const val EXPORTED_SMARTSPACE_TRAMPOLINE_ACTIVITY_NAME =
            "com.google.android.apps.gsa.staticplugins.opa.smartspace." +
                "ExportedSmartspaceTrampolineActivity"

        private const val KEY_SMARTSPACE_OPEN_IN_FOREGROUND = "KEY_OPEN_IN_FOREGROUND"

        private val SETTINGS_INTENT = Intent(Settings.ACTION_MEDIA_CONTROLS_SETTINGS)
    }
}
+97 −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.util

import android.app.WallpaperColors
import android.content.Context
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.Icon
import android.graphics.drawable.LayerDrawable
import android.util.Log
import com.android.systemui.media.controls.ui.animation.backgroundEndFromScheme
import com.android.systemui.media.controls.ui.animation.backgroundStartFromScheme
import com.android.systemui.monet.ColorScheme
import com.android.systemui.util.getColorWithAlpha
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext

object MediaArtworkHelper {

    /**
     * This method should be called from a background thread. WallpaperColors.fromBitmap takes a
     * good amount of time. We do that work on the background executor to avoid stalling animations
     * on the UI Thread.
     */
    suspend fun getWallpaperColor(
        applicationContext: Context,
        backgroundDispatcher: CoroutineDispatcher,
        artworkIcon: Icon?,
        tag: String,
    ): WallpaperColors? =
        withContext(backgroundDispatcher) {
            return@withContext artworkIcon?.let {
                if (it.type == Icon.TYPE_BITMAP || it.type == Icon.TYPE_ADAPTIVE_BITMAP) {
                    // Avoids extra processing if this is already a valid bitmap
                    it.bitmap.let { artworkBitmap ->
                        if (artworkBitmap.isRecycled) {
                            Log.d(tag, "Cannot load wallpaper color from a recycled bitmap")
                            null
                        } else {
                            WallpaperColors.fromBitmap(artworkBitmap)
                        }
                    }
                } else {
                    it.loadDrawable(applicationContext)?.let { artworkDrawable ->
                        WallpaperColors.fromDrawable(artworkDrawable)
                    }
                }
            }
        }

    /**
     * Returns a scaled [Drawable] of a given [Icon] centered in [width]x[height] background size.
     */
    fun getScaledBackground(context: Context, icon: Icon, width: Int, height: Int): Drawable? {
        val drawable = icon.loadDrawable(context)
        val bounds = Rect(0, 0, width, height)
        if (bounds.width() > width || bounds.height() > height) {
            val offsetX = (bounds.width() - width) / 2.0f
            val offsetY = (bounds.height() - height) / 2.0f
            bounds.offset(-offsetX.toInt(), -offsetY.toInt())
        }
        drawable?.bounds = bounds
        return drawable
    }

    /** Adds [gradient] on a given [albumArt] drawable using [colorScheme]. */
    fun setUpGradientColorOnDrawable(
        albumArt: Drawable?,
        gradient: GradientDrawable,
        colorScheme: ColorScheme,
        startAlpha: Float,
        endAlpha: Float
    ): LayerDrawable {
        gradient.colors =
            intArrayOf(
                getColorWithAlpha(backgroundStartFromScheme(colorScheme), startAlpha),
                getColorWithAlpha(backgroundEndFromScheme(colorScheme), endAlpha)
            )
        return LayerDrawable(arrayOf(albumArt, gradient))
    }
}
+32 −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.annotation.ColorInt
import android.graphics.drawable.Drawable

/** Models UI state for media guts menu */
data class GutsViewModel(
    val gutsText: CharSequence,
    @ColorInt val textColor: Int,
    @ColorInt val buttonBackgroundColor: Int,
    @ColorInt val buttonTextColor: Int,
    val isDismissEnabled: Boolean = true,
    val onDismissClicked: () -> Unit,
    val cancelTextBackground: Drawable?,
    val onSettingsClicked: () -> Unit,
)
Loading