test(android): capture read-only acceptance evidence

This commit is contained in:
key
2026-07-29 03:22:19 +08:00
parent feece9bb74
commit c3e0789f29
6 changed files with 365 additions and 2 deletions
+2 -1
View File
@@ -5,7 +5,8 @@
## Unreleased
- **2026-07-28 至 2026-07-29,8 月 1 日 Android 受控内测候选包与法务链路,当前总体 No-Go**:补齐登录页协议默认不勾选与法务地址失败关闭、App-Plus 原生隐私配置生成/校验、APK 包名/版本/API/签名/哈希/包内资源一致性/敏感值门禁、DCloud 云证书自定义基座和独立测试包流程;《服务协议》《隐私政策》静态页已部署到 `/legal/`,发布前整站备份为 `/opt/wygj/backups/www-20260728224710`。客户端法务校验已移除 App-Plus 不保证支持的浏览器 `URL` 构造器,测试验证码也不再以源码常量进入安装包;五个文件选择服务与九个消费页面进一步统一“用户取消/平台失败”分流,取消后不再残留上传或转写错误;直通车首次弱网加载失败后可原位重试,失败时清空旧通道并禁止提交。当前独立 APK `com.yincheng.wygj 0.1.10 (110)` 的 `targetSdkVersion=35`,SHA-256 为 `20CB16B4E9D024DBF5EEEF577BFBA3B4DF5E5E78B597BABB99CB7450C7AE5034`;Android 原生网络安全配置经 APK 反编译确认 `usesCleartextTraffic=false`、`networkSecurityConfig` 引用和系统证书信任配置均已进入安装包。本机 `Medium_Phone` Android 15/API 35 虚拟机已完成从 `0.1.9` 保留数据覆盖升级和冷启动,真实显示原生隐私弹窗,四项敏感权限保持未授权且未见 FATAL/ANR;`0.1.6` 已在 vivo V2443A(Android 15)完成覆盖安装和冷启动。代码候选提交 `dc20b920` 已在干净工作区通过统一 RC 门禁,`releaseEligible=true`:移动端 198 项测试、类型检查、H5/App 构建、管理端构建、`ruoyi-aihr` 698 项全量测试、后端打包及 APK 强化门禁全部通过;最小交付包 SHA-256 为 `4BAA79B2CA2C4D486B6194288D90022D5F994A4D8C8E05D7C2DDD87F9BF5B3B7`,解压后 9 个文件、其中 8 个载荷逐文件复验通过。只读生产预检确认根站、租户接口、移动首页和 64/64 schema 门禁通过,但生产后端模块、管理端和移动 H5 与本地候选仍不一致;用户隐私选择、权限反例、认证态核心业务和正式内容也未完成,故 Go/No-Go 为 **No-Go**。本次没有新增生产发布、服务重启、数据库或静态资源变更。
- **2026-07-29,发布预检支持定向后端/schema 范围,未发布**:`release-preflight.sh` 不再把远端后端或 schema 核验强制绑定到管理端/H5 静态匹配;定向后端发布可只启用 `RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true`,且只要求本地后端 JAR 新鲜。完整包口径仍必须同时启用远端静态、后端和 schema 三项。新增范围回归脚本,校验缺少 `RELEASE_REMOTE_URL` 时失败关闭、定向模式不回退为静态耦合、完整模式命令仍保留。提交 `566f222e` 后只重建后端并实跑生产只读定向预检:输出没有管理端/H5 哈希,根站、租户、移动首页、64/64 schema 和生产禁 DDL 门禁通过,最终仅因现网/候选 AIHR 模块哈希 `15f5513a…` / `c93460e8…` 不同而按预期失败;这证明门禁范围正确,不代表后端已经发布。本项没有执行生产同步、重启或数据库变更。
- **2026-07-29,发布预检支持定向后端/schema 范围和冻结物核验,未发布**:`release-preflight.sh` 不再把远端后端或 schema 核验强制绑定到管理端/H5 静态匹配;定向后端发布可只启用 `RELEASE_VERIFY_REMOTE_BACKEND=true RELEASE_VERIFY_REMOTE_SCHEMA=true`,且只要求本地后端 JAR 新鲜。完整包口径仍必须同时启用远端静态、后端和 schema 三项。新增范围回归脚本,校验缺少 `RELEASE_REMOTE_URL` 时失败关闭、定向模式不回退为静态耦合、完整模式命令仍保留;`feece9bb` 再新增 `RELEASE_ARTIFACT_COMMIT` 与 `RELEASE_LOCAL_BACKEND_PATH`,允许从后续文档提交上安全地核验冻结提交及其实际候选 JAR。提交 `566f222e` 后只重建后端并实跑生产只读定向预检:输出没有管理端/H5 哈希,根站、租户、移动首页、64/64 schema 和生产禁 DDL 门禁通过,最终仅因现网/候选 AIHR 模块哈希 `15f5513a…` / `c93460e8…` 不同而按预期失败。随后以 `dc20b920` 和发布 ZIP 解压 JAR 再跑后端只读预检,确认候选整包哈希 `8c676f2f…`,仍仅在同一模块差异处失败;这证明范围和冻结物选择正确,不代表后端已经发布。本项没有执行生产同步、重启或数据库变更。
- **2026-07-29,Android 人工验收新增只读统一取证**:新增 `capture-android-acceptance.ps1`,只读取指定设备当前包版本、前台 Activity、四项敏感权限、UI 层级、截图和应用退出记录,证据限定写入 Git 已忽略的 `output/`;静态回归拒绝安装、启动、停止、清数据、授权、撤权和输入类命令。脚本已在 `emulator-5554` 实跑,确认 `0.1.10 (110)` / API 35 的原生隐私标题与拒绝/同意按钮可见、手机号登录不可见、四项权限均未授权、退出记录无 Crash/ANR。本项只记录用户选择前状态,未代替用户点击隐私选项。
- **2026-07-29,8 月 1 日 APK 生产接口兼容性只读核验,未发布**:线上与候选 `ruoyi-aihr` 均含 819 个非目录/非 Manifest 条目,0 个增删、775 个逐字节相同、44 个不同;短信登录、移动首页、查/练/问、直通车和 ASR/TTS 对应的 9 个关键控制器 class 哈希逐一相同。未登录 GET 探针得到公开首页业务码 `200`、5 个鉴权入口 `401`、13 个仅 POST 入口 `405`,没有发送业务 POST 或写入数据。44 个差异仅来自 `AihrModelSeedService`、`AihrPracticeSeedService`、`AihrSopSeedService` 及内部类,内容是禁止兜底文案冒充已创建/派发工单、阻止含占位内容或不完整五维评分的场景发布;因此现网接口壳可承接 APK,但候选业务语义红线仍需授权后定向发布后端。管理端/H5 与 schema 不属于该定向后端发布;本次没有执行生产同步、重启或数据库变更。
- **2026-07-26,阿里云百炼 ASR 生产热修复**:`POST /api/ai/asr` 新增 `dashscope/qianwen + qwen3-asr-flash` 的 `/compatible-mode/v1/chat/completions` + Base64 `input_audio` 分支,原 SiliconFlow `/audio/transcriptions` multipart 路径继续兼容,ASR 超时放宽至 30 秒。定向 Maven 测试 10 项通过;后端 JAR(SHA-256 `46c99cff75d21f8536a71c3c058b6437e99dbf9ff4bae3542b44471f3f34ad84`)已发布,生产 ASR 已切换至 `dashscope/qwen3-asr-flash`,旧 `siliconflow/TeleAI/TeleSpeechASR` 已停用。生产接口获得正确转写与受控 `ossId`,Android HBuilderX 标准基座“按住说话”请求成功,线上日志无 403/ASR 异常。回滚目录为 `/opt/wygj/backups/asr-hotfix-20260726210122`;本次未发布静态资源、未变更 schema,也不代表完整包一致性或原生 App 全项验收。
- **2026-07-26,移动端编译链升级与 uni-ui 解析修复,未发布**:本机 HBuilderX GUI 已升级为 `5.22.2026071707`,CLI 为 `5.22.2026072503-alpha`;`mobile-uni` 的 App、App-Plus、H5 与 Vite 编译包统一到对应的 `3.0.0-alpha-5020220260725001` 批次,消除 5.15/5.21 混用产生的两套 `uni-cli-shared` 状态隔离,使登录页 `<uni-forms>`、`<uni-easyinput>` 等组件重新由 `pages.json` easycom 规则正常解析。H5 与 App 均以 `Compiler version: 5.22(vue3)` 构建通过;浏览器和 vivo V2443A(Android 15)真机均确认手机号、验证码标签及输入框正常。HBuilder 5.22 标准基座 `versionName=15.22` 已自动更新并成功运行,测试手机号配合固定验证码登录成功,可进入员工首页并切换“问”页,未出现编译器/SDK 版本不匹配弹窗或组件解析告警。升级后仍须重建并安装 5.22 自定义运行基座完成原生权限全清单;`verify:app-release` 当前因缺少含正式法务 HTTPS 链接的 `androidPrivacy.json` 按预期阻断。本项未发布生产 H5,也未云打包 APK/IPA,手机上的 `com.yincheng.wygj` 正式包未被覆盖。
+2
View File
@@ -318,6 +318,8 @@ RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true R
三个远端核验开关可以按实际发布单元独立组合,但只要启用任一开关就必须提供 `RELEASE_REMOTE_URL`。定向模式只要求对应的本地产物保持新鲜:静态核验要求管理端/H5,后端核验要求后端 JAR,单独 schema 核验不要求无关构建产物。默认以当前 `HEAD` 判断产物时间;冻结 RC 应同时设置 `RELEASE_ARTIFACT_COMMIT` 和 `RELEASE_LOCAL_BACKEND_PATH`,前者必须是当前 `HEAD` 的祖先,后者必须指向实际准备发布的 JAR,避免后续文档提交误伤冻结物或误用 `target` 下的其他构建。`RELEASE_VERIFY_REMOTE_BACKEND=true` 会打印整包 jar SHA-256 和 `ruoyi-aihr` 模块内容 SHA-256,实际匹配以模块内容 hash 为准;它不再隐式要求管理端/H5 静态资源匹配。只有同时启用 `RELEASE_VERIFY_REMOTE_MATCH=true`、`RELEASE_VERIFY_REMOTE_BACKEND=true` 和 `RELEASE_VERIFY_REMOTE_SCHEMA=true`,才能称为完整包匹配。远端后端默认核对 `/opt/wygj/app/ruoyi-admin.jar`,如发布路径不同可通过 `RELEASE_REMOTE_BACKEND_PATH` 覆盖;路径必须是安全的绝对路径。开启远端后端或 schema 核验时,预检还会只读检查 `wygj-aihr.service` 及其 `EnvironmentFile` 的有效配置,`AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP` 必须为 `false` 或未设置;无法读取引用的环境文件会失败关闭,而不会把本机环境变量当作线上证据。
Android 人工验收每完成一个步骤,用 `.\scripts\capture-android-acceptance.ps1 -Serial <adb-serial> -Stage <stage>` 保存当前状态;脚本只读取包版本、前台 Activity、权限、UI、截图和退出记录,不执行安装、启动、清数据、授权或点击。证据仅写入已忽略的 `output/aug1-rc/android-acceptance/`,详细口径见 `docs/MOBILE_APP_DEVICE_ACCEPTANCE.md`。
带 `RELEASE_REMOTE_URL` 时,预检同时校验租户接口 JSON 的业务 `code=200`;HTTP 200 但业务返回 401/405 会判定失败。
发布时必须保留 preflight 输出、远端备份目录和发布后浏览器回归结果;回滚优先使用对应备份目录恢复,再重启后端服务,不能直接覆盖当前线上目录而不留证据。
+13
View File
@@ -10,6 +10,16 @@
4. 每台设备保留系统版本、App `versionName/versionCode`、基座/包哈希、录屏或截图和失败日志。
5. HBuilderX/CLI 编译链必须与手机调试基座兼容;启动时出现版本不匹配弹窗即视为未通过。当前版本组合和排障命令见 `MOBILE_APP_PACKAGING.md`。
Android 每个人工步骤完成后,可在不改变设备状态的前提下保存统一证据。下列脚本只读取当前版本、前台 Activity、四项敏感权限、UI 层级、截图和应用退出记录;不会启动/停止应用、安装/卸载、清数据、授权/撤权或代替用户点击。`screen.png` 和 `window.xml` 可能包含屏幕可见信息,只会写入 Git 已忽略的 `output/`,不得转存手机号、验证码或业务原文到仓库:
```powershell
.\scripts\capture-android-acceptance.ps1 `
-Serial emulator-5554 `
-Stage privacy-before-choice
```
每次手动操作后换一个简短的 `-Stage`,例如 `privacy-rejected`、`privacy-consented`、`microphone-denied`。脚本不把“拍到页面”自动判成业务通过;验收人仍须结合预期、截图和 `acceptance-evidence.json` 签认。
## 当前证据边界
2026-07-26 已在 vivo V2443A(Android 15)的 HBuilderX 5.22 标准基座完成手机号登录,并通过生产 `dashscope/qwen3-asr-flash` 跑通“按住说话 → ASR → 提交”链路。2026-07-28 同一机型再次由 HBuilderX CLI `5.22.2026072503-alpha` 完成标准基座编译、同步和启动;未配置正式法务地址时,登录页协议默认未勾选并明确阻止验证码与登录,点击协议入口不会打开占位地址。
@@ -32,6 +42,8 @@
当前候选包已升级为 `0.1.10 (110)`,大小 `33,922,134` 字节,SHA-256 为 `20CB16B4E9D024DBF5EEEF577BFBA3B4DF5E5E78B597BABB99CB7450C7AE5034`。DCloud 云打包成功后,APK 元数据、测试签名、包内资源、法务链接、网络安全配置和敏感值门禁全部通过;同一虚拟机从 `0.1.9` 保留数据覆盖安装成功,Launcher 冷启动约 `1.271s`,显示最新原生隐私弹窗,四项敏感权限仍为 `granted=false`,未发现 FATAL 或 ANR。验收仍未代替用户点击拒绝或同意。
2026-07-29 又以新增的只读统一取证脚本复核同一虚拟机的当前状态:前台 Activity 为 `io.dcloud.PandoraEntryActivity`,原生隐私标题、“暂不同意”和“同意并继续”三个控件均可见,手机号登录尚不可见;相机、录音、图片和视频权限仍为 `false`,应用退出记录中未出现 Crash/ANR。截图、UI、权限、Activity、退出记录和 JSON 均落在忽略目录 `output/aug1-rc/android-acceptance/20260728-192056-privacy-before-choice/`。该次只记录选择前基线,仍未代替用户做隐私选择。
## 已完成的 Android 基座证据
- [x] 自定义基座包名、版本、API 35、单签名、v2 签名和 SHA-256 可重复校验。
@@ -44,6 +56,7 @@
- [x] `0.1.8 (108)` 在本机 Android 15/API 35 虚拟机完成卸载重装和冷启动,最新包原生隐私弹窗真实显示,四项敏感权限保持未授权、网络安全配置已进入 APK 且未见崩溃。
- [x] `0.1.8 (108)` 在同一虚拟机完成保留数据覆盖安装、标准 Launcher 启动和首次隐私弹窗前的后台/前台恢复,未见 FATAL 或 ANR。
- [x] `0.1.10 (110)` 从 `0.1.9` 保留数据覆盖安装并冷启动成功,最新原生隐私弹窗、未授权权限基线和无 FATAL/ANR 状态保持。
- [x] 只读统一取证脚本已通过状态变更命令回归并在虚拟机实跑,可重复保存当前版本、Activity、隐私控件、权限、UI、截图和退出记录。
- [x] 媒体选择取消/真实失败与直通车首次弱网恢复状态机已由 198/198 个移动端测试覆盖;真实 Android 文件选择器取消与断网恢复仍列为下方人工门禁。
## Android(最低 Android 10 + 一台当前主流机型)
@@ -26,10 +26,11 @@
| APK 内容门禁 | 包内业务资源等于当前 App-Plus 编译产物;法务链接、手机号、固定测试验证码、私钥和常见令牌扫描通过 |
| 真机安装启动 | vivo V2443A(Android 15)完成 `0.1.6` 覆盖安装和冷启动;相机、录音、媒体权限保持未授权,未见 `FATAL EXCEPTION` |
| 虚拟机安装与生命周期 | 本机 `Medium_Phone` Android 15/API 35 已完成历史卸载重装、同版本覆盖及冷/暖/热启动,并从 `0.1.9` 保留数据升级到 `0.1.10`;新包冷启动 `1.271s`,原生隐私弹窗与四项未授权状态保持,未见 FATAL/ANR |
| Android 统一取证 | 新增只读脚本并通过状态变更命令回归;实跑确认前台原生隐私弹窗标题与拒绝/同意按钮可见、登录不可见、四项敏感权限为 `false`、退出记录无 Crash/ANR,证据在忽略目录 `output/aug1-rc/android-acceptance/` |
| 首次隐私弹窗 | 最新包已在虚拟机干净启动显示原生弹窗;前一候选包的两个 HTTPS 链接已在 vivo 系统浏览器打开,仍需用户亲自执行拒绝/同意反例 |
| 法务静态页 | `https://peilian.njzhmj.top/legal/terms.html` 与 `https://peilian.njzhmj.top/legal/privacy.html` 已部署并可访问;发布前站点备份为 `/opt/wygj/backups/www-20260728224710` |
| 生产 API 只读契约 | 线上与候选包的 9 个关键控制器 class 哈希逐一相同,短信登录、移动首页、查/练/问、直通车和 ASR/TTS 路由均存在;未登录探针得到公开首页 `200`、5 个鉴权入口 `401`、13 个仅 POST 入口 `405`,全程未发送业务 POST、未创建账号或数据 |
| 定向发布预检 | `566f222e` 已支持只核对后端 + schema;实际只读运行跳过管理端/H5,根站、租户、移动首页、64/64 schema 和生产禁 DDL 门禁通过,最终仅因现网/候选模块哈希 `15f5513a…` / `c93460e8…` 不同而按预期失败 |
| 定向发布预检 | `566f222e` 已支持只核对后端 + schema;`feece9bb` 进一步允许以冻结提交和解压后的候选 JAR 为准。两次实际只读运行均跳过管理端/H5,根站、租户、移动首页和生产禁 DDL 门禁通过;后端 + schema 模式另通过 64/64 schema。冻结物模式确认本地 JAR SHA-256 为 `8c676f2f…`,最终均仅因现网/候选模块哈希 `15f5513a…` / `c93460e8…` 不同而按预期失败 |
## No-Go 阻断项
+305
View File
@@ -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])"
}
@@ -0,0 +1,41 @@
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$scriptPath = Join-Path $projectRoot 'scripts\capture-android-acceptance.ps1'
$source = Get-Content -Raw -Encoding UTF8 -LiteralPath $scriptPath
$requiredFragments = @(
"'get-state'",
"'dumpsys', 'package'",
"'dumpsys', 'activity', 'activities'",
"'dumpsys', 'activity', 'exit-info'",
"'uiautomator', 'dump', '/dev/tty'",
"'exec-out', 'screencap', '-p'",
"'android.permission.CAMERA'",
"'android.permission.RECORD_AUDIO'",
"'android.permission.READ_MEDIA_IMAGES'",
"'android.permission.READ_MEDIA_VIDEO'",
"'read-only-current-state'",
"Join-Path `$projectRoot 'output'"
)
foreach ($fragment in $requiredFragments) {
if (-not $source.Contains($fragment)) {
throw "Android acceptance capture is missing required fragment: $fragment"
}
}
$forbiddenPatterns = @(
'(?i)\bshell\s+input\b',
'(?i)\bpm\s+(?:clear|grant|revoke)\b',
'(?i)\binstall(?:-multiple)?\b',
'(?i)\buninstall\b',
'(?i)\bforce-stop\b',
'(?i)\bam\s+start\b',
'(?i)\bmonkey\b'
)
foreach ($pattern in $forbiddenPatterns) {
if ($source -match $pattern) {
throw "Android acceptance capture contains a state-changing adb pattern: $pattern"
}
}
Write-Output 'PASS: Android acceptance capture is scoped to read-only current-state evidence'