Loading packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/interactor/MediaRecommendationsInteractorTest.kt +67 −2 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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( Loading @@ -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() = Loading Loading @@ -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" Loading packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecommendationsViewModelTest.kt 0 → 100644 +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" } } packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaRecommendationsInteractor.kt +74 −1 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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> = Loading @@ -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 { Loading @@ -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) } } packages/SystemUI/src/com/android/systemui/media/controls/ui/util/MediaArtworkHelper.kt 0 → 100644 +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)) } } packages/SystemUI/src/com/android/systemui/media/controls/ui/viewmodel/GutsViewModel.kt 0 → 100644 +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
packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/interactor/MediaRecommendationsInteractorTest.kt +67 −2 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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( Loading @@ -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() = Loading Loading @@ -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" Loading
packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/ui/viewmodel/MediaRecommendationsViewModelTest.kt 0 → 100644 +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" } }
packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaRecommendationsInteractor.kt +74 −1 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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> = Loading @@ -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 { Loading @@ -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) } }
packages/SystemUI/src/com/android/systemui/media/controls/ui/util/MediaArtworkHelper.kt 0 → 100644 +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)) } }
packages/SystemUI/src/com/android/systemui/media/controls/ui/viewmodel/GutsViewModel.kt 0 → 100644 +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, )