diff --git a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java index 1714a634f..527bbc2dc 100644 --- a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java +++ b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java @@ -2,6 +2,8 @@ import android.accessibilityservice.AccessibilityService; import android.accessibilityservice.GestureDescription; +import android.app.ActivityManager; +import android.app.AppOpsManager; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; @@ -198,12 +200,18 @@ public void onCancelled(GestureDescription gestureDescription) { } /** - * Write text to the system clipboard. Clipboard writes are allowed from any - * app on every API level (only reads are restricted since Android 10), so this - * is the IME-free path the host uses for multiline and non-ASCII input: - * set the clip here, then KEYCODE_PASTE over adb. + * Write text to the system clipboard. This is the IME-free path the host uses + * for multiline and non-ASCII input: set the clip here, then KEYCODE_PASTE over + * adb. Some OEM builds restrict WRITE_CLIPBOARD to the foreground via AppOps, + * and ClipboardService then drops the write without throwing, so the mode is + * checked first; returning false lets the host fall back to another input path + * instead of pasting stale clipboard contents. */ public static boolean setClipboard(final AccessibilityService service, final String text) { + if (!isClipboardWriteAllowed(service)) { + Log.w(TAG, "setClipboard skipped: WRITE_CLIPBOARD is not allowed for this process"); + return false; + } final CountDownLatch latch = new CountDownLatch(1); final AtomicBoolean ok = new AtomicBoolean(false); MAIN_HANDLER.post(new Runnable() { @@ -229,4 +237,33 @@ public void run() { } return ok.get(); } + + private static final String OPSTR_WRITE_CLIPBOARD = "android:write_clipboard"; + // AppOpsManager.MODE_FOREGROUND, which only exists as a constant from API 29. + private static final int MODE_FOREGROUND = 4; + + private static boolean isClipboardWriteAllowed(Context context) { + AppOpsManager appOps = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE); + if (appOps == null) return true; + int mode; + try { + int uid = android.os.Process.myUid(); + String pkg = context.getPackageName(); + mode = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ? appOps.unsafeCheckOpNoThrow(OPSTR_WRITE_CLIPBOARD, uid, pkg) + : appOps.checkOpNoThrow(OPSTR_WRITE_CLIPBOARD, uid, pkg); + } catch (RuntimeException e) { + // Unknown op name on this build: keep the previous optimistic behaviour. + return true; + } + if (mode == AppOpsManager.MODE_ALLOWED || mode == AppOpsManager.MODE_DEFAULT) return true; + if (mode == MODE_FOREGROUND) return isProcessForeground(); + return false; + } + + private static boolean isProcessForeground() { + ActivityManager.RunningAppProcessInfo info = new ActivityManager.RunningAppProcessInfo(); + ActivityManager.getMyMemoryState(info); + return info.importance <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND_SERVICE; + } } diff --git a/tests/unit/test_drivers.py b/tests/unit/test_drivers.py index 4efba167e..2772beac3 100644 --- a/tests/unit/test_drivers.py +++ b/tests/unit/test_drivers.py @@ -101,6 +101,35 @@ async def test_android_driver_with_mock_adb(): mock_adb_device.shell.assert_called_with("input keyevent 4") +@pytest.mark.asyncio +async def test_android_input_text_falls_back_to_adbkeyboard_when_clipboard_rejected(): + """A rejected clipboard write must not paste; ADBKeyboard should receive the text.""" + mock_adb_client = MagicMock() + mock_adb_device = MagicMock() + mock_adb_client.device.return_value = mock_adb_device + mock_adb_device.shell.side_effect = lambda cmd: ( + "com.android.adbkeyboard/.AdbIME" + if cmd == "settings get secure default_input_method" + else "" + ) + mock_ui_client = MagicMock() + mock_ui_client.set_clipboard.return_value = False + + driver = AndroidAdbDriver( + device_id="emulator-5554", + adb_client=mock_adb_client, + ui_adb_client=mock_ui_client, + ) + + assert await driver.input_text("你好", clear_existing=False) is True + + commands = [call.args[0] for call in mock_adb_device.shell.call_args_list] + mock_ui_client.set_clipboard.assert_called_once_with("你好") + assert "input keyevent 279" not in commands + b64_text = base64.b64encode("你好".encode()).decode("utf-8") + assert f"am broadcast -a ADB_INPUT_B64 --es msg '{b64_text}'" in commands + + @pytest.mark.asyncio async def test_android_driver_preserves_elements_from_combined_screen_data(): """The combined uiautomator2 response is the authoritative live snapshot."""