Android

Android 7.1のApp Shortcutsの仕組み

まえおき

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