* feat(android): add optional Shizuku accessibility recovery path Integrate best-effort OpenLessAccessibilityService recovery via Shizuku UserService with safe rollback, manifest/Gradle scaffolding, JNI bridge, settings UI, and contract tests. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): add primary constructor for Shizuku UserService Stub Kotlin requires an explicit supertype initializer when extending AIDL Stub; fixes Gradle compile error on CI. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): replace accessibility heartbeat with IPC paste results Remove SharedPreferences cross-process heartbeat gating, use structured PASTE/PING IPC with safe retry rules, tighten editable target validation, and fix Shizuku manifest attribute merging for mixed-quote providers. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): use literal const values in accessibility command receiver Kotlin const val cannot reference enum codes; fixes release APK compile. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): 修复无障碍组件短名与全名匹配误判 Settings.Secure 常以 pkg/.Class 存储服务列表,主检测路径改用 ComponentName 规范化比较,避免 enabled=false 误报未授权;同步 Rust JNI 路径并补齐测试。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): 为 JNI 调用的无障碍检测方法添加 @Keep Release 构建中 R8 会剥离仅由 JNI 反射调用的 pingAccessibilityProcess,导致 NoSuchMethodError 与 operational=false 误报;同步保护 isEnabled 并补充契约测试。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): 恢复无障碍粘贴的可编辑目标回退查找 粘贴失败日志为 NO_FOCUSED_EDITOR:过严的 isFocused 校验与移除树遍历导致找不到输入框。恢复 FOCUS_ACCESSIBILITY、宽松焦点匹配与 findEditableInTree 回退,并保留严格缓存校验。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): 跨窗口扫描与缓存优先修复粘贴目标查找 微信等场景下 rootInActiveWindow 非输入窗口导致 NO_FOCUSED_EDITOR;改为缓存优先、遍历全部无障碍窗口,并加深树搜索。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): 识别非 isEditable 的粘贴目标节点 微信等 App 不向无障碍暴露 isEditable;扩展为 EditText/ACTION_PASTE/ACTION_SET_TEXT 目标,并订阅文本变更事件以预热缓存。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): 三层粘贴回退(无障碍 → Shizuku → 剪贴板) 无障碍失败后尝试 Shizuku 注入 KEYCODE_PASTE,再回退剪贴板;精简 Tier1 为 findFocus + ACTION_PASTE。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): 粘贴结果校验与 Shizuku processNameSuffix ACTION_PASTE 返回 true 但文本未写入时降级为 PASTE_REJECTED 并走 Tier2;修复 UserService 绑定 NPE;Tier1 成功后跳过 Shizuku。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): 恢复无障碍可编辑目标查找以修复微信等应用粘贴 aaa3fbf2 将 Tier1 简化为 findFocus 导致微信等应用 PASTE_REJECTED;恢复跨窗口/树扫描与焦点缓存,保留粘贴结果校验与 Promise.allSettled 容错。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): 通过 IPC 传递粘贴文本修复后台剪贴板读取被拒绝 无障碍子进程在应用未聚焦时无法读剪贴板,导致 Tier1 直接 PASTE_REJECTED;主进程将听写文本经 EXTRA_PASTE_TEXT 传入粘贴流程。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): Tier2 粘贴改用 Shizuku.newProcess 绕过 MTK UserService 崩溃 联发科/小米 ROM 上 UserService 进程在 LoadedApk.makeApplicationInner 因 procName 为 null 而 NPE;粘贴键注入优先走 Shizuku.newProcess,失败再回退 UserService。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): Tier2 粘贴改用 Shizuku daemon UserService newProcess 为私有 API;粘贴键注入改走 daemon(true) 绑定,避免 MTK 上 :shizuku 子进程 Application NPE。 Co-authored-by: Cursor <cursoragent@cursor.com> * test(android): 修正 daemon 粘贴契约断言 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): daemon 粘贴绑定补全 processNameSuffix Shizuku UserServiceArgs 要求 suffix 非空;daemon(true)+suffix=paste 避免此前 null NPE 与 MTK :shizuku 子进程崩溃。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): Tier2 粘贴经反射调用 Shizuku.newProcess MTK 上 UserService 子进程仍触发 LoadedApk NPE;粘贴键注入优先反射 newProcess 执行 input keyevent 279,UserService 作回退。 Co-authored-by: Cursor <cursoragent@cursor.com> * chore(android): 移除粘贴调试日志 微信等应用三层粘贴回退已在真机验证通过,清理 DBG-21a66f/DBG-53a00d 临时 instrumentation。 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(android): 修复 CI 单元测试在无 Robolectric 下失败 ComponentIds 改为纯 Kotlin 规范化(对齐 Rust);isValidServiceComponent 拒绝首尾空白;Target 测试改用 action id 常量。 Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
84 lines
3.2 KiB
JavaScript
84 lines
3.2 KiB
JavaScript
#!/usr/bin/env node
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const servicePath = fileURLToPath(
|
|
new URL('../android/kotlin/OpenLessAccessibilityService.kt', import.meta.url),
|
|
);
|
|
const source = readFileSync(servicePath, 'utf8');
|
|
|
|
function kotlinFunctionBody(functionSignature) {
|
|
const signatureIndex = source.indexOf(functionSignature);
|
|
assert.notEqual(signatureIndex, -1, `missing Kotlin function: ${functionSignature}`);
|
|
const openBrace = source.indexOf('{', signatureIndex);
|
|
assert.notEqual(openBrace, -1, `missing opening brace: ${functionSignature}`);
|
|
|
|
let depth = 0;
|
|
for (let index = openBrace; index < source.length; index += 1) {
|
|
if (source[index] === '{') depth += 1;
|
|
if (source[index] === '}') depth -= 1;
|
|
if (depth === 0) return source.slice(openBrace + 1, index);
|
|
}
|
|
assert.fail(`missing closing brace: ${functionSignature}`);
|
|
}
|
|
|
|
const pasteBody = kotlinFunctionBody('private fun performPasteToFocusedFieldInternal(pasteText: String? = null)');
|
|
assert.match(
|
|
pasteBody,
|
|
/finally\s*\{\s*target\.recycle\(\)\s*\}/s,
|
|
'paste completion must recycle only the per-call target',
|
|
);
|
|
assert.doesNotMatch(
|
|
pasteBody,
|
|
/finally\s*\{[\s\S]*?invalidateEditableCache\s*\(/,
|
|
'paste completion must retain the validated focus cache for a consecutive paste',
|
|
);
|
|
|
|
const targetBody = kotlinFunctionBody('private fun findEditableTarget()');
|
|
const rootBody = kotlinFunctionBody('private fun findEditableInRoot(root: AccessibilityNodeInfo)');
|
|
assert.match(
|
|
targetBody,
|
|
/lastEditableFocus\?\.let\s*\{\s*cached\s*->[\s\S]*?OpenLessAccessibilityTarget\.isPasteTarget\(cached\)/s,
|
|
'findEditableTarget must try lenient cached paste target before window scans',
|
|
);
|
|
assert.match(
|
|
targetBody,
|
|
/for\s*\(\s*window\s+in\s+windows\s*\)/,
|
|
'findEditableTarget must scan all accessibility windows',
|
|
);
|
|
assert.match(
|
|
rootBody,
|
|
/editableFocusedNode\(root, AccessibilityNodeInfo\.FOCUS_INPUT\)\?\.let\s*\{\s*fresh\s*->\s*cacheEditableTarget\(fresh\)\s*return fresh/s,
|
|
'a fresh focus target must refresh the service cache',
|
|
);
|
|
assert.match(
|
|
rootBody,
|
|
/lastEditableFocus\?\.let\s*\{\s*cached\s*->[\s\S]*?OpenLessAccessibilityTarget\.isValidCachedEditable\(cached, root\)[\s\S]*?return AccessibilityNodeInfo\.obtain\(cached\)/s,
|
|
'cached focus reuse must retain package, window, focus, and refresh validation',
|
|
);
|
|
assert.match(
|
|
rootBody,
|
|
/editableFocusedNode\(root, AccessibilityNodeInfo\.FOCUS_ACCESSIBILITY\)/,
|
|
'findEditableInRoot must try accessibility focus after input focus',
|
|
);
|
|
assert.match(
|
|
rootBody,
|
|
/findEditableInTree\(root, 0\)/,
|
|
'findEditableInRoot must fall back to editable tree search',
|
|
);
|
|
|
|
assert.match(source, /pasteAppearsApplied/, 'paste must verify editor text changed');
|
|
assert.match(source, /paste=unverified/, 'paste must log unverified ACTION_PASTE results');
|
|
assert.match(
|
|
readFileSync(
|
|
fileURLToPath(new URL('../android/kotlin/OpenLessAccessibilityCommandReceiver.kt', import.meta.url)),
|
|
'utf8',
|
|
),
|
|
/EXTRA_PASTE_TEXT/,
|
|
'paste IPC must carry text to the accessibility process',
|
|
);
|
|
assert.match(source, /EXTRA_PASTE_TEXT/, 'paste sender must include IPC text extra');
|
|
|
|
console.log('Android accessibility paste cache contract checks passed');
|