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

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

はじめに

この記事は、Android Advent Calendar 2019の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 device-2019-12-09-174441.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では、いくつかのクリップボードアプリが使えなくなっているといわれています。
そこでこの記事では、どのように実装すれば正しく取得することができるかについて書いていきたいと思います。

3行まとめ

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

原因

まず、原因と解決方法を詳しく調査するためにgooglesourceでClipboardManagerの実装を見ました。
原因となった修正のコミットdiffはこちらのようです。

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

-    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がフォーカスがあたっているかをチェックして、フォーカスがあたっている場合にクリップボードを使うことが出来るように制御しています。

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

+    /**
+     * 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

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の中で取得しましょう。

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
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  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
ユーザーは見つかりませんでした