Files
prop-ai-hr/scripts/capture-android-acceptance.ps1
T

354 lines
15 KiB
PowerShell

[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Za-z0-9._:-]+$')]
[string]$Serial,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')]
[string]$Stage,
[ValidatePattern('^[A-Za-z][A-Za-z0-9_.]+$')]
[string]$PackageName = 'com.yincheng.wygj',
[string]$AdbPath,
[string]$OutputRoot
)
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
$allowedOutputRoot = [IO.Path]::GetFullPath((Join-Path $projectRoot 'output'))
function Resolve-AdbPath {
param([string]$RequestedPath)
if (-not [string]::IsNullOrWhiteSpace($RequestedPath)) {
return (Resolve-Path -LiteralPath $RequestedPath).Path
}
$command = Get-Command adb -ErrorAction SilentlyContinue
if ($null -ne $command) {
return $command.Source
}
$candidates = @()
if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
$candidates += (Join-Path $env:LOCALAPPDATA 'Android\Sdk\platform-tools\adb.exe')
}
if (-not [string]::IsNullOrWhiteSpace($env:ANDROID_HOME)) {
$candidates += (Join-Path $env:ANDROID_HOME 'platform-tools\adb.exe')
}
if (-not [string]::IsNullOrWhiteSpace($env:ANDROID_SDK_ROOT)) {
$candidates += (Join-Path $env:ANDROID_SDK_ROOT 'platform-tools\adb.exe')
}
foreach ($candidate in $candidates) {
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
return (Resolve-Path -LiteralPath $candidate).Path
}
}
throw 'adb was not found. Provide Android platform-tools or pass -AdbPath.'
}
function Invoke-AdbText {
param(
[Parameter(Mandatory = $true)]
[string[]]$Arguments,
[switch]$AllowEmpty,
[switch]$AllowNonZero
)
$output = & $script:resolvedAdbPath @Arguments 2>&1
$exitCode = $LASTEXITCODE
$text = ($output | Out-String).Trim()
if ($exitCode -ne 0 -and -not $AllowNonZero) {
throw "adb failed with exit code $exitCode`: $($Arguments -join ' ')`n$text"
}
if (-not $AllowEmpty -and [string]::IsNullOrWhiteSpace($text)) {
throw "adb returned no output: $($Arguments -join ' ')"
}
return $text
}
function ConvertTo-ProcessArgument {
param([string]$Value)
if ($Value -notmatch '[\s"]') {
return $Value
}
return '"' + ($Value -replace '(\\*)"', '$1$1\"' -replace '(\\+)$', '$1$1') + '"'
}
function Save-AdbScreenshot {
param([string]$Destination)
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = $script:resolvedAdbPath
$startInfo.Arguments = (@('-s', $Serial, 'exec-out', 'screencap', '-p') |
ForEach-Object { ConvertTo-ProcessArgument $_ }) -join ' '
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
$process = New-Object System.Diagnostics.Process
$process.StartInfo = $startInfo
if (-not $process.Start()) {
throw 'Unable to start adb screenshot capture.'
}
$stream = [IO.File]::Open($Destination, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
try {
$process.StandardOutput.BaseStream.CopyTo($stream)
}
finally {
$stream.Dispose()
}
$errorText = $process.StandardError.ReadToEnd()
$process.WaitForExit()
if ($process.ExitCode -ne 0) {
throw "adb screenshot capture failed with exit code $($process.ExitCode): $errorText"
}
if ((Get-Item -LiteralPath $Destination).Length -lt 8) {
throw 'adb screenshot capture produced an empty file.'
}
}
function Get-FirstMatchingLine {
param(
[string]$Text,
[string]$Pattern
)
$line = ($Text -split "`r?`n") | Where-Object { $_ -match $Pattern } | Select-Object -First 1
if ($null -eq $line) {
return ''
}
return $line.Trim()
}
function ConvertFrom-CodePoints {
param([int[]]$CodePoints)
return -join @($CodePoints | ForEach-Object { [char]$_ })
}
$script:resolvedAdbPath = Resolve-AdbPath -RequestedPath $AdbPath
$basePath = if ([string]::IsNullOrWhiteSpace($OutputRoot)) {
Join-Path $allowedOutputRoot 'aug1-rc\android-acceptance'
}
elseif ([IO.Path]::IsPathRooted($OutputRoot)) {
$OutputRoot
}
else {
Join-Path $projectRoot $OutputRoot
}
$resolvedBasePath = [IO.Path]::GetFullPath($basePath)
$allowedPrefix = $allowedOutputRoot.TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar
if (-not $resolvedBasePath.StartsWith($allowedPrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw "Acceptance evidence must stay under $allowedOutputRoot"
}
$deviceState = Invoke-AdbText -Arguments @('-s', $Serial, 'get-state')
if ($deviceState -ne 'device') {
throw "Android target $Serial is not ready: $deviceState"
}
$packageDump = Invoke-AdbText -Arguments @('-s', $Serial, 'shell', 'dumpsys', 'package', $PackageName)
$versionMatch = [regex]::Match($packageDump, '(?m)^\s*versionName=(\S+)')
$sdkMatch = [regex]::Match($packageDump, '(?m)^\s*versionCode=(\d+)\s+minSdk=(\d+)\s+targetSdk=(\d+)')
if (-not $versionMatch.Success -or -not $sdkMatch.Success) {
throw "Package $PackageName is not present or its version metadata is unreadable."
}
$timestamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss')
$evidenceDirectory = Join-Path $resolvedBasePath "$timestamp-$Stage"
if (Test-Path -LiteralPath $evidenceDirectory) {
throw "Evidence directory already exists: $evidenceDirectory"
}
New-Item -ItemType Directory -Path $evidenceDirectory | Out-Null
$activityDump = Invoke-AdbText -Arguments @('-s', $Serial, 'shell', 'dumpsys', 'activity', 'activities')
$resumedActivity = Get-FirstMatchingLine -Text $activityDump -Pattern 'topResumedActivity=|mResumedActivity:'
$processId = Invoke-AdbText -Arguments @('-s', $Serial, 'shell', 'pidof', $PackageName) -AllowEmpty -AllowNonZero
$processRunning = -not [string]::IsNullOrWhiteSpace($processId)
$appForeground = $resumedActivity -match [regex]::Escape($PackageName)
$exitInfo = Invoke-AdbText -Arguments @('-s', $Serial, 'shell', 'dumpsys', 'activity', 'exit-info', $PackageName)
$crashOrAnrDetected = $exitInfo -match '(?m)reason=\d+\s+\((?:CRASH|CRASH_NATIVE|ANR)\)'
$uiDumpOutput = Invoke-AdbText -Arguments @('-s', $Serial, 'exec-out', 'uiautomator', 'dump', '/dev/tty')
$xmlStart = $uiDumpOutput.IndexOf('<?xml')
$xmlEndMarker = '</hierarchy>'
$xmlEnd = $uiDumpOutput.IndexOf($xmlEndMarker, [StringComparison]::Ordinal)
if ($xmlStart -lt 0 -or $xmlEnd -lt $xmlStart) {
throw 'Unable to extract Android UI hierarchy XML.'
}
$uiXml = $uiDumpOutput.Substring($xmlStart, $xmlEnd - $xmlStart + $xmlEndMarker.Length)
$uiDocument = [xml]$uiXml
$visibleTexts = @($uiDocument.SelectNodes('//node') |
ForEach-Object { [string]$_.GetAttribute('text') } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
$resourceIds = @($uiDocument.SelectNodes('//node') |
ForEach-Object { [string]$_.GetAttribute('resource-id') } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
$uiPackages = @($uiDocument.SelectNodes('//node') |
ForEach-Object { [string]$_.GetAttribute('package') } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
Select-Object -Unique)
$privacyTitleVisible = $resourceIds -contains "$PackageName`:id/tv_custom_privacy_title"
$privacyRejectVisible = $resourceIds -contains "$PackageName`:id/btn_custom_privacy_cancel"
$privacyConsentVisible = $resourceIds -contains "$PackageName`:id/btn_custom_privacy_sure"
$firstPrivacyTitle = ConvertFrom-CodePoints @(
0x670D, 0x52A1, 0x534F, 0x8BAE, 0x548C, 0x9690, 0x79C1, 0x653F, 0x7B56
)
$secondPrivacyTitle = ConvertFrom-CodePoints @(0x786E, 0x8BA4, 0x63D0, 0x793A)
$firstRejectLabel = ConvertFrom-CodePoints @(0x6682, 0x4E0D, 0x540C, 0x610F)
$exitLabel = ConvertFrom-CodePoints @(0x9000, 0x51FA, 0x5E94, 0x7528)
$consentLabel = ConvertFrom-CodePoints @(0x540C, 0x610F, 0x5E76, 0x7EE7, 0x7EED)
$loginLabel = ConvertFrom-CodePoints @(0x624B, 0x673A, 0x53F7, 0x767B, 0x5F55)
$firstPrivacyTitleVisible = $visibleTexts -contains $firstPrivacyTitle
$secondPrivacyTitleVisible = $visibleTexts -contains $secondPrivacyTitle
$firstRejectLabelVisible = $visibleTexts -contains $firstRejectLabel
$exitLabelVisible = $visibleTexts -contains $exitLabel
$consentLabelVisible = $visibleTexts -contains $consentLabel
$loginVisible = $visibleTexts -contains $loginLabel
$privacyDialogStage = if ($secondPrivacyTitleVisible -or $exitLabelVisible) {
'exit-confirmation'
}
elseif ($firstPrivacyTitleVisible -or $firstRejectLabelVisible) {
'first-choice'
}
elseif ($privacyTitleVisible -or $privacyRejectVisible -or $privacyConsentVisible) {
'unknown-privacy-dialog'
}
else {
'not-visible'
}
$permissionNames = @(
'android.permission.CAMERA',
'android.permission.RECORD_AUDIO',
'android.permission.READ_MEDIA_IMAGES',
'android.permission.READ_MEDIA_VIDEO'
)
$permissionStates = [ordered]@{}
$permissionEvidenceLines = @()
foreach ($permissionName in $permissionNames) {
$permissionMatches = [regex]::Matches(
$packageDump,
'(?m)^\s*' + [regex]::Escape($permissionName) + ':\s+granted=(true|false).*$'
)
if ($permissionMatches.Count -eq 0) {
$permissionStates[$permissionName] = 'not-declared-or-unreadable'
continue
}
$permissionMatch = $permissionMatches[$permissionMatches.Count - 1]
$permissionStates[$permissionName] = [bool]::Parse($permissionMatch.Groups[1].Value)
$permissionEvidenceLines += $permissionMatch.Value.Trim()
}
$device = [ordered]@{
serial = $Serial
manufacturer = Invoke-AdbText -Arguments @('-s', $Serial, 'shell', 'getprop', 'ro.product.manufacturer') -AllowEmpty
model = Invoke-AdbText -Arguments @('-s', $Serial, 'shell', 'getprop', 'ro.product.model') -AllowEmpty
androidRelease = Invoke-AdbText -Arguments @('-s', $Serial, 'shell', 'getprop', 'ro.build.version.release')
apiLevel = [int](Invoke-AdbText -Arguments @('-s', $Serial, 'shell', 'getprop', 'ro.build.version.sdk'))
displaySize = Invoke-AdbText -Arguments @('-s', $Serial, 'shell', 'wm', 'size')
displayDensity = Invoke-AdbText -Arguments @('-s', $Serial, 'shell', 'wm', 'density')
}
$screenshotPath = Join-Path $evidenceDirectory 'screen.png'
Save-AdbScreenshot -Destination $screenshotPath
$uiPath = Join-Path $evidenceDirectory 'window.xml'
$exitInfoPath = Join-Path $evidenceDirectory 'process-exit-info.txt'
$permissionPath = Join-Path $evidenceDirectory 'permissions.txt'
$activityPath = Join-Path $evidenceDirectory 'activity.txt'
[IO.File]::WriteAllText($uiPath, $uiXml, (New-Object Text.UTF8Encoding($false)))
[IO.File]::WriteAllText($exitInfoPath, $exitInfo + [Environment]::NewLine, (New-Object Text.UTF8Encoding($false)))
[IO.File]::WriteAllText($permissionPath, ($permissionEvidenceLines -join [Environment]::NewLine) + [Environment]::NewLine, (New-Object Text.UTF8Encoding($false)))
[IO.File]::WriteAllText($activityPath, $resumedActivity + [Environment]::NewLine, (New-Object Text.UTF8Encoding($false)))
$evidence = [ordered]@{
capturedAt = (Get-Date).ToUniversalTime().ToString('o')
stage = $Stage
captureMode = 'read-only-current-state'
warning = 'screen.png and window.xml may contain information visible on the device'
device = $device
app = [ordered]@{
packageName = $PackageName
versionName = $versionMatch.Groups[1].Value
versionCode = [int64]$sdkMatch.Groups[1].Value
minSdkVersion = [int]$sdkMatch.Groups[2].Value
targetSdkVersion = [int]$sdkMatch.Groups[3].Value
processId = $processId
processRunning = $processRunning
resumedActivity = $resumedActivity
foreground = $appForeground
}
screen = [ordered]@{
appUiVisible = $uiPackages -contains $PackageName
privacyDialogStage = $privacyDialogStage
privacyDialogTitleVisible = $privacyTitleVisible
privacyRejectVisible = $privacyRejectVisible
privacyConsentVisible = $privacyConsentVisible
firstPrivacyTitleVisible = $firstPrivacyTitleVisible
secondPrivacyTitleVisible = $secondPrivacyTitleVisible
firstRejectLabelVisible = $firstRejectLabelVisible
exitLabelVisible = $exitLabelVisible
consentLabelVisible = $consentLabelVisible
loginVisible = $loginVisible
}
permissions = $permissionStates
historicalCrashOrAnrDetected = $crashOrAnrDetected
files = [ordered]@{
screenshot = 'screen.png'
uiHierarchy = 'window.xml'
permissions = 'permissions.txt'
activity = 'activity.txt'
processExitInfo = 'process-exit-info.txt'
}
}
$jsonPath = Join-Path $evidenceDirectory 'acceptance-evidence.json'
$markdownPath = Join-Path $evidenceDirectory 'acceptance-evidence.md'
$json = $evidence | ConvertTo-Json -Depth 8
[IO.File]::WriteAllText($jsonPath, $json + [Environment]::NewLine, (New-Object Text.UTF8Encoding($false)))
$permissionSummary = $permissionStates.GetEnumerator() |
ForEach-Object { "- $($_.Key): $($_.Value)" }
$markdown = @(
'# Android acceptance evidence',
'',
"- Captured at: $($evidence.capturedAt)",
"- Stage: $Stage",
"- Device: $($device.manufacturer) $($device.model), Android $($device.androidRelease) / API $($device.apiLevel)",
"- App: $PackageName $($evidence.app.versionName) ($($evidence.app.versionCode)), target API $($evidence.app.targetSdkVersion)",
"- Resumed activity: $resumedActivity",
"- App process running: $processRunning",
"- App foreground: $appForeground",
"- Privacy dialog stage: $privacyDialogStage",
"- Privacy controls: title=$privacyTitleVisible, reject=$privacyRejectVisible, consent=$privacyConsentVisible",
"- Privacy labels: firstReject=$firstRejectLabelVisible, exit=$exitLabelVisible, consent=$consentLabelVisible",
"- Login visible: $loginVisible",
"- Historical crash or ANR found: $crashOrAnrDetected",
'',
'## Sensitive permissions',
'',
$permissionSummary,
'',
'> This script only captured the current device state. It did not make a privacy choice or alter app/package permissions.'
) -join [Environment]::NewLine
[IO.File]::WriteAllText($markdownPath, $markdown + [Environment]::NewLine, (New-Object Text.UTF8Encoding($false)))
Write-Output "android_acceptance_evidence=$evidenceDirectory"
Write-Output "device=$Serial Android/$($device.apiLevel)"
Write-Output "app=$PackageName $($evidence.app.versionName) ($($evidence.app.versionCode)) targetSdk=$($evidence.app.targetSdkVersion)"
Write-Output "resumed_activity=$resumedActivity"
Write-Output "app_state=process:$processRunning foreground:$appForeground ui:$($uiPackages -contains $PackageName)"
Write-Output "privacy_dialog=stage:$privacyDialogStage title:$privacyTitleVisible reject:$privacyRejectVisible consent:$privacyConsentVisible firstRejectLabel:$firstRejectLabelVisible exitLabel:$exitLabelVisible login:$loginVisible"
Write-Output "historical_crash_or_anr=$crashOrAnrDetected"
foreach ($permissionName in $permissionNames) {
Write-Output "permission_$permissionName=$($permissionStates[$permissionName])"
}