• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2020 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.statusbar.policy
18 
19 import android.app.ActivityOptions
20 import android.app.Flags.notificationsRedesignTemplates
21 import android.app.Notification
22 import android.app.Notification.Action.SEMANTIC_ACTION_MARK_CONVERSATION_AS_PRIORITY
23 import android.app.PendingIntent
24 import android.app.RemoteInput
25 import android.content.Context
26 import android.content.Intent
27 import android.graphics.Bitmap
28 import android.graphics.ImageDecoder
29 import android.graphics.drawable.AdaptiveIconDrawable
30 import android.graphics.drawable.BitmapDrawable
31 import android.graphics.drawable.Drawable
32 import android.graphics.drawable.GradientDrawable
33 import android.graphics.drawable.Icon
34 import android.os.Build
35 import android.os.Bundle
36 import android.os.SystemClock
37 import android.text.Annotation
38 import android.text.SpannableStringBuilder
39 import android.text.Spanned
40 import android.text.style.ForegroundColorSpan
41 import android.util.Log
42 import android.view.ContextThemeWrapper
43 import android.view.LayoutInflater
44 import android.view.View
45 import android.view.ViewGroup
46 import android.view.accessibility.AccessibilityNodeInfo
47 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction
48 import android.widget.Button
49 import com.android.systemui.Flags
50 import com.android.systemui.plugins.ActivityStarter
51 import com.android.systemui.res.R
52 import com.android.systemui.shared.system.ActivityManagerWrapper
53 import com.android.systemui.shared.system.DevicePolicyManagerWrapper
54 import com.android.systemui.shared.system.PackageManagerWrapper
55 import com.android.systemui.statusbar.NotificationRemoteInputManager
56 import com.android.systemui.statusbar.NotificationUiAdjustment
57 import com.android.systemui.statusbar.SmartReplyController
58 import com.android.systemui.statusbar.notification.collection.NotificationEntry
59 import com.android.systemui.statusbar.notification.headsup.HeadsUpManager
60 import com.android.systemui.statusbar.notification.logging.NotificationLogger
61 import com.android.systemui.statusbar.phone.KeyguardDismissUtil
62 import com.android.systemui.statusbar.policy.InflatedSmartReplyState.SuppressedActions
63 import com.android.systemui.statusbar.policy.SmartReplyView.SmartActions
64 import com.android.systemui.statusbar.policy.SmartReplyView.SmartButtonType
65 import com.android.systemui.statusbar.policy.SmartReplyView.SmartReplies
66 import java.util.concurrent.FutureTask
67 import java.util.concurrent.SynchronousQueue
68 import java.util.concurrent.ThreadPoolExecutor
69 import java.util.concurrent.TimeUnit
70 import javax.inject.Inject
71 import kotlin.system.measureTimeMillis
72 
73 /** Returns whether we should show the smart reply view and its smart suggestions. */
74 fun shouldShowSmartReplyView(
75     entry: NotificationEntry,
76     smartReplyState: InflatedSmartReplyState,
77 ): Boolean {
78     if (smartReplyState.smartReplies == null && smartReplyState.smartActions == null) {
79         // There are no smart replies and no smart actions.
80         return false
81     }
82     // If we are showing the spinner we don't want to add the buttons.
83     val showingSpinner =
84         entry.sbn.notification.extras.getBoolean(
85             Notification.EXTRA_SHOW_REMOTE_INPUT_SPINNER,
86             false,
87         )
88     if (showingSpinner) {
89         return false
90     }
91     // If we are keeping the notification around while sending we don't want to add the buttons.
92     return !entry.sbn.notification.extras.getBoolean(Notification.EXTRA_HIDE_SMART_REPLIES, false)
93 }
94 
95 /** Determines if two [InflatedSmartReplyState] are visually similar. */
areSuggestionsSimilarnull96 fun areSuggestionsSimilar(
97     left: InflatedSmartReplyState?,
98     right: InflatedSmartReplyState?,
99 ): Boolean =
100     when {
101         left === right -> true
102         left == null || right == null -> false
103         left.hasPhishingAction != right.hasPhishingAction -> false
104         left.smartRepliesList != right.smartRepliesList -> false
105         left.suppressedActionIndices != right.suppressedActionIndices -> false
106         else ->
107             !NotificationUiAdjustment.areDifferent(left.smartActionsList, right.smartActionsList)
108     }
109 
110 interface SmartReplyStateInflater {
inflateSmartReplyStatenull111     fun inflateSmartReplyState(entry: NotificationEntry): InflatedSmartReplyState
112 
113     fun inflateSmartReplyViewHolder(
114         sysuiContext: Context,
115         notifPackageContext: Context,
116         entry: NotificationEntry,
117         existingSmartReplyState: InflatedSmartReplyState?,
118         newSmartReplyState: InflatedSmartReplyState,
119     ): InflatedSmartReplyViewHolder
120 }
121 
122 /*internal*/ class SmartReplyStateInflaterImpl
123 @Inject
124 constructor(
125     private val constants: SmartReplyConstants,
126     private val activityManagerWrapper: ActivityManagerWrapper,
127     private val packageManagerWrapper: PackageManagerWrapper,
128     private val devicePolicyManagerWrapper: DevicePolicyManagerWrapper,
129     private val smartRepliesInflater: SmartReplyInflater,
130     private val smartActionsInflater: SmartActionInflater,
131 ) : SmartReplyStateInflater {
132 
133     override fun inflateSmartReplyState(entry: NotificationEntry): InflatedSmartReplyState =
134         chooseSmartRepliesAndActions(entry)
135 
136     override fun inflateSmartReplyViewHolder(
137         sysuiContext: Context,
138         notifPackageContext: Context,
139         entry: NotificationEntry,
140         existingSmartReplyState: InflatedSmartReplyState?,
141         newSmartReplyState: InflatedSmartReplyState,
142     ): InflatedSmartReplyViewHolder {
143         if (!shouldShowSmartReplyView(entry, newSmartReplyState)) {
144             return InflatedSmartReplyViewHolder(
145                 null /* smartReplyView */,
146                 null, /* smartSuggestionButtons */
147             )
148         }
149 
150         // Only block clicks if the smart buttons are different from the previous set - to avoid
151         // scenarios where a user incorrectly cannot click smart buttons because the
152         // notification is updated.
153         val delayOnClickListener =
154             !areSuggestionsSimilar(existingSmartReplyState, newSmartReplyState)
155 
156         val smartReplyView = SmartReplyView.inflate(sysuiContext, constants)
157 
158         val smartReplies = newSmartReplyState.smartReplies
159         smartReplyView.setSmartRepliesGeneratedByAssistant(smartReplies?.fromAssistant ?: false)
160         val smartReplyButtons =
161             smartReplies?.let {
162                 smartReplies.choices.asSequence().mapIndexed { index, choice ->
163                     smartRepliesInflater.inflateReplyButton(
164                         smartReplyView,
165                         entry,
166                         smartReplies,
167                         index,
168                         choice,
169                         delayOnClickListener,
170                     )
171                 }
172             } ?: emptySequence()
173 
174         val smartActionButtons =
175             newSmartReplyState.smartActions?.let { smartActions ->
176                 val themedPackageContext =
177                     ContextThemeWrapper(notifPackageContext, sysuiContext.theme)
178                 smartActions.actions
179                     .asSequence()
180                     .filter { it.actionIntent != null }
181                     .mapIndexed { index, action ->
182                         smartActionsInflater.inflateActionButton(
183                             smartReplyView,
184                             entry,
185                             smartActions,
186                             index,
187                             action,
188                             delayOnClickListener,
189                             themedPackageContext,
190                         )
191                     }
192             } ?: emptySequence()
193 
194         return InflatedSmartReplyViewHolder(
195             smartReplyView,
196             (smartReplyButtons + smartActionButtons).toList(),
197         )
198     }
199 
200     /**
201      * Chose what smart replies and smart actions to display. App generated suggestions take
202      * precedence. So if the app provides any smart replies, we don't show any replies or actions
203      * generated by the NotificationAssistantService (NAS), and if the app provides any smart
204      * actions we also don't show any NAS-generated replies or actions.
205      */
206     fun chooseSmartRepliesAndActions(entry: NotificationEntry): InflatedSmartReplyState {
207         val notification = entry.sbn.notification
208         val remoteInputActionPair = notification.findRemoteInputActionPair(false /* freeform */)
209         val freeformRemoteInputActionPair =
210             notification.findRemoteInputActionPair(true /* freeform */)
211         if (!constants.isEnabled) {
212             if (DEBUG) {
213                 Log.d(
214                     TAG,
215                     "Smart suggestions not enabled, not adding suggestions for " + entry.sbn.key,
216                 )
217             }
218             return InflatedSmartReplyState(null, null, null, false)
219         }
220         // Only use smart replies from the app if they target P or above. We have this check because
221         // the smart reply API has been used for other things (Wearables) in the past. The API to
222         // add smart actions is new in Q so it doesn't require a target-sdk check.
223         val enableAppGeneratedSmartReplies =
224             (!constants.requiresTargetingP() || entry.targetSdk >= Build.VERSION_CODES.P)
225         val appGeneratedSmartActions = notification.contextualActions
226 
227         var smartReplies: SmartReplies? =
228             when {
229                 enableAppGeneratedSmartReplies ->
230                     remoteInputActionPair?.let { pair ->
231                         pair.second.actionIntent?.let { actionIntent ->
232                             if (pair.first.choices?.isNotEmpty() == true)
233                                 SmartReplies(
234                                     pair.first.choices.asList(),
235                                     pair.first,
236                                     actionIntent,
237                                     false, /* fromAssistant */
238                                 )
239                             else null
240                         }
241                     }
242                 else -> null
243             }
244         var smartActions: SmartActions? =
245             when {
246                 appGeneratedSmartActions.isNotEmpty() ->
247                     SmartActions(appGeneratedSmartActions, false /* fromAssistant */)
248                 else -> null
249             }
250         // Apps didn't provide any smart replies / actions, use those from NAS (if any).
251         if (smartReplies == null && smartActions == null) {
252             val entryReplies = entry.smartReplies
253             val entryActions = entry.smartActions
254             if (
255                 entryReplies.isNotEmpty() &&
256                     freeformRemoteInputActionPair != null &&
257                     freeformRemoteInputActionPair.second.allowGeneratedReplies &&
258                     freeformRemoteInputActionPair.second.actionIntent != null
259             ) {
260                 smartReplies =
261                     SmartReplies(
262                         entryReplies,
263                         freeformRemoteInputActionPair.first,
264                         freeformRemoteInputActionPair.second.actionIntent,
265                         true, /* fromAssistant */
266                     )
267             }
268             if (entryActions.isNotEmpty() && notification.allowSystemGeneratedContextualActions) {
269                 val systemGeneratedActions: List<Notification.Action> =
270                     when {
271                         activityManagerWrapper.isLockTaskKioskModeActive ->
272                             // Filter actions if we're in kiosk-mode - we don't care about screen
273                             // pinning mode, since notifications aren't shown there anyway.
274                             filterAllowlistedLockTaskApps(entryActions)
275                         else -> entryActions
276                     }
277                 smartActions = SmartActions(systemGeneratedActions, true /* fromAssistant */)
278             }
279         }
280         val hasPhishingAction =
281             smartActions?.actions?.any {
282                 it.isContextual &&
283                     it.semanticAction ==
284                         Notification.Action.SEMANTIC_ACTION_CONVERSATION_IS_PHISHING
285             } ?: false
286         var suppressedActions: SuppressedActions? = null
287         if (hasPhishingAction) {
288             // If there is a phishing action, calculate the indices of the actions with RemoteInput
289             //  as those need to be hidden from the view.
290             val suppressedActionIndices =
291                 notification.actions.mapIndexedNotNull { index, action ->
292                     if (action.remoteInputs?.isNotEmpty() == true) index else null
293                 }
294             suppressedActions = SuppressedActions(suppressedActionIndices)
295         }
296         return InflatedSmartReplyState(
297             smartReplies,
298             smartActions,
299             suppressedActions,
300             hasPhishingAction,
301         )
302     }
303 
304     /**
305      * Filter actions so that only actions pointing to allowlisted apps are permitted. This
306      * filtering is only meaningful when in lock-task mode.
307      */
308     private fun filterAllowlistedLockTaskApps(
309         actions: List<Notification.Action>
310     ): List<Notification.Action> =
311         actions.filter { action ->
312             //  Only allow actions that are explicit (implicit intents are not handled in lock-task
313             //  mode), and link to allowlisted apps.
314             action.actionIntent
315                 ?.intent
316                 ?.let { intent -> packageManagerWrapper.resolveActivity(intent, 0 /* flags */) }
317                 ?.let { resolveInfo ->
318                     devicePolicyManagerWrapper.isLockTaskPermitted(
319                         resolveInfo.activityInfo.packageName
320                     )
321                 } ?: false
322         }
323 }
324 
325 interface SmartActionInflater {
inflateActionButtonnull326     fun inflateActionButton(
327         parent: ViewGroup,
328         entry: NotificationEntry,
329         smartActions: SmartActions,
330         actionIndex: Int,
331         action: Notification.Action,
332         delayOnClickListener: Boolean,
333         packageContext: Context,
334     ): Button
335 }
336 
337 private const val ICON_TASK_TIMEOUT_MS = 500L
338 private val iconTaskThreadPool = ThreadPoolExecutor(0, 25, 1, TimeUnit.MINUTES, SynchronousQueue())
339 
340 private fun loadIconDrawableWithTimeout(
341     icon: Icon,
342     packageContext: Context,
343     targetSize: Int,
344 ): Drawable? {
345     if (icon.type != Icon.TYPE_URI && icon.type != Icon.TYPE_URI_ADAPTIVE_BITMAP) {
346         return icon.loadDrawable(packageContext)
347     }
348     val bitmapTask = FutureTask {
349         val bitmap: Bitmap?
350         val durationMillis = measureTimeMillis {
351             val source = ImageDecoder.createSource(packageContext.contentResolver, icon.uri)
352             bitmap =
353                 ImageDecoder.decodeBitmap(source) { decoder, _, _ ->
354                     decoder.setTargetSize(targetSize, targetSize)
355                     decoder.allocator = ImageDecoder.ALLOCATOR_DEFAULT
356                 }
357         }
358         if (durationMillis > ICON_TASK_TIMEOUT_MS) {
359             Log.w(TAG, "Loading $icon took ${durationMillis / 1000f} sec")
360         }
361         checkNotNull(bitmap) { "ImageDecoder.decodeBitmap() returned null" }
362     }
363     val bitmap =
364         runCatching {
365                 iconTaskThreadPool.execute(bitmapTask)
366                 bitmapTask.get(ICON_TASK_TIMEOUT_MS, TimeUnit.MILLISECONDS)
367             }
368             .getOrElse { ex ->
369                 Log.e(TAG, "Failed to load $icon: $ex")
370                 bitmapTask.cancel(true)
371                 return null
372             }
373     // TODO(b/288561520): rewrite Icon so that we don't need to duplicate this logic
374     val bitmapDrawable = BitmapDrawable(packageContext.resources, bitmap)
375     val result =
376         if (icon.type == Icon.TYPE_URI_ADAPTIVE_BITMAP) AdaptiveIconDrawable(null, bitmapDrawable)
377         else bitmapDrawable
378     if (icon.hasTint()) {
379         result.mutate()
380         result.setTintList(icon.tintList)
381         result.setTintBlendMode(icon.tintBlendMode)
382     }
383     return result
384 }
385 
386 /* internal */ class SmartActionInflaterImpl
387 @Inject
388 constructor(
389     private val constants: SmartReplyConstants,
390     private val activityStarter: ActivityStarter,
391     private val smartReplyController: SmartReplyController,
392     private val headsUpManager: HeadsUpManager,
393 ) : SmartActionInflater {
394 
inflateActionButtonnull395     override fun inflateActionButton(
396         parent: ViewGroup,
397         entry: NotificationEntry,
398         smartActions: SmartActions,
399         actionIndex: Int,
400         action: Notification.Action,
401         delayOnClickListener: Boolean,
402         packageContext: Context,
403     ): Button {
404         val isAnimatedAction =
405             Flags.notificationAnimatedActionsTreatment() &&
406                 smartActions.fromAssistant &&
407                 action.extras.getBoolean(Notification.Action.EXTRA_IS_ANIMATED, false)
408         val layoutRes =
409             if (isAnimatedAction) {
410                 R.layout.animated_action_button
411             } else {
412                 if (notificationsRedesignTemplates()) {
413                     R.layout.notification_2025_smart_action_button
414                 } else {
415                     R.layout.smart_action_button
416                 }
417             }
418         return (LayoutInflater.from(parent.context).inflate(layoutRes, parent, false) as Button)
419             .apply {
420                 text = action.title
421 
422                 // We received the Icon from the application - so use the Context of the application
423                 // to
424                 // reference icon resources.
425                 val newIconSize =
426                     context.resources.getDimensionPixelSize(R.dimen.smart_action_button_icon_size)
427                 val iconDrawable =
428                     loadIconDrawableWithTimeout(action.getIcon(), packageContext, newIconSize)
429                         ?: GradientDrawable()
430                 iconDrawable.setBounds(0, 0, newIconSize, newIconSize)
431                 // Add the action icon to the Smart Action button.
432                 setCompoundDrawablesRelative(iconDrawable, null, null, null)
433 
434                 val onClickListener =
435                     View.OnClickListener {
436                         onSmartActionClick(entry, smartActions, actionIndex, action)
437                     }
438                 setOnClickListener(
439                     if (delayOnClickListener)
440                         DelayedOnClickListener(onClickListener, constants.onClickInitDelay)
441                     else onClickListener
442                 )
443 
444                 // Mark this as an Action button
445                 (layoutParams as SmartReplyView.LayoutParams).mButtonType = SmartButtonType.ACTION
446             }
447     }
448 
onSmartActionClicknull449     private fun onSmartActionClick(
450         entry: NotificationEntry,
451         smartActions: SmartActions,
452         actionIndex: Int,
453         action: Notification.Action,
454     ) =
455         if (
456             smartActions.fromAssistant &&
457                 SEMANTIC_ACTION_MARK_CONVERSATION_AS_PRIORITY == action.semanticAction
458         ) {
459             entry.row.doSmartActionClick(
460                 entry.row.x.toInt() / 2,
461                 entry.row.y.toInt() / 2,
462                 SEMANTIC_ACTION_MARK_CONVERSATION_AS_PRIORITY,
463             )
464             smartReplyController.smartActionClicked(
465                 entry,
466                 actionIndex,
467                 action,
468                 smartActions.fromAssistant,
469             )
470         } else {
<lambda>null471             activityStarter.startPendingIntentDismissingKeyguard(action.actionIntent, entry.row) {
472                 smartReplyController.smartActionClicked(
473                     entry,
474                     actionIndex,
475                     action,
476                     smartActions.fromAssistant,
477                 )
478             }
479         }
480 }
481 
482 interface SmartReplyInflater {
inflateReplyButtonnull483     fun inflateReplyButton(
484         parent: SmartReplyView,
485         entry: NotificationEntry,
486         smartReplies: SmartReplies,
487         replyIndex: Int,
488         choice: CharSequence,
489         delayOnClickListener: Boolean,
490     ): Button
491 }
492 
493 class SmartReplyInflaterImpl
494 @Inject
495 constructor(
496     private val constants: SmartReplyConstants,
497     private val keyguardDismissUtil: KeyguardDismissUtil,
498     private val remoteInputManager: NotificationRemoteInputManager,
499     private val smartReplyController: SmartReplyController,
500     private val context: Context,
501 ) : SmartReplyInflater {
502 
503     override fun inflateReplyButton(
504         parent: SmartReplyView,
505         entry: NotificationEntry,
506         smartReplies: SmartReplies,
507         replyIndex: Int,
508         choice: CharSequence,
509         delayOnClickListener: Boolean,
510     ): Button {
511         val enableAnimatedReply = Flags.notificationAnimatedActionsTreatment() &&
512                 smartReplies.fromAssistant && isAnimatedReply(choice)
513         val layoutRes = if (enableAnimatedReply) {
514             R.layout.animated_action_button
515         } else {
516             if (notificationsRedesignTemplates()) R.layout.notification_2025_smart_reply_button
517             else R.layout.smart_reply_button
518         }
519 
520         return (LayoutInflater.from(parent.context).inflate(layoutRes, parent, false) as Button)
521             .apply {
522                 // choiceToDeliver does not contain Annotation with extra data
523                 val choiceToDeliver: CharSequence
524                 if (enableAnimatedReply) {
525                     choiceToDeliver = choice.toString()
526                     // If the choice is animated reply, format the text by concatenating
527                     // attributionText with different color to choice text
528                     val fullTextWithAttribution = formatChoiceWithAttribution(choice)
529                     text = fullTextWithAttribution
530                 } else {
531                     choiceToDeliver = choice
532                     text = choice
533                 }
534 
535                 val onClickListener =
536                     View.OnClickListener {
537                         onSmartReplyClick(
538                             entry,
539                             smartReplies,
540                             replyIndex,
541                             parent,
542                             this,
543                             choiceToDeliver
544                         )
545                     }
546                 setOnClickListener(
547                     if (delayOnClickListener)
548                         DelayedOnClickListener(onClickListener, constants.onClickInitDelay)
549                     else onClickListener
550                 )
551                 accessibilityDelegate =
552                     object : View.AccessibilityDelegate() {
553                         override fun onInitializeAccessibilityNodeInfo(
554                             host: View,
555                             info: AccessibilityNodeInfo,
556                         ) {
557                             super.onInitializeAccessibilityNodeInfo(host, info)
558                             val label =
559                                 parent.resources.getString(R.string.accessibility_send_smart_reply)
560                             val action =
561                                 AccessibilityAction(AccessibilityNodeInfo.ACTION_CLICK, label)
562                             info.addAction(action)
563                         }
564                     }
565                 // TODO: probably shouldn't do this here, bad API
566                 // Mark this as a Reply button
567                 (layoutParams as SmartReplyView.LayoutParams).mButtonType = SmartButtonType.REPLY
568             }
569     }
570 
571     private fun onSmartReplyClick(
572         entry: NotificationEntry,
573         smartReplies: SmartReplies,
574         replyIndex: Int,
575         smartReplyView: SmartReplyView,
576         button: Button,
577         choice: CharSequence,
578     ) =
579         keyguardDismissUtil.executeWhenUnlocked(!entry.isRowPinned) {
580             val canEditBeforeSend =
581                 constants.getEffectiveEditChoicesBeforeSending(
582                     smartReplies.remoteInput.editChoicesBeforeSending
583                 )
584             if (canEditBeforeSend) {
585                 remoteInputManager.activateRemoteInput(
586                     button,
587                     arrayOf(smartReplies.remoteInput),
588                     smartReplies.remoteInput,
589                     smartReplies.pendingIntent,
590                     NotificationEntry.EditedSuggestionInfo(choice, replyIndex),
591                 )
592             } else {
593                 smartReplyController.smartReplySent(
594                     entry,
595                     replyIndex,
596                     button.text,
597                     NotificationLogger.getNotificationLocation(entry).toMetricsEventEnum(),
598                     false, /* modifiedBeforeSending */
599                 )
600                 entry.setHasSentReply()
601                 try {
602                     val intent = createRemoteInputIntent(smartReplies, choice)
603                     val opts = ActivityOptions.makeBasic()
604                     opts.setPendingIntentBackgroundActivityStartMode(
605                         ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
606                     )
607                     smartReplies.pendingIntent.send(
608                         context,
609                         0,
610                         intent, /* onFinished */
611                         null,
612                         /* handler */ null, /* requiredPermission */
613                         null,
614                         opts.toBundle(),
615                     )
616                 } catch (e: PendingIntent.CanceledException) {
617                     Log.w(TAG, "Unable to send smart reply", e)
618                 }
619                 smartReplyView.hideSmartSuggestions()
620             }
621             false // do not defer
622         }
623 
624     private fun createRemoteInputIntent(smartReplies: SmartReplies, choice: CharSequence): Intent {
625         val results = Bundle()
626         results.putString(smartReplies.remoteInput.resultKey, choice.toString())
627         val intent = Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
628         RemoteInput.addResultsToIntent(arrayOf(smartReplies.remoteInput), intent, results)
629         RemoteInput.setResultsSource(intent, RemoteInput.SOURCE_CHOICE)
630         return intent
631     }
632 
633     // Check if the choice is animated reply
634     private fun isAnimatedReply(choice: CharSequence): Boolean {
635         if (choice is Spanned) {
636             val annotations = choice.getSpans(0, choice.length, Annotation::class.java)
637             for (annotation in annotations) {
638                 if (annotation.key == "isAnimatedReply" && annotation.value == "1") {
639                     return true
640                 }
641             }
642         }
643         return false
644     }
645 
646     // Format the text by concatenating attributionText with attribution text color to choice text
647     private fun formatChoiceWithAttribution(choice: CharSequence): CharSequence {
648         val colorInt = context.getColor(R.color.animated_action_button_attribution_color)
649         if (choice is Spanned) {
650             val annotations = choice.getSpans(0, choice.length, Annotation::class.java)
651             for (annotation in annotations) {
652                 if (annotation.key == "attributionText") {
653                     // Extract the attribution text
654                     val extraText = annotation.value
655                     // Concatenate choice text and attribution text
656                     val spannableWithColor = SpannableStringBuilder(choice)
657                     spannableWithColor.append(" $extraText")
658                     // Apply color to attribution text
659                     spannableWithColor.setSpan(
660                         ForegroundColorSpan(colorInt),
661                         choice.length,
662                         spannableWithColor.length,
663                         Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
664                     )
665                     return spannableWithColor
666                 }
667             }
668         }
669 
670         // Return the original if no attributionText found
671         return choice.toString()
672     }
673 }
674 
675 /**
676  * An OnClickListener wrapper that blocks the underlying OnClickListener for a given amount of time.
677  */
678 private class DelayedOnClickListener(
679     private val mActualListener: View.OnClickListener,
680     private val mInitDelayMs: Long,
681 ) : View.OnClickListener {
682 
683     private val mInitTimeMs = SystemClock.elapsedRealtime()
684 
onClicknull685     override fun onClick(v: View) {
686         if (hasFinishedInitialization()) {
687             mActualListener.onClick(v)
688         } else {
689             Log.i(TAG, "Accidental Smart Suggestion click registered, delay: $mInitDelayMs")
690         }
691     }
692 
hasFinishedInitializationnull693     private fun hasFinishedInitialization(): Boolean =
694         SystemClock.elapsedRealtime() >= mInitTimeMs + mInitDelayMs
695 }
696 
697 private const val TAG = "SmartReplyViewInflater"
698 private val DEBUG = Log.isLoggable(TAG, Log.DEBUG)
699 
700 // convenience function that swaps parameter order so that lambda can be placed at the end
701 private fun KeyguardDismissUtil.executeWhenUnlocked(
702     requiresShadeOpen: Boolean,
703     onDismissAction: () -> Boolean,
704 ) = executeWhenUnlocked(onDismissAction, requiresShadeOpen, false)
705 
706 // convenience function that swaps parameter order so that lambda can be placed at the end
707 private fun ActivityStarter.startPendingIntentDismissingKeyguard(
708     intent: PendingIntent,
709     associatedView: View?,
710     runnable: () -> Unit,
711 ) = startPendingIntentDismissingKeyguard(intent, runnable::invoke, associatedView)
712