1 /* <lambda>null2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.systemui.media.controls.ui.viewmodel 18 19 import android.content.Context 20 import android.content.Intent 21 import android.graphics.Bitmap 22 import android.graphics.Color 23 import android.graphics.drawable.BitmapDrawable 24 import android.graphics.drawable.ColorDrawable 25 import android.graphics.drawable.Drawable 26 import android.graphics.drawable.GradientDrawable 27 import android.graphics.drawable.Icon 28 import android.graphics.drawable.LayerDrawable 29 import android.os.Process 30 import android.util.Log 31 import androidx.appcompat.content.res.AppCompatResources 32 import com.android.internal.logging.InstanceId 33 import com.android.systemui.animation.Expandable 34 import com.android.systemui.dagger.SysUISingleton 35 import com.android.systemui.dagger.qualifiers.Application 36 import com.android.systemui.dagger.qualifiers.Background 37 import com.android.systemui.media.controls.domain.pipeline.interactor.MediaRecommendationsInteractor 38 import com.android.systemui.media.controls.shared.model.MediaRecModel 39 import com.android.systemui.media.controls.shared.model.MediaRecommendationsModel 40 import com.android.systemui.media.controls.ui.animation.accentPrimaryFromScheme 41 import com.android.systemui.media.controls.ui.animation.surfaceFromScheme 42 import com.android.systemui.media.controls.ui.animation.textPrimaryFromScheme 43 import com.android.systemui.media.controls.ui.animation.textSecondaryFromScheme 44 import com.android.systemui.media.controls.ui.controller.MediaViewController.Companion.GUTS_ANIMATION_DURATION 45 import com.android.systemui.media.controls.ui.util.MediaArtworkHelper 46 import com.android.systemui.media.controls.util.MediaDataUtils 47 import com.android.systemui.media.controls.util.MediaUiEventLogger 48 import com.android.systemui.monet.ColorScheme 49 import com.android.systemui.monet.Style 50 import com.android.systemui.res.R 51 import javax.inject.Inject 52 import kotlinx.coroutines.CoroutineDispatcher 53 import kotlinx.coroutines.ExperimentalCoroutinesApi 54 import kotlinx.coroutines.flow.Flow 55 import kotlinx.coroutines.flow.distinctUntilChanged 56 import kotlinx.coroutines.flow.flatMapLatest 57 import kotlinx.coroutines.flow.flowOn 58 import kotlinx.coroutines.flow.map 59 import kotlinx.coroutines.withContext 60 61 /** Models UI state and handles user input for media recommendations */ 62 @SysUISingleton 63 class MediaRecommendationsViewModel 64 @Inject 65 constructor( 66 @Application private val applicationContext: Context, 67 @Background private val backgroundDispatcher: CoroutineDispatcher, 68 private val interactor: MediaRecommendationsInteractor, 69 private val logger: MediaUiEventLogger, 70 ) { 71 72 @OptIn(ExperimentalCoroutinesApi::class) 73 val mediaRecsCard: Flow<MediaRecsCardViewModel?> = 74 interactor.onAnyMediaConfigurationChange 75 .flatMapLatest { 76 interactor.recommendations.map { recsCard -> toRecsViewModel(recsCard) } 77 } 78 .distinctUntilChanged() 79 .flowOn(backgroundDispatcher) 80 81 /** 82 * Called whenever the recommendation has been expired or removed by the user. This method 83 * removes the recommendation card entirely from the carousel. 84 */ 85 private fun onMediaRecommendationsDismissed( 86 key: String, 87 uid: Int, 88 packageName: String, 89 dismissIntent: Intent?, 90 instanceId: InstanceId? 91 ) { 92 // TODO (b/330897926) log smartspace card reported (SMARTSPACE_CARD_DISMISS_EVENT). 93 logger.logLongPressDismiss(uid, packageName, instanceId) 94 interactor.removeMediaRecommendations(key, dismissIntent, GUTS_DISMISS_DELAY_MS_DURATION) 95 } 96 97 private fun onClicked( 98 expandable: Expandable, 99 intent: Intent?, 100 packageName: String, 101 instanceId: InstanceId?, 102 index: Int 103 ) { 104 if (intent == null || intent.extras == null) { 105 Log.e(TAG, "No tap action can be set up") 106 return 107 } 108 109 if (index == -1) { 110 logger.logRecommendationCardTap(packageName, instanceId) 111 } else { 112 logger.logRecommendationItemTap(packageName, instanceId, index) 113 } 114 // TODO (b/330897926) log smartspace card reported (SMARTSPACE_CARD_CLICK_EVENT). 115 116 // set the package name of the player added by recommendation once the media is loaded. 117 interactor.switchToMediaControl(packageName) 118 119 interactor.startClickIntent(expandable, intent) 120 } 121 122 private suspend fun toRecsViewModel(model: MediaRecommendationsModel): MediaRecsCardViewModel? { 123 if (!model.areRecommendationsValid) { 124 Log.e(TAG, "Received an invalid recommendation list") 125 return null 126 } 127 if (model.appName == null || model.uid == Process.INVALID_UID) { 128 Log.w(TAG, "Fail to get media recommendation's app info") 129 return null 130 } 131 132 val scheme = 133 MediaArtworkHelper.getColorScheme(applicationContext, model.packageName, TAG) 134 ?: return null 135 136 // Capture width & height from views in foreground for artwork scaling in background 137 val width = 138 applicationContext.resources.getDimensionPixelSize(R.dimen.qs_media_rec_album_width) 139 val height = 140 applicationContext.resources.getDimensionPixelSize( 141 R.dimen.qs_media_rec_album_height_expanded 142 ) 143 144 val appIcon = applicationContext.packageManager.getApplicationIcon(model.packageName) 145 val textPrimaryColor = textPrimaryFromScheme(scheme) 146 val textSecondaryColor = textSecondaryFromScheme(scheme) 147 val backgroundColor = surfaceFromScheme(scheme) 148 149 var areTitlesVisible = false 150 var areSubtitlesVisible = false 151 val mediaRecs = 152 model.mediaRecs.map { mediaRecModel -> 153 areTitlesVisible = areTitlesVisible || !mediaRecModel.title.isNullOrEmpty() 154 areSubtitlesVisible = areSubtitlesVisible || !mediaRecModel.subtitle.isNullOrEmpty() 155 val progress = MediaDataUtils.getDescriptionProgress(mediaRecModel.extras) ?: 0.0 156 MediaRecViewModel( 157 contentDescription = 158 setUpMediaRecContentDescription(mediaRecModel, model.appName), 159 title = mediaRecModel.title ?: "", 160 titleColor = textPrimaryColor, 161 subtitle = mediaRecModel.subtitle ?: "", 162 subtitleColor = textSecondaryColor, 163 progress = (progress * 100).toInt(), 164 progressColor = textPrimaryColor, 165 albumIcon = 166 getRecCoverBackground( 167 mediaRecModel.icon, 168 width, 169 height, 170 ), 171 appIcon = appIcon, 172 onClicked = { expandable, index -> 173 onClicked( 174 expandable, 175 mediaRecModel.intent, 176 model.packageName, 177 model.instanceId, 178 index, 179 ) 180 } 181 ) 182 } 183 // Subtitles should only be visible if titles are visible. 184 areSubtitlesVisible = areTitlesVisible && areSubtitlesVisible 185 186 return MediaRecsCardViewModel( 187 contentDescription = { gutsVisible -> 188 if (gutsVisible) { 189 applicationContext.getString( 190 R.string.controls_media_close_session, 191 model.appName 192 ) 193 } else { 194 applicationContext.getString(R.string.controls_media_smartspace_rec_header) 195 } 196 }, 197 cardColor = backgroundColor, 198 cardTitleColor = textPrimaryColor, 199 onClicked = { expandable -> 200 onClicked( 201 expandable, 202 model.dismissIntent, 203 model.packageName, 204 model.instanceId, 205 index = -1 206 ) 207 }, 208 onLongClicked = { 209 logger.logLongPressOpen(model.uid, model.packageName, model.instanceId) 210 }, 211 mediaRecs = mediaRecs, 212 areTitlesVisible = areTitlesVisible, 213 areSubtitlesVisible = areSubtitlesVisible, 214 gutsMenu = toGutsViewModel(model, scheme), 215 ) 216 } 217 218 private fun toGutsViewModel( 219 model: MediaRecommendationsModel, 220 scheme: ColorScheme 221 ): GutsViewModel { 222 return GutsViewModel( 223 gutsText = 224 applicationContext.getString(R.string.controls_media_close_session, model.appName), 225 textPrimaryColor = textPrimaryFromScheme(scheme), 226 accentPrimaryColor = accentPrimaryFromScheme(scheme), 227 surfaceColor = surfaceFromScheme(scheme), 228 onDismissClicked = { 229 onMediaRecommendationsDismissed( 230 model.key, 231 model.uid, 232 model.packageName, 233 model.dismissIntent, 234 model.instanceId 235 ) 236 }, 237 cancelTextBackground = 238 applicationContext.getDrawable(R.drawable.qs_media_outline_button), 239 onSettingsClicked = { 240 logger.logLongPressSettings(model.uid, model.packageName, model.instanceId) 241 interactor.startSettings() 242 }, 243 ) 244 } 245 246 /** Returns the recommendation album cover of [width]x[height] size. */ 247 private suspend fun getRecCoverBackground(icon: Icon?, width: Int, height: Int): Drawable = 248 withContext(backgroundDispatcher) { 249 return@withContext MediaArtworkHelper.getWallpaperColor( 250 applicationContext, 251 backgroundDispatcher, 252 icon, 253 TAG, 254 ) 255 ?.let { wallpaperColors -> 256 addGradientToRecommendationAlbum( 257 icon!!, 258 ColorScheme(wallpaperColors, true, Style.CONTENT), 259 width, 260 height 261 ) 262 } 263 ?: ColorDrawable(Color.TRANSPARENT) 264 } 265 266 private fun addGradientToRecommendationAlbum( 267 artworkIcon: Icon, 268 mutableColorScheme: ColorScheme, 269 width: Int, 270 height: Int 271 ): LayerDrawable { 272 // First try scaling rec card using bitmap drawable. 273 // If returns null, set drawable bounds. 274 val albumArt = 275 getScaledRecommendationCover(artworkIcon, width, height) 276 ?: MediaArtworkHelper.getScaledBackground( 277 applicationContext, 278 artworkIcon, 279 width, 280 height 281 ) 282 val gradient = 283 AppCompatResources.getDrawable(applicationContext, R.drawable.qs_media_rec_scrim) 284 ?.mutate() as GradientDrawable 285 return MediaArtworkHelper.setUpGradientColorOnDrawable( 286 albumArt, 287 gradient, 288 mutableColorScheme, 289 MEDIA_REC_SCRIM_START_ALPHA, 290 MEDIA_REC_SCRIM_END_ALPHA 291 ) 292 } 293 294 private fun setUpMediaRecContentDescription( 295 mediaRec: MediaRecModel, 296 appName: CharSequence? 297 ): CharSequence { 298 // Set up the accessibility label for the media item. 299 val artistName = mediaRec.extras?.getString(KEY_SMARTSPACE_ARTIST_NAME, "") 300 return if (artistName.isNullOrEmpty()) { 301 applicationContext.getString( 302 R.string.controls_media_smartspace_rec_item_no_artist_description, 303 mediaRec.title, 304 appName 305 ) 306 } else { 307 applicationContext.getString( 308 R.string.controls_media_smartspace_rec_item_description, 309 mediaRec.title, 310 artistName, 311 appName 312 ) 313 } 314 } 315 316 /** Returns a [Drawable] of a given [artworkIcon] scaled to [width]x[height] size, . */ 317 private fun getScaledRecommendationCover( 318 artworkIcon: Icon, 319 width: Int, 320 height: Int 321 ): Drawable? { 322 check(width > 0) { "Width must be a positive number but was $width" } 323 check(height > 0) { "Height must be a positive number but was $height" } 324 325 return if ( 326 artworkIcon.type == Icon.TYPE_BITMAP || artworkIcon.type == Icon.TYPE_ADAPTIVE_BITMAP 327 ) { 328 artworkIcon.bitmap?.let { 329 val bitmap = Bitmap.createScaledBitmap(it, width, height, false) 330 BitmapDrawable(applicationContext.resources, bitmap) 331 } 332 } else { 333 null 334 } 335 } 336 337 companion object { 338 private const val TAG = "MediaRecommendationsViewModel" 339 private const val KEY_SMARTSPACE_ARTIST_NAME = "artist_name" 340 private const val MEDIA_REC_SCRIM_START_ALPHA = 0.15f 341 private const val MEDIA_REC_SCRIM_END_ALPHA = 1.0f 342 /** 343 * Delay duration is based on [GUTS_ANIMATION_DURATION], it should have 100 ms increase in 344 * order to let the animation end. 345 */ 346 private const val GUTS_DISMISS_DELAY_MS_DURATION = 334L 347 } 348 } 349