test(android): capture read-only acceptance evidence
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
[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
|
||||
)
|
||||
|
||||
$output = & $script:resolvedAdbPath @Arguments 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
$text = ($output | Out-String).Trim()
|
||||
if ($exitCode -ne 0) {
|
||||
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
|
||||
)
|
||||
return (($Text -split "`r?`n") | Where-Object { $_ -match $Pattern } | Select-Object -First 1).Trim()
|
||||
}
|
||||
|
||||
$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
|
||||
$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($_) })
|
||||
$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"
|
||||
$loginLabel = -join @(
|
||||
[char]0x624B,
|
||||
[char]0x673A,
|
||||
[char]0x53F7,
|
||||
[char]0x767B,
|
||||
[char]0x5F55
|
||||
)
|
||||
$loginVisible = $visibleTexts -contains $loginLabel
|
||||
|
||||
$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
|
||||
resumedActivity = $resumedActivity
|
||||
}
|
||||
screen = [ordered]@{
|
||||
privacyDialogTitleVisible = $privacyTitleVisible
|
||||
privacyRejectVisible = $privacyRejectVisible
|
||||
privacyConsentVisible = $privacyConsentVisible
|
||||
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",
|
||||
"- Privacy dialog: title=$privacyTitleVisible, reject=$privacyRejectVisible, consent=$privacyConsentVisible",
|
||||
"- 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 "privacy_dialog=title:$privacyTitleVisible reject:$privacyRejectVisible consent:$privacyConsentVisible login:$loginVisible"
|
||||
Write-Output "historical_crash_or_anr=$crashOrAnrDetected"
|
||||
foreach ($permissionName in $permissionNames) {
|
||||
Write-Output "permission_$permissionName=$($permissionStates[$permissionName])"
|
||||
}
|
||||
Reference in New Issue
Block a user