test(android-2b): 真机 IME 提交 proof 与字节安全证据采集

ime_commit_proof 补上 phone_chain_proof 没覆盖的那半段:IME **显示**状态下
按键被输入法消费,只有 RemoteInputConnection.commitText 能到达我们——这正是
方案风险 R1(vivo ROM + 第三方中文 IME 行为未观测)此前唯一没有证据的点。
真机实测:mInputShown=true、mHaveConnection=true、状态栏 commit 2u、
Windows 靶窗收到 Hi(讯飞把首字母自动大写,属输入法行为,不是链路缺陷)。
back 键第一次只关键盘不断会话,也在这支脚本里断言。

shot.ps1 / capture_evidence.ps1:PS 5.1 的 '>' 以 UTF-16 写文件,
'adb exec-out screencap -p > x.png' 产出的 9 张证据图首字节全是 FF FE,
全部不可用;改为设备内 screencap 落地再 pull,并校验 PNG 签名。

V5 等待预算 30s→50s:不可达 IP 实测 32s 才回表单报 timed out,
预算过小会把「仍在超时中」误判成验收失败。
This commit is contained in:
郑博文 2026-10-30 13:06:08 +08:00
parent 9f0b0742a8
commit 0089ae86e1
3 changed files with 348 additions and 0 deletions

143
tools/capture_evidence.ps1 Normal file
View File

@ -0,0 +1,143 @@
# 2b evidence capture: byte-safe device screenshots of each acceptance state.
# ASCII-only source. Screenshots go through tools\shot.ps1 because PS 5.1 `>`
# writes UTF-16 and corrupts PNG bytes (see that file's header).
# Text/state assertions use uiautomator (lessons R-6: vivo logcat is throttled).
$ErrorActionPreference = "Stop"
$repo = (Resolve-Path "$PSScriptRoot\..").Path
$ev = "D:\working\projects\yuankong\evidence\2b"
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
$phoneIp = "192.168.1.180"
function Adb-Shell {
param([Parameter(Mandatory)][string[]]$Cmd)
$old = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$out = & $adb shell @Cmd 2>$null
return @{ out = ($out -join "`n"); code = $LASTEXITCODE }
}
finally { $ErrorActionPreference = $old }
}
function Get-Ui {
[void](Adb-Shell @("uiautomator", "dump", "/sdcard/ui.xml"))
$old = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try { & $adb pull /sdcard/ui.xml "$env:TEMP\cap-ui.xml" 2>$null | Out-Null }
finally { $ErrorActionPreference = $old }
if (-not (Test-Path "$env:TEMP\cap-ui.xml")) { return "" }
return [System.IO.File]::ReadAllText("$env:TEMP\cap-ui.xml", [System.Text.Encoding]::UTF8)
}
function Wait-Live([int]$Seconds) {
$deadline = (Get-Date).AddSeconds($Seconds)
while ((Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 900
$x = Get-Ui
if ($x -match "RTT=") { return $x }
}
return ""
}
function Shot([string]$name) {
$out = Join-Path $ev $name
& powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out $out
}
New-Item -ItemType Directory -Force -Path $ev | Out-Null
# drop the earlier broken captures (PS 5.1 '>' wrote them as UTF-16)
Get-ChildItem $ev -Filter "*.png" | ForEach-Object {
$b = [System.IO.File]::ReadAllBytes($_.FullName)
if ($b.Length -lt 4 -or ($b[0] -ne 0x89)) { Remove-Item $_.FullName -Force }
}
Write-Host "=== ensure fresh v3 agent ==="
$agentExe = "$repo\target\release\agent.exe"
$ag = Get-Process agent -ErrorAction SilentlyContinue
if ($ag -and (Get-Item $agentExe).LastWriteTime -gt $ag.StartTime) {
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 800; $ag = $null
}
if (-not $ag) {
Start-Process $agentExe -ArgumentList "--port","47800","--code","t" -WindowStyle Hidden
Start-Sleep -Seconds 1
}
Write-Host "=== 1. connect form (V4 entry state) ==="
[void](Adb-Shell @("am","force-stop","com.verify.remote"))
[void](Adb-Shell @("am","start","-n","com.verify.remote/.MainActivity"))
Start-Sleep -Seconds 3
$ui = Get-Ui
if ($ui -notmatch "android\.widget\.EditText") { throw "form fields missing" }
Shot "4b-form.png"
Write-Host "=== 2. failure path (V5): unreachable host via intent ==="
[void](Adb-Shell @("am","force-stop","com.verify.remote"))
[void](Adb-Shell @("am","start","-n","com.verify.remote/.MainActivity",
"--es","host","192.168.1.199","--ei","port","47800","--es","code","t"))
# Measured 2026-10-30: an unreachable host returns to the form with a concrete
# reason after ~32s (quinn's default connect timeout). Budget 50s, not 30s -
# the earlier 30s budget made this step look like a failure when it was only
# still inside the timeout. UX follow-up (logged in the result record): during
# those 30s the panel says only "connecting" with no cancel affordance.
$deadline = (Get-Date).AddSeconds(50)
$err = ""
while ((Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 1000
$ui = Get-Ui
if ($ui -match "android\.widget\.EditText") {
$shown = [regex]::Matches($ui, 'text="([^"]+)"') | ForEach-Object { $_.Groups[1].Value }
$err = $shown -join " | "
break
}
}
if ($err) { Shot "4b-v5-error.png" } else { throw "V5: never returned to form with an error" }
Write-Host " error state visible: yes"
Write-Host "=== 3. live session (V4 via intent) ==="
[void](Adb-Shell @("am","force-stop","com.verify.remote"))
[void](Adb-Shell @("am","start","-n","com.verify.remote/.MainActivity",
"--es","host",$phoneIp,"--ei","port","47800","--es","code","t"))
$ui = Wait-Live 25
if (-not $ui) { throw "session never became live" }
Shot "4b-connected-stream.png"
Write-Host " $([regex]::Match($ui,'text="([^"]*RTT=[^"]*)"').Groups[1].Value)"
Write-Host "=== 4. keyboard shown (real IME bound to our InputConnection) ==="
$btns = [regex]::Matches($ui, '<node[^>]*class="android\.widget\.Button"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
if ($btns.Count -lt 1) { throw "no keyboard button" }
$b0 = $btns[0]
$kx = [int](([int]$b0.Groups[1].Value + [int]$b0.Groups[3].Value) / 2)
$ky = [int](([int]$b0.Groups[2].Value + [int]$b0.Groups[4].Value) / 2)
[void](Adb-Shell @("input","tap","$kx","$ky"))
Start-Sleep -Seconds 3
$ime = (Adb-Shell @("dumpsys","input_method")).out
if ($ime -notmatch "mInputShown=true") { throw "IME not shown" }
if ($ime -notmatch "mHaveConnection=true") { throw "no InputConnection bound" }
Shot "4b-ime-shown.png"
Write-Host "=== 5. IME commit reaches the pipeline (diagnostic shows commit) ==="
[void](Adb-Shell @("input","text","hello"))
Start-Sleep -Milliseconds 1200
[void](Adb-Shell @("input","keyevent","62")) # SPACE: accept any pending candidate
Start-Sleep -Seconds 2
$ui = Get-Ui
$diag = [regex]::Match($ui, 'text="([^"]*RTT=[^"]*)"').Groups[1].Value
Write-Host " phone status: $diag"
Shot "4b-ime-committed.png"
Write-Host "=== 6. disconnect (back key) ==="
[void](Adb-Shell @("input","keyevent","4")) # dismiss keyboard
Start-Sleep -Milliseconds 800
[void](Adb-Shell @("input","keyevent","4")) # disconnect
Start-Sleep -Seconds 2
$ui = Get-Ui
if ($ui -match "RTT=") { throw "still connected after two BACK presses" }
Shot "4b-disconnected.png"
Write-Host "=== CAPTURE COMPLETE ==="
Get-ChildItem $ev -Filter "4b-*.png" | ForEach-Object {
$b = [System.IO.File]::ReadAllBytes($_.FullName)
$sig = ($b[0..3] | ForEach-Object { $_.ToString("X2") }) -join " "
"{0,-28} {1,9} {2}" -f $_.Name, $b.Length, $sig
}

182
tools/ime_commit_proof.ps1 Normal file
View File

@ -0,0 +1,182 @@
# 2b V6/V7 on-device IME path: does a real IME's commitText reach the remote
# desktop? ASCII-only source (see personal lessons on PS 5.1 + BOM-less .ps1).
#
# Difference from phone_chain_proof.ps1: that one drives Activity.onKeyDown with
# adb keyevents (IME hidden). Here the IME is SHOWN, so keys are consumed by the
# input method and reach us only through RemoteInputConnection.commitText /
# sendKeyEvent. That is the path Chinese candidates take, and it is the risk R1
# item in the plan (vivo ROM + third-party IME behaviour was never observed).
#
# What this proves mechanically: IME is bound to our InputConnection and its
# committed text lands in a Windows window we own, via protocol v3.
# What it cannot prove: human candidate-list selection (needs a finger); see
# the result record - V6/V7 full judgement is listed as manual acceptance.
#
# Usage:
# powershell -ExecutionPolicy Bypass -File tools\ime_commit_proof.ps1
param(
[string]$PhoneIp = "192.168.1.180",
[int]$Port = 47800,
[string]$Code = "t",
[string]$Text = "hi"
)
$ErrorActionPreference = "Stop"
$repo = (Resolve-Path "$PSScriptRoot\..").Path
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
$tmp = "$env:TEMP\remote-ime-proof"
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
$pidFile = "$tmp\target.pid"
$stopFile = "$tmp\stop.flag"
$resultFile = "$tmp\received.txt"
Remove-Item $pidFile,$stopFile,$resultFile -ErrorAction SilentlyContinue
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public static class FgApi2 {
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
}
"@
function Adb {
param([Parameter(Mandatory)][string[]]$AdbArgs)
$old = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$out = & $adb @AdbArgs 2>$null
return @{ out = ($out -join "`n"); code = $LASTEXITCODE }
}
finally { $ErrorActionPreference = $old }
}
function Adb-Shell { param([Parameter(Mandatory)][string[]]$Cmd) return Adb -AdbArgs (@("shell") + $Cmd) }
function Wait-File {
param([string]$Path, [int]$TimeoutMs = 20000)
$t = 0
while (-not (Test-Path $Path) -and $t -lt ($TimeoutMs / 150)) { Start-Sleep -Milliseconds 150; $t++ }
if (-not (Test-Path $Path)) { throw "timeout waiting for $Path" }
}
function Assert-Foreground([IntPtr]$hwnd, [string]$where) {
$deadline = (Get-Date).AddMilliseconds(2000)
while ((Get-Date) -lt $deadline) {
if ([FgApi2]::GetForegroundWindow() -eq $hwnd) { return }
Start-Sleep -Milliseconds 30
}
throw "$where : foreground=$([FgApi2]::GetForegroundWindow()) want=$($hwnd.ToInt64()) - refusing to inject"
}
function Get-PhoneUiText {
[void](Adb-Shell @("uiautomator", "dump", "/sdcard/ui.xml"))
$r = Adb -AdbArgs @("pull", "/sdcard/ui.xml", "$tmp\ui.xml")
if ($r.code -ne 0 -or -not (Test-Path "$tmp\ui.xml")) { return "" }
return [System.IO.File]::ReadAllText("$tmp\ui.xml", [System.Text.Encoding]::UTF8)
}
function Read-Received {
if (-not (Test-Path $resultFile)) { return "" }
$b = [System.IO.File]::ReadAllBytes($resultFile)
$s = [System.Text.Encoding]::UTF8.GetString($b)
if ($s.Length -gt 0 -and $s[0] -eq [char]0xFEFF) { $s = $s.Substring(1) }
return $s
}
Write-Host "=== 0. retire earlier targets (touch only existing stop flags) ==="
$flags = @(Get-ChildItem -Path $env:TEMP -Recurse -Filter "stop.flag" -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -like "*remote*proof*" })
if ($flags.Count -gt 0) {
Write-Host " retiring $($flags.Count)"
foreach ($f in $flags) { (Get-Item $f.FullName).LastWriteTime = Get-Date }
Start-Sleep -Seconds 3
}
Write-Host "=== 1. fresh target window ==="
Start-Process powershell.exe -ArgumentList "-NoProfile","-ExecutionPolicy","Bypass",`
"-File","$repo\tools\text_target.ps1","-PidFile",$pidFile,`
"-ResultFile",$resultFile,"-StopFile",$stopFile,"-GrabForeground"
Wait-File $pidFile
$parts = @((Get-Content $pidFile -Raw) -split "`n")
$tid = [int]$parts[0].Trim()
$hwnd = [IntPtr][long]$parts[1].Trim()
Assert-Foreground $hwnd "after launch"
Write-Host " target pid=$tid hwnd=$($hwnd.ToInt64())"
Write-Host "=== 2. agent freshness + phone session ==="
$agentExe = "$repo\target\release\agent.exe"
$ag = Get-Process agent -ErrorAction SilentlyContinue
if ($ag -and (Get-Item $agentExe).LastWriteTime -gt $ag.StartTime) {
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 800; $ag = $null
}
if (-not $ag) {
Start-Process $agentExe -ArgumentList "--port",$Port,"--code",$Code -WindowStyle Hidden
Start-Sleep -Seconds 1
}
[void](Adb-Shell @("am", "force-stop", "com.verify.remote"))
[void](Adb-Shell @("am", "start", "-n", "com.verify.remote/.MainActivity",
"--es", "host", $PhoneIp, "--ei", "port", "$Port", "--es", "code", $Code))
$deadline = (Get-Date).AddSeconds(25)
$xml = ""
while ((Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 900; $xml = Get-PhoneUiText; if ($xml -match "RTT=") { break } }
if ($xml -notmatch "RTT=") { throw "phone did not connect" }
Write-Host " $([regex]::Match($xml,'text="([^"]*RTT=[^"]*)"').Groups[1].Value)"
Write-Host "=== 3. show the soft keyboard (tap the app's keyboard button) ==="
# Button coordinates come from the live hierarchy. Our remote-state bar adds
# "keyboard" before "disconnect", so index 0 is the keyboard button - asserted
# rather than assumed by checking the count and printing what we tapped.
$btns = [regex]::Matches($xml, '<node[^>]*class="android\.widget\.Button"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
if ($btns.Count -lt 1) { throw "no buttons found in remote state" }
$b0 = $btns[0]
$kx = [int](([int]$b0.Groups[1].Value + [int]$b0.Groups[3].Value) / 2)
$ky = [int](([int]$b0.Groups[2].Value + [int]$b0.Groups[4].Value) / 2)
Write-Host " tapping first button ($kx,$ky)"
[void](Adb-Shell @("input", "tap", "$kx", "$ky"))
Start-Sleep -Seconds 3
$ime = (Adb-Shell @("dumpsys", "input_method")).out
$shown = ($ime -match "mInputShown=true")
$bound = ($ime -match "mHaveConnection=true")
$curId = [regex]::Match($ime, "mCurId=(\S+)").Groups[1].Value
Write-Host " IME: shown=$shown boundToUs=$bound cur=$curId"
if (-not $shown) { throw "soft keyboard did not appear" }
if (-not $bound) { throw "no InputConnection bound - our onCheckIsTextEditor/onCreateInputConnection path failed" }
Write-Host "=== 4. type through the IME, expect commitText -> v3 Text -> Windows ==="
$before = Read-Received
Assert-Foreground $hwnd "before IME text"
[void](Adb-Shell @("input", "text", $Text))
Start-Sleep -Milliseconds 1500
# Latin input in most Chinese IMEs commits without a candidate pick; if the IME
# instead left it composing, nudge with SPACE to force a commit.
$got = Read-Received
if ($got -notmatch [regex]::Escape($Text)) {
Write-Host " not committed yet (got [$got]); sending SPACE to accept candidate"
[void](Adb-Shell @("input", "keyevent", "62")) # KEYCODE_SPACE
Start-Sleep -Milliseconds 1500
$got = Read-Received
}
Write-Host " target received: [$got] (expected to contain [$Text])"
$diag = [regex]::Match((Get-PhoneUiText), 'text="([^"]*RTT=[^"]*)"').Groups[1].Value
Write-Host " phone status: $diag"
if ($got -notmatch [regex]::Escape($Text)) {
throw "IME COMMIT FAIL: nothing from the IME reached the Windows target"
}
Write-Host " PASS: real IME -> RemoteInputConnection -> v3 Text -> Windows window"
Write-Host "=== 5. back key must close keyboard first, not the session ==="
[void](Adb-Shell @("input", "keyevent", "4"))
Start-Sleep -Seconds 2
$ime2 = (Adb-Shell @("dumpsys", "input_method")).out
$xml2 = Get-PhoneUiText
$stillLive = ($xml2 -match "RTT=")
$kbHidden = ($ime2 -match "mInputShown=false")
Write-Host " after BACK: session live=$stillLive keyboard hidden=$kbHidden"
if (-not ($kbHidden -and $stillLive)) { throw "BACK did not behave as keyboard-first-then-session" }
Write-Host " PASS: first BACK dismissed the keyboard, session kept"
Write-Host "=== 6. cleanup ==="
New-Item -ItemType File -Force -Path $stopFile | Out-Null
Start-Sleep -Milliseconds 500
Write-Host "=== IME COMMIT PROOF COMPLETE ==="

23
tools/shot.ps1 Normal file
View File

@ -0,0 +1,23 @@
# Byte-safe device screenshot helper.
# ASCII-only. PS 5.1 `>` redirection writes UTF-16, which corrupts PNG bytes
# (measured 2026-10-30: `adb exec-out screencap -p > x.png` produced a file
# starting with FF FE). Capture on-device and pull instead.
param(
[Parameter(Mandatory = $true)][string]$Out,
[string]$Device = "/sdcard/shot.png"
)
$ErrorActionPreference = "Stop"
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
$old = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
& $adb shell screencap -p $Device | Out-Null
if ($LASTEXITCODE -ne 0) { throw "screencap failed ($LASTEXITCODE)" }
& $adb pull $Device $Out | Out-Null
if ($LASTEXITCODE -ne 0) { throw "pull failed ($LASTEXITCODE)" }
}
finally { $ErrorActionPreference = $old }
$b = [System.IO.File]::ReadAllBytes($Out)
$sig = ($b[0..3] | ForEach-Object { $_.ToString("X2") }) -join " "
if ($sig -ne "89 50 4E 47") { throw "not a PNG (sig=$sig): $Out" }
Write-Host ("OK {0} ({1:N0} bytes)" -f (Split-Path $Out -Leaf), $b.Length)