[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('' $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])" }