From 08ccd79c23e99b130854a3d639c622397dc4b3a6 Mon Sep 17 00:00:00 2001 From: theworker02 Date: Sun, 27 Sep 2026 15:30:53 -0400 Subject: [PATCH] fix(helper): report denied clipboard writes so text input can fall back Some OEM builds restrict WRITE_CLIPBOARD to the foreground via AppOps and ClipboardService drops the write without throwing. Check the AppOps mode before writing so setClipboard returns false and the host falls back to ADBKeyboard instead of pasting nothing. Fixes #127 --- .../com/artemis/helper/GestureController.java | 45 +++++++++++++++++-- tests/unit/test_drivers.py | 29 ++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) 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."""