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