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.server.policy;
18 
19 import android.annotation.SuppressLint;
20 import android.app.role.RoleManager;
21 import android.content.ActivityNotFoundException;
22 import android.content.ComponentName;
23 import android.content.Context;
24 import android.content.Intent;
25 import android.content.pm.PackageManager;
26 import android.content.res.XmlResourceParser;
27 import android.hardware.input.InputManager;
28 import android.os.Handler;
29 import android.os.RemoteException;
30 import android.os.UserHandle;
31 import android.text.TextUtils;
32 import android.util.Log;
33 import android.util.LongSparseArray;
34 import android.util.Slog;
35 import android.util.SparseArray;
36 import android.view.InputDevice;
37 import android.view.KeyCharacterMap;
38 import android.view.KeyEvent;
39 
40 import com.android.internal.policy.IShortcutService;
41 import com.android.internal.util.XmlUtils;
42 import com.android.server.input.KeyboardMetricsCollector;
43 import com.android.server.input.KeyboardMetricsCollector.KeyboardLogEvent;
44 
45 import org.xmlpull.v1.XmlPullParser;
46 import org.xmlpull.v1.XmlPullParserException;
47 
48 import java.io.IOException;
49 import java.util.HashMap;
50 import java.util.Map;
51 
52 /**
53  * Manages quick launch shortcuts by:
54  * <li> Keeping the local copy in sync with the database (this is an observer)
55  * <li> Returning a shortcut-matching intent to clients
56  * <li> Returning particular kind of application intent by special key.
57  */
58 public class ModifierShortcutManager {
59     private static final String TAG = "ModifierShortcutManager";
60 
61     private static final String TAG_BOOKMARKS = "bookmarks";
62     private static final String TAG_BOOKMARK = "bookmark";
63 
64     private static final String ATTRIBUTE_PACKAGE = "package";
65     private static final String ATTRIBUTE_CLASS = "class";
66     private static final String ATTRIBUTE_SHORTCUT = "shortcut";
67     private static final String ATTRIBUTE_CATEGORY = "category";
68     private static final String ATTRIBUTE_SHIFT = "shift";
69     private static final String ATTRIBUTE_ROLE = "role";
70 
71     private final SparseArray<Intent> mIntentShortcuts = new SparseArray<>();
72     private final SparseArray<Intent> mShiftShortcuts = new SparseArray<>();
73     private final SparseArray<String> mRoleShortcuts = new SparseArray<String>();
74     private final SparseArray<String> mShiftRoleShortcuts = new SparseArray<String>();
75     private final Map<String, Intent> mRoleIntents = new HashMap<String, Intent>();
76 
77     private LongSparseArray<IShortcutService> mShortcutKeyServices = new LongSparseArray<>();
78 
79     /* Table of Application Launch keys.  Maps from key codes to intent categories.
80      *
81      * These are special keys that are used to launch particular kinds of applications,
82      * such as a web browser.  HID defines nearly a hundred of them in the Consumer (0x0C)
83      * usage page.  We don't support quite that many yet...
84      */
85     static SparseArray<String> sApplicationLaunchKeyCategories;
86     static SparseArray<String> sApplicationLaunchKeyRoles;
87     static {
88         sApplicationLaunchKeyRoles = new SparseArray<String>();
89         sApplicationLaunchKeyCategories = new SparseArray<String>();
sApplicationLaunchKeyRoles.append( KeyEvent.KEYCODE_EXPLORER, RoleManager.ROLE_BROWSER)90         sApplicationLaunchKeyRoles.append(
91                 KeyEvent.KEYCODE_EXPLORER, RoleManager.ROLE_BROWSER);
sApplicationLaunchKeyCategories.append( KeyEvent.KEYCODE_ENVELOPE, Intent.CATEGORY_APP_EMAIL)92         sApplicationLaunchKeyCategories.append(
93                 KeyEvent.KEYCODE_ENVELOPE, Intent.CATEGORY_APP_EMAIL);
sApplicationLaunchKeyCategories.append( KeyEvent.KEYCODE_CONTACTS, Intent.CATEGORY_APP_CONTACTS)94         sApplicationLaunchKeyCategories.append(
95                 KeyEvent.KEYCODE_CONTACTS, Intent.CATEGORY_APP_CONTACTS);
sApplicationLaunchKeyCategories.append( KeyEvent.KEYCODE_CALENDAR, Intent.CATEGORY_APP_CALENDAR)96         sApplicationLaunchKeyCategories.append(
97                 KeyEvent.KEYCODE_CALENDAR, Intent.CATEGORY_APP_CALENDAR);
sApplicationLaunchKeyCategories.append( KeyEvent.KEYCODE_MUSIC, Intent.CATEGORY_APP_MUSIC)98         sApplicationLaunchKeyCategories.append(
99                 KeyEvent.KEYCODE_MUSIC, Intent.CATEGORY_APP_MUSIC);
sApplicationLaunchKeyCategories.append( KeyEvent.KEYCODE_CALCULATOR, Intent.CATEGORY_APP_CALCULATOR)100         sApplicationLaunchKeyCategories.append(
101                 KeyEvent.KEYCODE_CALCULATOR, Intent.CATEGORY_APP_CALCULATOR);
102     }
103 
104     public static final String EXTRA_ROLE =
105             "com.android.server.policy.ModifierShortcutManager.EXTRA_ROLE";
106 
107     private final Context mContext;
108     private final Handler mHandler;
109     private final RoleManager mRoleManager;
110     private final PackageManager mPackageManager;
111     private boolean mSearchKeyShortcutPending = false;
112     private boolean mConsumeSearchKeyUp = true;
113 
ModifierShortcutManager(Context context, Handler handler)114     ModifierShortcutManager(Context context, Handler handler) {
115         mContext = context;
116         mHandler = handler;
117         mPackageManager = mContext.getPackageManager();
118         mRoleManager = mContext.getSystemService(RoleManager.class);
119         mRoleManager.addOnRoleHoldersChangedListenerAsUser(mContext.getMainExecutor(),
120                 (String roleName, UserHandle user) -> {
121                     mRoleIntents.remove(roleName);
122                 }, UserHandle.ALL);
123         loadShortcuts();
124     }
125 
126     /**
127      * Gets the shortcut intent for a given keycode+modifier. Make sure you
128      * strip whatever modifier is used for invoking shortcuts (for example,
129      * if 'Sym+A' should invoke a shortcut on 'A', you should strip the
130      * 'Sym' bit from the modifiers before calling this method.
131      * <p>
132      * This will first try an exact match (with modifiers), and then try a
133      * match without modifiers (primary character on a key).
134      *
135      * @param kcm The key character map of the device on which the key was pressed.
136      * @param keyCode The key code.
137      * @param metaState The meta state, omitting any modifiers that were used
138      * to invoke the shortcut.
139      * @return The intent that matches the shortcut, or null if not found.
140      */
getIntent(KeyCharacterMap kcm, int keyCode, int metaState)141     private Intent getIntent(KeyCharacterMap kcm, int keyCode, int metaState) {
142         // If a modifier key other than shift is also pressed, skip it.
143         final boolean isShiftOn = KeyEvent.metaStateHasModifiers(metaState, KeyEvent.META_SHIFT_ON);
144         if (!isShiftOn && !KeyEvent.metaStateHasNoModifiers(metaState)) {
145             return null;
146         }
147 
148         Intent shortcutIntent = null;
149 
150         // If the Shift key is pressed, then search for the shift shortcuts.
151         SparseArray<Intent> shortcutMap = isShiftOn ? mShiftShortcuts : mIntentShortcuts;
152 
153         // First try the exact keycode (with modifiers).
154         int shortcutChar = kcm.get(keyCode, metaState);
155         if (shortcutChar != 0) {
156             shortcutIntent = shortcutMap.get(shortcutChar);
157         }
158 
159         // Next try the primary character on that key.
160         if (shortcutIntent == null) {
161             shortcutChar = Character.toLowerCase(kcm.getDisplayLabel(keyCode));
162             if (shortcutChar != 0) {
163                 shortcutIntent = shortcutMap.get(shortcutChar);
164 
165                 if (shortcutIntent == null) {
166                     // Check for role based shortcut
167                     String role = isShiftOn ? mShiftRoleShortcuts.get(shortcutChar)
168                             : mRoleShortcuts.get(shortcutChar);
169                     if (role != null) {
170                         shortcutIntent = getRoleLaunchIntent(role);
171                     }
172                 }
173             }
174         }
175 
176         return shortcutIntent;
177     }
178 
getRoleLaunchIntent(String role)179     private Intent getRoleLaunchIntent(String role) {
180         Intent intent = mRoleIntents.get(role);
181         if (intent == null) {
182             if (mRoleManager.isRoleAvailable(role)) {
183                 String rolePackage = mRoleManager.getDefaultApplication(role);
184                 if (rolePackage != null) {
185                     intent = mPackageManager.getLaunchIntentForPackage(rolePackage);
186                     intent.putExtra(EXTRA_ROLE, role);
187                     mRoleIntents.put(role, intent);
188                 } else {
189                     Log.w(TAG, "No default application for role " + role);
190                 }
191             } else {
192                 Log.w(TAG, "Role " + role + " is not available.");
193             }
194         }
195         return intent;
196     }
197 
loadShortcuts()198     private void loadShortcuts() {
199 
200         try {
201             XmlResourceParser parser = mContext.getResources().getXml(
202                     com.android.internal.R.xml.bookmarks);
203             XmlUtils.beginDocument(parser, TAG_BOOKMARKS);
204 
205             while (true) {
206                 XmlUtils.nextElement(parser);
207 
208                 if (parser.getEventType() == XmlPullParser.END_DOCUMENT) {
209                     break;
210                 }
211 
212                 if (!TAG_BOOKMARK.equals(parser.getName())) {
213                     break;
214                 }
215 
216                 String packageName = parser.getAttributeValue(null, ATTRIBUTE_PACKAGE);
217                 String className = parser.getAttributeValue(null, ATTRIBUTE_CLASS);
218                 String shortcutName = parser.getAttributeValue(null, ATTRIBUTE_SHORTCUT);
219                 String categoryName = parser.getAttributeValue(null, ATTRIBUTE_CATEGORY);
220                 String shiftName = parser.getAttributeValue(null, ATTRIBUTE_SHIFT);
221                 String roleName = parser.getAttributeValue(null, ATTRIBUTE_ROLE);
222 
223                 if (TextUtils.isEmpty(shortcutName)) {
224                     Log.w(TAG, "Shortcut required for bookmark with category=" + categoryName
225                             + " packageName=" + packageName + " className=" + className
226                             + " role=" + roleName + "shiftName=" + shiftName);
227                     continue;
228                 }
229 
230                 final int shortcutChar = shortcutName.charAt(0);
231                 final boolean isShiftShortcut = (shiftName != null && shiftName.equals("true"));
232                 final Intent intent;
233                 if (packageName != null && className != null) {
234                     if (roleName != null || categoryName != null) {
235                         Log.w(TAG, "Cannot specify role or category when package and class"
236                                 + " are present for bookmark packageName=" + packageName
237                                 + " className=" + className + " shortcutChar=" + shortcutChar);
238                         continue;
239                     }
240                     ComponentName componentName = new ComponentName(packageName, className);
241                     try {
242                         mPackageManager.getActivityInfo(componentName,
243                                 PackageManager.MATCH_DIRECT_BOOT_AWARE
244                                         | PackageManager.MATCH_DIRECT_BOOT_UNAWARE
245                                         | PackageManager.MATCH_UNINSTALLED_PACKAGES);
246                     } catch (PackageManager.NameNotFoundException e) {
247                         String[] packages = mPackageManager.canonicalToCurrentPackageNames(
248                                 new String[] { packageName });
249                         componentName = new ComponentName(packages[0], className);
250                         try {
251                             mPackageManager.getActivityInfo(componentName,
252                                     PackageManager.MATCH_DIRECT_BOOT_AWARE
253                                             | PackageManager.MATCH_DIRECT_BOOT_UNAWARE
254                                             | PackageManager.MATCH_UNINSTALLED_PACKAGES);
255                         } catch (PackageManager.NameNotFoundException e1) {
256                             Log.w(TAG, "Unable to add bookmark: " + packageName
257                                     + "/" + className + " not found.");
258                             continue;
259                         }
260                     }
261 
262                     intent = new Intent(Intent.ACTION_MAIN);
263                     intent.addCategory(Intent.CATEGORY_LAUNCHER);
264                     intent.setComponent(componentName);
265                 } else if (categoryName != null) {
266                     if (roleName != null) {
267                         Log.w(TAG, "Cannot specify role bookmark when category is present for"
268                                 + " bookmark shortcutChar=" + shortcutChar
269                                 + " category= " + categoryName);
270                         continue;
271                     }
272                     intent = Intent.makeMainSelectorActivity(Intent.ACTION_MAIN, categoryName);
273                 } else if (roleName != null) {
274                     // We can't resolve the role at the time of this file being parsed as the
275                     // device hasn't finished booting, so we will look it up lazily.
276                     if (isShiftShortcut) {
277                         mShiftRoleShortcuts.put(shortcutChar, roleName);
278                     } else {
279                         mRoleShortcuts.put(shortcutChar, roleName);
280                     }
281                     continue;
282                 } else {
283                     Log.w(TAG, "Unable to add bookmark for shortcut " + shortcutName
284                             + ": missing package/class, category or role attributes");
285                     continue;
286                 }
287 
288                 if (isShiftShortcut) {
289                     mShiftShortcuts.put(shortcutChar, intent);
290                 } else {
291                     mIntentShortcuts.put(shortcutChar, intent);
292                 }
293             }
294         } catch (XmlPullParserException | IOException e) {
295             Log.e(TAG, "Got exception parsing bookmarks.", e);
296         }
297     }
298 
registerShortcutKey(long shortcutCode, IShortcutService shortcutService)299     void registerShortcutKey(long shortcutCode, IShortcutService shortcutService)
300             throws RemoteException {
301         IShortcutService service = mShortcutKeyServices.get(shortcutCode);
302         if (service != null && service.asBinder().pingBinder()) {
303             throw new RemoteException("Key already exists.");
304         }
305 
306         mShortcutKeyServices.put(shortcutCode, shortcutService);
307     }
308 
309     /**
310      * Handle the shortcut to {@link IShortcutService}
311      * @param keyCode The key code of the event.
312      * @param metaState The meta key modifier state.
313      * @return True if invoked the shortcut, otherwise false.
314      */
handleShortcutService(int keyCode, int metaState)315     private boolean handleShortcutService(int keyCode, int metaState) {
316         long shortcutCode = keyCode;
317         if ((metaState & KeyEvent.META_CTRL_ON) != 0) {
318             shortcutCode |= ((long) KeyEvent.META_CTRL_ON) << Integer.SIZE;
319         }
320 
321         if ((metaState & KeyEvent.META_ALT_ON) != 0) {
322             shortcutCode |= ((long) KeyEvent.META_ALT_ON) << Integer.SIZE;
323         }
324 
325         if ((metaState & KeyEvent.META_SHIFT_ON) != 0) {
326             shortcutCode |= ((long) KeyEvent.META_SHIFT_ON) << Integer.SIZE;
327         }
328 
329         if ((metaState & KeyEvent.META_META_ON) != 0) {
330             shortcutCode |= ((long) KeyEvent.META_META_ON) << Integer.SIZE;
331         }
332 
333         IShortcutService shortcutService = mShortcutKeyServices.get(shortcutCode);
334         if (shortcutService != null) {
335             try {
336                 shortcutService.notifyShortcutKeyPressed(shortcutCode);
337             } catch (RemoteException e) {
338                 mShortcutKeyServices.delete(shortcutCode);
339             }
340             return true;
341         }
342         return false;
343     }
344 
345     /**
346      * Handle the shortcut to {@link Intent}
347      *
348      * @param kcm the {@link KeyCharacterMap} associated with the keyboard device.
349      * @param keyEvent The key event.
350      * @param metaState The meta key modifier state.
351      * @return True if invoked the shortcut, otherwise false.
352      */
353     @SuppressLint("MissingPermission")
handleIntentShortcut(KeyCharacterMap kcm, KeyEvent keyEvent, int metaState)354     private boolean handleIntentShortcut(KeyCharacterMap kcm, KeyEvent keyEvent, int metaState) {
355         final int keyCode = keyEvent.getKeyCode();
356         // Shortcuts are invoked through Search+key, so intercept those here
357         // Any printing key that is chorded with Search should be consumed
358         // even if no shortcut was invoked.  This prevents text from being
359         // inadvertently inserted when using a keyboard that has built-in macro
360         // shortcut keys (that emit Search+x) and some of them are not registered.
361         if (mSearchKeyShortcutPending) {
362             if (kcm.isPrintingKey(keyCode)) {
363                 mConsumeSearchKeyUp = true;
364                 mSearchKeyShortcutPending = false;
365             } else {
366                 return false;
367             }
368         } else if ((metaState & KeyEvent.META_META_MASK) != 0) {
369             // Invoke shortcuts using Meta.
370             metaState &= ~KeyEvent.META_META_MASK;
371         } else {
372             Intent intent = null;
373             // Handle application launch keys.
374             String role = sApplicationLaunchKeyRoles.get(keyCode);
375             String category = sApplicationLaunchKeyCategories.get(keyCode);
376             if (role != null) {
377                 intent = getRoleLaunchIntent(role);
378             } else if (category != null) {
379                 intent = Intent.makeMainSelectorActivity(Intent.ACTION_MAIN, category);
380             }
381 
382             if (intent != null) {
383                 intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
384                 try {
385                     mContext.startActivityAsUser(intent, UserHandle.CURRENT);
386                 } catch (ActivityNotFoundException ex) {
387                     Slog.w(TAG, "Dropping application launch key because "
388                             + "the activity to which it is registered was not found: "
389                             + "keyCode=" + KeyEvent.keyCodeToString(keyCode) + ","
390                             + " category=" + category + " role=" + role);
391                 }
392                 logKeyboardShortcut(keyEvent, KeyboardLogEvent.getLogEventFromIntent(intent));
393                 return true;
394             } else {
395                 return false;
396             }
397         }
398 
399         final Intent shortcutIntent = getIntent(kcm, keyCode, metaState);
400         if (shortcutIntent != null) {
401             shortcutIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
402             try {
403                 mContext.startActivityAsUser(shortcutIntent, UserHandle.CURRENT);
404             } catch (ActivityNotFoundException ex) {
405                 Slog.w(TAG, "Dropping shortcut key combination because "
406                         + "the activity to which it is registered was not found: "
407                         + "META+ or SEARCH" + KeyEvent.keyCodeToString(keyCode));
408             }
409             logKeyboardShortcut(keyEvent, KeyboardLogEvent.getLogEventFromIntent(shortcutIntent));
410             return true;
411         }
412         return false;
413     }
414 
logKeyboardShortcut(KeyEvent event, KeyboardLogEvent logEvent)415     private void logKeyboardShortcut(KeyEvent event, KeyboardLogEvent logEvent) {
416         mHandler.post(() -> handleKeyboardLogging(event, logEvent));
417     }
418 
handleKeyboardLogging(KeyEvent event, KeyboardLogEvent logEvent)419     private void handleKeyboardLogging(KeyEvent event, KeyboardLogEvent logEvent) {
420         final InputManager inputManager = mContext.getSystemService(InputManager.class);
421         final InputDevice inputDevice = inputManager != null
422                 ? inputManager.getInputDevice(event.getDeviceId()) : null;
423         KeyboardMetricsCollector.logKeyboardSystemsEventReportedAtom(inputDevice,
424                 logEvent, event.getMetaState(), event.getKeyCode());
425     }
426 
427     /**
428      * Handle the shortcut from {@link KeyEvent}
429      *
430      * @param event Description of the key event.
431      * @return True if invoked the shortcut, otherwise false.
432      */
interceptKey(KeyEvent event)433     boolean interceptKey(KeyEvent event) {
434         if (event.getRepeatCount() != 0) {
435             return false;
436         }
437 
438         final int metaState = event.getModifiers();
439         final int keyCode = event.getKeyCode();
440         if (keyCode == KeyEvent.KEYCODE_SEARCH) {
441             if (event.getAction() == KeyEvent.ACTION_DOWN) {
442                 mSearchKeyShortcutPending = true;
443                 mConsumeSearchKeyUp = false;
444             } else {
445                 mSearchKeyShortcutPending = false;
446                 if (mConsumeSearchKeyUp) {
447                     mConsumeSearchKeyUp = false;
448                     return true;
449                 }
450             }
451             return false;
452         }
453 
454         if (event.getAction() != KeyEvent.ACTION_DOWN) {
455             return false;
456         }
457 
458         final KeyCharacterMap kcm = event.getKeyCharacterMap();
459         if (handleIntentShortcut(kcm, event, metaState)) {
460             return true;
461         }
462 
463         if (handleShortcutService(keyCode, metaState)) {
464             return true;
465         }
466 
467         return false;
468     }
469 }
470