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