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.domain.pipeline
18
19 import android.annotation.WorkerThread
20 import android.app.ActivityOptions
21 import android.app.BroadcastOptions
22 import android.app.Notification
23 import android.app.PendingIntent
24 import android.content.Context
25 import android.graphics.drawable.Animatable
26 import android.graphics.drawable.Icon
27 import android.media.session.MediaController
28 import android.media.session.PlaybackState
29 import android.service.notification.StatusBarNotification
30 import android.util.Log
31 import androidx.media.utils.MediaConstants
32 import com.android.systemui.Flags
33 import com.android.systemui.media.NotificationMediaManager.isConnectingState
34 import com.android.systemui.media.NotificationMediaManager.isPlayingState
35 import com.android.systemui.media.controls.domain.pipeline.LegacyMediaDataManagerImpl.Companion.MAX_COMPACT_ACTIONS
36 import com.android.systemui.media.controls.domain.pipeline.LegacyMediaDataManagerImpl.Companion.MAX_NOTIFICATION_ACTIONS
37 import com.android.systemui.media.controls.shared.MediaControlDrawables
38 import com.android.systemui.media.controls.shared.model.MediaAction
39 import com.android.systemui.media.controls.shared.model.MediaButton
40 import com.android.systemui.media.controls.shared.model.MediaNotificationAction
41 import com.android.systemui.plugins.ActivityStarter
42 import com.android.systemui.res.R
43 import com.android.systemui.util.kotlin.logI
44
45 private const val TAG = "MediaActions"
46
47 /**
48 * Generates action button info for this media session based on the PlaybackState
49 *
50 * @param packageName Package name for the media app
51 * @param controller MediaController for the current session
52 * @return a Pair consisting of a list of media actions, and a list of ints representing which of
53 * those actions should be shown in the compact player
54 */
55 @WorkerThread
56 fun createActionsFromState(
57 context: Context,
58 packageName: String,
59 controller: MediaController,
60 ): MediaButton? {
61 val state = controller.playbackState ?: return null
62 // First, check for standard actions
63 val playOrPause =
64 if (isConnectingState(state.state)) {
65 // Spinner needs to be animating to render anything. Start it here.
66 val drawable =
67 context.getDrawable(com.android.internal.R.drawable.progress_small_material)
68 (drawable as Animatable).start()
69 MediaAction(
70 drawable,
71 null, // no action to perform when clicked
72 context.getString(R.string.controls_media_button_connecting),
73 if (Flags.mediaControlsUiUpdate()) {
74 context.getDrawable(R.drawable.ic_media_connecting_button_container)
75 } else {
76 context.getDrawable(R.drawable.ic_media_connecting_container)
77 },
78 // Specify a rebind id to prevent the spinner from restarting on later binds.
79 com.android.internal.R.drawable.progress_small_material,
80 )
81 } else if (isPlayingState(state.state)) {
82 getStandardAction(context, controller, state.actions, PlaybackState.ACTION_PAUSE)
83 } else {
84 getStandardAction(context, controller, state.actions, PlaybackState.ACTION_PLAY)
85 }
86 val prevButton =
87 getStandardAction(context, controller, state.actions, PlaybackState.ACTION_SKIP_TO_PREVIOUS)
88 val nextButton =
89 getStandardAction(context, controller, state.actions, PlaybackState.ACTION_SKIP_TO_NEXT)
90
91 // Then, create a way to build any custom actions that will be needed
92 val customActions =
93 state.customActions
94 .asSequence()
95 .filterNotNull()
96 .map { getCustomAction(context, packageName, controller, it) }
97 .iterator()
98 fun nextCustomAction() = if (customActions.hasNext()) customActions.next() else null
99
100 // Finally, assign the remaining button slots: play/pause A B C D
101 // A = previous, else custom action (if not reserved)
102 // B = next, else custom action (if not reserved)
103 // C and D are always custom actions
104 val reservePrev =
105 controller.extras?.getBoolean(
106 MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV
107 ) == true
108 val reserveNext =
109 controller.extras?.getBoolean(
110 MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT
111 ) == true
112
113 val prevOrCustom =
114 if (prevButton != null) {
115 prevButton
116 } else if (!reservePrev) {
117 nextCustomAction()
118 } else {
119 null
120 }
121
122 val nextOrCustom =
123 if (nextButton != null) {
124 nextButton
125 } else if (!reserveNext) {
126 nextCustomAction()
127 } else {
128 null
129 }
130
131 return MediaButton(
132 playOrPause,
133 nextOrCustom,
134 prevOrCustom,
135 nextCustomAction(),
136 nextCustomAction(),
137 reserveNext,
138 reservePrev,
139 )
140 }
141
142 /**
143 * Create a [MediaAction] for a given action and media session
144 *
145 * @param controller MediaController for the session
146 * @param stateActions The actions included with the session's [PlaybackState]
147 * @param action A [PlaybackState.Actions] value representing what action to generate. One of:
148 * [PlaybackState.ACTION_PLAY] [PlaybackState.ACTION_PAUSE]
149 * [PlaybackState.ACTION_SKIP_TO_PREVIOUS] [PlaybackState.ACTION_SKIP_TO_NEXT]
150 * @return A [MediaAction] with correct values set, or null if the state doesn't support it
151 */
getStandardActionnull152 private fun getStandardAction(
153 context: Context,
154 controller: MediaController,
155 stateActions: Long,
156 @PlaybackState.Actions action: Long,
157 ): MediaAction? {
158 if (!includesAction(stateActions, action)) {
159 return null
160 }
161
162 return when (action) {
163 PlaybackState.ACTION_PLAY -> {
164 MediaAction(
165 if (Flags.mediaControlsUiUpdate()) {
166 context.getDrawable(R.drawable.ic_media_play_button)
167 } else {
168 context.getDrawable(R.drawable.ic_media_play)
169 },
170 { controller.transportControls.play() },
171 context.getString(R.string.controls_media_button_play),
172 if (Flags.mediaControlsUiUpdate()) {
173 context.getDrawable(R.drawable.ic_media_play_button_container)
174 } else {
175 context.getDrawable(R.drawable.ic_media_play_container)
176 },
177 )
178 }
179 PlaybackState.ACTION_PAUSE -> {
180 MediaAction(
181 if (Flags.mediaControlsUiUpdate()) {
182 context.getDrawable(R.drawable.ic_media_pause_button)
183 } else {
184 context.getDrawable(R.drawable.ic_media_pause)
185 },
186 { controller.transportControls.pause() },
187 context.getString(R.string.controls_media_button_pause),
188 if (Flags.mediaControlsUiUpdate()) {
189 context.getDrawable(R.drawable.ic_media_pause_button_container)
190 } else {
191 context.getDrawable(R.drawable.ic_media_pause_container)
192 },
193 )
194 }
195 PlaybackState.ACTION_SKIP_TO_PREVIOUS -> {
196 MediaAction(
197 MediaControlDrawables.getPrevIcon(context),
198 { controller.transportControls.skipToPrevious() },
199 context.getString(R.string.controls_media_button_prev),
200 null,
201 )
202 }
203 PlaybackState.ACTION_SKIP_TO_NEXT -> {
204 MediaAction(
205 MediaControlDrawables.getNextIcon(context),
206 { controller.transportControls.skipToNext() },
207 context.getString(R.string.controls_media_button_next),
208 null,
209 )
210 }
211 else -> null
212 }
213 }
214
215 /** Get a [MediaAction] representing a [PlaybackState.CustomAction] */
getCustomActionnull216 private fun getCustomAction(
217 context: Context,
218 packageName: String,
219 controller: MediaController,
220 customAction: PlaybackState.CustomAction,
221 ): MediaAction {
222 return MediaAction(
223 Icon.createWithResource(packageName, customAction.icon).loadDrawable(context),
224 { controller.transportControls.sendCustomAction(customAction, customAction.extras) },
225 customAction.name,
226 null,
227 )
228 }
229
230 /** Check whether the actions from a [PlaybackState] include a specific action */
includesActionnull231 private fun includesAction(stateActions: Long, @PlaybackState.Actions action: Long): Boolean {
232 if (
233 (action == PlaybackState.ACTION_PLAY || action == PlaybackState.ACTION_PAUSE) &&
234 (stateActions and PlaybackState.ACTION_PLAY_PAUSE > 0L)
235 ) {
236 return true
237 }
238 return (stateActions and action != 0L)
239 }
240
241 /** Generate action buttons based on notification actions */
createActionsFromNotificationnull242 fun createActionsFromNotification(
243 context: Context,
244 sbn: StatusBarNotification,
245 ): Pair<List<MediaNotificationAction>, List<Int>> {
246 val notif = sbn.notification
247 val actionIcons: MutableList<MediaNotificationAction> = ArrayList()
248 val actions = notif.actions
249 var actionsToShowCollapsed =
250 notif.extras.getIntArray(Notification.EXTRA_COMPACT_ACTIONS)?.toMutableList()
251 ?: mutableListOf()
252 if (actionsToShowCollapsed.size > MAX_COMPACT_ACTIONS) {
253 Log.e(
254 TAG,
255 "Too many compact actions for ${sbn.key}, limiting to first $MAX_COMPACT_ACTIONS",
256 )
257 actionsToShowCollapsed = actionsToShowCollapsed.subList(0, MAX_COMPACT_ACTIONS)
258 }
259
260 actions?.let {
261 if (it.size > MAX_NOTIFICATION_ACTIONS) {
262 Log.w(
263 TAG,
264 "Too many notification actions for ${sbn.key}, " +
265 "limiting to first $MAX_NOTIFICATION_ACTIONS",
266 )
267 }
268
269 for ((index, action) in it.take(MAX_NOTIFICATION_ACTIONS).withIndex()) {
270 if (action.getIcon() == null) {
271 logI(TAG) { "No icon for action $index ${action.title}" }
272 actionsToShowCollapsed.remove(index)
273 continue
274 }
275
276 val themeText =
277 com.android.settingslib.Utils.getColorAttr(
278 context,
279 com.android.internal.R.attr.textColorPrimary,
280 )
281 .defaultColor
282
283 val mediaActionIcon =
284 when (action.getIcon().type) {
285 Icon.TYPE_RESOURCE ->
286 Icon.createWithResource(sbn.packageName, action.getIcon()!!.getResId())
287 else -> action.getIcon()
288 }
289 .setTint(themeText)
290 .loadDrawable(context)
291
292 val mediaAction =
293 MediaNotificationAction(
294 action.isAuthenticationRequired,
295 action.actionIntent,
296 mediaActionIcon,
297 action.title,
298 )
299 actionIcons.add(mediaAction)
300 }
301 }
302 return Pair(actionIcons, actionsToShowCollapsed)
303 }
304
305 /**
306 * Converts [MediaNotificationAction] list into [MediaAction] list
307 *
308 * @param actions list of [MediaNotificationAction]
309 * @param activityStarter starter for activities
310 * @return list of [MediaAction]
311 */
getNotificationActionsnull312 fun getNotificationActions(
313 actions: List<MediaNotificationAction>,
314 activityStarter: ActivityStarter,
315 ): List<MediaAction> {
316 return actions.map { action ->
317 val runnable =
318 action.actionIntent?.let { actionIntent ->
319 Runnable {
320 when {
321 actionIntent.isActivity ->
322 activityStarter.startPendingIntentDismissingKeyguard(
323 action.actionIntent
324 )
325 action.isAuthenticationRequired ->
326 activityStarter.dismissKeyguardThenExecute(
327 { sendPendingIntent(action.actionIntent) },
328 {},
329 true,
330 )
331 else -> sendPendingIntent(actionIntent)
332 }
333 }
334 }
335 MediaAction(action.icon, runnable, action.contentDescription, background = null)
336 }
337 }
338
sendPendingIntentnull339 private fun sendPendingIntent(intent: PendingIntent): Boolean {
340 return try {
341 intent.send(
342 BroadcastOptions.makeBasic()
343 .apply {
344 setInteractive(true)
345 setPendingIntentBackgroundActivityStartMode(
346 ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
347 )
348 }
349 .toBundle()
350 )
351 true
352 } catch (e: PendingIntent.CanceledException) {
353 Log.d(TAG, "Intent canceled", e)
354 false
355 }
356 }
357