1 /*
2 * Copyright (C) 2022 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 @file:OptIn(InternalNoteTaskApi::class)
18
19 package com.android.systemui.notetask
20
21 import android.app.ActivityManager
22 import android.app.KeyguardManager
23 import android.app.admin.DevicePolicyManager
24 import android.app.role.OnRoleHoldersChangedListener
25 import android.app.role.RoleManager
26 import android.app.role.RoleManager.ROLE_NOTES
27 import android.content.ActivityNotFoundException
28 import android.content.ComponentName
29 import android.content.Context
30 import android.content.Intent
31 import android.content.pm.PackageManager
32 import android.content.pm.ShortcutManager
33 import android.graphics.drawable.Icon
34 import android.os.Process
35 import android.os.UserHandle
36 import android.os.UserManager
37 import android.provider.Settings
38 import android.widget.Toast
39 import androidx.annotation.VisibleForTesting
40 import com.android.systemui.R
41 import com.android.systemui.dagger.SysUISingleton
42 import com.android.systemui.dagger.qualifiers.Application
43 import com.android.systemui.devicepolicy.areKeyguardShortcutsDisabled
44 import com.android.systemui.log.DebugLogger.debugLog
45 import com.android.systemui.notetask.NoteTaskEntryPoint.QUICK_AFFORDANCE
46 import com.android.systemui.notetask.NoteTaskEntryPoint.TAIL_BUTTON
47 import com.android.systemui.notetask.NoteTaskRoleManagerExt.createNoteShortcutInfoAsUser
48 import com.android.systemui.notetask.NoteTaskRoleManagerExt.getDefaultRoleHolderAsUser
49 import com.android.systemui.notetask.shortcut.CreateNoteTaskShortcutActivity
50 import com.android.systemui.settings.UserTracker
51 import com.android.systemui.shared.system.ActivityManagerKt.isInForeground
52 import com.android.systemui.util.settings.SecureSettings
53 import com.android.wm.shell.bubbles.Bubble
54 import com.android.wm.shell.bubbles.Bubbles.BubbleExpandListener
55 import java.util.concurrent.atomic.AtomicReference
56 import javax.inject.Inject
57 import kotlinx.coroutines.CoroutineScope
58 import kotlinx.coroutines.launch
59
60 /**
61 * Entry point for creating and managing note.
62 *
63 * The controller decides how a note is launched based in the device state: locked or unlocked.
64 *
65 * Currently, we only support a single task per time.
66 */
67 @SysUISingleton
68 class NoteTaskController
69 @Inject
70 constructor(
71 private val context: Context,
72 private val roleManager: RoleManager,
73 private val shortcutManager: ShortcutManager,
74 private val resolver: NoteTaskInfoResolver,
75 private val eventLogger: NoteTaskEventLogger,
76 private val noteTaskBubblesController: NoteTaskBubblesController,
77 private val userManager: UserManager,
78 private val keyguardManager: KeyguardManager,
79 private val activityManager: ActivityManager,
80 @NoteTaskEnabledKey private val isEnabled: Boolean,
81 private val devicePolicyManager: DevicePolicyManager,
82 private val userTracker: UserTracker,
83 private val secureSettings: SecureSettings,
84 @Application private val applicationScope: CoroutineScope
85 ) {
86
87 @VisibleForTesting val infoReference = AtomicReference<NoteTaskInfo?>()
88
89 /** @see BubbleExpandListener */
onBubbleExpandChangednull90 fun onBubbleExpandChanged(isExpanding: Boolean, key: String?) {
91 if (!isEnabled) return
92
93 val info = infoReference.getAndSet(null) ?: return
94
95 if (key != Bubble.getAppBubbleKeyForApp(info.packageName, info.user)) return
96
97 // Safe guard mechanism, this callback should only be called for app bubbles.
98 if (info.launchMode != NoteTaskLaunchMode.AppBubble) return
99
100 if (isExpanding) {
101 debugLog { "onBubbleExpandChanged - expanding: $info" }
102 eventLogger.logNoteTaskOpened(info)
103 } else {
104 debugLog { "onBubbleExpandChanged - collapsing: $info" }
105 eventLogger.logNoteTaskClosed(info)
106 }
107 }
108
109 /** Starts the notes role setting. */
startNotesRoleSettingnull110 fun startNotesRoleSetting(activityContext: Context, entryPoint: NoteTaskEntryPoint?) {
111 val user =
112 if (entryPoint == null) {
113 userTracker.userHandle
114 } else {
115 getUserForHandlingNotesTaking(entryPoint)
116 }
117 activityContext.startActivityAsUser(
118 Intent(Intent.ACTION_MANAGE_DEFAULT_APP).apply {
119 putExtra(Intent.EXTRA_ROLE_NAME, ROLE_NOTES)
120 },
121 user
122 )
123 }
124
125 /**
126 * Returns the [UserHandle] of an android user that should handle the notes taking [entryPoint].
127 * 1. tail button entry point: In COPE or work profile devices, the user can select whether the
128 * work or main profile notes app should be launched in the Settings app. In non-management
129 * or device owner devices, the user can only select main profile notes app.
130 * 2. lock screen quick affordance: since there is no user setting, the main profile notes app
131 * is used as default for work profile devices while the work profile notes app is used for
132 * COPE devices.
133 * 3. Other entry point: the current user from [UserTracker.userHandle].
134 */
getUserForHandlingNotesTakingnull135 fun getUserForHandlingNotesTaking(entryPoint: NoteTaskEntryPoint): UserHandle =
136 when {
137 entryPoint == TAIL_BUTTON -> secureSettings.preferredUser
138 devicePolicyManager.isOrganizationOwnedDeviceWithManagedProfile &&
139 entryPoint == QUICK_AFFORDANCE -> {
140 userTracker.userProfiles
141 .firstOrNull { userManager.isManagedProfile(it.id) }
142 ?.userHandle
143 ?: userTracker.userHandle
144 }
145 // On work profile devices, SysUI always run in the main user.
146 else -> userTracker.userHandle
147 }
148
149 /**
150 * Shows a note task. How the task is shown will depend on when the method is invoked.
151 *
152 * If the keyguard is locked, notes will open as a full screen experience. A locked device has
153 * no contextual information which let us use the whole screen space available.
154 *
155 * If the keyguard is unlocked, notes will open as a bubble OR it will be collapsed if the notes
156 * bubble is already opened.
157 *
158 * That will let users open other apps in full screen, and take contextual notes.
159 */
showNoteTasknull160 fun showNoteTask(
161 entryPoint: NoteTaskEntryPoint,
162 ) {
163 if (!isEnabled) return
164
165 showNoteTaskAsUser(entryPoint, getUserForHandlingNotesTaking(entryPoint))
166 }
167
168 /** A variant of [showNoteTask] which launches note task in the given [user]. */
showNoteTaskAsUsernull169 fun showNoteTaskAsUser(
170 entryPoint: NoteTaskEntryPoint,
171 user: UserHandle,
172 ) {
173 if (!isEnabled) return
174
175 applicationScope.launch { awaitShowNoteTaskAsUser(entryPoint, user) }
176 }
177
awaitShowNoteTaskAsUsernull178 private suspend fun awaitShowNoteTaskAsUser(
179 entryPoint: NoteTaskEntryPoint,
180 user: UserHandle,
181 ) {
182 if (!isEnabled) return
183
184 if (!noteTaskBubblesController.areBubblesAvailable()) {
185 debugLog { "Bubbles not available in the system user SysUI instance" }
186 return
187 }
188
189 // TODO(b/249954038): We should handle direct boot (isUserUnlocked). For now, we do nothing.
190 if (!userManager.isUserUnlocked) return
191
192 val isKeyguardLocked = keyguardManager.isKeyguardLocked
193 // KeyguardQuickAffordanceInteractor blocks the quick affordance from showing in the
194 // keyguard if it is not allowed by the admin policy. Here we block any other way to show
195 // note task when the screen is locked.
196 if (
197 isKeyguardLocked &&
198 devicePolicyManager.areKeyguardShortcutsDisabled(userId = user.identifier)
199 ) {
200 debugLog { "Enterprise policy disallows launching note app when the screen is locked." }
201 return
202 }
203
204 val info = resolver.resolveInfo(entryPoint, isKeyguardLocked, user)
205
206 if (info == null) {
207 debugLog { "Default notes app isn't set" }
208 showNoDefaultNotesAppToast()
209 return
210 }
211
212 infoReference.set(info)
213
214 try {
215 // TODO(b/266686199): We should handle when app not available. For now, we log.
216 debugLog { "onShowNoteTask - start: $info on user#${user.identifier}" }
217 when (info.launchMode) {
218 is NoteTaskLaunchMode.AppBubble -> {
219 val intent = createNoteTaskIntent(info)
220 val icon =
221 Icon.createWithResource(context, R.drawable.ic_note_task_shortcut_widget)
222 noteTaskBubblesController.showOrHideAppBubble(intent, user, icon)
223 // App bubble logging happens on `onBubbleExpandChanged`.
224 debugLog { "onShowNoteTask - opened as app bubble: $info" }
225 }
226 is NoteTaskLaunchMode.Activity -> {
227 if (info.isKeyguardLocked && activityManager.isInForeground(info.packageName)) {
228 // Force note task into background by calling home.
229 val intent = createHomeIntent()
230 context.startActivityAsUser(intent, user)
231 eventLogger.logNoteTaskClosed(info)
232 debugLog { "onShowNoteTask - closed as activity: $info" }
233 } else {
234 val intent = createNoteTaskIntent(info)
235 context.startActivityAsUser(intent, user)
236 eventLogger.logNoteTaskOpened(info)
237 debugLog { "onShowNoteTask - opened as activity: $info" }
238 }
239 }
240 }
241 debugLog { "onShowNoteTask - success: $info" }
242 } catch (e: ActivityNotFoundException) {
243 debugLog { "onShowNoteTask - failed: $info" }
244 }
245 debugLog { "onShowNoteTask - completed: $info" }
246 }
247
248 @VisibleForTesting
showNoDefaultNotesAppToastnull249 fun showNoDefaultNotesAppToast() {
250 Toast.makeText(context, R.string.set_default_notes_app_toast_content, Toast.LENGTH_SHORT)
251 .show()
252 }
253
254 /**
255 * Set `android:enabled` property in the `AndroidManifest` associated with the Shortcut
256 * component to [value].
257 *
258 * If the shortcut entry `android:enabled` is set to `true`, the shortcut will be visible in the
259 * Widget Picker to all users.
260 */
setNoteTaskShortcutEnablednull261 fun setNoteTaskShortcutEnabled(value: Boolean, user: UserHandle) {
262 if (!userManager.isUserUnlocked(user)) {
263 debugLog { "setNoteTaskShortcutEnabled call but user locked: user=$user" }
264 return
265 }
266
267 val componentName = ComponentName(context, CreateNoteTaskShortcutActivity::class.java)
268
269 val enabledState =
270 if (value) {
271 PackageManager.COMPONENT_ENABLED_STATE_ENABLED
272 } else {
273 PackageManager.COMPONENT_ENABLED_STATE_DISABLED
274 }
275
276 val userContext = context.createContextAsUser(user, /* flags= */ 0)
277
278 userContext.packageManager.setComponentEnabledSetting(
279 componentName,
280 enabledState,
281 PackageManager.DONT_KILL_APP,
282 )
283
284 debugLog { "setNoteTaskShortcutEnabled for user $user- completed: $enabledState" }
285 }
286
287 /**
288 * Like [updateNoteTaskAsUser] but automatically apply to the current user and all its work
289 * profiles.
290 *
291 * @see updateNoteTaskAsUser
292 * @see UserTracker.userHandle
293 * @see UserTracker.userProfiles
294 */
updateNoteTaskForCurrentUserAndManagedProfilesnull295 fun updateNoteTaskForCurrentUserAndManagedProfiles() {
296 updateNoteTaskAsUser(userTracker.userHandle)
297 for (profile in userTracker.userProfiles) {
298 if (userManager.isManagedProfile(profile.id)) {
299 updateNoteTaskAsUser(profile.userHandle)
300 }
301 }
302 }
303
304 /**
305 * Updates all [NoteTaskController] related information, including but not exclusively the
306 * widget shortcut created by the [user] - by default it will use the current user.
307 *
308 * If the user is not current user, the update will be dispatched to run in that user's process.
309 *
310 * Keep in mind the shortcut API has a
311 * [rate limiting](https://developer.android.com/develop/ui/views/launch/shortcuts/managing-shortcuts#rate-limiting)
312 * and may not be updated in real-time. To reduce the chance of stale shortcuts, we run the
313 * function during System UI initialization.
314 */
updateNoteTaskAsUsernull315 fun updateNoteTaskAsUser(user: UserHandle) {
316 if (!userManager.isUserUnlocked(user)) {
317 debugLog { "updateNoteTaskAsUser call but user locked: user=$user" }
318 return
319 }
320
321 // When switched to a secondary user, the sysUI is still running in the main user, we will
322 // need to update the shortcut in the secondary user.
323 if (user == getCurrentRunningUser()) {
324 updateNoteTaskAsUserInternal(user)
325 } else {
326 // TODO(b/278729185): Replace fire and forget service with a bounded service.
327 val intent = NoteTaskControllerUpdateService.createIntent(context)
328 context.startServiceAsUser(intent, user)
329 }
330 }
331
332 @InternalNoteTaskApi
updateNoteTaskAsUserInternalnull333 fun updateNoteTaskAsUserInternal(user: UserHandle) {
334 if (!userManager.isUserUnlocked(user)) {
335 debugLog { "updateNoteTaskAsUserInternal call but user locked: user=$user" }
336 return
337 }
338
339 val packageName = roleManager.getDefaultRoleHolderAsUser(ROLE_NOTES, user)
340 val hasNotesRoleHolder = isEnabled && !packageName.isNullOrEmpty()
341
342 setNoteTaskShortcutEnabled(hasNotesRoleHolder, user)
343
344 if (hasNotesRoleHolder) {
345 shortcutManager.enableShortcuts(listOf(SHORTCUT_ID))
346 val updatedShortcut = roleManager.createNoteShortcutInfoAsUser(context, user)
347 shortcutManager.updateShortcuts(listOf(updatedShortcut))
348 } else {
349 shortcutManager.disableShortcuts(listOf(SHORTCUT_ID))
350 }
351 }
352
353 /** @see OnRoleHoldersChangedListener */
onRoleHoldersChangednull354 fun onRoleHoldersChanged(roleName: String, user: UserHandle) {
355 if (roleName != ROLE_NOTES) return
356
357 updateNoteTaskAsUser(user)
358 }
359
360 // Returns the [UserHandle] that this class is running on.
getCurrentRunningUsernull361 @VisibleForTesting internal fun getCurrentRunningUser(): UserHandle = Process.myUserHandle()
362
363 private val SecureSettings.preferredUser: UserHandle
364 get() {
365 val trackingUserId = userTracker.userHandle.identifier
366 val userId =
367 secureSettings.getIntForUser(
368 /* name= */ Settings.Secure.DEFAULT_NOTE_TASK_PROFILE,
369 /* def= */ trackingUserId,
370 /* userHandle= */ trackingUserId,
371 )
372 return UserHandle.of(userId)
373 }
374
375 companion object {
376 val TAG = NoteTaskController::class.simpleName.orEmpty()
377
378 const val SHORTCUT_ID = "note_task_shortcut_id"
379
380 /**
381 * Shortcut extra which can point to a package name and can be used to indicate an alternate
382 * badge info. Launcher only reads this if the shortcut comes from a system app.
383 *
384 * Duplicated from [com.android.launcher3.icons.IconCache].
385 *
386 * @see com.android.launcher3.icons.IconCache.EXTRA_SHORTCUT_BADGE_OVERRIDE_PACKAGE
387 */
388 const val EXTRA_SHORTCUT_BADGE_OVERRIDE_PACKAGE = "extra_shortcut_badge_override_package"
389 }
390 }
391
392 /** Creates an [Intent] for [ROLE_NOTES]. */
createNoteTaskIntentnull393 private fun createNoteTaskIntent(info: NoteTaskInfo): Intent =
394 Intent(Intent.ACTION_CREATE_NOTE).apply {
395 setPackage(info.packageName)
396
397 // EXTRA_USE_STYLUS_MODE does not mean a stylus is in-use, but a stylus entrypoint
398 // was used to start the note task.
399 val useStylusMode = info.entryPoint != NoteTaskEntryPoint.KEYBOARD_SHORTCUT
400 putExtra(Intent.EXTRA_USE_STYLUS_MODE, useStylusMode)
401
402 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
403 // We should ensure the note experience can be opened both as a full screen (lockscreen)
404 // and inside the app bubble (contextual). These additional flags will do that.
405 if (info.launchMode == NoteTaskLaunchMode.Activity) {
406 addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
407 addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT)
408 }
409 }
410
411 /** Creates an [Intent] which forces the current app to background by calling home. */
createHomeIntentnull412 private fun createHomeIntent(): Intent =
413 Intent(Intent.ACTION_MAIN).apply {
414 addCategory(Intent.CATEGORY_HOME)
415 flags = Intent.FLAG_ACTIVITY_NEW_TASK
416 }
417