Android10でもクリップボードを使いたいっ

## はじめに
この記事は、[Android Advent Calendar 2019](https://qiita.com/advent-calendar/2019/android)の9日目です！

Androidでクリップボードを扱うには、以下のようにClipboardManagerを使うのが一般的です。
クリップボードを画面起動時に取得するにはonResumeなどでClipboardManagerを呼び出して取得していました。

```MainActivity.kt
class MainActivity : AppCompatActivity() {
    // 中略

    override fun onResume() {
        super.onResume()
        
        Snackbar.make(root, getClipboard(), Snackbar.LENGTH_SHORT).show()
    }

    private fun Context.getClipboard(): String {
        val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager?
        return clipboard?.primaryClip?.getItemAt(0)?.text?.toString().orEmpty()
    }
}
```

しかしAndroid10では、上記の方法でクリップボードの中身を取得しようとするとnullが帰ってきて取得することが出来なくなってしまいました。

|Android10|それ以下の端末|
|:--:|:--:|
|![device-2019-12-09-174310.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43128/5e901478-b0bb-90b7-69a3-23baef081465.png)|![device-2019-12-09-174441.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43128/43b62e2a-2653-d563-85e4-646a8650d9d7.png)|

これの現象はAndroid10で、ユーザーのプライバシーを守るためにクリップボードの取得に制限がかけられたことに起因しています。

> Limited access to clipboard data
> Unless your app is the default input method editor (IME) or is the app that currently has focus, your app cannot access clipboard data on Android 10 or higher.
> https://developer.android.com/about/versions/10/privacy/changes

この記事で書かれているように、Android10ではクリップボードのデータにアクセスするためには入力システム(IME)か現在フォーカスを持っているアプリである必要があります。
そのためAndroid10では、いくつかのクリップボードアプリが使えなくなっていると[いわれて](https://sakana-hakoniwa.com/android10%E3%81%A7%E3%82%AF%E3%83%AA%E3%83%83%E3%83%97%E3%83%9C%E3%83%BC%E3%83%89%E3%83%84%E3%83%BC%E3%83%AB%E3%81%8C%E4%BD%BF%E3%81%88%E3%81%AA%E3%81%8F%E3%81%AA%E3%81%A3%E3%81%9F%E7%90%86%E7%94%B1/)います。
そこでこの記事では、どのように実装すれば正しく取得することができるかについて書いていきたいと思います。

## 3行まとめ
- クリップボードを取得するためにはフォーカスが必要になった。
- Androidでフォーカスが来たことを検知するのは`Activity#onWindowFocusChanged`。
- `Activity#onWindowFocusChanged`でクリップボードは取得するようにする。

## 原因
まず、原因と解決方法を詳しく調査するためにgooglesourceでClipboardManagerの実装を見ました。
原因となった修正の[コミット](https://android.googlesource.com/platform/frameworks/base/+/33f56060d57a0aca3693caedbc37b5dc94a11ef3)と[diff](https://android.googlesource.com/platform/frameworks/base/+/33f56060d57a0aca3693caedbc37b5dc94a11ef3%5E%21/#F0)はこちらのようです。

変更されたコードを見ていくと`clipboardAccessAllowed`というメソッドが今回の修正で大きく変更されていることがわかります。

```diff
-    private boolean clipboardAccessAllowed(int op, String callingPackage, int callingUid) {
+    private boolean clipboardAccessAllowed(int op, String callingPackage, int uid,
+            @UserIdInt int userId) {
        // Check the AppOp.
-        if (mAppOps.noteOp(op, callingUid, callingPackage) != AppOpsManager.MODE_ALLOWED) {
+        if (mAppOps.noteOp(op, uid, callingPackage) != AppOpsManager.MODE_ALLOWED) {
            return false;
        }
        // Shell can access the clipboard for testing purposes.
@@ -641,7 +750,6 @@
            return true;
        }
        // The default IME is always allowed to access the clipboard.
-        int userId = UserHandle.getUserId(callingUid);
        String defaultIme = Settings.Secure.getStringForUser(getContext().getContentResolver(),
                Settings.Secure.DEFAULT_INPUT_METHOD, userId);
        if (!TextUtils.isEmpty(defaultIme)) {
@@ -654,16 +762,31 @@
        switch (op) {
            case AppOpsManager.OP_READ_CLIPBOARD:
                // Clipboard can only be read by applications with focus..
-                boolean allowed = mWm.isUidFocused(callingUid);
+                // or the application have the INTERNAL_SYSTEM_WINDOW and INTERACT_ACROSS_USERS_FULL
+                // at the same time. e.x. SystemUI. It needs to check the window focus of
+                // Binder.getCallingUid(). Without checking, the user X can't copy any thing from
+                // INTERNAL_SYSTEM_WINDOW to the other applications.
+                boolean allowed = mWm.isUidFocused(uid)
+                        || isInternalSysWindowAppWithWindowFocus(callingPackage);
                if (!allowed && mContentCaptureInternal != null) {
                    // ...or the Content Capture Service
-                    allowed = mContentCaptureInternal.isContentCaptureServiceForUser(callingUid,
-                            userId);
+                    // The uid parameter of mContentCaptureInternal.isContentCaptureServiceForUser
+                    // is used to check if the uid has the permission BIND_CONTENT_CAPTURE_SERVICE.
+                    // if the application has the permission, let it to access user's clipboard.
+                    // To passed synthesized uid user#10_app#systemui may not tell the real uid.
+                    // userId must pass intending userId. i.e. user#10.
+                    allowed = mContentCaptureInternal.isContentCaptureServiceForUser(
+                            Binder.getCallingUid(), userId);
                }
                if (!allowed && mAutofillInternal != null) {
                    // ...or the Augmented Autofill Service
-                    allowed = mAutofillInternal.isAugmentedAutofillServiceForUser(callingUid,
-                            userId);
+                    // The uid parameter of mAutofillInternal.isAugmentedAutofillServiceForUser
+                    // is used to check if the uid has the permission BIND_AUTOFILL_SERVICE.
+                    // if the application has the permission, let it to access user's clipboard.
+                    // To passed synthesized uid user#10_app#systemui may not tell the real uid.
+                    // userId must pass intending userId. i.e. user#10.
+                    allowed = mAutofillInternal.isAugmentedAutofillServiceForUser(
+                            Binder.getCallingUid(), userId);
                }
                if (!allowed) {
                    Slog.e(TAG, "Denying clipboard access to " + callingPackage
```


このメソッドの中では、`isInternalSysWindowAppWithWindowFocus`が現在のアカウントでアプリにフォーカスがあたっているかを判定しています。isInternalSysWindowAppWithWindowFocusは、WindowManagerがフォーカスがあたっているかをチェックして、フォーカスがあたっている場合にクリップボードを使うことが出来るように制御しています。

```diff
- boolean allowed = mWm.isUidFocused(callingUid);
+ boolean allowed = mWm.isUidFocused(uid) || isInternalSysWindowAppWithWindowFocus(callingPackage);

```

```diff
+    /**
+     * To check if the application has granted the INTERNAL_SYSTEM_WINDOW permission and window
+     * focus.
+     * <p>
+     * All of applications granted INTERNAL_SYSTEM_WINDOW has the risk to leak clip information to
+     * the other user because INTERNAL_SYSTEM_WINDOW is signature level. i.e. platform key. Because
+     * some of applications have both of INTERNAL_SYSTEM_WINDOW and INTERACT_ACROSS_USERS_FULL at
+     * the same time, that means they show the same window to all of users.
+     * </p><p>
+     * Unfortunately, all of applications with INTERNAL_SYSTEM_WINDOW starts very early and then
+     * the real window show is belong to user 0 rather user X. The result of
+     * WindowManager.isUidFocused checking user X window is false.
+     * </p>
+     * @return true if the app granted INTERNAL_SYSTEM_WINDOW permission.
+     */
+    private boolean isInternalSysWindowAppWithWindowFocus(String callingPackage) {
+        // Shell can access the clipboard for testing purposes.
+        if (mPm.checkPermission(Manifest.permission.INTERNAL_SYSTEM_WINDOW,
+                    callingPackage) == PackageManager.PERMISSION_GRANTED) {
+            if (mWm.isUidFocused(Binder.getCallingUid())) {
+                return true;
+            }
+        }
+
+        return false;
+    }
```

そのためこの修正の入ったAndroid10では、IMEもしくはフォーカスがあたったアプリでしかクリップボードは取得できなくなっています。

## 解決策
解決策としてはフォーカスが当たったタイミングが取得できればクリップボードも取得できるはずです。
Androidのライフサイクルは以下の順で呼ばれます。
onResumeのタイミングではまだ画面が生成され終わっていないためフォーカスを取得するにはonWindowFocusChangedで取得する必要があります。

|ライフサイクル|タイミング|
|:--|:--|
|onCreate|Activity起動時|
|onStart|Activity表示時|
|onResume|Activityが前面になる時|
|onWindowFocusChanged|フォーカスが変わった時|

onWindowFocusChangedは、フォーカスが変化した時に呼ばれ、hasFocusでFocusの値を取得することができます。hasFocusがtrueになったときはアプリにフォーカスが来ているので、以下のように書くことで画面起動時にクリップボードの値を正しく取得することが可能になります。

![device-2019-12-09-174441.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43128/c885dcd2-c673-94c3-b586-be9325ff8706.png)


```MainActivity.kt
class MainActivity : AppCompatActivity() {
    // 中略

    override fun onWindowFocusChanged(hasFocus: Boolean) {
        super.onWindowFocusChanged(hasFocus)

        if (hasFocus) {
            Snackbar.make(root, getClipboard(), Snackbar.LENGTH_SHORT).show()
        }
    }

    private fun Context.getClipboard(): String {
        val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager?
        return clipboard?.primaryClip?.getItemAt(0)?.text?.toString().orEmpty()
    }
}
```

## まとめ
これまで、Androidではクリップボードを何も考えずに使うことができていました。しかしセキュリティの観点からもAndroid10では難しくなってしまいました。
今後アプリ起動時にクリップボードからデータを取得する際には`onWindowFocusChanged`の中で取得しましょう。