2026-10-30 00:58:36 -04:00
|
|
|
# 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<n><hwnd>": 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, '<node[^>]*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")) }
|
2026-10-30 23:08:16 -04:00
|
|
|
# Type the SAME host this run was given. A hardcoded literal broke this
|
|
|
|
|
# proof on 2026-10-31 when the dev box's LAN address changed
|
|
|
|
|
# (192.168.1.180 -> 192.168.1.110): the form dialed the stale address,
|
|
|
|
|
# timed out, and it looked like a product failure when the harness was
|
|
|
|
|
# simply out of date. Digits are KEYCODE_0..9 = 7..16, '.' = 56.
|
|
|
|
|
$ipKeys = @()
|
|
|
|
|
foreach ($ch in $PhoneIp.ToCharArray()) {
|
|
|
|
|
if ($ch -eq '.') { $ipKeys += 56 }
|
|
|
|
|
elseif ($ch -ge '0' -and $ch -le '9') { $ipKeys += (7 + [int][string]$ch) }
|
|
|
|
|
else { throw "unsupported character in PhoneIp: $ch" }
|
2026-10-30 00:58:36 -04:00
|
|
|
}
|
2026-10-30 23:08:16 -04:00
|
|
|
foreach ($k in $ipKeys) { [void](Adb-Shell @("input", "keyevent", "$k")) }
|
2026-10-30 00:58:36 -04:00
|
|
|
[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
|
2026-10-30 01:31:28 -04:00
|
|
|
# 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, '<node[^>]*>')) {
|
|
|
|
|
$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".
|
2026-10-30 00:58:36 -04:00
|
|
|
Write-Host " form now: $($vals -join ' | ')"
|
2026-10-30 01:31:28 -04:00
|
|
|
if ($vals.Count -lt 3) { throw "expected 3 fields after typing, got $($vals.Count)" }
|
2026-10-30 23:08:16 -04:00
|
|
|
if ($vals[0] -ne $PhoneIp) { throw "V4 FAIL: IP field is [$($vals[0])], expected [$PhoneIp]" }
|
2026-10-30 00:58:36 -04:00
|
|
|
$btn = [regex]::Match($xml3, '<node[^>]*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 ==="
|