"""剪贴板注入公共模块(设备端 Agent 通道)。 背景:Android 10+ 禁止后台(atx-agent/shell)写剪贴板——u2.set_clipboard 调用"成功"但内容被系统静默丢弃(Android 12/13 实测)。设备端 APK 通过 am start 启动**透明 Activity**,窗口聚焦后 setPrimaryClip;shell 身份启动 Activity 不受 后台启动限制(广播方式在 MIUI 会被 Background activity start 拦截,必须用 am start)。 **注入目标按顺序尝试**:先设备端聚合 Agent(`com.example.deviceagent`,现在的标准通道), 再兜底老设备上可能残留的独立 ClipInject(`com.example.clipinject`)。 两者只有一个 `--es text_b64` 的契约差异为零,所以是"换个包名再试一次"。 工具页(web/tools_api.py)与任务执行器(tasks/generic/task.py)共用本模块。 """ import base64 import subprocess import time from concurrent.futures import ThreadPoolExecutor from config import ADB_PATH from core.adb_helper import adb_connect from core.logger import get_logger _log = get_logger("core.clip") # (包名, am start 目标, 给用户看的名字);顺序 = 尝试顺序 _CLIP_TARGETS = ( ("com.example.deviceagent", "com.example.deviceagent/.ClipActivity", "设备端 Agent"), ("com.example.clipinject", "com.example.clipinject/.ClipActivity", "旧版 ClipInject"), ) def _am_start(serial, activity, b64): """拉起透明 Activity 写剪贴板。返回 (输出文本, 是否执行成功)。""" try: r = subprocess.run( [ADB_PATH, "-s", serial, "shell", "am", "start", "-n", activity, "--es", "text_b64", b64], capture_output=True, timeout=30) except subprocess.TimeoutExpired: return "am start 超时(adb 无响应)", False except Exception as e: return f"{type(e).__name__}: {str(e)[:120]}", False out = ((r.stdout or b"") + (r.stderr or b"")).decode(errors="replace") # "Starting: Intent {…}" 后面跟 "Error type 3 / Error: Activity class … does not exist." # 时也是失败——所以不能只看有没有 Starting if "does not exist" in out or "unable to resolve Intent" in out or "Error type 3" in out: return out, False if "Error" in out or ("Starting" not in out and r.returncode != 0): return out, False return out, True def inject_clipboard(serial, text, d=None): """向设备剪贴板注入文字,返回 (ok, msg)。 - 依次尝试 `_CLIP_TARGETS`(设备端 Agent → 旧版 ClipInject):设备上装哪个用哪个, 两个都没有时明确报"需要装设备端 Agent"(不再只说 ClipInject,用户会以为要装老 APK) - d:可选,任务执行器已持有 u2 Device 时传入复用(跳过重复连接), 读回验证直接用它;不传则临时 u2.connect(工具页场景,带超时保护) - IP:port 设备先 adb connect(已连接自动跳过;绝不 disconnect,红线) - 通道缺失 / 读回不一致都会明确报错(不再静默失败) """ import uiautomator2 as u2 if ":" in serial: try: adb_connect(serial) except Exception as e: return False, f"adb 连接失败: {e}" b64 = base64.b64encode(text.encode("utf-8")).decode() # 1. am start 透明 Activity 写剪贴板(shell 身份,不受后台启动限制) tried = [] for pkg, activity, label in _CLIP_TARGETS: out, started = _am_start(serial, activity, b64) if started: if tried: # 走到兜底通道:记一条,好提醒"这台设备该装 Agent 了" _log.info(f"[{serial}] 剪贴板注入:{'、'.join(tried)} 都没有," f"改用{label}({pkg})成功 —— 建议给这台设备装设备端 Agent") break if "does not exist" in out or "unable to resolve Intent" in out or "Error type 3" in out: tried.append(label) continue # 装了但启动失败:直接报错,不再往下试(下一个包大概率也没装) return False, f"am start 失败({label}): {out.strip()[:120]}" else: return False, ("设备未安装剪贴板注入通道:需要设备端 Agent" "(com.example.deviceagent)——从「应用管理 → 设备端应用商店」安装;" "旧版独立 ClipInject(com.example.clipinject)也可以,但已不再随平台分发") # 2. 读回验证:**必须轮询**。透明 Activity 要等窗口拿到焦点才写(还有 800ms 兜底), # 只睡一次 0.5s 会读到**上一次的剪贴板内容** → 误报"写入可能被拒"(实测踩过: # 内容明明写进去了,接口却回 1 台失败)。轮询到 ~3 秒:一致即成功; # 一直不一致才算真的被拒。 try: if d is None: with ThreadPoolExecutor(max_workers=1) as pool: d = pool.submit(u2.connect, serial).result(timeout=30) except Exception as e: return True, f"已注入(读回验证不可用: {type(e).__name__}: {str(e)[:80]})" deadline = time.time() + 3.0 got = None while True: time.sleep(0.4) try: with ThreadPoolExecutor(max_workers=1) as pool: got = pool.submit(lambda: d.clipboard).result(timeout=15) except Exception as e: # 读回不可用(旧版 u2/连接失败)时按写入成功处理,避免误报,但带上具体原因 return True, f"已注入(读回验证不可用: {type(e).__name__}: {str(e)[:80]})" if got == text: return True, "已注入" if time.time() >= deadline: return False, f"剪贴板读回不一致(写入可能被拒): {str(got)[:60]!r}"