# 2b full-chain proof (phone side): Android key -> v3 Text channel -> agent # SendInput -> dedicated Windows target window, read back from that window. # ASCII-only source (PS 5.1 + BOM-less = GBK corruption; see personal lessons). # State checks use ASCII markers ("RTT=" / EditText presence) rather than # matching the app's Chinese labels, for the same reason. # # Why ONE atomic script: SendInput lands on the FOREGROUND window, and Windows' # foreground lock means a background process cannot simply reclaim it. This # box's interactive GUI steals foreground between separate tool calls, so # launch -> assert foreground -> drive phone -> read back must run without # yielding. Every injection is preceded by a foreground assertion: if the target # lost foreground we ABORT rather than inject into some other application. # # Prereqs: vivo device online via adb, app installed, v3 agent build present. # Usage: # powershell -ExecutionPolicy Bypass -File tools\phone_chain_proof.ps1 param( [string]$PhoneIp = "192.168.1.180", [int]$Port = 47800, [string]$Code = "t" ) $ErrorActionPreference = "Stop" $repo = (Resolve-Path "$PSScriptRoot\..").Path $adb = "D:\working\android\sdk\platform-tools\adb.exe" $tmp = "$env:TEMP\remote-phone-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 FgApi { [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); } "@ function Adb { param([Parameter(Mandatory)][string[]]$AdbArgs) # adb writes progress ("1 file pulled...") to stderr; PS 5.1 with # $ErrorActionPreference=Stop turns every stderr line into a terminating # error. Same workaround build-android.ps1 documents: relax for native # calls, judge by exit code and captured stdout. $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) { # Bounded wait, not a single instant check: the target re-asserts itself # every 60ms in proof mode, so a momentary steal by the interactive desktop # is recoverable. Still refuse to inject if it never comes back - a stray # injection lands in whatever window DOES hold foreground. $deadline = (Get-Date).AddMilliseconds(1500) while ((Get-Date) -lt $deadline) { if ([FgApi]::GetForegroundWindow() -eq $hwnd) { return } Start-Sleep -Milliseconds 30 } throw "$where : foreground=$([FgApi]::GetForegroundWindow()) want=$hwnd after 1.5s - refusing to inject" } function Read-Received { Wait-File $resultFile 8000 $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 } 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 Wait-For-LiveSession([int]$Seconds, [ref]$outXml) { $deadline = (Get-Date).AddSeconds($Seconds) while ((Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 900 $x = Get-PhoneUiText if ($x -match "RTT=") { $outXml.Value = $x; return $true } } $outXml.Value = (Get-PhoneUiText) return $false } Write-Host "=== 0. stale-agent guard (same rule as the desktop proof) ===" $agentExe = "$repo\target\release\agent.exe" $ag = Get-Process agent -ErrorAction SilentlyContinue if ($ag -and (Get-Item $agentExe).LastWriteTime -gt $ag.StartTime) { Write-Host " running agent predates current build; restarting it" 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 Write-Host " agent started (v3)" } Write-Host "=== 1. launch target window (own process, empty field) ===" # Retire leftovers from earlier runs WITHOUT killing them. Killing a foreground # window hands foreground to the next app (this box's interactive GUI), and the # foreground lock then refuses a brand-new background process any claim - the # new target could never take foreground back. # Signal only stop files that ALREADY EXIST: creating a fresh one here would # hit the stop path of the run we are about to start, and that target would # close itself the moment it launched (seen 2026-10-30: pid reported then gone). $existingFlags = @(Get-ChildItem -Path $env:TEMP -Recurse -Filter "stop.flag" -ErrorAction SilentlyContinue | Where-Object { $_.FullName -like "*remote*proof*" }) if ($existingFlags.Count -gt 0) { Write-Host " retiring $($existingFlags.Count) earlier target(s) via their own stop file" foreach ($f in $existingFlags) { (Get-Item $f.FullName).LastWriteTime = Get-Date } Start-Sleep -Seconds 3 } Start-Process powershell.exe -ArgumentList "-NoProfile","-ExecutionPolicy","Bypass",` "-File","$repo\tools\text_target.ps1","-PidFile",$pidFile,` "-ResultFile",$resultFile,"-StopFile",$stopFile,"-GrabForeground" Wait-File $pidFile # pid file carries "pid": the target writes its own handle once the # form is realized, which removes the Get-Process MainWindowHandle race. $parts = @((Get-Content $pidFile -Raw) -split "`n") $tid = [int]$parts[0].Trim() $hwnd = [IntPtr][long]$parts[1].Trim() if ($hwnd -eq 0) { throw "target reported a null window handle" } Assert-Foreground $hwnd "after launch" Write-Host " target pid=$tid hwnd=$($hwnd.ToInt64()) foreground=yes" Write-Host "=== 2. start app, connect via intent extras ===" [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)) $xmlRef = "" if (-not (Wait-For-LiveSession 25 ([ref]$xmlRef))) { throw "phone never reported a live session (no RTT marker)" } $script:xml = $xmlRef Write-Host " phone: $([regex]::Match($script:xml,'text="([^"]*RTT=[^"]*)"').Groups[1].Value)" Assert-Foreground $hwnd "after connect" Write-Host "=== 3. phone keys A B C -> Activity.onKeyDown -> v3 Text channel ===" # 29/30/31 = KEYCODE_A/B/C. adb-injected keys arrive with a printable # unicodeChar, so the app routes them to nativeSendText (IME hidden). That is # the product path introduced in 2b, not a stand-in for it. foreach ($k in 29,30,31) { Assert-Foreground $hwnd "before keyevent $k" [void](Adb-Shell @("input", "keyevent", "$k")) Start-Sleep -Milliseconds 250 } $deadline = (Get-Date).AddSeconds(10) $got = "" while ((Get-Date) -lt $deadline) { $got = Read-Received if ($got -eq "abc") { break } Start-Sleep -Milliseconds 300 } Write-Host " target received: [$got]" if ($got -ne "abc") { Write-Host " foreground now: $([FgApi]::GetForegroundWindow()) target: $hwnd" throw "FULL-CHAIN FAIL: expected [abc] got [$got]" } Write-Host " PASS: Android key -> v3 Text -> agent KEYEVENTF_UNICODE -> Windows window" Write-Host "=== 4. phone ENTER (66): control key uses the scancode channel ===" # Enter carries no printable unicodeChar, so it must go down the WinScancode # path. Observable acceptance: session survives and keeps streaming (a # single-line TextBox swallows Enter, so glyph-level proof is not possible). Assert-Foreground $hwnd "before ENTER" [void](Adb-Shell @("input", "keyevent", "66")) Start-Sleep -Milliseconds 900 $xmlRef = "" if (-not (Wait-For-LiveSession 6 ([ref]$xmlRef))) { throw "session died after ENTER" } Write-Host " session alive: $([regex]::Match($xmlRef,'text="([^"]*RTT=[^"]*)"').Groups[1].Value)" Write-Host "=== 5. phone BACK (4): disconnect and return to form ===" [void](Adb-Shell @("input", "keyevent", "4")) Start-Sleep -Seconds 2 $xml2 = Get-PhoneUiText if ($xml2 -match "RTT=") { throw "BACK did not disconnect (still live session)" } if ($xml2 -notmatch "android\.widget\.EditText") { throw "BACK did not return to the form" } Write-Host " PASS: disconnected, form visible again" Write-Host "=== 6. connect again FROM THE FORM (V4 through real UI, not intent) ===" # Form typing uses keyevent, not 'adb input text': with the Chinese IME active, # text injection gets composed (ASCII '.' becomes U+3002). We disable the IME, # restore it in finally, and never assume a Chinese IME is absent. $origIme = (Adb-Shell @("settings", "get", "secure", "default_input_method")).out.Trim() Write-Host " original IME: [$origIme]" if ($origIme -and $origIme -ne "null") { [void](Adb-Shell @("ime", "disable", $origIme)) } try { $xml3 = Get-PhoneUiText $fields = [regex]::Matches($xml3, ']*class="android\.widget\.EditText"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"') if ($fields.Count -lt 3) { throw "expected 3 form fields, found $($fields.Count)" } function Center($m) { $a = [int]$m.Groups[1].Value; $b = [int]$m.Groups[2].Value $c = [int]$m.Groups[3].Value; $d = [int]$m.Groups[4].Value return @(([int](($a + $c) / 2)), ([int](($b + $d) / 2))) } $ipC = Center $fields[0]; $codeC = Center $fields[2] # KEYCODE_0=7..KEYCODE_9=16, PERIOD=56, DEL=67, T=48 (A=29 -> T=29+19; # all from javap against android-34, not memory) [void](Adb-Shell @("input", "tap", "$($ipC[0])", "$($ipC[1])")) Start-Sleep -Milliseconds 400 for ($i = 0; $i -lt 24; $i++) { [void](Adb-Shell @("input", "keyevent", "67")) } foreach ($k in @(8,16,9,56,8,13,15,56,8,56,8,15,7)) { # 192.168.1.180 [void](Adb-Shell @("input", "keyevent", "$k")) } [void](Adb-Shell @("input", "tap", "$($codeC[0])", "$($codeC[1])")) Start-Sleep -Milliseconds 400 for ($i = 0; $i -lt 6; $i++) { [void](Adb-Shell @("input", "keyevent", "67")) } [void](Adb-Shell @("input", "keyevent", "48")) $xml3 = Get-PhoneUiText # Attribute order in uiautomator dumps is text= ... class= ... bounds=; # matching "class=...text=" silently finds nothing (seen 2026-10-30: printed # an empty form line). Parse per-node and pull attributes independently. $vals = @() foreach ($n in [regex]::Matches($xml3, ']*>')) { $v = $n.Value if ($v -match 'class="android\.widget\.EditText"') { $vals += [regex]::Match($v, 'text="([^"]*)"').Groups[1].Value } } # NOTE: an EMPTY EditText reports its hint as text (verified: text="被控端 IP"), # so "field text == typed value" is the assertion, not "field text non-empty". Write-Host " form now: $($vals -join ' | ')" if ($vals.Count -lt 3) { throw "expected 3 fields after typing, got $($vals.Count)" } if ($vals[0] -ne "192.168.1.180") { throw "V4 FAIL: IP field is [$($vals[0])], typing did not land" } $btn = [regex]::Match($xml3, ']*class="android\.widget\.Button"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"') if (-not $btn.Success) { throw "connect button not found" } $bc = Center $btn Assert-Foreground $hwnd "before form connect" [void](Adb-Shell @("input", "tap", "$($bc[0])", "$($bc[1])")) $xmlRef = "" if (-not (Wait-For-LiveSession 25 ([ref]$xmlRef))) { $shown = [regex]::Matches($xmlRef, 'text="([^"]+)"') | ForEach-Object { $_.Groups[1].Value } throw "V4 FAIL: form connect did not reach a live session. screen=[$($shown -join ' | ')]" } Write-Host " V4 PASS via form UI: $([regex]::Match($xmlRef,'text="([^"]*RTT=[^"]*)"').Groups[1].Value)" } finally { if ($origIme -and $origIme -ne "null") { [void](Adb-Shell @("ime", "enable", $origIme)) [void](Adb-Shell @("ime", "set", $origIme)) Write-Host " IME restored: $((Adb-Shell @("settings", "get", "secure", "default_input_method")).out.Trim())" } } Write-Host "=== 7. stop target ===" New-Item -ItemType File -Force -Path $stopFile | Out-Null Start-Sleep -Milliseconds 600 Write-Host "=== PHONE CHAIN PROOF COMPLETE ==="