diff options
| author | 2024-03-24 21:30:31 +0000 | |
|---|---|---|
| committer | 2024-04-01 17:16:18 +0000 | |
| commit | a1bb826994588acbc7665c556bef93aa6ee5aff8 (patch) | |
| tree | fa106420b999096a0fcf5c744e8aaf965e37cf36 | |
| parent | 689af7a504a912f81d4a265dde3d6cedb25ebd42 (diff) | |
Add media recommendations view-model
Flag: ACONFIG media_controls_refactor DISABLED
Bug: 328207006
Test: atest SystemUiRoboTests:MediaRecommendationsViewModelTest
Change-Id: Ie5fdb6ce8d2a820d211b50edc434886b88b8d34b
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, + ) + } |