まえおき
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を見てみます。
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
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
への変換の流れをみていきます。
データクラス
class ShortcutUser {
private ArrayMap<パッケージ名, ShortcutPackage> mPackages;
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のソースはこのようになっていました。
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つのようです。
ただ、独自にコア機能を提供するサービスではなく、ランチャーアプリで使いそうな機能を単に集約してるだけのラッパーサービスみたいです。
ショートカットの取得は
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の中にありました。
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
みたいなことをやってるので、その先を見てみると、
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に、ふつうにショートカットの変化通知を出してるところがありました
ただ、ポイントとしては、だれでも簡単にショートカットの監視はできません。
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が最大のアプリ)以外からは使えないようなロジックを持っています。
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 }
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はデフォルトのホームアプリからしかアクセスできないような制約があるので、一般のアプリがショートカット情報を覗くことはできない。