summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Michael Mikhail <michaelmikhil@google.com> 2024-03-24 21:30:31 +0000
committer Michael Mikhail <michaelmikhil@google.com> 2024-04-01 17:16:18 +0000
commita1bb826994588acbc7665c556bef93aa6ee5aff8 (patch)
treefa106420b999096a0fcf5c744e8aaf965e37cf36
parent689af7a504a912f81d4a265dde3d6cedb25ebd42 (diff)
Add media recommendations view-model
Flag: ACONFIG media_controls_refactor DISABLED Bug: 328207006 Test: atest SystemUiRoboTests:MediaRecommendationsViewModelTest Change-Id: Ie5fdb6ce8d2a820d211b50edc434886b88b8d34b
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/interactor/MediaRecommendationsInteractorTest.kt69
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecommendationsViewModelTest.kt88
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaRecommendationsInteractor.kt75
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/controls/ui/util/MediaArtworkHelper.kt97
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/controls/ui/viewmodel/GutsViewModel.kt32
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecViewModel.kt36
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecommendationsViewModel.kt353
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecsCardViewModel.kt33
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/controls/util/MediaUiEventLogger.kt10
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/broadcast/BroadcastSenderKosmos.kt29
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaRecommendationsInteractorKosmos.kt4
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecommendationsViewModelKosmos.kt33
12 files changed, 851 insertions, 8 deletions
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/interactor/MediaRecommendationsInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/interactor/MediaRecommendationsInteractorTest.kt
index 28995e1feb0e..9656511817dc 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/interactor/MediaRecommendationsInteractorTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/interactor/MediaRecommendationsInteractorTest.kt
@@ -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"
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecommendationsViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecommendationsViewModelTest.kt
new file mode 100644
index 000000000000..51b1911be5d5
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecommendationsViewModelTest.kt
@@ -0,0 +1,88 @@
+/*
+ * 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"
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaRecommendationsInteractor.kt b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaRecommendationsInteractor.kt
index 40a132a491a1..d57b04938d2c 100644
--- a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaRecommendationsInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaRecommendationsInteractor.kt
@@ -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)
+ }
}
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/ui/util/MediaArtworkHelper.kt b/packages/SystemUI/src/com/android/systemui/media/controls/ui/util/MediaArtworkHelper.kt
new file mode 100644
index 000000000000..eec43a68adfd
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/ui/util/MediaArtworkHelper.kt
@@ -0,0 +1,97 @@
+/*
+ * 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))
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/ui/viewmodel/GutsViewModel.kt b/packages/SystemUI/src/com/android/systemui/media/controls/ui/viewmodel/GutsViewModel.kt
new file mode 100644
index 000000000000..e508e1bb1b67
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/ui/viewmodel/GutsViewModel.kt
@@ -0,0 +1,32 @@
+/*
+ * 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,
+)
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecViewModel.kt b/packages/SystemUI/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecViewModel.kt
new file mode 100644
index 000000000000..2f9fc9bc699a
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecViewModel.kt
@@ -0,0 +1,36 @@
+/*
+ * 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
+import com.android.systemui.animation.Expandable
+
+/** Models UI state for media recommendation item */
+data class MediaRecViewModel(
+ val contentDescription: CharSequence,
+ val title: CharSequence = "",
+ @ColorInt val titleColor: Int,
+ val subtitle: CharSequence = "",
+ @ColorInt val subtitleColor: Int,
+ /** track progress [0 - 100] for the recommendation album. */
+ val progress: Int = 0,
+ @ColorInt val progressColor: Int,
+ val albumIcon: Drawable? = null,
+ val appIcon: Drawable? = null,
+ val onClicked: ((Expandable, Int) -> Unit),
+)
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecommendationsViewModel.kt b/packages/SystemUI/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecommendationsViewModel.kt
new file mode 100644
index 000000000000..19ea00d439b2
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecommendationsViewModel.kt
@@ -0,0 +1,353 @@
+/*
+ * 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.app.WallpaperColors
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.GradientDrawable
+import android.graphics.drawable.Icon
+import android.graphics.drawable.LayerDrawable
+import android.os.Process
+import android.util.Log
+import androidx.appcompat.content.res.AppCompatResources
+import com.android.internal.logging.InstanceId
+import com.android.systemui.animation.Expandable
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.media.controls.domain.pipeline.interactor.MediaRecommendationsInteractor
+import com.android.systemui.media.controls.shared.model.MediaRecModel
+import com.android.systemui.media.controls.shared.model.MediaRecommendationsModel
+import com.android.systemui.media.controls.ui.animation.accentPrimaryFromScheme
+import com.android.systemui.media.controls.ui.animation.surfaceFromScheme
+import com.android.systemui.media.controls.ui.animation.textPrimaryFromScheme
+import com.android.systemui.media.controls.ui.animation.textSecondaryFromScheme
+import com.android.systemui.media.controls.ui.controller.MediaViewController.Companion.GUTS_ANIMATION_DURATION
+import com.android.systemui.media.controls.ui.util.MediaArtworkHelper
+import com.android.systemui.media.controls.util.MediaDataUtils
+import com.android.systemui.media.controls.util.MediaUiEventLogger
+import com.android.systemui.monet.ColorScheme
+import com.android.systemui.monet.Style
+import com.android.systemui.res.R
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withContext
+
+/** Models UI state and handles user input for media recommendations */
+@SysUISingleton
+class MediaRecommendationsViewModel
+@Inject
+constructor(
+ @Application private val applicationContext: Context,
+ @Background private val backgroundDispatcher: CoroutineDispatcher,
+ private val interactor: MediaRecommendationsInteractor,
+ private val logger: MediaUiEventLogger,
+) {
+
+ val mediaRecsCard: Flow<MediaRecsCardViewModel?> =
+ interactor.recommendations
+ .map { recsCard -> toRecsViewModel(recsCard) }
+ .distinctUntilChanged()
+ .flowOn(backgroundDispatcher)
+
+ /**
+ * Called whenever the recommendation has been expired or removed by the user. This method
+ * removes the recommendation card entirely from the carousel.
+ */
+ private fun onMediaRecommendationsDismissed(
+ key: String,
+ uid: Int,
+ packageName: String,
+ dismissIntent: Intent?,
+ instanceId: InstanceId?
+ ) {
+ // TODO (b/330897926) log smartspace card reported (SMARTSPACE_CARD_DISMISS_EVENT).
+ logger.logLongPressDismiss(uid, packageName, instanceId)
+ interactor.removeMediaRecommendations(key, dismissIntent, GUTS_DISMISS_DELAY_MS_DURATION)
+ }
+
+ private fun onClicked(
+ expandable: Expandable,
+ intent: Intent?,
+ packageName: String,
+ instanceId: InstanceId?,
+ index: Int
+ ) {
+ if (intent == null || intent.extras == null) {
+ Log.e(TAG, "No tap action can be set up")
+ return
+ }
+
+ if (index == -1) {
+ logger.logRecommendationCardTap(packageName, instanceId)
+ } else {
+ logger.logRecommendationItemTap(packageName, instanceId, index)
+ }
+ // TODO (b/330897926) log smartspace card reported (SMARTSPACE_CARD_CLICK_EVENT).
+ interactor.startClickIntent(expandable, intent)
+ }
+
+ private suspend fun toRecsViewModel(model: MediaRecommendationsModel): MediaRecsCardViewModel? {
+ if (!model.areRecommendationsValid) {
+ Log.e(TAG, "Received an invalid recommendation list")
+ return null
+ }
+ if (model.appName == null || model.uid == Process.INVALID_UID) {
+ Log.w(TAG, "Fail to get media recommendation's app info")
+ return null
+ }
+
+ val scheme = getColorScheme(model.packageName) ?: return null
+
+ // Capture width & height from views in foreground for artwork scaling in background
+ val width =
+ applicationContext.resources.getDimensionPixelSize(R.dimen.qs_media_rec_album_width)
+ val height =
+ applicationContext.resources.getDimensionPixelSize(
+ R.dimen.qs_media_rec_album_height_expanded
+ )
+
+ val appIcon = applicationContext.packageManager.getApplicationIcon(model.packageName)
+ val textPrimaryColor = textPrimaryFromScheme(scheme)
+ val textSecondaryColor = textSecondaryFromScheme(scheme)
+ val backgroundColor = surfaceFromScheme(scheme)
+
+ var areTitlesVisible = false
+ var areSubtitlesVisible = false
+ val mediaRecs =
+ model.mediaRecs.map { mediaRecModel ->
+ areTitlesVisible = areTitlesVisible || !mediaRecModel.title.isNullOrEmpty()
+ areSubtitlesVisible = areSubtitlesVisible || !mediaRecModel.subtitle.isNullOrEmpty()
+ val progress = MediaDataUtils.getDescriptionProgress(mediaRecModel.extras) ?: 0.0
+ MediaRecViewModel(
+ contentDescription =
+ setUpMediaRecContentDescription(mediaRecModel, model.appName),
+ title = mediaRecModel.title ?: "",
+ titleColor = textPrimaryColor,
+ subtitle = mediaRecModel.subtitle ?: "",
+ subtitleColor = textSecondaryColor,
+ progress = (progress * 100).toInt(),
+ progressColor = textPrimaryColor,
+ albumIcon =
+ getRecCoverBackground(
+ mediaRecModel.icon,
+ width,
+ height,
+ ),
+ appIcon = appIcon,
+ onClicked = { expandable, index ->
+ onClicked(
+ expandable,
+ mediaRecModel.intent,
+ model.packageName,
+ model.instanceId,
+ index,
+ )
+ }
+ )
+ }
+ // Subtitles should only be visible if titles are visible.
+ areSubtitlesVisible = areTitlesVisible && areSubtitlesVisible
+
+ return MediaRecsCardViewModel(
+ contentDescription = { gutsVisible ->
+ if (gutsVisible) {
+ applicationContext.getString(
+ R.string.controls_media_close_session,
+ model.appName
+ )
+ } else {
+ applicationContext.getString(R.string.controls_media_smartspace_rec_header)
+ }
+ },
+ cardColor = backgroundColor,
+ cardTitleColor = textPrimaryColor,
+ onClicked = { expandable ->
+ onClicked(
+ expandable,
+ model.dismissIntent,
+ model.packageName,
+ model.instanceId,
+ index = -1
+ )
+ },
+ onLongClicked = {
+ logger.logLongPressOpen(model.uid, model.packageName, model.instanceId)
+ },
+ mediaRecs = mediaRecs,
+ areTitlesVisible = areTitlesVisible,
+ areSubtitlesVisible = areSubtitlesVisible,
+ gutsMenu = toGutsViewModel(model, scheme),
+ )
+ }
+
+ private fun toGutsViewModel(
+ model: MediaRecommendationsModel,
+ scheme: ColorScheme
+ ): GutsViewModel {
+ return GutsViewModel(
+ gutsText =
+ applicationContext.getString(R.string.controls_media_close_session, model.appName),
+ textColor = textPrimaryFromScheme(scheme),
+ buttonBackgroundColor = accentPrimaryFromScheme(scheme),
+ buttonTextColor = surfaceFromScheme(scheme),
+ onDismissClicked = {
+ onMediaRecommendationsDismissed(
+ model.key,
+ model.uid,
+ model.packageName,
+ model.dismissIntent,
+ model.instanceId
+ )
+ },
+ cancelTextBackground =
+ applicationContext.getDrawable(R.drawable.qs_media_outline_button),
+ onSettingsClicked = {
+ logger.logLongPressSettings(model.uid, model.packageName, model.instanceId)
+ interactor.startSettings()
+ },
+ )
+ }
+
+ /** Returns the recommendation album cover of [width]x[height] size. */
+ private suspend fun getRecCoverBackground(icon: Icon?, width: Int, height: Int): Drawable =
+ withContext(backgroundDispatcher) {
+ return@withContext MediaArtworkHelper.getWallpaperColor(
+ applicationContext,
+ backgroundDispatcher,
+ icon,
+ TAG,
+ )
+ ?.let { wallpaperColors ->
+ addGradientToRecommendationAlbum(
+ icon!!,
+ ColorScheme(wallpaperColors, true, Style.CONTENT),
+ width,
+ height
+ )
+ }
+ ?: ColorDrawable(Color.TRANSPARENT)
+ }
+
+ private fun addGradientToRecommendationAlbum(
+ artworkIcon: Icon,
+ mutableColorScheme: ColorScheme,
+ width: Int,
+ height: Int
+ ): LayerDrawable {
+ // First try scaling rec card using bitmap drawable.
+ // If returns null, set drawable bounds.
+ val albumArt =
+ getScaledRecommendationCover(artworkIcon, width, height)
+ ?: MediaArtworkHelper.getScaledBackground(
+ applicationContext,
+ artworkIcon,
+ width,
+ height
+ )
+ val gradient =
+ AppCompatResources.getDrawable(applicationContext, R.drawable.qs_media_rec_scrim)
+ ?.mutate() as GradientDrawable
+ return MediaArtworkHelper.setUpGradientColorOnDrawable(
+ albumArt,
+ gradient,
+ mutableColorScheme,
+ MEDIA_REC_SCRIM_START_ALPHA,
+ MEDIA_REC_SCRIM_END_ALPHA
+ )
+ }
+
+ private fun setUpMediaRecContentDescription(
+ mediaRec: MediaRecModel,
+ appName: CharSequence?
+ ): CharSequence {
+ // Set up the accessibility label for the media item.
+ val artistName = mediaRec.extras?.getString(KEY_SMARTSPACE_ARTIST_NAME, "")
+ return if (artistName.isNullOrEmpty()) {
+ applicationContext.getString(
+ R.string.controls_media_smartspace_rec_item_no_artist_description,
+ mediaRec.title,
+ appName
+ )
+ } else {
+ applicationContext.getString(
+ R.string.controls_media_smartspace_rec_item_description,
+ mediaRec.title,
+ artistName,
+ appName
+ )
+ }
+ }
+
+ private fun getColorScheme(packageName: String): ColorScheme? {
+ // Set up recommendation card's header.
+ return try {
+ val packageManager = applicationContext.packageManager
+ val applicationInfo = packageManager.getApplicationInfo(packageName, 0 /* flags */)
+ // Set up media source app's logo.
+ val icon = packageManager.getApplicationIcon(applicationInfo)
+ ColorScheme(WallpaperColors.fromDrawable(icon), darkTheme = true)
+ } catch (e: PackageManager.NameNotFoundException) {
+ Log.w(TAG, "Fail to get media recommendation's app info", e)
+ null
+ }
+ }
+
+ /** Returns a [Drawable] of a given [artworkIcon] scaled to [width]x[height] size, . */
+ private fun getScaledRecommendationCover(
+ artworkIcon: Icon,
+ width: Int,
+ height: Int
+ ): Drawable? {
+ check(width > 0) { "Width must be a positive number but was $width" }
+ check(height > 0) { "Height must be a positive number but was $height" }
+
+ return if (
+ artworkIcon.type == Icon.TYPE_BITMAP || artworkIcon.type == Icon.TYPE_ADAPTIVE_BITMAP
+ ) {
+ artworkIcon.bitmap?.let {
+ val bitmap = Bitmap.createScaledBitmap(it, width, height, false)
+ BitmapDrawable(applicationContext.resources, bitmap)
+ }
+ } else {
+ null
+ }
+ }
+
+ companion object {
+ private const val TAG = "MediaRecommendationsViewModel"
+ private const val KEY_SMARTSPACE_ARTIST_NAME = "artist_name"
+ private const val MEDIA_REC_SCRIM_START_ALPHA = 0.15f
+ private const val MEDIA_REC_SCRIM_END_ALPHA = 1.0f
+ /**
+ * Delay duration is based on [GUTS_ANIMATION_DURATION], it should have 100 ms increase in
+ * order to let the animation end.
+ */
+ private const val GUTS_DISMISS_DELAY_MS_DURATION = 334L
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecsCardViewModel.kt b/packages/SystemUI/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecsCardViewModel.kt
new file mode 100644
index 000000000000..d1713b5cd2fd
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecsCardViewModel.kt
@@ -0,0 +1,33 @@
+/*
+ * 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 com.android.systemui.animation.Expandable
+
+/** Models UI state for media recommendations card. */
+data class MediaRecsCardViewModel(
+ val contentDescription: (Boolean) -> CharSequence,
+ @ColorInt val cardColor: Int,
+ @ColorInt val cardTitleColor: Int,
+ val onClicked: (Expandable) -> Unit,
+ val onLongClicked: () -> Unit,
+ val mediaRecs: List<MediaRecViewModel>,
+ val areTitlesVisible: Boolean,
+ val areSubtitlesVisible: Boolean,
+ val gutsMenu: GutsViewModel,
+)
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaUiEventLogger.kt b/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaUiEventLogger.kt
index 2c25fe2ecb29..09f973cca343 100644
--- a/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaUiEventLogger.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaUiEventLogger.kt
@@ -99,15 +99,15 @@ class MediaUiEventLogger @Inject constructor(private val logger: UiEventLogger)
logger.log(MediaUiEvent.DISMISS_SWIPE)
}
- fun logLongPressOpen(uid: Int, packageName: String, instanceId: InstanceId) {
+ fun logLongPressOpen(uid: Int, packageName: String, instanceId: InstanceId?) {
logger.logWithInstanceId(MediaUiEvent.OPEN_LONG_PRESS, uid, packageName, instanceId)
}
- fun logLongPressDismiss(uid: Int, packageName: String, instanceId: InstanceId) {
+ fun logLongPressDismiss(uid: Int, packageName: String, instanceId: InstanceId?) {
logger.logWithInstanceId(MediaUiEvent.DISMISS_LONG_PRESS, uid, packageName, instanceId)
}
- fun logLongPressSettings(uid: Int, packageName: String, instanceId: InstanceId) {
+ fun logLongPressSettings(uid: Int, packageName: String, instanceId: InstanceId?) {
logger.logWithInstanceId(
MediaUiEvent.OPEN_SETTINGS_LONG_PRESS,
uid,
@@ -188,7 +188,7 @@ class MediaUiEventLogger @Inject constructor(private val logger: UiEventLogger)
)
}
- fun logRecommendationItemTap(packageName: String, instanceId: InstanceId, position: Int) {
+ fun logRecommendationItemTap(packageName: String, instanceId: InstanceId?, position: Int) {
logger.logWithInstanceIdAndPosition(
MediaUiEvent.MEDIA_RECOMMENDATION_ITEM_TAP,
0,
@@ -198,7 +198,7 @@ class MediaUiEventLogger @Inject constructor(private val logger: UiEventLogger)
)
}
- fun logRecommendationCardTap(packageName: String, instanceId: InstanceId) {
+ fun logRecommendationCardTap(packageName: String, instanceId: InstanceId?) {
logger.logWithInstanceId(
MediaUiEvent.MEDIA_RECOMMENDATION_CARD_TAP,
0,
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/broadcast/BroadcastSenderKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/broadcast/BroadcastSenderKosmos.kt
new file mode 100644
index 000000000000..42ad6797040c
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/broadcast/BroadcastSenderKosmos.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.broadcast
+
+import android.content.applicationContext
+import com.android.systemui.concurrency.fakeExecutor
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.util.mockito.mock
+import com.android.systemui.util.wakelock.WakeLockFake
+
+val Kosmos.mockBroadcastSender by Kosmos.Fixture { mock<BroadcastSender>() }
+var Kosmos.broadcastSender by
+ Kosmos.Fixture {
+ BroadcastSender(applicationContext, WakeLockFake.Builder(applicationContext), fakeExecutor)
+ }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaRecommendationsInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaRecommendationsInteractorKosmos.kt
index 372a1961159d..1edd405f4af6 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaRecommendationsInteractorKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaRecommendationsInteractorKosmos.kt
@@ -17,10 +17,12 @@
package com.android.systemui.media.controls.domain.pipeline.interactor
import android.content.applicationContext
+import com.android.systemui.broadcast.broadcastSender
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.applicationCoroutineScope
import com.android.systemui.media.controls.data.repository.mediaFilterRepository
import com.android.systemui.media.controls.domain.pipeline.mediaDataProcessor
+import com.android.systemui.plugins.activityStarter
val Kosmos.mediaRecommendationsInteractor by
Kosmos.Fixture {
@@ -29,5 +31,7 @@ val Kosmos.mediaRecommendationsInteractor by
applicationContext = applicationContext,
repository = mediaFilterRepository,
mediaDataProcessor = mediaDataProcessor,
+ broadcastSender = broadcastSender,
+ activityStarter = activityStarter,
)
}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecommendationsViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecommendationsViewModelKosmos.kt
new file mode 100644
index 000000000000..34a527781979
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecommendationsViewModelKosmos.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.content.applicationContext
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.media.controls.domain.pipeline.interactor.mediaRecommendationsInteractor
+import com.android.systemui.media.controls.util.mediaUiEventLogger
+
+val Kosmos.mediaRecommendationsViewModel by
+ Kosmos.Fixture {
+ MediaRecommendationsViewModel(
+ applicationContext = applicationContext,
+ backgroundDispatcher = testDispatcher,
+ interactor = mediaRecommendationsInteractor,
+ logger = mediaUiEventLogger,
+ )
+ }