Help us understand the problem. What is going on with this article?

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はデフォルトのホームアプリからしかアクセスできないような制約があるので、一般のアプリがショートカット情報を覗くことはできない。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away