306 lines
12 KiB
PowerShell
306 lines
12 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
|
|
)
|
|
|
|
$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])"
|
|
}
|