Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand All @@ -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;
}
}
29 changes: 29 additions & 0 deletions tests/unit/test_drivers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading