Edited at

Android 7.1のApp Shortcutsの仕組み

More than 1 year has passed since last update.


まえおき

Android 7.1から利用できるApp shortcuts、Android 8.0から利用できるNotification Badges、いずれも、Androidフレームワークとランチャーアプリのコラボで実現されている機能です。

ただ、ランチャーアプリでできることは、中の仕組みを理解しておけばランチャーアプリじゃなくてもできるはず。

きっといつかランチャーアプリ以外からショートカット情報をとりたいときが来るかもしれない(いや、来るはずがないのだけどw)と気になって、フレームワークの中の仕組みを調べてみました。

この記事では、App Shortcutsの方をみていきます。(Notification Badgesの記事はこちらです)


App Shortcuts

結論をまず1つ先に言うと、App shortcutsの実現のために、Androidのシステムサービスに1つサービスが増えていました。ShortcutServiceです。

そして、各アプリからShortcutSeriveを叩くためのインターフェースが

ShortcutManager shortcutManager = getSystemService(ShortcutManager.class);

リファレンス にも記載のあるShortcutManagerのようです。

ポイントとしては、いずれもPackageManagerにぶら下がる形での実装になっています。

PackageManagerということは、なんとなく


  • アプリのインストール時にXMLのパースをしてショートカットのDBが作られる

  • ショートカットを提供する各アプリからShortcutManager経由で要求があればショートカットのDBを書き換える

  • ランチャーアプリは、何らかの方法でショートカットのDBをみて、ショートカットの描画をする

みたいなストーリーが想像できます。

さて、順に見ていきましょう


ショートカット情報の在り処

端末が再起動してもショートカットはリセットされないので、きっとどこかのXMLなりSQLiteDBなりに書かれているのでしょう。という予測のもとShortcutServiceを見てみます。


frameworks/base/services/core/java/com/android/server/pm/ShortcutService.java

   1913     @Override

1914 public ParceledListSlice<ShortcutInfo> getDynamicShortcuts(String packageName,
1915 @UserIdInt int userId) {
1916 verifyCaller(packageName, userId);
1917
1918 synchronized (mLock) {
1919 throwIfUserLockedL(userId);
1920
1921 return getShortcutsWithQueryLocked(
1922 packageName, userId, ShortcutInfo.CLONE_REMOVE_FOR_CREATOR,
1923 ShortcutInfo::isDynamic);
1924 }
1925 }
1926
1927 @Override
1928 public ParceledListSlice<ShortcutInfo> getManifestShortcuts(String packageName,
1929 @UserIdInt int userId) {
1930 verifyCaller(packageName, userId);
1931
1932 synchronized (mLock) {
1933 throwIfUserLockedL(userId);
1934
1935 return getShortcutsWithQueryLocked(
1936 packageName, userId, ShortcutInfo.CLONE_REMOVE_FOR_CREATOR,
1937 ShortcutInfo::isManifestShortcut);
1938 }
1939 }

アプリからショートカット情報を取るAPIである、get****Shortcutsを見ると、いずれも getShortcutsWithQueryLocked というメソッドを実行しています。こいつの元をたどっていくと

getPackageShortcutsForPublisherLocked

 ↓

getUserShortcutsLocked | getPackageShortcuts

 ↓

loadUserLocked


frameworks/base/services/core/java/com/android/server/pm/ShortcutService.java

    950     @Nullable

951 private ShortcutUser loadUserLocked(@UserIdInt int userId) {
952 final File path = getUserFile(userId);
953 if (DEBUG) {
954 Slog.d(TAG, "Loading from " + path);
955 }
956 final AtomicFile file = new AtomicFile(path);
957
958 final FileInputStream in;
959 try {
960 in = file.openRead();
961 } catch (FileNotFoundException e) {
962 if (DEBUG) {
963 Slog.d(TAG, "Not found " + path);
964 }
965 return null;
966 }
967 try {
968 final ShortcutUser ret = loadUserInternal(userId, in, /* forBackup= */ false);
969 return ret;
970 } catch (IOException | XmlPullParserException | InvalidFileFormatException e) {
971 Slog.e(TAG, "Failed to read file " + file.getBaseFile(), e);
972 return null;
973 } finally {
974 IoUtils.closeQuietly(in);
975 }
976 }
977
978 private ShortcutUser loadUserInternal(@UserIdInt int userId, InputStream is,
979 boolean fromBackup) throws XmlPullParserException, IOException,
980 InvalidFileFormatException {
981
982 final BufferedInputStream bis = new BufferedInputStream(is);
983
984 ShortcutUser ret = null;
985 XmlPullParser parser = Xml.newPullParser();
986 parser.setInput(bis, StandardCharsets.UTF_8.name());
987
988 int type;
989 while ((type = parser.next()) != XmlPullParser.END_DOCUMENT) {
990 if (type != XmlPullParser.START_TAG) {
991 continue;
992 }
993 final int depth = parser.getDepth();
994
995 final String tag = parser.getName();
996 if (DEBUG_LOAD) {
997 Slog.d(TAG, String.format("depth=%d type=%d name=%s",
998 depth, type, tag));
999 }
1000 if ((depth == 1) && ShortcutUser.TAG_ROOT.equals(tag)) {
1001 ret = ShortcutUser.loadFromXml(this, parser, userId, fromBackup);
1002 continue;
1003 }
1004 throwForInvalidTag(depth, tag);
1005 }
1006 return ret;
1007 }

それっぽいロード処理がありました。(キャッシュしてるのかと思いきや、毎回叩いてる感じがします)

XMLっぽいものを読んでいます。

    170     @VisibleForTesting

171 static final String FILENAME_BASE_STATE = "shortcut_service.xml";
172
173 @VisibleForTesting
174 static final String DIRECTORY_PER_USER = "shortcut_service";
175
176 @VisibleForTesting
177 static final String FILENAME_USER_PACKAGES = "shortcuts.xml";

なんとなく想像がつく限り、こんな感じのファイル名、ディレクトリ名のものが /data/system に居る気がします。

実機をみてみましょう。

$ adb shell ls /data/system/

appops.xml
batterystats-checkin.bin
batterystats-daily.xml
batterystats.bin
cachequota.xml
device_policies.xml
diskstats_cache.json
dropbox
entropy.dat
gatekeeper.password.key
gatekeeper.pattern.key
graphicsstats
heapdump
ifw
inputmethod
install_sessions
install_sessions.xml
job
last-fstrim
last-header.txt
locksettings.db
locksettings.db-shm
locksettings.db-wal
log-files.xml
ndebugsocket
netpolicy.xml
netstats
notification_log.db
notification_log.db-journal
notification_policy.xml
overlays.xml
package-cstats.list
package-dex-usage.list
package-usage.list
package_cache
packages.list
packages.xml
procstats
profiles.xml
screen_on_time
sensor_service
sensors
shortcut_service.xml
sync
time
uiderrors.txt
urigrants.xml
usagestats
users

shortcut_service.xmlいたー!

$ adb shell ls /data/system_ce/0/

accounts_ce.db
recent_images
recent_tasks
shortcut_service
snapshots

shortcut_serviceいたー!

$ adb shell ls /data/system_ce/0/shortcut_service

bitmaps
shortcuts.xml

ということで、 /data/ のシステムディレクトリ配下にXMLがあって、それを読み書きしてるようですね。

自分の開発機だと、こんな感じの内容になっていました。

アイコン画像の在り処、起動するインテント、などなど事細かにXMLに記載されていますね。


ショートカットの取得

前章でおおむね流れは読めた感じはしますが、 getManifestShortcuts() などのShortcutManagerのAPIで返ってくる ShortcutInfo への変換の流れをみていきます。


データクラス


ShortcutUser

class ShortcutUser {

private ArrayMap<パッケージ名, ShortcutPackage> mPackages;


ShortcutPackage

class ShortcutPackage extends ShortcutPackageItem {

private ArrayMap<パッケージ名, ShortcutInfo> mShortcuts;


  • ユーザー hasMany パッケージ

  • パッケージ hasMany ショートカット

という関係をあらわすための、データクラスとして、ShortcutUser、ShortcutPackageが定義されています。

いずれもpackage-privateなので、システムサーバの外からは見えません。


処理の流れ

たとえば getManifestShortcuts() であれば


  • XMLを読んで、ShortcutUser, ShortcutPackageというデータクラスに変換


  • getPackageShortcuts で、ShortcutManagerのAPIを叩いたアプリのパッケージに紐づくShortcutPackageを取り出す。

  • ShortcutPackageに紐づくShortcutInfoを、 ShortcutInfo.java#isManifestShortcut で絞り込んで返す

みたいなシンプルな流れになっていました。

(なにげにJava8のメソッド参照が使われてるのが微妙に驚きw)


ホームアプリはどうやってショートカット情報を取得しているのか?

たいていのホームアプリは、なんらかのモデル/ビューモデルをそのまま画面に写像するような作りをしています。

なので、おそらくショートカット情報のアクセスは


  • ホームアプリ起動時、ShortcutManagerを使ってショートカットを取得して、ビューモデルに反映する

  • ショートカットの内容に変更があれば、再度ショートカット情報を取得してビューモデルに反映する

の二本立てになっているものと想像します。


ショートカットを取得してビューモデルに反映

今まで説明してきた、ShortcutManagerの get****Shortcut() 系のAPIを使っているのかと思いきや!

AOSPのLauncher3のソースはこのようになっていました。


packages/apps/Launcher3/src/com/android/launcher3/LauncherModel.java

    1785        private void loadDeepShortcuts() {

1786 sBgDataModel.deepShortcutMap.clear();
1787 DeepShortcutManager shortcutManager = DeepShortcutManager.getInstance(mContext);
1788 mHasShortcutHostPermission = shortcutManager.hasHostPermission();
1789 if (mHasShortcutHostPermission) {
1790 for (UserHandle user : mUserManager.getUserProfiles()) {
1791 if (mUserManager.isUserUnlocked(user)) {
1792 List<ShortcutInfoCompat> shortcuts =
1793 shortcutManager.queryForAllShortcuts(user);
1794 sBgDataModel.updateDeepShortcutMap(null, user, shortcuts);
1795 }
1796 }
1797 }
1798 }

こんなかんじで、DeepShortcutManagerというラッパーを噛ませて LauncherApps#getShortcuts() にアクセスしていました。


LauncherApps??

LauncherAppsもまた、パッケージマネージャー系のシステムサービスの1つのようです。

ただ、独自にコア機能を提供するサービスではなく、ランチャーアプリで使いそうな機能を単に集約してるだけのラッパーサービスみたいです。

ショートカットの取得は


frameworks/base/services/core/java/com/android/server/pm/LauncherAppsService.java

    374         @Override

375 public ParceledListSlice getShortcuts(String callingPackage, long changedSince,
376 String packageName, List shortcutIds, ComponentName componentName, int flags,
377 UserHandle user) {
378 ensureShortcutPermission(callingPackage, user);
379 if (!isUserEnabled(user)) {
380 return new ParceledListSlice<>(new ArrayList(0));
381 }
382 if (shortcutIds != null && packageName == null) {
383 throw new IllegalArgumentException(
384 "To query by shortcut ID, package name must also be set");
385 }
386
387 // TODO(b/29399275): Eclipse compiler requires explicit List<ShortcutInfo> cast below.
388 return new ParceledListSlice<>((List<ShortcutInfo>)
389 mShortcutServiceInternal.getShortcuts(getCallingUserId(),
390 callingPackage, changedSince, packageName, shortcutIds,
391 componentName, flags, user.getIdentifier()));
392 }

このように、ShortcutManagerServiceに要求をパスしているだけ、みたいな実装でした。


ショートカットの内容に変更があれば・・・

これまで出てこなかった話で、ショートカットの変化通知はどうやってとるのでしょうか?

手がかりは、先ほど見つけたLauncherAppsServiceの中にありました。


frameworks/base/services/core/java/com/android/server/pm/LauncherAppsService.java

    147         /*

148 * @see android.content.pm.ILauncherApps#addOnAppsChangedListener(
149 * android.content.pm.IOnAppsChangedListener)
150 */

151 @Override
152 public void addOnAppsChangedListener(String callingPackage, IOnAppsChangedListener listener)
153 throws RemoteException {
154 verifyCallingPackage(callingPackage);
155 synchronized (mListeners) {
156 if (DEBUG) {
157 Log.d(TAG, "Adding listener from " + Binder.getCallingUserHandle());
158 }
159 if (mListeners.getRegisteredCallbackCount() == 0) {
160 if (DEBUG) {
161 Log.d(TAG, "Starting package monitoring");
162 }
163 startWatchingPackageBroadcasts();
164 }
165 mListeners.unregister(listener);
166 mListeners.register(listener, new BroadcastCookie(UserHandle.of(getCallingUserId()),
167 callingPackage));
168 }
169 }
170
171 /*
172 * @see android.content.pm.ILauncherApps#removeOnAppsChangedListener(
173 * android.content.pm.IOnAppsChangedListener)
174 */

175 @Override
176 public void removeOnAppsChangedListener(IOnAppsChangedListener listener)
177 throws RemoteException {
178 synchronized (mListeners) {
179 if (DEBUG) {
180 Log.d(TAG, "Removing listener from " + Binder.getCallingUserHandle());
181 }
182 mListeners.unregister(listener);
183 if (mListeners.getRegisteredCallbackCount() == 0) {
184 stopWatchingPackageBroadcasts();
185 }
186 }
187 }

LauncherAppsServiceが自前でリスナー管理している。

自前のリスナーが0→1になったときや1→0になったときに、startWatchingPackageBroadcasts stopWatchingPackageBroadcasts みたいなことをやってるので、その先を見てみると、


frameworks/base/services/core/java/com/android/server/pm/ShortcutService.java

   1543     /**

1544 * - Sends a notification to LauncherApps
1545 * - Write to file
1546 */

1547 void packageShortcutsChanged(@NonNull String packageName, @UserIdInt int userId) {
1548 if (DEBUG) {
1549 Slog.d(TAG, String.format(
1550 "Shortcut changes: package=%s, user=%d", packageName, userId));
1551 }
1552 notifyListeners(packageName, userId);
1553 scheduleSaveUser(userId);
1554 }
1555
1556 private void notifyListeners(@NonNull String packageName, @UserIdInt int userId) {
1557 injectPostToHandler(() -> {
1558 try {
1559 final ArrayList<ShortcutChangeListener> copy;
1560 synchronized (mLock) {
1561 if (!isUserUnlockedL(userId)) {
1562 return;
1563 }
1564
1565 copy = new ArrayList<>(mListeners);
1566 }
1567 // Note onShortcutChanged() needs to be called with the system service permissions.
1568 for (int i = copy.size() - 1; i >= 0; i--) {
1569 copy.get(i).onShortcutChanged(packageName, userId);
1570 }
1571 } catch (Exception ignore) {
1572 }
1573 });
1574 }

ShortcutServiceに、ふつうにショートカットの変化通知を出してるところがありました :sweat_smile:

ただ、ポイントとしては、だれでも簡単にショートカットの監視はできません。

   2249     /**

2250 * Entry point from {@link LauncherApps}.
2251 */
2252 private class LocalService extends ShortcutServiceInternal {
2253
:
:
2427 @Override
2428 public void addListener(@NonNull ShortcutChangeListener listener) {
2429 synchronized (mLock) {
2430 mListeners.add(Preconditions.checkNotNull(listener));
2431 }
2432 }

LocalServicesという、システムサービスが持ってるインスタンスマッパーに、↑がインスタンスが登録されていて、LocalServices.getService(ShortcutServiceInternal.class) で取得できる人しか addListener はできません。

で、いまのところそれをやっているのは LauncherAppsServiceだけです。

LauncherAppsは、デフォルト設定されてるランチャーアプリ(もしくは、デフォルト設定がないときはpriorityが最大のアプリ)以外からは使えないようなロジックを持っています。


frameworks/base/services/core/java/com/android/server/pm/LauncherAppsService.java

    360         private void ensureShortcutPermission(@NonNull String callingPackage, UserHandle user) {

361 ensureShortcutPermission(callingPackage, user.getIdentifier());
362 }
363
364 private void ensureShortcutPermission(@NonNull String callingPackage, int userId) {
365 verifyCallingPackage(callingPackage);
366 ensureInUserProfiles(userId, "Cannot start activity for unrelated profile " + userId);
367
368 if (!mShortcutServiceInternal.hasShortcutHostPermission(getCallingUserId(),
369 callingPackage)) {
370 throw new SecurityException("Caller can't access shortcut information");
371 }
372 }


frameworks/base/services/core/java/com/android/server/pm/ShortcutService.java

   2098     // This method is extracted so we can directly call this method from unit tests,

2099 // even when hasShortcutPermission() is overridden.
2100 @VisibleForTesting
2101 boolean hasShortcutHostPermissionInner(@NonNull String callingPackage, int userId) {
2102 synchronized (mLock) {
2103 throwIfUserLockedL(userId);
2104
2105 final ShortcutUser user = getUserShortcutsLocked(userId);
2106
2107 // Always trust the in-memory cache.
2108 final ComponentName cached = user.getCachedLauncher();
2109 if (cached != null) {
2110 if (cached.getPackageName().equals(callingPackage)) {
2111 return true;
2112 }
2113 }
2114 // If the cached one doesn't match, then go ahead
2115
2116 final List<ResolveInfo> allHomeCandidates = new ArrayList<>();
2117
2118 // Default launcher from package manager.
2119 final long startGetHomeActivitiesAsUser = injectElapsedRealtime();
2120 final ComponentName defaultLauncher = mPackageManagerInternal
2121 .getHomeActivitiesAsUser(allHomeCandidates, userId);
2122 logDurationStat(Stats.GET_DEFAULT_HOME, startGetHomeActivitiesAsUser);
2123
2124 ComponentName detected;
2125 if (defaultLauncher != null) {
2126 detected = defaultLauncher;
2127 if (DEBUG) {
2128 Slog.v(TAG, "Default launcher from PM: " + detected);
2129 }
2130 } else {
2131 detected = user.getLastKnownLauncher();
2132
2133 if (detected != null) {
2134 if (injectIsActivityEnabledAndExported(detected, userId)) {
2135 if (DEBUG) {
2136 Slog.v(TAG, "Cached launcher: " + detected);
2137 }
2138 } else {
2139 Slog.w(TAG, "Cached launcher " + detected + " no longer exists");
2140 detected = null;
2141 user.clearLauncher();
2142 }
2143 }
2144 }
2145
2146 if (detected == null) {
2147 // If we reach here, that means it's the first check since the user was created,
2148 // and there's already multiple launchers and there's no default set.
2149 // Find the system one with the highest priority.
2150 // (We need to check the priority too because of FallbackHome in Settings.)
2151 // If there's no system launcher yet, then no one can access shortcuts, until
2152 // the user explicitly
2153 final int size = allHomeCandidates.size();
2154
2155 int lastPriority = Integer.MIN_VALUE;
2156 for (int i = 0; i < size; i++) {
2157 final ResolveInfo ri = allHomeCandidates.get(i);
2158 if (!ri.activityInfo.applicationInfo.isSystemApp()) {
2159 continue;
2160 }
2161 if (DEBUG) {
2162 Slog.d(TAG, String.format("hasShortcutPermissionInner: pkg=%s prio=%d",
2163 ri.activityInfo.getComponentName(), ri.priority));
2164 }
2165 if (ri.priority < lastPriority) {
2166 continue;
2167 }
2168 detected = ri.activityInfo.getComponentName();
2169 lastPriority = ri.priority;
2170 }
2171 }
2172
2173 // Update the cache.
2174 user.setLauncher(detected);
2175 if (detected != null) {
2176 if (DEBUG) {
2177 Slog.v(TAG, "Detected launcher: " + detected);
2178 }
2179 return detected.getPackageName().equals(callingPackage);
2180 } else {
2181 // Default launcher not found.
2182 return false;
2183 }
2184 }
2185 }

なので、たとえばアプリ屋さんが「一部のアプリのショートカットを通知に出すようなアプリを作ってやろう、イッヒッヒ〜」と思っても、SecurityExceptionになってしまい、アプリは作れません。

残念・・・!

(このへんは、去年のアドベントカレンダーに少し詳しく書いてる人が居たので、そちらも参考にしてみて下さい。 https://qiita.com/oxsoft/items/541b8606eafd53603696


ShortcutManagerまとめ


  • /data/system_ce/shortcut_service/ を覗くと、ユーザーのショートカットの内容はすべてわかる

  • ShortcutServiceがショートカットの情報管理、LauncherAppsServiceがランチャーアプリ向けにショートカットの取得・変化通知のラッパーとして働いている

  • LauncherAppsServiceはデフォルトのホームアプリからしかアクセスできないような制約があるので、一般のアプリがショートカット情報を覗くことはできない。