1 /*
2 * Copyright (C) 2021 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.notification.collection.coordinator
18
19 import android.app.Flags.lifetimeExtensionRefactor
20 import android.app.Notification.FLAG_LIFETIME_EXTENDED_BY_DIRECT_REPLY
21 import android.os.Handler
22 import android.service.notification.NotificationListenerService.REASON_CANCEL
23 import android.service.notification.NotificationListenerService.REASON_CLICK
24 import android.util.Log
25 import androidx.annotation.VisibleForTesting
26 import com.android.systemui.Dumpable
27 import com.android.systemui.dagger.qualifiers.Main
28 import com.android.systemui.dump.DumpManager
29 import com.android.systemui.statusbar.NotificationRemoteInputManager
30 import com.android.systemui.statusbar.NotificationRemoteInputManager.RemoteInputListener
31 import com.android.systemui.statusbar.RemoteInputController
32 import com.android.systemui.statusbar.RemoteInputNotificationRebuilder
33 import com.android.systemui.statusbar.SmartReplyController
34 import com.android.systemui.statusbar.notification.collection.NotifPipeline
35 import com.android.systemui.statusbar.notification.collection.NotificationEntry
36 import com.android.systemui.statusbar.notification.collection.coordinator.dagger.CoordinatorScope
37 import com.android.systemui.statusbar.notification.collection.notifcollection.InternalNotifUpdater
38 import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener
39 import com.android.systemui.statusbar.notification.collection.notifcollection.NotifLifetimeExtender
40 import com.android.systemui.statusbar.notification.collection.notifcollection.SelfTrackingLifetimeExtender
41 import com.android.systemui.statusbar.notification.collection.notifcollection.UpdateSource
42 import java.io.PrintWriter
43 import javax.inject.Inject
44
45 private const val TAG = "RemoteInputCoordinator"
46
47 /**
48 * How long to wait before auto-dismissing a notification that was kept for active remote input, and
49 * has now sent a remote input. We auto-dismiss, because the app may not cannot cancel these given
50 * that they technically don't exist anymore. We wait a bit in case the app issues an update, and to
51 * also give the other lifetime extenders a beat to decide they want it.
52 */
53 private const val REMOTE_INPUT_ACTIVE_EXTENDER_AUTO_CANCEL_DELAY: Long = 500
54
55 /**
56 * How long to wait before releasing a lifetime extension when requested to do so due to a user
57 * interaction (such as tapping another action). We wait a bit in case the app issues an update in
58 * response to the action, but not too long or we risk appearing unresponsive to the user.
59 */
60 private const val REMOTE_INPUT_EXTENDER_RELEASE_DELAY: Long = 200
61
62 /** Whether this class should print spammy debug logs */
<lambda>null63 private val DEBUG: Boolean by lazy { Log.isLoggable(TAG, Log.DEBUG) }
64
65 @CoordinatorScope
66 class RemoteInputCoordinator
67 @Inject
68 constructor(
69 dumpManager: DumpManager,
70 private val mRebuilder: RemoteInputNotificationRebuilder,
71 private val mNotificationRemoteInputManager: NotificationRemoteInputManager,
72 @Main private val mMainHandler: Handler,
73 private val mSmartReplyController: SmartReplyController,
74 ) : Coordinator, RemoteInputListener, Dumpable {
75
76 @VisibleForTesting val mRemoteInputHistoryExtender = RemoteInputHistoryExtender()
77 @VisibleForTesting val mSmartReplyHistoryExtender = SmartReplyHistoryExtender()
78 @VisibleForTesting val mRemoteInputActiveExtender = RemoteInputActiveExtender()
79 private val mRemoteInputLifetimeExtenders =
80 listOf(mRemoteInputHistoryExtender, mSmartReplyHistoryExtender, mRemoteInputActiveExtender)
81
82 private lateinit var mNotifUpdater: InternalNotifUpdater
83
84 init {
85 dumpManager.registerDumpable(this)
86 }
87
getLifetimeExtendersnull88 fun getLifetimeExtenders(): List<NotifLifetimeExtender> = mRemoteInputLifetimeExtenders
89
90 override fun attach(pipeline: NotifPipeline) {
91 mNotificationRemoteInputManager.setRemoteInputListener(this)
92 if (lifetimeExtensionRefactor()) {
93 pipeline.addNotificationLifetimeExtender(mRemoteInputActiveExtender)
94 } else {
95 mRemoteInputLifetimeExtenders.forEach { pipeline.addNotificationLifetimeExtender(it) }
96 }
97 mNotifUpdater = pipeline.getInternalNotifUpdater(TAG)
98 pipeline.addCollectionListener(mCollectionListener)
99 }
100
101 /*
102 * Listener that updates the appearance of the notification if it has been lifetime extended
103 * by a a direct reply or a smart reply, and cancelled.
104 */
105 val mCollectionListener =
106 object : NotifCollectionListener {
onEntryUpdatednull107 override fun onEntryUpdated(entry: NotificationEntry, source: UpdateSource) {
108 if (DEBUG) {
109 Log.d(
110 TAG,
111 "mCollectionListener.onEntryUpdated(entry=${entry.key}," +
112 " source=$source)",
113 )
114 }
115 if (source != UpdateSource.SystemUi) {
116 if (lifetimeExtensionRefactor()) {
117 if (
118 (entry.getSbn().getNotification().flags and
119 FLAG_LIFETIME_EXTENDED_BY_DIRECT_REPLY) > 0
120 ) {
121 // If we've received an update from the system and the entry is marked
122 // as lifetime extended, that means system server has received a
123 // cancelation in response to a direct reply, and sent an update to
124 // let system ui know that it should rebuild the notification with
125 // that direct reply.
126 if (
127 mNotificationRemoteInputManager.shouldKeepForSmartReplyHistory(
128 entry
129 )
130 ) {
131 val newSbn = mRebuilder.rebuildForCanceledSmartReplies(entry)
132 mSmartReplyController.stopSending(entry)
133 mNotifUpdater.onInternalNotificationUpdate(
134 newSbn,
135 "Extending lifetime of notification with smart reply",
136 )
137 } else {
138 val newSbn = mRebuilder.rebuildForRemoteInputReply(entry)
139 entry.onRemoteInputInserted()
140 mNotifUpdater.onInternalNotificationUpdate(
141 newSbn,
142 "Extending lifetime of notification with remote input",
143 )
144 }
145 } else {
146 // Notifications updated without FLAG_LIFETIME_EXTENDED_BY_DIRECT_REPLY
147 // should have their remote inputs list cleared.
148 entry.remoteInputs = null
149 }
150 } else {
151 // Mark smart replies as sent whenever a notification is updated by the app,
152 // otherwise the smart replies are never marked as sent.
153 mSmartReplyController.stopSending(entry)
154 }
155 }
156 }
157
onEntryRemovednull158 override fun onEntryRemoved(entry: NotificationEntry, reason: Int) {
159 if (DEBUG) Log.d(TAG, "mCollectionListener.onEntryRemoved(entry=${entry.key})")
160 // We're removing the notification, the smart reply controller can forget about it.
161 // TODO(b/145659174): track 'sending' state on the entry to avoid having to clear
162 // it.
163 mSmartReplyController.stopSending(entry)
164
165 // When we know the entry will not be lifetime extended, clean up the remote input
166 // view
167 // TODO: Share code with NotifCollection.cannotBeLifetimeExtended
168 if (reason == REASON_CANCEL || reason == REASON_CLICK) {
169 mNotificationRemoteInputManager.cleanUpRemoteInputForUserRemoval(entry)
170 }
171 }
172 }
173
dumpnull174 override fun dump(pw: PrintWriter, args: Array<out String>) {
175 mRemoteInputLifetimeExtenders.forEach { it.dump(pw, args) }
176 }
177
onRemoteInputSentnull178 override fun onRemoteInputSent(entry: NotificationEntry) {
179 if (DEBUG) Log.d(TAG, "onRemoteInputSent(entry=${entry.key})")
180 // These calls effectively ensure the freshness of the lifetime extensions.
181 // NOTE: This is some trickery! By removing the lifetime extensions when we know they should
182 // be immediately re-upped, we ensure that the side-effects of the lifetime extenders get to
183 // fire again, thus ensuring that we add subsequent replies to the notification.
184 if (!lifetimeExtensionRefactor()) {
185 mRemoteInputHistoryExtender.endLifetimeExtension(entry.key)
186 mSmartReplyHistoryExtender.endLifetimeExtension(entry.key)
187 }
188
189 // If we're extending for remote input being active, then from the apps point of
190 // view it is already canceled, so we'll need to cancel it on the apps behalf
191 // now that a reply has been sent. However, delay so that the app has time to posts an
192 // update in the mean time, and to give another lifetime extender time to pick it up.
193 mRemoteInputActiveExtender.endLifetimeExtensionAfterDelay(
194 entry.key,
195 REMOTE_INPUT_ACTIVE_EXTENDER_AUTO_CANCEL_DELAY,
196 )
197 }
198
onSmartReplySentnull199 private fun onSmartReplySent(entry: NotificationEntry, reply: CharSequence) {
200 if (DEBUG) Log.d(TAG, "onSmartReplySent(entry=${entry.key})")
201 val newSbn = mRebuilder.rebuildForSendingSmartReply(entry, reply)
202 mNotifUpdater.onInternalNotificationUpdate(newSbn, "Adding smart reply spinner for sent")
203
204 // If we're extending for remote input being active, then from the apps point of
205 // view it is already canceled, so we'll need to cancel it on the apps behalf
206 // now that a reply has been sent. However, delay so that the app has time to posts an
207 // update in the mean time, and to give another lifetime extender time to pick it up.
208 mRemoteInputActiveExtender.endLifetimeExtensionAfterDelay(
209 entry.key,
210 REMOTE_INPUT_ACTIVE_EXTENDER_AUTO_CANCEL_DELAY,
211 )
212 }
213
onPanelCollapsednull214 override fun onPanelCollapsed() {
215 mRemoteInputActiveExtender.endAllLifetimeExtensions()
216 }
217
isNotificationKeptForRemoteInputHistorynull218 override fun isNotificationKeptForRemoteInputHistory(key: String) =
219 if (!lifetimeExtensionRefactor()) {
220 mRemoteInputHistoryExtender.isExtending(key) ||
221 mSmartReplyHistoryExtender.isExtending(key)
222 } else false
223
releaseNotificationIfKeptForRemoteInputHistorynull224 override fun releaseNotificationIfKeptForRemoteInputHistory(entryKey: String) {
225 if (DEBUG) Log.d(TAG, "releaseNotificationIfKeptForRemoteInputHistory(entry=${entryKey})")
226 if (!lifetimeExtensionRefactor()) {
227 mRemoteInputHistoryExtender.endLifetimeExtensionAfterDelay(
228 entryKey,
229 REMOTE_INPUT_EXTENDER_RELEASE_DELAY,
230 )
231 mSmartReplyHistoryExtender.endLifetimeExtensionAfterDelay(
232 entryKey,
233 REMOTE_INPUT_EXTENDER_RELEASE_DELAY,
234 )
235 }
236 mRemoteInputActiveExtender.endLifetimeExtensionAfterDelay(
237 entryKey,
238 REMOTE_INPUT_EXTENDER_RELEASE_DELAY,
239 )
240 }
241
setRemoteInputControllernull242 override fun setRemoteInputController(remoteInputController: RemoteInputController) {
243 mSmartReplyController.setCallback(this::onSmartReplySent)
244 }
245
246 @VisibleForTesting
247 inner class RemoteInputHistoryExtender :
248 SelfTrackingLifetimeExtender(TAG, "RemoteInputHistory", DEBUG, mMainHandler) {
249
queryShouldExtendLifetimenull250 override fun queryShouldExtendLifetime(entry: NotificationEntry): Boolean =
251 mNotificationRemoteInputManager.shouldKeepForRemoteInputHistory(entry)
252
253 override fun onStartedLifetimeExtension(entry: NotificationEntry) {
254 val newSbn = mRebuilder.rebuildForRemoteInputReply(entry)
255 entry.onRemoteInputInserted()
256 mNotifUpdater.onInternalNotificationUpdate(
257 newSbn,
258 "Extending lifetime of notification with remote input",
259 )
260 // TODO: Check if the entry was removed due perhaps to an inflation exception?
261 }
262 }
263
264 @VisibleForTesting
265 inner class SmartReplyHistoryExtender :
266 SelfTrackingLifetimeExtender(TAG, "SmartReplyHistory", DEBUG, mMainHandler) {
267
queryShouldExtendLifetimenull268 override fun queryShouldExtendLifetime(entry: NotificationEntry): Boolean =
269 mNotificationRemoteInputManager.shouldKeepForSmartReplyHistory(entry)
270
271 override fun onStartedLifetimeExtension(entry: NotificationEntry) {
272 val newSbn = mRebuilder.rebuildForCanceledSmartReplies(entry)
273 mSmartReplyController.stopSending(entry)
274 mNotifUpdater.onInternalNotificationUpdate(
275 newSbn,
276 "Extending lifetime of notification with smart reply",
277 )
278 // TODO: Check if the entry was removed due perhaps to an inflation exception?
279 }
280
onCanceledLifetimeExtensionnull281 override fun onCanceledLifetimeExtension(entry: NotificationEntry) {
282 // TODO(b/145659174): track 'sending' state on the entry to avoid having to clear it.
283 mSmartReplyController.stopSending(entry)
284 }
285 }
286
287 @VisibleForTesting
288 inner class RemoteInputActiveExtender :
289 SelfTrackingLifetimeExtender(TAG, "RemoteInputActive", DEBUG, mMainHandler) {
290
queryShouldExtendLifetimenull291 override fun queryShouldExtendLifetime(entry: NotificationEntry): Boolean =
292 mNotificationRemoteInputManager.isRemoteInputActive(entry)
293 }
294 }
295