diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index af90abb..e4bf54a 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -84,6 +84,10 @@ jobs:
shell: pwsh
run: ./tools/verify-scrcpy-latest.ps1
+ - name: Verify Root tool release pins
+ shell: pwsh
+ run: ./tools/verify-roottools-latest.ps1
+
package:
name: Package ${{ matrix.product }} ${{ matrix.rid }}
runs-on: ${{ matrix.os }}
@@ -167,6 +171,98 @@ jobs:
Write-Host "Verified macOS ZIP contains $appName."
+ - name: Verify package integrity and Root tools isolation
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
+
+ $zip = Get-ChildItem -Path artifacts -Filter '*.zip' | Select-Object -First 1
+ if ($null -eq $zip) {
+ throw 'No ZIP artifact was produced.'
+ }
+
+ $sidecarPath = "$($zip.FullName).sha256"
+ if (-not (Test-Path -LiteralPath $sidecarPath)) {
+ throw "ZIP '$($zip.Name)' is missing its SHA-256 sidecar."
+ }
+
+ $sidecar = (Get-Content -LiteralPath $sidecarPath -Raw).Trim()
+ if ($sidecar -notmatch '^([0-9a-fA-F]{64}) \*([^\\/\r\n]+)$') {
+ throw "SHA-256 sidecar for '$($zip.Name)' has an invalid format."
+ }
+
+ $expectedHash = $Matches[1].ToLowerInvariant()
+ $expectedName = $Matches[2]
+ $actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $zip.FullName).Hash.ToLowerInvariant()
+ if ($expectedName -ne $zip.Name -or $expectedHash -ne $actualHash) {
+ throw "SHA-256 sidecar does not match ZIP '$($zip.Name)'."
+ }
+
+ $product = '${{ matrix.product }}'
+ $rid = '${{ matrix.rid }}'
+ $appPrefix = if ($rid -eq 'osx-arm64') { 'AndroidTreeView.app/Contents/MacOS/' } else { '' }
+ $archive = [System.IO.Compression.ZipFile]::OpenRead($zip.FullName)
+ try {
+ $entries = @($archive.Entries | ForEach-Object { $_.FullName })
+ } finally {
+ $archive.Dispose()
+ }
+
+ if ($rid -eq 'win-x64') {
+ $executable = if ($product -eq 'Mini') { 'AndroidTreeView.App.mini.exe' } else { 'AndroidTreeView.App.exe' }
+ $requiredWindowsEntries = @(
+ $executable,
+ 'release.json',
+ 'scrcpy/scrcpy.exe',
+ 'scrcpy/adb.exe'
+ )
+ foreach ($entry in $requiredWindowsEntries) {
+ if ($entries -notcontains $entry) {
+ throw "Windows ZIP '$($zip.Name)' is missing required entry '$entry'."
+ }
+ }
+ }
+
+ $rootEntries = @($entries | Where-Object { $_ -match '(^|/)root-tools/' })
+ if ($product -eq 'Mini') {
+ if ($rootEntries.Count -ne 0) {
+ throw "Mini ZIP '$($zip.Name)' contains App-only Root tools: $($rootEntries -join ', ')."
+ }
+ Write-Host "Verified Mini ZIP does not contain root-tools/."
+ exit 0
+ }
+
+ $payloadExecutable = if ($rid -eq 'win-x64') { 'payload-dumper-go.exe' } else { 'payload-dumper-go' }
+ $fastbootExecutable = if ($rid -eq 'win-x64') { 'fastboot.exe' } else { 'fastboot' }
+ $requiredEntries = @(
+ "${appPrefix}scrcpy/$fastbootExecutable",
+ "${appPrefix}root-tools/magisk/Magisk-v30.7.apk",
+ "${appPrefix}root-tools/payload-dumper/$payloadExecutable"
+ )
+ foreach ($entry in $requiredEntries) {
+ if ($entries -notcontains $entry) {
+ throw "App ZIP '$($zip.Name)' is missing required Root tool '$entry'."
+ }
+ }
+
+ if ($rid -eq 'osx-arm64') {
+ $extractDir = Join-Path $env:RUNNER_TEMP 'root-tools-mode-check'
+ New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
+ & unzip -q $zip.FullName -d $extractDir
+ if ($LASTEXITCODE -ne 0) {
+ throw "Could not extract '$($zip.FullName)' to verify executable mode."
+ }
+
+ $payloadPath = Join-Path $extractDir "${appPrefix}root-tools/payload-dumper/$payloadExecutable"
+ $mode = [System.IO.File]::GetUnixFileMode($payloadPath)
+ if (-not ($mode -band [System.IO.UnixFileMode]::UserExecute)) {
+ throw "macOS payload-dumper-go is not executable in '$($zip.Name)' (mode: $mode)."
+ }
+ }
+
+ Write-Host "Verified App ZIP contains fastboot, Magisk, and payload-dumper-go."
+
- name: Upload package artifact
uses: actions/upload-artifact@v7
with:
diff --git a/.gitignore b/.gitignore
index c0cd4d4..3a5a6f5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,6 +54,10 @@ coverage*.info
# ---- Bundled tooling (Android platform-tools, fetched at build time) ----
tools/*
!tools/verify-scrcpy-latest.ps1
+!tools/verify-roottools-latest.ps1
+
+# ---- Local agent/tooling config ----
+.claude/
# ---- OS cruft ----
.DS_Store
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..57c655e
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,172 @@
+# AGENTS.md
+
+This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
+
+## What this is
+
+AndroidTreeView is a **.NET 10 + Avalonia** Windows desktop tool for inspecting, testing and managing
+Android devices over ADB. Despite the name it is a C#/.NET solution, **not** a Java/Kotlin Android app.
+It has two shipping executables that share the same core:
+
+- **App** (`src/AndroidTreeView.App`) — the full Avalonia UI: device overview, per-category details,
+ screen mirroring (scrcpy), CLI tools, settings, updates.
+- **Mini** (`src/AndroidTreeView.Mini`) — a lightweight WinForms tray/monitor app that stays resident,
+ watches for devices, and auto-launches scrcpy once a device is connected and authorized. Mini
+ deliberately does **not** carry the Avalonia/Skia runtime.
+
+`Mini.Mac` is an Avalonia-based Mini variant for macOS.
+
+## Commands
+
+Requires the **.NET 10 SDK**. Run from the repo root.
+
+```bash
+dotnet restore AndroidTreeView.sln
+dotnet build AndroidTreeView.sln
+dotnet test AndroidTreeView.sln
+dotnet run --project src/AndroidTreeView.App
+dotnet run --project src/AndroidTreeView.Mini
+```
+
+Building on non-Windows (App/Mini target Windows) needs the targeting pack flag — CI uses it everywhere:
+
+```bash
+dotnet build AndroidTreeView.sln -p:EnableWindowsTargeting=true
+```
+
+Run a single test project / a single test:
+
+```bash
+dotnet test tests/AndroidTreeView.Adb.Tests
+dotnet test AndroidTreeView.sln --filter "FullyQualifiedName~BatteryParser"
+```
+
+Format check (CI runs this, non-blocking — but keep it clean):
+
+```bash
+dotnet format AndroidTreeView.sln --verify-no-changes
+```
+
+Package/verify the release ZIP link locally (real releases only ship via the GitHub `Publish` workflow,
+x64 only):
+
+```powershell
+./packaging/build-update-zip.ps1 -Product App -Rid win-x64
+./packaging/build-update-zip.ps1 -Product Mini -Rid win-x64
+```
+
+There are ready-made Rider/VS run configs under `.run/`.
+
+## Architecture
+
+The layering is strict and enforced by project references — respect the dependency direction. Lower
+layers never reference upper ones.
+
+```
+Models domain records/enums, no project deps
+ ↑
+Core interfaces, options (AppSettings, AdbOptions), typed exceptions, DeviceMonitor
+ ↑
+Adb / Infrastructure Adb: ProcessRunner, command builders, parsers, AdbDeviceService, LogcatService
+ ↑ Infrastructure: SettingsService (JSON), logging, update check/install
+Shared the shared composition root — AddAndroidTreeViewSharedServices() wires ADB, monitoring,
+ ↑ scrcpy, settings, and update services identically for both App and Mini
+App / Mini / Mini.Mac executables (each references Shared)
+```
+
+Key architectural rules (see `docs/architecture.md` for the full binding type/interface contract, and
+`docs/app-contract.md`):
+
+- **Shared is the single wiring point.** Both App and Mini call
+ `AddAndroidTreeViewSharedServices(...)` so their ADB/scrcpy/settings/update behavior can't drift.
+ When adding a service that both should use, register it there (it uses `TryAdd*`), not per-app.
+- **App is strict MVVM** with CommunityToolkit.Mvvm (`[ObservableProperty]` / `[RelayCommand]`) and
+ `Microsoft.Extensions` DI/Hosting/Logging. `Program.cs` builds the host + `IServiceProvider`, then
+ Avalonia resolves `MainWindowViewModel`. `ViewLocator` maps `*.ViewModels.XxxViewModel` →
+ `*.Views.XxxView` by name convention.
+- **All ADB is out-of-process** via `ProcessRunner` (async stdout/stderr, kills the whole process tree
+ on cancel/timeout). Device info comes from parsing ADB text output.
+- **Parsers are pure and unit-tested.** ADB output parsing lives in `AndroidTreeView.Adb.Parsers` as
+ stateless/static functions (`GetPropParser`, `BatteryParser`, `AdbDevicesParser`, etc.). Any change to
+ parsing logic must come with a parser test — fixtures live under the Adb.Tests project.
+- `AdbLocator` resolves adb (configured path → PATH → common SDK locations); `IAdbEnvironment` holds the
+ current location as a singleton. If adb is missing the App shows a Setup page instead of crashing.
+- `DeviceMonitor` owns a `PeriodicTimer` background loop and raises `DevicesChanged`; VMs marshal to the
+ UI thread themselves.
+
+## Conventions (from `.editorconfig` + `CONTRIBUTING.md`)
+
+- File-scoped namespaces, 4-space indent, interfaces `I`-prefixed, `using` outside the namespace,
+ CRLF line endings, UTF-8.
+- **Async-only for I/O**: `async` + `CancellationToken` propagated everywhere. No `.Result` / `.Wait()` /
+ `Task.Run` for ADB/process work. UI mutations only on the UI thread (`Dispatcher.UIThread.Post`).
+- Views use **compiled bindings** (`x:DataType`) and bind only to members that exist on the VM. No
+ business logic or ADB calls in views.
+- **No hardcoded user-facing strings.** Use localization for every user-visible string (see below).
+- **Safe by design**: device inspection remains read-only. The only write exception is the dedicated
+ semi-automatic Root wizard, which may install the pinned Magisk APK and flash only an evidence-backed
+ `boot` / `init_boot` image after backup, ADB-to-fastboot identity verification, unlock/layout checks,
+ and two explicit confirmations. Never automate bootloader unlock, erase/format, or other partitions.
+ Real flash is gated only by those in-app confirmations; there is no environment-variable override.
+- Keep files under ~400 lines; the App never crashes on normal ADB/device errors — map them to friendly
+ `ErrorMessage` state.
+
+## Localization
+
+Every user-facing string is localized — never hardcode display text. Default language is **Simplified
+Chinese**; English is the neutral/fallback resource.
+
+- **Contract**: `ILocalizationService` (`AndroidTreeView.Core/Interfaces`) — `Get(key)`, `Format(key, args)`,
+ the `this[key]` indexer, `SetLanguage(AppLanguage)`, and a `LanguageChanged` event. `AppLanguage` lives
+ in `AndroidTreeView.Core/Options/SettingsEnums.cs`. `Get` returns the key itself if a resource is
+ missing, so a missing translation is visible, not a crash.
+- **Implementation**: `LocalizationService` in `src/AndroidTreeView.App/Localization`, backed by two ResX
+ files with **identical key sets** (keep them in sync — currently 329 keys each):
+ - `src/AndroidTreeView.App/Resources/Strings.resx` — English (neutral fallback)
+ - `src/AndroidTreeView.App/Resources/Strings.zh-Hans.resx` — Simplified Chinese (default)
+ - Keys are dotted/namespaced: `app.title`, `nav.devices`, `common.refresh`, etc.
+- **In ViewModels**: inject `ILocalizationService` and call `_localization.Get("key")` / `.Format(...)`.
+- **In XAML**: use the `{loc:Localize Key=some.key}` markup extension (`LocalizeExtension` +
+ `LocalizeKeyConverter`). Bindings watch `LocalizationService.LanguageTick` so text refreshes live when
+ the language changes (the indexer's own `INotifyPropertyChanged` was unreliable in this Avalonia
+ version — that's why the plain `LanguageTick` counter exists; keep using it for live-refresh bindings).
+
+**When adding a string**: add the key to **both** ResX files, then reference it via `Get`/`Format` (VMs)
+or `{loc:Localize}` (XAML). Never add a key to only one file.
+
+## Tests
+
+xUnit across four projects (`dotnet test AndroidTreeView.sln`, or target one project — see Commands).
+Fixtures are **inline `const` strings** inside the test classes, not external `.txt` files.
+
+- **`Adb.Tests`** — the bulk of the suite. `Parsers/` has one test class per parser/builder
+ (`BatteryParserTests`, `StorageParserTests`, `AdbDevicesParserTests`, `GetPropParserTests`,
+ `RootStatusParserTests`, `NetworkParserTests`, the `*BuilderTests`, etc.), `Commands/` covers argv
+ building (`AdbArgsTests`), `Services/` covers device-action/screen-capture services. **Any change to ADB
+ output parsing must add/update the matching parser test here** — this is where correctness is pinned.
+- **`Core.Tests`** — `AppSettings` clone/serialize, `DeviceMonitor` start/stop (with a fake
+ `IDeviceService`), `SemanticVersion`, file-transfer service.
+- **`Infrastructure.Tests`** — `SettingsService` persistence, update check (`GitHubUpdateService`) and
+ `UpdateInstaller`, using test doubles in `TestDoubles.cs`.
+- **`App.Tests`** — ViewModel logic with fake services (`Fakes.cs`): device-list reconcile keeps
+ selection, RawProperties filtering, Logcat bounding, DI graph resolves (`ServiceGraphTests`), and a boot
+ smoke test via `Avalonia.Headless` (`TestAppBuilder.cs`, `BootSmokeTests.cs`). Root wizard tests cover
+ device locking, both confirmation gates, retry/partial-write states, localization, and compiled bindings.
+ No real ADB, no on-screen rendering.
+
+## Versioning & updates
+
+Version is unified across runtime version, App/Mini assembly versions, manifests, and
+`packaging/build-update-zip.ps1` — keep them in sync when bumping (currently `1.0.7`). Update channels:
+`android-tree-view-app` (App) and `android-tree-view-mini` (Mini). `GitHubUpdateService` checks the latest
+GitHub Release, compares semver, and selects the matching product asset; `UpdateInstaller` downloads,
+verifies SHA-256, unpacks the x64 ZIP, and runs a local update script. Loose ZIPs without a supported
+`release.json` manifest are rejected.
+
+## Bundled tools
+
+`build/AndroidTreeView.Scrcpy.targets` downloads and bundles scrcpy (which ships adb) into `tools/scrcpy`
+at build/publish time on Windows. `tools/verify-scrcpy-latest.ps1` checks for newer scrcpy releases.
+The full App also imports `build/AndroidTreeView.RootTools.targets`, which pins and verifies the official
+Magisk APK and payload-dumper-go for `win-x64` / `osx-arm64`. Mini variants never import or ship Root tools.
+Fastboot comes from pinned Android SDK Platform-Tools 37.0.0 and is hash-verified before packaging.
diff --git a/README-CN.md b/README-CN.md
index 1c653a0..8dcda41 100644
--- a/README-CN.md
+++ b/README-CN.md
@@ -13,6 +13,10 @@ AndroidTreeView 是一个用于 Android 设备巡检、测试与管理的桌面
当前发布请查看上方 Release 徽章或 [GitHub Releases](https://github.com/Birditch/AndroidTreeView/releases/latest)。运行时版本、目标框架和打包配置以项目文件与发布工作流为准。
+## 产品样式展示
+
+
+
## 功能
- 设备卡片总览与详情页。
@@ -34,6 +38,8 @@ dotnet run --project src/AndroidTreeView.App
dotnet run --project src/AndroidTreeView.Mini
```
+功能规划与实施文档入口见 [docs/roadmap-features.md](docs/roadmap-features.md)。
+
## 使用说明
1. 安装 Android platform-tools,或让应用在启动时引导选择 `adb.exe`。
@@ -56,6 +62,26 @@ ADB 安装与排错见 [docs/adb-requirements.md](docs/adb-requirements.md)。
- Windows 更新包使用带 `release.json` 的 x64 ZIP;GitHub Release 同时包含 macOS Apple Silicon ZIP。
- ZIP 中没有受支持的发布清单时会拒绝安装,避免用户手动替换文件。
+## 打包
+
+正式发布只通过 GitHub Actions 的 `Publish` 工作流完成。本地命令仅用于验证打包链路:
+
+```powershell
+./packaging/build-update-zip.ps1 -Product App -Rid win-x64
+./packaging/build-update-zip.ps1 -Product Mini -Rid win-x64
+```
+
+产物版本由项目文件和发布工作流统一提供。验证输出包含主程序和 Mini 的上传 ZIP,例如:
+
+```text
+artifacts/AndroidTreeView-<版本>-win-x64.zip
+artifacts/AndroidTreeView-<版本>-osx-arm64.zip
+artifacts/AndroidTreeView-Mini-<版本>-win-x64.zip
+artifacts/AndroidTreeView-Mini-<版本>-osx-arm64.zip
+```
+
+更多细节见 [docs/packaging.md](docs/packaging.md)。
+
## 验证
```bash
@@ -65,13 +91,6 @@ dotnet build src/AndroidTreeView.Mini.Mac/AndroidTreeView.Mini.Mac.csproj --no-r
dotnet test AndroidTreeView.sln --no-restore
```
-## 许可证与致谢
-
-
-
-
-
-
-
+## 许可证
AndroidTreeView 基于 [MIT License](LICENSE) 开源。
diff --git a/README.en.md b/README.en.md
index e3e1681..fad4e57 100644
--- a/README.en.md
+++ b/README.en.md
@@ -13,6 +13,10 @@ AndroidTreeView is a desktop tool for inspecting, testing, and managing Android
See the Release badge above or [GitHub Releases](https://github.com/Birditch/AndroidTreeView/releases/latest) for the current published version. Runtime versions, target frameworks, and packaging settings are defined by the project files and release workflow.
+## Product Preview
+
+
+
## Features
- Card-first device overview with serial, model, Android version, battery, charging, temperature, cycle count, connection state, root state, and last refresh time.
@@ -24,9 +28,29 @@ See the Release badge above or [GitHub Releases](https://github.com/Birditch/And
- Update automation downloads packages, verifies SHA-256 metadata when available, extracts Windows x64 ZIP packages, and starts a local update script so users are not asked to manually replace files.
- Simplified Chinese and English UI, with Light / Dark / System theme modes.
+## Repository Layout
+
+```text
+src/
+ AndroidTreeView.Models
+ AndroidTreeView.Core
+ AndroidTreeView.Adb
+ AndroidTreeView.Infrastructure
+ AndroidTreeView.Shared
+ AndroidTreeView.App
+ AndroidTreeView.Mini
+ AndroidTreeView.Mini.Mac
+tests/
+ AndroidTreeView.*.Tests
+packaging/
+ win-x64 / osx-arm64 ZIP packaging and optional WiX MSI packaging
+build/
+ Shared MSBuild targets
+```
+
## Build And Run
-Install the .NET SDK targeted by the project files:
+Requires the .NET 10 SDK:
```bash
dotnet restore AndroidTreeView.sln
@@ -36,6 +60,8 @@ dotnet run --project src/AndroidTreeView.App
dotnet run --project src/AndroidTreeView.Mini
```
+See [docs/roadmap-features.md](docs/roadmap-features.md) for feature roadmaps and implementation plans.
+
If ADB is not found, the app opens an ADB setup screen. Install Android platform-tools and add it to `PATH`, or choose `adb.exe` manually.
## Enable USB Debugging
@@ -54,11 +80,31 @@ See [docs/adb-requirements.md](docs/adb-requirements.md) for platform-tools setu
3. Use the device cards for status, mirroring, CLI access, and non-destructive tools.
4. Use Settings or About to check and install updates.
+## Release ZIP Packaging
+
+Artifact versions are supplied consistently by the project files and release workflow. Official releases are produced only by the GitHub Actions `Publish` workflow.
+
+Local commands are for packaging validation only:
+
+```powershell
+./packaging/build-update-zip.ps1 -Product App -Rid win-x64
+./packaging/build-update-zip.ps1 -Product Mini -Rid win-x64
+```
+
+Example output:
+
+```text
+artifacts/AndroidTreeView--win-x64.zip
+artifacts/AndroidTreeView--osx-arm64.zip
+artifacts/AndroidTreeView-Mini--win-x64.zip
+artifacts/AndroidTreeView-Mini--osx-arm64.zip
+```
+
## Auto Update
- Full app update key: `android-tree-view-app`.
- Mini update key: `android-tree-view-mini`.
-- `NekoIndexUpdateService` checks the internal update channel and compares semantic versions.
+- `GitHubUpdateService` queries GitHub Releases and compares semantic versions.
- `UpdateInstaller` downloads, verifies, extracts, and launches the local update script.
- Supported Windows updater packages are x64 ZIP files with `release.json`; GitHub Releases also contain macOS Apple Silicon ZIPs.
- Loose-file ZIP replacement is intentionally rejected.
@@ -72,13 +118,6 @@ dotnet build src/AndroidTreeView.Mini.Mac/AndroidTreeView.Mini.Mac.csproj --no-r
dotnet test AndroidTreeView.sln --no-restore
```
-## License And Credits
-
-
-
-
-
-
-
+## License
AndroidTreeView is open source under the [MIT License](LICENSE).
diff --git a/README.md b/README.md
index 7ecc995..d610975 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,10 @@ AndroidTreeView 是一个用于 Android 设备巡检、测试与管理的桌面
当前发布请查看上方 Release 徽章或 [GitHub Releases](https://github.com/Birditch/AndroidTreeView/releases/latest)。运行时版本、目标框架和打包配置以项目文件与发布工作流为准。
+## 产品样式展示
+
+
+
## 核心能力
- 设备卡片总览:展示设备名称、序列号、型号、Android 版本、电量、充电状态、温度、循环次数、连接状态、Root 状态和最后刷新时间。
@@ -25,9 +29,29 @@ AndroidTreeView 是一个用于 Android 设备巡检、测试与管理的桌面
- 自动更新会下载、校验 SHA-256、解包 x64 ZIP,并启动本地更新脚本完成替换和重启。
- 中英文 UI,支持跟随系统、浅色、深色主题。
+## 项目结构
+
+```text
+src/
+ AndroidTreeView.Models
+ AndroidTreeView.Core
+ AndroidTreeView.Adb
+ AndroidTreeView.Infrastructure
+ AndroidTreeView.Shared
+ AndroidTreeView.App
+ AndroidTreeView.Mini
+ AndroidTreeView.Mini.Mac
+tests/
+ AndroidTreeView.*.Tests
+packaging/
+ win-x64 / osx-arm64 ZIP packaging and optional WiX MSI packaging
+build/
+ Shared MSBuild targets
+```
+
## 开发运行
-需要安装项目文件目标框架对应的 .NET SDK:
+需要 .NET 10 SDK:
```bash
dotnet restore AndroidTreeView.sln
@@ -37,6 +61,8 @@ dotnet run --project src/AndroidTreeView.App
dotnet run --project src/AndroidTreeView.Mini
```
+功能规划与实施文档入口见 [docs/roadmap-features.md](docs/roadmap-features.md)。
+
如果找不到 ADB,主程序会显示 ADB 设置页。请安装 Android platform-tools 并加入 `PATH`,或手动选择 `adb.exe`。
## 开启 USB 调试
@@ -61,11 +87,31 @@ ADB 安装和排错见 [docs/adb-requirements.md](docs/adb-requirements.md)。
- 首次打开若被 Gatekeeper 拦截(未签名),右键选择「打开」,或执行 `xattr -dr com.apple.quarantine AndroidTreeView.app` 放行。
- 设备卡片的「CLI 终端」在 macOS 上通过 Terminal.app 打开,提供与 Windows 一致的编号菜单(设备信息 / adb shell / logcat / 重启 / 关机 等)。
+## Release ZIP 打包
+
+产物版本由项目文件和发布工作流统一提供。正式发布只通过 GitHub Actions 的 `Publish` 工作流完成。
+
+本地命令仅用于验证打包链路:
+
+```powershell
+./packaging/build-update-zip.ps1 -Product App -Rid win-x64
+./packaging/build-update-zip.ps1 -Product Mini -Rid win-x64
+```
+
+示例输出:
+
+```text
+artifacts/AndroidTreeView-<版本>-win-x64.zip
+artifacts/AndroidTreeView-<版本>-osx-arm64.zip
+artifacts/AndroidTreeView-Mini-<版本>-win-x64.zip
+artifacts/AndroidTreeView-Mini-<版本>-osx-arm64.zip
+```
+
## 自动更新
- 主程序更新通道:`android-tree-view-app`。
- Mini 更新通道:`android-tree-view-mini`。
-- `NekoIndexUpdateService` 检查内部更新通道并比较语义版本。
+- `GitHubUpdateService` 查询 GitHub Releases 并比较语义版本。
- `UpdateInstaller` 下载、校验、解包并启动本地更新脚本。
- Windows 自动更新支持带 `release.json` 的 x64 ZIP;GitHub Release 同时包含 macOS Apple Silicon ZIP。
- 没有受支持发布清单的散文件 ZIP 会被拒绝。
@@ -79,13 +125,6 @@ dotnet build src/AndroidTreeView.Mini.Mac/AndroidTreeView.Mini.Mac.csproj --no-r
dotnet test AndroidTreeView.sln --no-restore
```
-## 许可证与致谢
-
-
-
-
-
-
-
+## 许可证
AndroidTreeView 基于 [MIT License](LICENSE) 开源。
diff --git a/build/AndroidTreeView.RootTools.targets b/build/AndroidTreeView.RootTools.targets
new file mode 100644
index 0000000..7eeda6c
--- /dev/null
+++ b/build/AndroidTreeView.RootTools.targets
@@ -0,0 +1,103 @@
+
+
+
+
+ 30.7
+ Magisk-v$(MagiskVersion).apk
+ https://github.com/topjohnwu/Magisk/releases/download/v$(MagiskVersion)/$(MagiskAssetName)
+ e0d32d2123532860f97123d927b1bb86c4e08e6fd8a48bfc6b5bee0afae9ebd5
+
+ 1.3.0
+ windows_amd64
+ darwin_arm64
+ payload-dumper-go.exe
+ payload-dumper-go
+ 0f96e07477963327f7f50a03bf2aa9dac5c76dba110ab332dc759321ae345d52
+ e6b95df4b08e4bf452077e35cc2c0d644ce8fd454696d1aceedde6887ef0df84
+ cd017857a28d029e80b0830531bcc960be5bbd4b8c937b122024285197012cd7
+ 56d5dd0f402cecc2548a563d840f3fd9e707521b5bd398430f59894c79d08450
+ payload-dumper-go_$(PayloadDumperVersion)_$(PayloadDumperPlatform).tar.gz
+ https://github.com/ssut/payload-dumper-go/releases/download/$(PayloadDumperVersion)/$(PayloadDumperAssetName)
+
+ $(IntermediateOutputPath)root-tools
+ $(IntermediateOutputPath)root-tools-download
+ true
+ <_RootToolsSupportedRid Condition="'$(RuntimeIdentifier)' == 'win-x64' or '$(RuntimeIdentifier)' == 'osx-arm64'">true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_RootToolsOut Include="$(RootToolsDir)/**/*" />
+
+
+
+
+
+
+ <_RootToolsPub Include="$(RootToolsDir)/**/*" />
+
+ root-tools/%(_RootToolsPub.RecursiveDir)%(_RootToolsPub.Filename)%(_RootToolsPub.Extension)
+ PreserveNewest
+
+
+
+
+
diff --git a/build/AndroidTreeView.Scrcpy.targets b/build/AndroidTreeView.Scrcpy.targets
index d386b46..22a266e 100644
--- a/build/AndroidTreeView.Scrcpy.targets
+++ b/build/AndroidTreeView.Scrcpy.targets
@@ -3,14 +3,17 @@
- 4.0
+ 4.1
+ 37.0.0
$(MSBuildThisFileDirectory)../tools/scrcpy
scrcpy.exe
scrcpy
fastboot.exe
fastboot
https://github.com/Genymobile/scrcpy/releases/download/v$(ScrcpyVersion)/scrcpy-win64-v$(ScrcpyVersion).zip
- https://dl.google.com/android/repository/platform-tools-latest-windows.zip
+ https://dl.google.com/android/repository/platform-tools_r$(PlatformToolsVersion)-win.zip
+ 4fe305812db074cea32903a489d061eb4454cbc90a49e8fea677f4b7af764918
+ dd55fef77ab2753b6423f37f39d91cb00ce53ab4539a2431577f07c4abcaa32a
true
@@ -40,6 +43,12 @@
DestinationFileName="platform-tools.zip" Retries="1" ContinueOnError="true">
+
+
+
+
@@ -51,6 +60,15 @@
DestinationFolder="$(ScrcpyDir)" SkipUnchangedFiles="true" ContinueOnError="true" />
+
+
+
+
+
+
+
<_ScrcpyOut Include="$(ScrcpyDir)/**/*" Exclude="$(ScrcpyDir)/.omc/**/*" />
diff --git a/docs/app-contract.md b/docs/app-contract.md
index 0f9d351..93c1cd8 100644
--- a/docs/app-contract.md
+++ b/docs/app-contract.md
@@ -2,7 +2,7 @@
This document records the App-layer contract for the full app and the Mini companion.
-Current product version: `1.0.6`.
+Current product version: `1.0.7`.
## Shared Services
@@ -12,7 +12,9 @@ The full App and Mini must share infrastructure wherever practical. Shared regis
src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs
```
-Shared services include ADB location/environment, device monitoring, scrcpy launch, settings, update checks, and update installation.
+Shared services include ADB location/environment, device monitoring, scrcpy launch, settings, update checks,
+update installation, and the lazily resolved Root workflow services. Mini never resolves the Root workflow and
+does not import or package its tools.
## Update Products
@@ -21,7 +23,7 @@ Shared services include ADB location/environment, device monitoring, scrcpy laun
- Full App: `UpdateProductOptions.ForMainApp()` -> `AppInfo.AppUpdateKey`
- Mini: `UpdateProductOptions.ForMiniApp()` -> `AppInfo.MiniUpdateKey`
-Both products use `NekoIndexUpdateService` and `UpdateInstaller`.
+Both products use `GitHubUpdateService` and `UpdateInstaller`.
## App ViewModels
@@ -36,6 +38,7 @@ The full App owns the rich UI:
- `SetupViewModel`
- category view models for Overview, Hardware, Battery, System, Storage, Network, Root, Logcat, and Raw Properties
- `ScreenMirrorViewModel`
+- `RootWizardViewModel` (singleton so an active workflow survives navigation)
Update commands exposed to UI:
@@ -61,6 +64,8 @@ Mini must not copy ADB, scrcpy, or update implementation details from the full A
Main App views live in `src/AndroidTreeView.App/Views`.
+`RootWizardView` is a top-level App page. The per-device `RootStatusView` remains read-only.
+
Windows Mini views live in `src/AndroidTreeView.Mini/Views`.
macOS Apple Silicon Mini views live in `src/AndroidTreeView.Mini.Mac`.
@@ -74,7 +79,21 @@ Authoritative App localization resources:
- `src/AndroidTreeView.App/Resources/Strings.resx`
- `src/AndroidTreeView.App/Resources/Strings.zh-Hans.resx`
-The two files must contain the same key set. Current expected count: 197 keys each.
+The two files must contain the same key set. Current expected count: 329 keys each.
+
+## Root Workflow Safety
+
+The Root wizard is the only controlled device-write exception. It locks one ADB identity, validates the
+firmware target, backs up the original image, patches with components extracted from the pinned official
+Magisk APK, captures the pre-reboot fastboot baseline, verifies identity/unlock/layout, and requires a second
+risk acknowledgement before writing only `boot` or `init_boot`. It never unlocks the bootloader or writes
+`system`, `vendor`, `vbmeta`, `recovery`, or `super`.
+
+Partial A/B writes retain completed partitions for retry. Cancellation during an active flash is reported as
+an unknown outcome and is never treated as a clean rollback.
+
+Real flash is gated only by the in-app confirmation screen and its explicit risk acknowledgement. Once the
+user confirms, the wizard writes the patched image to the evidence-backed target partition.
## Versioning
diff --git a/docs/architecture.md b/docs/architecture.md
index e1787d8..2e8bb79 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -366,6 +366,20 @@ public interface IAdbEnvironment { // shared adb availability state, updated b
- Static `AdbCommands` constants live in `AndroidTreeView.Adb.Commands` (see §7). Property-key
constants (`ro.product.manufacturer`, etc.) live in `AndroidTreeView.Adb.Commands.PropKeys`.
+### 6.6 Semi-automatic Root workflow
+
+- Root domain records live under `AndroidTreeView.Models.Rooting`; `RootWizardSnapshot` is immutable and
+ contains machine state only.
+- `RootWizardService` is the UI-independent state machine. It depends on `IBootImageExtractor`,
+ `IBootBackupService`, `IMagiskPatcher`, and the strict `IRootFastbootService`.
+- Existing `IFastbootService` keeps best-effort device-list semantics. Dangerous workflow operations use
+ the separate strict interface so errors, partial writes, and unknown outcomes cannot be swallowed.
+- The only permitted flash targets are the evidence-backed `boot` / `init_boot` partitions. Unknown target,
+ package mismatch, unverified fastboot identity, locked bootloader, unknown slot layout, missing backup,
+ or absent risk acknowledgement are hard blockers.
+- A/B writes are ordered `_a` then `_b`; retry receives the completed partition set and never repeats a
+ confirmed write. Cancellation during a running command produces `FlashOutcome.Unknown`.
+
## 7. Adb (project `AndroidTreeView.Adb`)
### 7.1 `.Commands`
diff --git a/docs/packaging.md b/docs/packaging.md
index 2c2d9f0..245c678 100644
--- a/docs/packaging.md
+++ b/docs/packaging.md
@@ -2,7 +2,7 @@
Packaging files live under `packaging/`.
-Current default product version: `1.0.6`.
+Current default product version: `1.0.7`.
Official release artifacts are created by GitHub Actions (`.github/workflows/publish.yml`) only. Local packaging commands are validation helpers and must not be used as the authority for publishing a release.
@@ -11,17 +11,17 @@ Official release artifacts are created by GitHub Actions (`.github/workflows/pub
Every GitHub Release must contain exactly these four ZIP packages plus matching `.sha256` sidecars:
```text
-artifacts/AndroidTreeView-1.0.6-win-x64.zip
-artifacts/AndroidTreeView-1.0.6-win-x64.zip.sha256
-artifacts/AndroidTreeView-1.0.6-osx-arm64.zip
-artifacts/AndroidTreeView-1.0.6-osx-arm64.zip.sha256
-artifacts/AndroidTreeView-Mini-1.0.6-win-x64.zip
-artifacts/AndroidTreeView-Mini-1.0.6-win-x64.zip.sha256
-artifacts/AndroidTreeView-Mini-1.0.6-osx-arm64.zip
-artifacts/AndroidTreeView-Mini-1.0.6-osx-arm64.zip.sha256
+artifacts/AndroidTreeView-1.0.7-win-x64.zip
+artifacts/AndroidTreeView-1.0.7-win-x64.zip.sha256
+artifacts/AndroidTreeView-1.0.7-osx-arm64.zip
+artifacts/AndroidTreeView-1.0.7-osx-arm64.zip.sha256
+artifacts/AndroidTreeView-Mini-1.0.7-win-x64.zip
+artifacts/AndroidTreeView-Mini-1.0.7-win-x64.zip.sha256
+artifacts/AndroidTreeView-Mini-1.0.7-osx-arm64.zip
+artifacts/AndroidTreeView-Mini-1.0.7-osx-arm64.zip.sha256
```
-Windows ZIPs contain the published application files, the platform-matched `scrcpy` bundle, and `release.json` at the ZIP root. macOS ZIPs contain a top-level `.app` bundle (`AndroidTreeView.app` or `AndroidTreeView Mini.app`); the published files, `scrcpy`, and `release.json` live inside the bundle.
+Windows ZIPs contain the published application files, the platform-matched `scrcpy` bundle, and `release.json` at the ZIP root. Full App ZIPs additionally contain verified `root-tools/` assets; Mini ZIPs must not. macOS ZIPs contain a top-level `.app` bundle (`AndroidTreeView.app` or `AndroidTreeView Mini.app`); the published files, tool bundles, and `release.json` live inside the bundle.
The Windows updater treats the ZIP as the source of truth for application files. During an update, files that exist in the installed directory but are missing from the new ZIP are removed unless they are config-like files such as `.env`, `settings.json`, `appsettings.*.json`, `*.local.json`, `*.user`, `.config`, `.ini`, `.json`, `.yaml`, `.yml`, or `.toml`.
@@ -54,13 +54,19 @@ Supported release RIDs are `win-x64` and `osx-arm64`.
The script:
-1. downloads the matching upstream scrcpy asset (`scrcpy-win64-v4.0.zip` or `scrcpy-macos-aarch64-v4.0.tar.gz`)
-2. folds `fastboot` into the full App package
-3. runs `dotnet publish`
-4. writes `release.json`
-5. stages a macOS `.app` bundle for `osx-arm64`
-6. compresses the package folder to `artifacts/`
-7. writes `.sha256`
+1. downloads the matching upstream scrcpy asset (`scrcpy-win64-v4.1.zip` or `scrcpy-macos-aarch64-v4.1.tar.gz`)
+2. folds hash-verified Android SDK Platform-Tools 37.0.0 `fastboot` into the full App package
+3. for the full App only, downloads and verifies Magisk v30.7 and payload-dumper-go 1.3.0
+4. runs `dotnet publish`
+5. writes `release.json`
+6. stages a macOS `.app` bundle for `osx-arm64`
+7. verifies App Root-tool presence and Mini Root-tool absence
+8. compresses the package folder to `artifacts/`
+9. writes `.sha256`
+
+Root assets use fixed SHA-256 values in `build/AndroidTreeView.RootTools.targets` and
+`packaging/build-update-zip.ps1`. `tools/verify-roottools-latest.ps1` checks the pinned upstream release
+metadata and payload checksum manifest without downloading the large assets.
macOS ZIPs are created with the system `zip` command so executable bits and `.app` bundle layout are preserved.
@@ -74,7 +80,7 @@ The updater uses `release.json` to distinguish an automated release ZIP from a r
"product": "App",
"productName": "AndroidTreeView",
"appKey": "android-tree-view-app",
- "version": "1.0.6",
+ "version": "1.0.7",
"platform": "win",
"arch": "x64",
"rid": "win-x64",
@@ -102,8 +108,8 @@ The WiX project rejects non-x64 platforms.
`build-update-zip.ps1` writes checksums automatically. Manual verification:
```powershell
-Get-FileHash -Algorithm SHA256 artifacts\AndroidTreeView-1.0.6-win-x64.zip
-Get-FileHash -Algorithm SHA256 artifacts\AndroidTreeView-Mini-1.0.6-win-x64.zip
+Get-FileHash -Algorithm SHA256 artifacts\AndroidTreeView-1.0.7-win-x64.zip
+Get-FileHash -Algorithm SHA256 artifacts\AndroidTreeView-Mini-1.0.7-win-x64.zip
```
The sidecar uses ` *` format for compatibility with `sha256sum -c`.
diff --git a/docs/publishing.md b/docs/publishing.md
index cbe0b4f..231acfe 100644
--- a/docs/publishing.md
+++ b/docs/publishing.md
@@ -2,7 +2,7 @@
Repository: `Birditch/AndroidTreeView`.
-Current product version: `1.0.6`.
+Current product version: `1.0.7`.
## Release Policy
@@ -14,6 +14,8 @@ Each Windows ZIP must contain:
- published application files
- platform-matched `scrcpy`/`adb`
+- full App only: `fastboot`, verified Magisk APK, and payload-dumper-go under `root-tools/`
+- Mini: no `root-tools/` entries
- `release.json`
- the product executable named by `release.json`
@@ -37,27 +39,32 @@ Keep these values identical for every release:
- `src/AndroidTreeView.App/app.manifest` -> `assemblyIdentity version`
- `packaging/build-update-zip.ps1` -> default `Version`
-For `1.0.6`, release artifacts are named:
+For `1.0.7`, release artifacts are named:
```text
-AndroidTreeView-1.0.6-win-x64.zip
-AndroidTreeView-1.0.6-win-x64.zip.sha256
-AndroidTreeView-1.0.6-osx-arm64.zip
-AndroidTreeView-1.0.6-osx-arm64.zip.sha256
-AndroidTreeView-Mini-1.0.6-win-x64.zip
-AndroidTreeView-Mini-1.0.6-win-x64.zip.sha256
-AndroidTreeView-Mini-1.0.6-osx-arm64.zip
-AndroidTreeView-Mini-1.0.6-osx-arm64.zip.sha256
+AndroidTreeView-1.0.7-win-x64.zip
+AndroidTreeView-1.0.7-win-x64.zip.sha256
+AndroidTreeView-1.0.7-osx-arm64.zip
+AndroidTreeView-1.0.7-osx-arm64.zip.sha256
+AndroidTreeView-Mini-1.0.7-win-x64.zip
+AndroidTreeView-Mini-1.0.7-win-x64.zip.sha256
+AndroidTreeView-Mini-1.0.7-osx-arm64.zip
+AndroidTreeView-Mini-1.0.7-osx-arm64.zip.sha256
```
## Build Upload ZIP Packages
Official publication happens through the `Publish` GitHub Actions workflow:
-- push a tag named `v..`, for example `v1.0.6`
+- push a tag named `v..`, for example `v1.0.7`
- or run `workflow_dispatch` and provide the same version string
-The workflow validates on Windows, verifies the pinned scrcpy release against the latest upstream release, builds the two Windows portable ZIPs and two macOS `.app` bundle ZIPs, writes SHA-256 sidecars, uploads workflow artifacts, and creates the GitHub Release.
+The workflow validates on Windows, verifies the pinned scrcpy and Root-tool releases, builds the two Windows
+portable ZIPs and two macOS `.app` bundle ZIPs, checks Root-tool package isolation and macOS executable bits,
+writes SHA-256 sidecars, uploads workflow artifacts, and creates the GitHub Release.
+
+The packaged `fastboot` binary is pinned to Android SDK Platform-Tools 37.0.0 and verified against the
+repository-maintained archive and executable SHA-256 values. Do not switch back to a `latest` URL.
For local validation only:
@@ -75,52 +82,19 @@ The full app and Mini share the same update implementation but use different app
- Full app: `AppInfo.AppUpdateKey` -> `android-tree-view-app`
- Mini: `AppInfo.MiniUpdateKey` -> `android-tree-view-mini`
-The Windows update channel must point each product key at its own Windows ZIP:
-
-- `android-tree-view-app` -> `AndroidTreeView-1.0.6-win-x64.zip`
-- `android-tree-view-mini` -> `AndroidTreeView-Mini-1.0.6-win-x64.zip`
-
-`NekoIndexUpdateService` queries the configured internal update API and compares the returned version with `AppInfo.Version`. `UpdateInstaller` downloads the package, verifies SHA-256 metadata when present, extracts the ZIP, verifies the portable Windows x64 manifest, and starts the automated update script.
-
-The internal update API endpoint is:
-
-```text
-GET http://192.168.89.71:14000/api/update/{appKey}/latest
-Accept: application/json
-```
-
-Minimum response contract:
-
-```json
-{
- "ok": true,
- "data": {
- "appKey": "android-tree-view-app",
- "title": "AndroidTreeView",
- "version": "1.0.6",
- "zip": {
- "sha256": "<64-character sha256>",
- "downloadUrl": "/api/resources/android-tree-view-app/versions/latest/archive"
- },
- "files": []
- },
- "error": null
-}
-```
+Each product resolves its own Windows ZIP from the latest GitHub Release:
-`downloadUrl` may be absolute or relative to `AppInfo.UpdateServerBaseUrl`. The updater requires the response `appKey` to match the configured product channel and the package `release.json` to match the same `appKey`, version, Windows x64 architecture, and executable.
+- `android-tree-view-app` -> `AndroidTreeView-1.0.7-win-x64.zip`
+- `android-tree-view-mini` -> `AndroidTreeView-Mini-1.0.7-win-x64.zip`
-## Internal Update Deployment
+`GitHubUpdateService` queries `AppInfo.LatestReleaseApiUrl`, compares the release tag with `AppInfo.Version`, and resolves the matching ZIP and `.sha256` assets. `UpdateInstaller` downloads the package, verifies SHA-256 metadata when present, extracts the ZIP, verifies the portable Windows x64 manifest, and starts the automated update script.
-Use this flow for the intranet update server:
+## GitHub Release Update Deployment
-1. Push a `v1.0.6` tag or run the `Publish` GitHub Actions workflow manually.
-2. Download the GitHub Actions-built Windows ZIP and `.sha256` sidecar for each Windows update channel.
-3. Upload the ZIPs to the internal server storage.
-4. Configure `/api/update/android-tree-view-app/latest` to return `AndroidTreeView-1.0.6-win-x64.zip` metadata.
-5. Configure `/api/update/android-tree-view-mini/latest` to return `AndroidTreeView-Mini-1.0.6-win-x64.zip` metadata.
-6. Confirm the `sha256` value matches the uploaded ZIP.
-7. Use the app "Check for updates" action to test download, validation, replacement, cleanup, and restart.
+1. Push a `v` tag or run the `Publish` GitHub Actions workflow manually.
+2. Confirm the release contains the App and Mini ZIPs plus their `.sha256` sidecars.
+3. Confirm the release tag is a valid semantic version and matches the asset filenames.
+4. Use the app "Check for updates" action to test download, validation, replacement, cleanup, and restart.
macOS Apple Silicon ZIPs are published to GitHub Releases as `.app` bundle ZIPs for distribution, but the current automated in-app updater accepts the Windows `portable-x64` package kind.
@@ -149,11 +123,10 @@ Preserved config-like files include `.env`, `settings.json`, `appsettings.json`,
4. Locally smoke-test at least the Windows App/Mini packaging scripts.
5. Push a `v` tag or run the `Publish` GitHub Actions workflow manually.
6. Confirm the workflow produced all four ZIPs and checksum sidecars.
-7. Point configured Windows update channels at the GitHub Actions-built Windows ZIPs only.
-8. Confirm each internal update API response returns the correct product key, version, download URL, and SHA-256.
-9. Confirm the full app update flow downloads, applies, removes obsolete non-config files, preserves config files, and restarts.
-10. Confirm Mini auto-update downloads, applies, removes obsolete non-config files, preserves config files, and restarts.
+7. Confirm the GitHub Release exposes the expected product ZIPs and checksum sidecars.
+8. Confirm the full app update flow downloads, applies, removes obsolete non-config files, preserves config files, and restarts.
+9. Confirm Mini auto-update downloads, applies, removes obsolete non-config files, preserves config files, and restarts.
## GitHub Releases
-The `Publish` workflow creates a GitHub Release for each release tag and uploads the four ZIPs plus checksum files. The in-app updater may still consume an internal NekoIndex channel, but that channel must reference artifacts built by GitHub Actions, not local builds.
+The `Publish` workflow creates a GitHub Release for each release tag and uploads the four ZIPs plus checksum files. The in-app updater reads this public release directly and selects the Windows x64 asset for the running product.
diff --git a/docs/requirements-v1.md b/docs/requirements-v1.md
index 179ee4a..583990f 100644
--- a/docs/requirements-v1.md
+++ b/docs/requirements-v1.md
@@ -1,6 +1,6 @@
# AndroidTreeView Requirements Addendum
-Current product version: `1.0.6`.
+Current product version: `1.0.7`.
This addendum extends `docs/architecture.md` and reflects the current App + Mini direction.
@@ -17,7 +17,7 @@ This addendum extends `docs/architecture.md` and reflects the current App + Mini
- ADB location/environment/device monitor services
- scrcpy launch logic
- settings service
- - NekoIndex update checking
+ - GitHub Releases update checking
- update downloading/verification/automatic apply flow
- scrcpy asset distribution through `build/AndroidTreeView.Scrcpy.targets`
- Full App and Mini use different update keys:
@@ -33,7 +33,7 @@ This addendum extends `docs/architecture.md` and reflects the current App + Mini
## Update Automation
-- `IUpdateService` + `NekoIndexUpdateService` query the internal update API.
+- `IUpdateService` + `GitHubUpdateService` query the latest GitHub Release and resolve the matching product asset.
- `IUpdateInstaller` + `UpdateInstaller` download packages, verify SHA-256 metadata when present, extract Windows x64 ZIP packages, and start the automated apply flow.
- The user must not be asked to download a ZIP and replace files manually.
- ZIP packages without a supported `release.json` and executable are rejected.
diff --git a/docs/roadmap-features.md b/docs/roadmap-features.md
index 4e50d1b..1e90806 100644
--- a/docs/roadmap-features.md
+++ b/docs/roadmap-features.md
@@ -13,9 +13,24 @@ This roadmap captures the current App + Mini direction after the shared service
- Mini uses `android-tree-view-mini`.
- Updates are ZIP-driven. The updater downloads, verifies SHA-256 metadata when present, extracts Windows x64 release ZIPs, and starts the automated apply flow. Unsupported loose-file ZIPs are rejected.
- Packaging is product-aware and can produce first-class App and Mini ZIPs for Windows x64 and macOS Apple Silicon.
+- The full App includes a semi-automatic Root wizard with safe package extraction, original-image backup,
+ pinned official Magisk components, ADB-to-fastboot identity continuity, two confirmation gates, and
+ single-slot/A-B partial-write reporting. Mini remains Root-tool-free.
## Remaining Backlog
+### Root Wizard Release Validation
+
+- Design: [`superpowers/specs/2026-07-09-semi-auto-root-design.md`](superpowers/specs/2026-07-09-semi-auto-root-design.md)
+- Implementation plan: [`superpowers/plans/2026-07-10-semi-auto-root-implementation.md`](superpowers/plans/2026-07-10-semi-auto-root-implementation.md)
+- Complete M0 on expendable devices for both `boot` and Android 13+ `init_boot` patching.
+- Complete M1 on recoverable single-slot and A/B devices, including second-slot failure recovery.
+- Validate recovery-only blocking against representative standard v0-v4 images and document any OEM-wrapped
+ boot image formats; unrecognized formats remain blocked rather than guessed.
+- Smoke-test pinned payload-dumper and packaged executable permissions on Windows x64 and macOS arm64.
+- Remove the default flash gate only after those records are accepted; until then the environment override is
+ restricted to recoverable M1 test devices.
+
### Device Actions
- Keep right-click/card actions universal, non-destructive by default, and no-root-required unless a root-only action is explicitly marked.
diff --git a/docs/superpowers/plans/2026-07-10-semi-auto-root-implementation.md b/docs/superpowers/plans/2026-07-10-semi-auto-root-implementation.md
new file mode 100644
index 0000000..059df1b
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-10-semi-auto-root-implementation.md
@@ -0,0 +1,388 @@
+# 半自动 Root 功能实施计划
+
+- 日期:2026-07-10
+- 状态:代码实施完成;等待 M0/M1 实机与双平台发布包验证
+- 设计依据:[`../specs/2026-07-09-semi-auto-root-design.md`](../specs/2026-07-09-semi-auto-root-design.md)
+- 目标平台:`win-x64`、`osx-arm64`
+
+## 1. 交付目标
+
+在完整 App 中增加可恢复、可取消、带两次风险确认的 Root 向导。用户选择与当前设备匹配的
+刷机包后,App 根据设备与包证据选择原始 `boot.img` 或 `init_boot.img`,完成备份和 Magisk 修补,
+验证 ADB → fastboot 设备身份连续性,再检测解锁与槽位并在明确确认后刷入、重启。
+
+Mini、Mini.Mac 不提供 Root 向导,也不携带 Magisk 或 payload-dumper。
+
+## 2. 仓库现状与计划校正
+
+实施时以当前代码为准,不能照设计稿重复建设已经存在的能力:
+
+- `IFastbootService`、`FastbootService`、fastboot 设备合并和卡片展示已经存在。
+- `build/AndroidTreeView.Scrcpy.targets` 与 `packaging/build-update-zip.ps1` 已把 fastboot 放在
+ App 的 `scrcpy/` 目录,Windows 与 macOS App 包均可携带 fastboot。
+- 完整 App 已是 `net10.0 + Avalonia`,`osx-arm64` 发布链路已经存在,不需要新建 macOS App。
+- 现有 `FastbootService` 面向设备列表,普通失败会被吞掉;Root 写入链路必须返回可诊断的严格结果。
+- 现有 `ProcessRunner` 是静态内部类,无法直接用假对象覆盖 payload-dumper 和 fastboot 错误路径。
+- 当前通用 `IDialogService` 不支持“阅读风险并勾选确认”,第二确认点应由 Root 页面状态门控。
+
+## 3. 不可跳过的技术门槛
+
+### M0:验证 Magisk 修补链路
+
+这是发布和实机可用声明的前置门槛。Magisk `v30.7` APK 中存在 `assets/boot_patch.sh`、
+`assets/util_functions.sh` 和各 ABI 的 `libmagisk*.so`,但 `boot_patch.sh` 明确要求脚本、
+`magiskboot`、`magiskinit`、`magisk`、`init-ld` 与 `stub.apk` 位于同一目录。安装 APK 并不等于
+这些文件可被普通 `adb shell` 直接访问。
+
+验证步骤:
+
+1. 使用可丢弃数据、bootloader 已解锁的测试机和与设备版本匹配的原始镜像;M0 必须分别验证
+ `boot` 目标与 Android 13+ GKI 的 `init_boot` 目标。recovery-only 设备明确判为首版不支持。
+2. 执行 `adb install -r Magisk-v30.7.apk`,记录 package path、ABI 与 shell 可访问的组件路径。
+3. 尝试在非 root 的 `adb shell` 中调用已安装 App 的修补组件,产出 `new-boot.img`。
+4. pull 修补结果并让 Magisk App/`magiskboot` 验证镜像;只有验证通过才进入 Task 1。
+
+通过标准:不依赖设备已 root,命令可重复执行,修补产物非空,清理后无残留临时文件。
+`boot` 与 `init_boot` 两条路径都通过后 M0 才算完成。
+
+实现已采用“从已校验 APK 精确提取官方组件后 push”,同时保留官方 APK 安装步骤,不依赖设备端
+`unzip`,也未换成第三方桌面 `magiskboot`。M0 失败时必须暂停发布并更新命令链路;不得绕过门槛。
+
+### M1:首次真实刷写
+
+只在单元测试、包验证和 M0 全部通过后执行。先用非 A/B 测试机或可恢复设备验证单槽写入,再验证
+A/B 双槽及第二槽失败提示。真实刷写不放入 CI。
+
+## 4. 依赖顺序
+
+```text
+M0 Magisk 实机验证
+ -> Task 1 可替换的外部命令执行层
+ -> Task 2 领域模型与 Core 合约
+ -> Task 3 ZIP / 嵌套 ZIP 提取
+ -> Task 4 Root 工具固定版本与打包
+ -> Task 5 payload.bin 提取
+ -> Task 6 原始目标镜像备份
+ -> Task 7 Magisk 修补服务
+ -> Task 8 严格 fastboot 检测与刷写
+ -> Task 9 向导状态机
+ -> Task 10 App 页面、导航、本地化
+ -> Task 11 集成与回归测试
+ -> Task 12 发布包验证、文档和 M1
+```
+
+## 5. 分步实施
+
+### Task 1:建立可替换的外部命令执行层
+
+文件:
+
+- 新增 `src/AndroidTreeView.Core/Interfaces/IExternalCommandRunner.cs`
+- 新增 `src/AndroidTreeView.Core/Services/ExternalCommandRequest.cs`
+- 新增 `src/AndroidTreeView.Core/Services/ExternalCommandResult.cs`
+- 新增 `src/AndroidTreeView.Adb/Services/ExternalCommandRunner.cs`
+- 修改 `src/AndroidTreeView.Adb/Services/FastbootService.cs`
+- 修改 `src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/TestDoubles/FakeExternalCommandRunner.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Services/FastbootServiceTests.cs`
+
+步骤:
+
+1. 先写失败测试,覆盖 fastboot 缺失、超时、非零退出、stderr 输出和取消传播。
+2. 用 `ExternalCommandRunner` 适配现有 `ProcessRunner.RunAsync`,保留参数列表传递,禁止拼 shell 字符串。
+3. 让 `FastbootService` 注入 runner;设备列表相关 API 继续保持 best-effort 行为。
+4. 在 Shared 中用 `TryAddSingleton` 注册 runner,不改 Mini 的现有行为。
+
+验收:`dotnet test tests/AndroidTreeView.Adb.Tests --filter FullyQualifiedName~FastbootServiceTests`
+
+### Task 2:定义 Root 领域模型与 Core 合约
+
+文件:
+
+- 新增 `src/AndroidTreeView.Models/Rooting/BootImageSource.cs`
+- 新增 `src/AndroidTreeView.Models/Rooting/BootPartitionTarget.cs`
+- 新增 `src/AndroidTreeView.Models/Rooting/BootImageInfo.cs`
+- 新增 `src/AndroidTreeView.Models/Rooting/RootWizardState.cs`
+- 新增 `src/AndroidTreeView.Models/Rooting/RootWizardSnapshot.cs`
+- 新增 `src/AndroidTreeView.Models/Rooting/FastbootBootLayout.cs`
+- 新增 `src/AndroidTreeView.Models/Rooting/FirmwarePackageMetadata.cs`
+- 新增 `src/AndroidTreeView.Models/Rooting/RootErrorCode.cs`
+- 新增 `src/AndroidTreeView.Core/Interfaces/IBootImageExtractor.cs`
+- 新增 `src/AndroidTreeView.Core/Interfaces/IBootBackupService.cs`
+- 新增 `src/AndroidTreeView.Core/Interfaces/IMagiskPatcher.cs`
+- 新增 `src/AndroidTreeView.Core/Interfaces/IRootWizardService.cs`
+- 新增 `src/AndroidTreeView.Core/Exceptions/RootWorkflowException.cs`
+
+约束:
+
+- `BootImageInfo` 显式保存 `BootPartitionTarget`(`Boot` / `InitBoot`),禁止从文件名反推刷写分区。
+- `RootWizardSnapshot` 保存 ADB serial、USB 路径、product、重启前 fastboot 基线、匹配后的 fastboot
+ serial、包路径、目标分区、工作目录、原始/修补镜像、备份路径、槽位和错误码。
+- 错误对 UI 暴露稳定错误码,不直接把任意 stderr 当成用户文案。
+- 状态增加 `BlockedFastbootIdentity`:ADB 重启后没有出现新设备,或 serial / USB 路径 / product
+ 等证据不能证明 fastboot 设备就是所选 ADB 设备时禁止刷写;列表中仅有一台设备不算证明。
+- 所有 I/O 接口均为 async,并传递 `CancellationToken`。
+
+验收:Core 和 Models 无上层项目引用,`dotnet build AndroidTreeView.sln -p:EnableWindowsTargeting=true`。
+
+### Task 3:实现安全的 ZIP 与 Pixel 嵌套 ZIP 提取
+
+文件:
+
+- 新增 `src/AndroidTreeView.Adb/Parsers/PackageTypeDetector.cs`
+- 新增 `src/AndroidTreeView.Adb/Parsers/BootPartitionTargetDetector.cs`
+- 新增 `src/AndroidTreeView.Adb/Parsers/FirmwarePackageMetadataParser.cs`
+- 新增 `src/AndroidTreeView.Adb/Services/BootImageExtractor.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Parsers/PackageTypeDetectorTests.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Parsers/BootPartitionTargetDetectorTests.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Parsers/FirmwarePackageMetadataParserTests.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Services/BootImageExtractorTests.cs`
+
+测试先覆盖:顶层 `boot.img` / `init_boot.img`、两者同时存在、GKI 13+ 选择 `init_boot`、存在
+`init_boot` 的 Android 12 内核仍选择 `boot`、大小写差异、Pixel `image-*.zip`、payload、目标镜像
+缺失、设备与包证据冲突、recovery-only、重复目标、损坏 ZIP、路径穿越、取消和工作目录清理。
+
+实现约束:
+
+- 只允许一层嵌套 ZIP;条目路径必须经过 `Path.GetFullPath` 边界检查。
+- 不把整个大包读入内存;使用流复制。
+- 设定单条目和总展开大小上限,拒绝 ZIP bomb。
+- 读取 OTA `META-INF/com/android/metadata` 与 Pixel `android-info.txt`;元数据明确不匹配当前
+ `ro.product.device`/product 时硬阻止,元数据缺失时标记为“无法自动验证”并留给第二确认点。
+- 解析设备的 `init_boot` 分区存在性和内核/GKI 版本;仅当 `init_boot` 存在、内核满足 GKI 13+
+ 且包中包含 `init_boot.img` 时选择 `InitBoot`,否则选择 `Boot`。设备要求 `init_boot` 但包中缺失、
+ 证据矛盾时返回稳定阻塞错误,不回退猜测其他分区。
+- 对实际提取的目标解析标准 Android boot header v0-v4;`boot` 的 `ramdisk_size == 0` 返回
+ `RecoveryOnlyUnsupported`,未知/OEM 包装或布局越界返回 `TargetEvidenceConflict`。不能识别的
+ 格式保持 fail closed,不以 recovery 分区存在性作推断。
+- 每次会话使用独立的 `root-work//`,失败时清理,成功时由向导结束后清理。
+
+验收:提取测试全部通过,且测试用临时目录在成功、失败、取消后均被回收。
+
+### Task 4:固定并打包 Root 工具
+
+固定版本:
+
+- Magisk:`v30.7`,资产 `Magisk-v30.7.apk`,SHA-256
+ `e0d32d2123532860f97123d927b1bb86c4e08e6fd8a48bfc6b5bee0afae9ebd5`。
+- payload-dumper-go:`1.3.0`,使用上游 `payload-dumper-go_sha256checksums.txt` 中的
+ `windows_amd64` 与 `darwin_arm64` 校验值。
+- fastboot:继续复用现有 App `scrcpy/fastboot[.exe]`,不新增第二份 platform-tools。
+
+文件:
+
+- 新增 `build/AndroidTreeView.RootTools.targets`
+- 修改 `src/AndroidTreeView.App/AndroidTreeView.App.csproj`
+- 修改 `packaging/build-update-zip.ps1`
+- 修改 `.github/workflows/publish.yml`
+- 新增 `tools/verify-roottools-latest.ps1`
+
+步骤:
+
+1. App 单独导入 RootTools target;Mini 和 Mini.Mac 不导入。
+2. 下载后先计算 SHA-256,不匹配立即失败,再解包或复制。
+3. 输出布局固定为 `root-tools/magisk/Magisk-v30.7.apk` 和
+ `root-tools/payload-dumper/payload-dumper-go[.exe]`。
+4. macOS 包为 payload-dumper 设置执行位。
+5. 发布工作流验证 App 包含三个 Root 必需工具,Mini 包不包含 `root-tools/`。
+
+验收:对 `win-x64` 与 `osx-arm64` 各运行一次 App 打包,并检查 ZIP 条目和哈希失败路径。
+
+### Task 5:接入 payload.bin 提取
+
+文件:
+
+- 修改 `src/AndroidTreeView.Adb/Services/BootImageExtractor.cs`
+- 新增 `src/AndroidTreeView.Adb/Services/RootToolPaths.cs`
+- 扩展 `tests/AndroidTreeView.Adb.Tests/Services/BootImageExtractorTests.cs`
+
+步骤:
+
+1. 用 fake runner 写测试,固定断言可执行文件、argv、超时和输出目录。
+2. 对裸 payload 与 ZIP 内 payload 使用同一分支,按已解析的 `BootPartitionTarget` 只请求
+ `boot` 或 `init_boot` 分区。
+3. runner 非零退出、超时或未产出目标镜像时抛稳定错误码。
+4. 校验输出文件存在、大小合理,再返回 `BootImageInfo`。
+
+验收:不安装真实 payload-dumper 也能覆盖所有流程分支;打包 smoke test 再验证真实二进制可启动。
+
+### Task 6:实现原始目标镜像备份
+
+文件:
+
+- 新增 `src/AndroidTreeView.Infrastructure/Rooting/BootBackupService.cs`
+- 新增 `tests/AndroidTreeView.Infrastructure.Tests/BootBackupServiceTests.cs`
+- 修改 `src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs`
+
+约束:
+
+- 目录为 `~/.androidtreeview/root-backups/`。
+- serial 先清洗为文件名安全字符;使用 UTC 时间和随机后缀防碰撞。
+- 先写临时文件,再原子移动;取消或复制失败不得留下半个备份。
+- 返回绝对路径,并验证备份长度与源文件相同。
+
+验收:覆盖非法 serial、同秒多次备份、取消、源文件不存在和成功内容一致。
+
+### Task 7:实现 Magisk 修补服务
+
+文件:
+
+- 新增 `src/AndroidTreeView.Adb/Parsers/CpuAbiParser.cs`
+- 新增 `src/AndroidTreeView.Adb/Services/MagiskPatcher.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Parsers/CpuAbiParserTests.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Services/MagiskPatcherTests.cs`
+- 修改 `src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs`
+
+严格按 M0 验证通过的命令序列实现:安装固定 APK、探测 ABI、创建会话临时目录、push 镜像、执行
+官方修补脚本、pull 结果、校验产物,并在 `finally` 中清理手机临时目录。
+每条 ADB 命令都必须通过 argv 显式指定 `RootWizardSnapshot` 中锁定的 ADB serial。
+
+测试覆盖:安装失败、未知 ABI、push 失败、脚本失败、pull 失败、空产物、取消和清理失败不遮蔽主错误。
+日志不得输出完整敏感 stderr;UI 只接收错误码与经过裁剪的诊断摘要。
+
+验收:`MagiskPatcherTests` 全部通过,随后在 M0 测试机重复一次端到端修补。
+
+### Task 8:增加严格 fastboot 检测与刷写结果
+
+文件:
+
+- 新增 `src/AndroidTreeView.Adb/Commands/FastbootArgs.cs`
+- 新增 `src/AndroidTreeView.Adb/Parsers/FastbootVarParser.cs`
+- 修改 `src/AndroidTreeView.Core/Interfaces/IFastbootService.cs`
+- 修改 `src/AndroidTreeView.Adb/Services/FastbootService.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Commands/FastbootArgsTests.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Parsers/FastbootVarParserTests.cs`
+- 扩展 `tests/AndroidTreeView.Adb.Tests/Services/FastbootServiceTests.cs`
+
+新增能力:重启前捕获 `fastboot devices -l` 基线,等待并匹配重启后新出现的 fastboot 设备,读取
+`unlocked`、`slot-count`、`has-slot:` 与 `current-slot`,按 `BootPartitionTarget` 执行单槽
+或 A/B 双槽刷写,返回失败分区和 stderr 摘要并重启系统。
+
+安全规则:
+
+- 重启前保存已有 fastboot serial 集合和所选 ADB 设备的 serial、USB 路径、product;重启后只考虑
+ 基线之外的新设备。优先匹配保持不变的 serial,再用 USB 路径和 `getvar product` 交叉验证。
+- 没有新设备、证据冲突或无法充分匹配时返回 `FastbootIdentityUnverified`,绝不因列表中只有一台
+ 就选择它。后续每条 fastboot 命令都必须显式带 `-s `。
+- 可执行文件先查已定位 adb 的同目录,再回退到 App 自带 `scrcpy/fastboot[.exe]`。
+- `unlocked` 不是明确 yes/true 时按未解锁处理。
+- `has-slot:=no` 或 `slot-count` 明确回 `0`/`1` 才判为非 A/B(旧机型 bootloader 不实现
+ `has-slot`/`current-slot`,只会用 `slot-count` 表达单槽);`has-slot:=yes` 且槽位数明确时
+ 判为 A/B;信息矛盾或全部缺失时不猜布局,进入阻塞状态。
+- A/B 的 `_a` 成功、`_b` 失败时返回部分写入结果,UI 必须显示设备处于危险中间态。
+- 非零退出和超时不得继续执行下一条写命令。
+
+验收:argv、stdout/stderr 解析和全部失败分支均由纯单测固定;至少覆盖“已有其他 fastboot 设备且
+目标重启失败”“目标作为新设备出现”“serial 改变但 USB/product 匹配”“唯一设备但无身份证据”四种场景。
+
+### Task 9:实现可恢复的 Root 向导状态机
+
+文件:
+
+- 新增 `src/AndroidTreeView.Core/Services/RootWizardService.cs`
+- 新增 `tests/AndroidTreeView.Core.Tests/RootWizardServiceTests.cs`
+- 修改 `src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs`
+
+状态转换由显式方法驱动:`SelectPackage`、`ExtractAndPatchAsync`、`ConfirmBootloaderAsync`、
+`DetectFastbootAsync`、`ConfirmFlashAsync`、`RetryAsync`、`CancelAsync`。服务不弹 UI,也不自行越过
+确认点。
+`ConfirmBootloaderAsync` 必须先捕获 fastboot 基线,再对锁定的 ADB serial 执行 reboot;
+`DetectFastbootAsync` 只接收 `WaitForMatchingFastbootDeviceAsync` 返回的已验证 serial。
+
+测试固定以下不变量:
+
+- 未完成备份不能进入 flash 确认。
+- 包元数据明确与设备不匹配时不能进入修补或 flash;无法验证时必须显示额外风险提示。
+- 未解锁、槽位未知、目标分区未知、fastboot 身份未验证、未勾选风险确认均不能调用 flash。
+- 取消会杀掉当前外部进程并保留原始备份。
+- 重试只重跑失败步骤,不重复刷已成功的分区。
+- 完成/中止后清理工作目录,但永不删除备份。
+
+验收:状态转换表的每条边和每个阻塞状态至少有一个测试。
+
+### Task 10:接入 App 导航、页面和本地化
+
+文件:
+
+- 新增 `src/AndroidTreeView.App/ViewModels/RootWizardViewModel.cs`
+- 新增 `src/AndroidTreeView.App/Views/RootWizardView.axaml`
+- 新增 `src/AndroidTreeView.App/Views/RootWizardView.axaml.cs`
+- 修改 `src/AndroidTreeView.App/ViewModels/AppEnums.cs`
+- 修改 `src/AndroidTreeView.App/ViewModels/MainWindowViewModel.cs`
+- 修改 `src/AndroidTreeView.App/Views/MainWindow.axaml`
+- 修改 `src/AndroidTreeView.App/AppServices.cs`
+- 修改 `src/AndroidTreeView.App/Services/IFilePickerService.cs`
+- 修改 `src/AndroidTreeView.App/Services/FilePickerService.cs`
+- 修改两个 `Strings*.resx`
+
+实现:
+
+- Root VM 注册为 singleton,使用户切换导航后不丢工作流。
+- 向导第一步列出在线且已授权的 ADB 设备并要求单选;多设备时不默认选第一台。开始提取后锁定
+ serial、USB 路径和 product,中途切换设备必须先中止当前向导。
+- 文件选择器新增单选刷机包入口,只接受 ZIP 与 payload;给 `FilePickerService` 注入
+ `ILocalizationService`,选择器标题与文件类型名称也必须本地化。
+- 第一次确认可用现有 `IDialogService`;第二次在 Root 页面显示备份路径、目标分区和风险复选框。
+- 第二确认点同时显示 ADB/fastboot 身份匹配结果、设备代号、目标分区和包元数据匹配结果;无法自动
+ 验证设备身份时不显示可绕过警告,而是保持刷写按钮禁用。
+- 新增 `root.blocked.fastboot.identity`、`root.blocked.partition.unsupported` 和
+ `root.confirm.flash.targetpartition` 等中英文资源键,两个 ResX 键集保持一致。
+- `FlashCommand` 的 CanExecute 同时依赖状态和 `HasAcknowledgedRisk`。
+- 页面只绑定 VM,使用 `x:DataType`;所有正常错误映射成 `root.error.*`。
+- 狭窄窗口下步骤条可横向滚动,底部操作区不遮挡错误和日志。
+
+验收:两个 ResX 键集合完全一致,App headless boot 能解析 Root 页面全部 compiled bindings。
+
+### Task 11:补齐 App 集成和回归测试
+
+文件:
+
+- 新增 `tests/AndroidTreeView.App.Tests/RootWizardViewModelTests.cs`
+- 修改 `tests/AndroidTreeView.App.Tests/Fakes.cs`
+- 修改 `tests/AndroidTreeView.App.Tests/ServiceGraphTests.cs`
+- 修改 `tests/AndroidTreeView.App.Tests/BootSmokeTests.cs`
+
+覆盖:导航、单/多设备选择、禁止默认选择第一台、选包取消、`boot` / `init_boot` 目标选择、
+recovery-only 阻塞、两个确认门、锁定设备、ADB → fastboot 身份匹配失败、A/B 警告、第二槽失败、
+重试、取消、语言切换、DI 图和 XAML 启动。
+再运行全量 build/test/format,确认设备列表与现有 best-effort fastboot 行为未回归。
+
+验收命令:
+
+```bash
+dotnet restore AndroidTreeView.sln -p:EnableWindowsTargeting=true
+dotnet build AndroidTreeView.sln -c Release --no-restore -p:EnableWindowsTargeting=true
+dotnet test AndroidTreeView.sln -c Release --no-build -p:EnableWindowsTargeting=true
+dotnet format AndroidTreeView.sln --verify-no-changes
+```
+
+### Task 12:发布验证、文档同步和 M1
+
+修改:`CLAUDE.md`、`AGENTS.md`、`docs/architecture.md`、`docs/app-contract.md`、
+`docs/packaging.md`、`docs/publishing.md`、`docs/roadmap-features.md` 和统一版本位置。
+
+发布前:
+
+1. 生成 App 的 `win-x64` 与 `osx-arm64` ZIP,确认 fastboot、Magisk APK、payload-dumper 和执行位。
+2. 生成两个 Mini ZIP,确认没有 `root-tools/`,体积与依赖没有意外增长。
+3. 在干净 Windows 与 macOS 机器启动 App,完成选包、提取和到达第一次确认点的无写入 smoke test。
+4. 按 M1 在可恢复真机执行刷写;记录设备、包版本、槽位、命令结果和回滚方式。
+5. M1 通过后再 bump 版本、更新 changelog 并进入发布流程。
+
+## 6. 完成定义
+
+- M0、M1 有可复核记录,且没有未说明的手工步骤。
+- 两个平台可从发布 ZIP 启动并走完整向导。
+- 未解锁、fastboot 身份未验证、目标分区或槽位未知、包不匹配、包损坏、工具缺失和命令失败都
+ 无法越过写入门。
+- 原始目标镜像在任何刷写前已完成本地备份,失败或取消不会删除。
+- App 不因正常设备/命令错误崩溃;错误均有中英文文案和可操作的下一步。
+- 全量测试通过,两个 ResX 键集合一致,Mini 不携带 Root 工具。
+
+## 7. 明确不在本计划内
+
+- 自动解锁 bootloader。
+- 刷写 `boot` / `init_boot` 以外分区。
+- 自动回刷、卸载 Root 或救砖向导。
+- 厂商私有包与动态分区拆包。
+- Linux 发布包或 macOS x64 发布包。
diff --git a/docs/superpowers/specs/2026-07-09-semi-auto-root-design.md b/docs/superpowers/specs/2026-07-09-semi-auto-root-design.md
new file mode 100644
index 0000000..249a3d4
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-09-semi-auto-root-design.md
@@ -0,0 +1,352 @@
+# 半自动 Root 功能 — 设计规格
+
+- 日期:2026-07-09
+- 状态:代码已实现并通过自动化验证;Magisk 修补链路待 M0、真实刷写待 M1 实机验证
+- 关联记忆:`root-feature-relaxes-safety`
+- 实施计划:[`../plans/2026-07-10-semi-auto-root-implementation.md`](../plans/2026-07-10-semi-auto-root-implementation.md)
+
+> 仓库现状校正(2026-07-10):当前代码已包含 `IFastbootService` / `FastbootService`、fastboot
+> 设备列表合并、Windows/macOS App 打包和 App 内 fastboot 分发。实施时扩展这些现有能力,
+> 不再新建第二套 fastboot locator/environment 或 platform-tools 目录。Magisk“安装 APK 后直接
+> 调用组件”仍是 M0 硬门槛,未通过前不得进入刷写主链路。
+
+> 实现校正(2026-07-14):普通 Android shell 不保证提供 `unzip`,安装 APK 也不会把 `assets`
+> 自动展开为可执行文件。实际实现先安装同一固定官方 APK,再由桌面端 `ZipArchive` 从经过
+> SHA-256 校验的 APK 中按精确白名单提取官方脚本和当前 ABI 组件,逐个 push 到隔离会话目录。
+> 这不引入第三方 `magiskboot`,并移除了设备端 `unzip` 依赖。真实 flash 不再由环境变量门控,
+> 仅由应用内确认页与显式风险勾选把关;用户确认后即写入目标分区。
+
+## 1. 目标与范围
+
+为 AndroidTreeView 增加一个**半自动 Root 功能**:用户手动上传刷机包,工具根据设备安全选择
+`boot.img` 或 `init_boot.img`、用官方 Magisk 组件修补、引导进入 bootloader、把修补后的镜像
+刷入对应分区。以**分步向导**形态交付,写操作前强制确认,刷入前自动备份原始未修补镜像。
+
+### 1.1 第一版明确要做
+
+- 上传刷机包(本地文件选择)。
+- 提取目标启动镜像:
+ - zip 内含裸 `boot.img` / `init_boot.img`(含 Pixel factory 的嵌套 `image-*.zip`)。
+ - `payload.bin`(A/B OTA),通过打包的 `payload_dumper` 按需解出 `boot` / `init_boot`。
+ - 设备明确存在 `init_boot`、内核满足 GKI 13+ 且包中也有 `init_boot.img` 时选择 `init_boot`;
+ 其他受支持设备选择 `boot`。证据矛盾、目标镜像缺失,或 Magisk 判定必须修补 recovery 时
+ 阻止流程,第一版不猜测、不刷 recovery。
+- Magisk 修补:工具**构建时打包固定版本官方 Magisk APK**(SHA-256 校验),运行时
+ `adb install` 到手机,调用**已装 Magisk App 自带的** `boot_patch.sh` + native 组件在手机端
+ 完成修补,pull 回修补后镜像。
+- 写操作链路:`adb push` → `adb reboot bootloader` → **flash 目标启动分区(A/B 机型两个槽都刷)** →
+ `fastboot reboot`。
+ - **非 A/B 机型**:`fastboot flash `。
+ - **A/B 机型**:`fastboot flash _a ` **且** `fastboot flash _b `
+ (两槽都刷入同一修补后镜像,避免槽位翻转/刷错槽导致 Magisk 不生效)。
+ - 刷入前**强制提示用户**双槽都会被写入及其风险(见 §5.3 确认 2、§3 已知风险)。
+- 只读检测:`fastboot getvar unlocked`(解锁状态)、**A/B 布局探测
+ (联合 `slot-count`、`has-slot:`、`current-slot`,矛盾或缺失时阻止刷写)**、设备 ABI 探测。
+- ADB → fastboot 身份连续性:重启前保存 fastboot 基线和所选 ADB 设备身份;重启后只接受新出现且
+ serial / USB 路径 / product 等证据能匹配的设备。无法证明是同一设备时阻止刷写。
+- **刷入前自动备份原始未修补目标镜像** 到本地用户目录。
+- 平台:**Windows + macOS 双平台**,共用同一套 MVVM 代码。
+
+### 1.2 第一版明确不做(后续再议)
+
+- `fastboot flashing unlock`(解锁 bootloader,会抹数据)——**仅检测状态 + 文字引导,不代执行**。
+- 刷其他分区(system / vbmeta / recovery / super 等)。
+- 卸载 root / 一键恢复原厂 boot(仅提供备份文件与引导,不做自动回刷向导)。
+- 厂商私有包格式(`.ozip`、动态分区 super.img 拆分等)。
+
+## 2. 方向性决策(已与用户确认)
+
+| 决策 | 选择 | 理由 |
+|---|---|---|
+| 与"只读/安全"定位冲突 | **放宽定位**,接受工具含刷机写操作 | 用户明确要 fastboot 刷入的半自动 root |
+| 支持平台 | **Windows + macOS 双平台** | 两平台都要能真正刷机 |
+| Magisk 修补执行位置 | **手机端执行官方 magiskboot/组件** | 官方 Android 二进制天然可跑,比桌面第三方二进制可靠 |
+| Magisk 组件获取 | **构建时打包固定版 Magisk APK,运行时 `adb install`,用已装 App 自带组件修补** | APK 内即含各 ABI native 组件;装 App 后 `boot_patch.sh` 环境完整,比裸跑 `/data/local/tmp` 坑少 |
+| 自动化程度 | **分步向导 + 写操作前强制确认** | 变砖风险高,不做无人值守一键刷 |
+| 包格式范围 | **zip(含 Pixel 嵌套) + payload.bin** | 覆盖 factory image 与现代 A/B OTA |
+| 目标启动分区 | **仅在 `init_boot` 存在 + GKI 13+ + 包含对应镜像时选 `init_boot`,否则选 `boot`;歧义或 recovery-only 时阻止** | 对齐 Magisk 官方 `find_boot_image` 逻辑,避免把补丁刷进错误分区 |
+| ADB → fastboot 身份连续性 | **重启前记录基线,重启后只接受有充分身份证据的新设备** | 唯一设备不等于目标设备,无法证明身份时必须阻止刷写 |
+| A/B 机型刷入策略 | **目标分区的 `_a` / `_b` 两个槽都刷同一镜像 + 刷入前强制提示** | 免探测 active slot、免刷错槽;代价(跨版本双槽变砖)以提示告知用户承担 |
+| 工具打包 | **构建时按平台下载打包进 `tools/`** | 对齐现有 scrcpy/adb 打包,开箱即用 |
+| UI 落点 | **App 新导航页,复用现有 Windows/macOS App 发布链路** | 双平台共用同一套 Avalonia MVVM 代码 |
+| 修补第 4 步实现 | **安装官方 Magisk APK 后跑其自带 `boot_patch.sh`** | 最贴近官方;实机验证列为高风险里程碑 |
+
+## 3. 可行性结论
+
+核心链路技术上全部可实现,跨平台可行。真正的风险**不在技术**,而在三个工具无法根除、
+必须由用户承担的前提:
+
+- **前提 1 — bootloader 已解锁**:未解锁则 `fastboot flash` 必然失败。工具只检测并引导,
+ 不自动解锁(解锁抹数据、需在手机上按键、部分厂商需解锁码)。
+- **前提 2 — 包与设备匹配**:刷错版本/架构可能变砖。工具做基本校验与警告,但无法保证包正确。
+- **前提 3 — A/B 双槽刷入的跨版本风险(已知、以提示承担)**:A/B 机型两个槽都刷入同一
+ 修补后镜像。若两个槽当前系统版本不一致(如 OTA 后未满一个周期),把当前版本的目标启动镜像刷进
+ 另一槽会造成 boot 与该槽 system/vendor 版本错配,**日后回滚到该槽可能 bootloop**(延迟变砖,
+ 刷入当下无异常)。工具**无法可靠检测两槽版本**,故在**确认 2 强制提示**用户此风险,由用户
+ 在知情后承担(对应 §2 决策:省掉刷错槽 → 接受此代价)。
+
+设计上以**分步向导 + 强制确认 + 自动备份原始目标镜像** 将风险降到可控。
+
+**最高技术不确定性**:Magisk 修补的第 4 步(执行 `boot_patch.sh`)不是单条命令。现方案先
+`adb install` 官方 Magisk APK,再调用其自带的 `boot_patch.sh` + native 组件——装 App 后
+`boot_patch.sh` 环境(`MAGISKBIN`/APK 资源)更完整,比裸跑 `/data/local/tmp` 可靠,但
+**"已装 App 的 `boot_patch.sh` 能否被 `adb shell` 直接调到"仍需实机验证**(assets 内脚本安装后
+不一定落在可直接执行位置,可能仍需从 APK 提取),**必须实机验证**(见 §8 里程碑)。
+
+## 4. 分层与工程落点
+
+严格遵循项目分层(下层不引用上层):
+
+```
+Models FlashPackage, BootImageInfo(Path, Source, OriginalPackageName, TargetPartition),
+ BootPartitionTarget(enum), RootWizardState(enum), DeviceFastbootStatus, PackageType(enum)
+ ↑
+Core 接口:IBootImageExtractor, IMagiskPatcher, IFastbootService, IRootWizardService
+ IExternalCommandRunner、RootException 类型
+ ↑
+Adb / Infrastructure
+ Adb:扩展现有 FastbootService、
+ BootImageExtractor(zip + payload.bin)、MagiskPatcher(install APK + 手机端执行)、
+ Parsers:FastbootVarParser、PackageTypeDetector、BootPartitionTargetDetector、CpuAbiParser
+ Infra:BootBackupService(备份原始目标镜像到用户目录)
+ ↑
+Shared AddAndroidTreeViewSharedServices() 内 TryAdd 注册全部新服务
+ ↑
+App RootWizardViewModel + RootWizardView(新导航页,分步向导)
+```
+
+- 所有 fastboot/adb 写操作走 `ProcessRunner`(异步、可取消、杀进程树)。
+- 解析类为纯函数放 `AndroidTreeView.Adb.Parsers`,配套解析测试(项目硬规则)。
+- fastboot 复用 App 已有 `scrcpy/fastboot[.exe]`;Magisk APK 与 payload-dumper 通过新建
+ `build/AndroidTreeView.RootTools.targets` 按发布 RID 下载、校验并只打包进完整 App。
+
+## 5. 向导状态机与流程
+
+### 5.1 状态枚举 `RootWizardState`
+
+```
+Idle 初始
+PackageSelected 已选刷机包
+Extracting 检测并提取 boot/init_boot 目标镜像(自动)
+BootExtracted 已提取(含来源:PlainZip / NestedZip / Payload)
+Blocked_UnsupportedTarget 目标分区证据冲突、镜像缺失或设备必须修补 recovery,禁止继续
+Patching 修补中(adb install Magisk APK → 手机执行 boot_patch.sh → pull 回)
+BootPatched 已得修补后目标镜像 + 已本地备份原始目标镜像
+AwaitingBootloaderConfirm ⛔ 强制确认点 1:即将重启进 bootloader
+RebootingToBootloader adb reboot bootloader(自动)
+InFastboot 已确认同一设备进入 fastboot,检测解锁状态 + 目标分区 A/B 布局
+Blocked_DeviceMismatch 无法证明 fastboot 设备与所选 ADB 设备相同,禁止继续
+Blocked_Locked 未解锁:引导用户,终止自动流程(可重新检测)
+AwaitingFlashConfirm ⛔ 强制确认点 2:即将 flash 目标分区(显示分区/槽位;A/B 双槽都刷)
+Flashing fastboot flash (A/B:先 _a 再 _b,自动)
+Rebooting fastboot reboot(自动)
+Completed 完成
+Failed 任一步失败(带错误信息 + 重试当前步 / 中止)
+```
+
+### 5.2 Happy Path 时序
+
+```
+选包 → [自动]提取 → [自动]修补+备份 → ⛔确认1 → [自动]进bootloader
+ → 检测解锁 → ⛔确认2 → [自动]flash → [自动]reboot → 完成
+```
+
+### 5.3 两个强制确认点
+
+1. **确认 1(进 bootloader 前)**:提示手机将重启进 fastboot、保持数据线稳定。
+2. **确认 2(flash 前)**:显示目标分区 `boot` 或 `init_boot`、修补后镜像、**原始镜像备份路径**、
+ **目标槽位(A/B 机型明确显示将同时刷入 `_a` 和 `_b`)**、红色"刷错可能变砖"警告;
+ **A/B 机型额外红字提示**:两个槽都会被写入同一镜像,若两槽系统版本不一致,日后回滚到另一槽
+ 可能变砖(§3 前提 3);用户勾选"我已了解风险"后方可点"刷入"。
+
+### 5.4 关键分支
+
+- **Blocked_Locked(未解锁)**:向导停止,显示解锁引导(开发者选项 OEM 解锁、
+ `fastboot flashing unlock` 会抹数据、部分厂商需解锁码)。**工具不代执行解锁**。
+ 用户自行解锁后可"重新检测"继续。
+- **Blocked_DeviceMismatch(身份不连续)**:若目标重启后没有出现新 fastboot 设备,或新设备的
+ serial / USB 路径 / product 无法与重启前记录相互印证,禁止继续;不得因列表中只剩一台设备就选它。
+- **Blocked_UnsupportedTarget(目标分区不受支持)**:设备与包对 `boot` / `init_boot` 的证据冲突、
+ 目标镜像缺失,或设备必须修补 recovery 时终止流程;第一版不允许用户绕过该阻塞。
+- **Failed(任一步失败)**:显示该步 stderr/错误摘要(映射成友好文案),提供"重试当前步"或
+ "中止"。中止不留危险中间态(若已在 fastboot,引导用户 `fastboot reboot` 回系统)。
+- **可取消**:每个自动步走 `CancellationToken`,用户可随时取消(取消后进入 Failed/可重试)。
+
+## 6. 核心机制
+
+### 6.1 目标启动镜像提取(`BootImageExtractor`)
+
+输入包文件和设备探测结果,按类型分派,输出目标镜像本地路径、目标分区 + 来源标记
+(`BootImageInfo { Path, TargetPartition, Source, OriginalPackageName }`)。
+
+**包类型判定**(纯函数 `PackageTypeDetector`,读文件头/枚举 zip 条目):
+
+- zip 顶层有 `boot.img` / `init_boot.img` → **PlainZip**
+- zip 含 `image-*.zip`(Pixel factory)→ **NestedZip**(解一层内层 zip 再取目标镜像)
+- zip 含 `payload.bin` → **Payload**
+- 裸 `payload.bin` 文件 → **Payload**
+- 都不匹配 → 抛 `RootException`,友好提示"未在包内找到受支持的 boot/init_boot 镜像"
+
+**提取**:
+
+- PlainZip / NestedZip → 纯 C# `System.IO.Compression`,无外部依赖。
+- Payload → 调用打包的 `payload_dumper`(走 `ProcessRunner`)按探测结果解出 `boot` 或 `init_boot`。
+- 目标判定必须同时满足设备侧分区、GKI 版本证据与包内候选镜像;只有 `init_boot` 存在且内核为
+ GKI 13+ 时才选择 `init_boot`。设备要求 `init_boot` 但包中缺失,或两侧证据矛盾时返回阻塞错误。
+ 实际目标镜像落盘后解析标准 Android boot header v0-v4,并验证 header、页布局、文件边界和
+ `ramdisk_size`。`boot` 的 ramdisk 为零时按 recovery-only 明确阻断;未知版本、OEM 包装或解析
+ 矛盾时 fail closed,不猜测 `boot`、`recovery` 或其他分区。
+- 输出到工作区 `~/.androidtreeview/root-work//`。
+
+**可测试性**:`PackageTypeDetector` 纯函数(zip 条目列表/文件头样本做测试);payload 解包用假
+`ProcessRunner` 测试流程/错误。
+
+### 6.2 Magisk 修补(`MagiskPatcher`,安装官方 APK 后手机端执行)
+
+前提:设备已连接、adb 已授权(复用现有 `DeviceMonitor` / 设备状态)。
+
+1. **安装 Magisk App**(`EnsureMagiskInstalledAsync`):`adb install -r tools/magisk/Magisk.apk`
+ (`-r` 覆盖重装,兼容用户已装旧版)。APK 内即含各 ABI native 组件(`lib//lib*.so`)。
+ 装 App **不设新强制确认点**,仅在步骤条/日志告知"正在安装 Magisk App"(loc key
+ `root.step.install_magisk`)。install 失败进 `Failed`。
+2. **探测 ABI**:`adb shell getprop ro.product.cpu.abi`(纯函数 `CpuAbiParser`),用于定位已装
+ Magisk App 的 native 组件路径(`lib/`)。
+3. **本地备份原始目标镜像**(`BootBackupService`):复制到
+ `~/.androidtreeview/root-backups/---original.img`,路径供确认 2 显示。
+4. **修补**:push 待修补目标镜像到手机临时目录 `/data/local/tmp/atv_root/`,`adb shell` 调用
+ **已装 Magisk App 自带的** `boot_patch.sh`(其可自解析 `MAGISKBIN`/APK 资源,内部调用
+ `magiskboot unpack/cpio/repack` + `magiskinit`/`magisk`),产出 `new-boot.img`。
+5. **pull 回**:`adb pull .../new-boot.img /boot-patched.img`。
+6. **清理**手机临时目录。
+
+上述所有 ADB 命令都必须显式指定向导开始时锁定的 ADB serial,不依赖 adb 的“唯一设备”隐式选择。
+
+> ⚠️ 第 4 步为最高技术不确定性环节:装 App 后 `boot_patch.sh` 环境更完整,但**"已装 App 的
+> `boot_patch.sh` 能否被 `adb shell` 直接调到"仍未完全消除不确定性**(APK 内 `assets` 脚本安装后
+> 不一定落在可直接执行位置,可能仍需从 APK 提取脚本再 push),需实机验证(见 §8)。
+
+### 6.3 fastboot 服务(`FastbootService`,走 `ProcessRunner`,全部 async + CancellationToken)
+
+| 方法 | 命令 | 说明 |
+|---|---|---|
+| `RebootToBootloaderAsync` | `adb reboot bootloader` | 从系统进 fastboot |
+| `CaptureFastbootBaselineAsync` | `fastboot devices -l` | 重启前记录已有 fastboot serial / USB 身份,防止误认其他设备 |
+| `WaitForMatchingFastbootDeviceAsync` | `fastboot devices -l` 轮询 + `getvar product` | 只接受新出现且能与所选 ADB 设备相互印证的设备 |
+| `GetUnlockStatusAsync` | `fastboot getvar unlocked` | 只读,`FastbootVarParser` 解析 `unlocked: yes/no` |
+| `GetBootLayoutAsync` | `fastboot getvar slot-count` + `has-slot:` + `current-slot` | 只读;联合判定目标分区 A/B,矛盾或证据全缺时返回 Unknown;`slot-count=0/1` 与 `has-slot=no` 同为单槽的肯定证据 |
+| `FlashBootAsync` | 非 A/B:`fastboot flash `;A/B:依次刷 `_a`、`_b` | 核心写操作;A/B 两槽都刷同一镜像 |
+| `RebootAsync` | `fastboot reboot` | 刷完回系统 |
+
+解析 `getvar` / `fastboot devices -l` 输出为纯函数 + 解析测试。所有设备命令都必须携带
+`-s `;单纯“列表中只有一台”不是身份匹配证据。`slot-count`、
+`has-slot:`、`current-slot` 复用同一 `FastbootVarParser`。A/B 双槽刷入时,第一槽成功、第二槽失败要
+视为整体失败(进 `Failed`,错误摘要注明哪个槽失败),不可停在"只刷了一个槽"的中间态。
+
+### 6.4 fastboot 定位(复用现有 `FastbootService`)
+
+现有 `FastbootService.ExecutablePath` 从 `IAdbEnvironment` 已定位的 adb 同目录解析
+`fastboot[.exe]`;发布脚本也已把 fastboot 放入 App 的 `scrcpy/` 目录。Root 向导扩展该服务的
+严格检测/刷写结果,不新建第二套 locator/environment。缺失时向导显示“未找到 fastboot”,不崩溃。
+
+### 6.5 备份服务(`BootBackupService`,Infrastructure 层)
+
+- 修补前把原始目标镜像存到 `~/.androidtreeview/root-backups/`,文件名含序列号、目标分区 + 时间戳。
+- 提供"列出/打开备份目录"给 UI,便于变砖时找回。
+
+## 7. 工具打包 — `build/AndroidTreeView.RootTools.targets`
+
+仿 `AndroidTreeView.Scrcpy.targets`,构建/发布时按平台下载并打包:
+
+```
+tools/
+ scrcpy/ 现有目录,已含 adb + fastboot
+ root-tools/
+ payload-dumper/ win-x64 / osx-arm64 对应 payload-dumper-go
+ magisk/
+ Magisk.apk 固定版本官方 APK,运行时 adb install
+```
+
+- Root 工具只打包进完整 App;Mini / Mini.Mac 不携带。
+- 下载源写明 URL + SHA-256 校验,文档登记版本,对齐项目"下载工具需校验 SHA-256"风格:
+ - fastboot:复用现有 Google platform-tools 下载与 App 打包路径。
+ - magisk:官方 Magisk GitHub release 的 APK 整包(不在桌面侧拆 native 二进制,组件随 App
+ 安装到手机后使用)。
+ - payload_dumper:可信开源发布。
+- 新增 `tools/verify-roottools-latest.ps1`(仿 `verify-scrcpy-latest.ps1`)检查上游新版本。
+
+## 8. 高风险里程碑(实机验证)
+
+以下无法单测,须实机验证并在开发中预留调整轮次:
+
+1. **`boot_patch.sh` 修补链路** — `adb install` 官方 Magisk APK 后,能调用其自带
+ `boot_patch.sh` 在真机产出可用 `new-boot.img`(重点验证已装 App 的脚本是否可被 `adb shell`
+ 直接调到);`boot` 与 `init_boot` 两种目标都必须留下验证记录。
+2. **真实 flash** — `fastboot flash boot` 与 `fastboot flash init_boot` 分别在对应的已解锁真机上
+ 成功且能正常开机。
+3. **payload.bin 解包** — `payload_dumper` 在 `win-x64`、`osx-arm64` 均能按目标解出
+ `boot` / `init_boot` 分区。
+4. **跨平台 fastboot/adb** — Windows x64 与 macOS arm64 下二进制均能定位与执行。
+
+## 9. App UI
+
+- 新导航页,命名遵循 ViewLocator 约定(`RootWizardViewModel` → `RootWizardView`),严格 MVVM
+ (`[ObservableProperty]` / `[RelayCommand]`),编译绑定 `x:DataType`。
+- 向导第一步先显示在线且已授权的设备单选列表;多设备时不预选,必须由用户明确选择。开始提取后
+ 锁定所选设备身份,除非中止并重开向导,否则不能切换目标设备。
+- 侧栏新增导航项(新 loc key `nav.root`)。页面:顶部步骤条反映 `RootWizardState`,中间当前步
+ 内容/进度/日志,底部操作按钮(下一步/确认/取消/重试)。
+- 两个强制确认为醒目对话框 + 复选框(未勾选"我已了解风险"则"刷入"禁用)。
+- flash 步骤用红色警告样式,与只读功能区分。
+- `RootWizardViewModel` 仅编排 `IRootWizardService`,负责状态→UI 映射、UI 线程 marshal
+ (`Dispatcher.UIThread.Post`)、错误映射成友好 `ErrorMessage`(App 永不因正常错误崩溃)。
+- macOS 支持:让 App 能在 macOS 构建/运行,Root 页双平台共用同一 MVVM 代码。
+
+## 10. 本地化
+
+- 所有向导文案、按钮、警告、错误映射、引导说明新增 key 到**两个 ResX 都加**
+ (`Strings.resx` 英文 + `Strings.zh-Hans.resx` 中文),键集保持一致。
+- 命名示例:`root.wizard.title`、`root.step.extract`、`root.step.install_magisk`(安装 Magisk
+ App 步骤/日志文案)、`root.confirm.flash.warning`、
+ `root.confirm.flash.ab.dualslot`(双槽刷入提示)、`root.confirm.flash.targetslot`(目标槽位显示)、
+ `root.confirm.flash.targetpartition`(`boot` / `init_boot`)、
+ `root.blocked.fastboot.identity`(ADB → fastboot 身份无法验证)、
+ `root.blocked.partition.unsupported`(目标分区歧义或 recovery-only)、
+ `root.blocked.locked.guide`、`root.error.*`(含 `root.error.install.magisk`:装 APK 失败;
+ `root.error.flash.slotb`:第二槽刷入失败)。
+- XAML 用 `{loc:Localize Key=...}`,VM 用 `_localization.Get/Format`。
+
+## 11. 测试
+
+- **Adb.Tests** 新增:
+ - `Parsers/FastbootVarParserTests`(`unlocked` / `slot-count` / `has-slot:` /
+ `current-slot` / `fastboot devices -l` / 身份证据)
+ - `Parsers/PackageTypeDetectorTests`(zip 条目 → 包类型)
+ - `Parsers/BootPartitionTargetDetectorTests`(设备/包证据 → `boot` / `init_boot` / 阻塞)
+ - `Parsers/CpuAbiParserTests`(`ro.product.cpu.abi` 解析)
+ - `Commands/`:fastboot/adb argv 构建测试(含 `boot` / `init_boot` 和 A/B 双槽 argv)
+ - `Services/`:`BootImageExtractor`、`MagiskPatcher`、`FastbootService`(假
+ `IExternalCommandRunner`,
+ 覆盖流程与错误路径;`MagiskPatcher` 覆盖 `adb install` 成功→调 `boot_patch.sh`、install
+ 失败→`Failed`、ABI 探测;`FlashBootAsync` 覆盖 `boot` / `init_boot`、非 A/B 单槽、A/B 双槽成功、
+ A/B 第二槽失败→整体失败)
+- **App.Tests** 新增:`RootWizardViewModel` 状态机推进、确认门控(未勾选不能 flash)、错误映射、
+ 未解锁→Blocked 分支;DI 图解析新服务(`ServiceGraphTests`)。
+- **实机验证**(§8):无法单测,作为手动验证清单。
+
+## 12. CLAUDE.md 更新
+
+因放宽只读定位,实现时需:
+
+- 修改"Read-only / safe by design"条目:说明工具现含**受控、需多重确认的刷机写操作**(仅
+ Root 向导内 boot 刷入链路),其余功能仍保持只读;解锁 bootloader 仍不自动执行。
+- Architecture / Bundled tools 段补充 fastboot、magisk 组件、payload_dumper 打包说明。
+- 版本按发布规则统一 bump。
+
+## 13. 明确的非目标(YAGNI)
+
+- 不做 bootloader 自动解锁。
+- 不做 boot 以外分区刷写。
+- 不做一键恢复/卸载 root 向导(仅备份 + 引导)。
+- 不支持厂商私有包格式。
+- 不引入桌面版第三方 `magiskboot`(改用安装官方 Magisk APK 后调用其自带组件,不在桌面侧拆
+ native 二进制)。
diff --git a/packaging/Bundle.wxs b/packaging/Bundle.wxs
index e305dad..fabe6b9 100644
--- a/packaging/Bundle.wxs
+++ b/packaging/Bundle.wxs
@@ -8,13 +8,13 @@
Build after the MSI has been produced:
wix build packaging/Bundle.wxs -arch x64 ^
- -d ProductVersion=1.0.6 ^
+ -d ProductVersion=1.0.7 ^
-d Platform=x64 ^
- -d MsiPath=artifacts\AndroidTreeView-1.0.6-x64.msi ^
+ -d MsiPath=artifacts\AndroidTreeView-1.0.7-x64.msi ^
-d DotNetRuntimeExe=C:\path\windowsdesktop-runtime-10.0.x-win-x64.exe ^
-ext WixToolset.BootstrapperApplications.wixext ^
-ext WixToolset.Netfx.wixext ^
- -o artifacts\AndroidTreeView-1.0.6-x64-setup.exe
+ -o artifacts\AndroidTreeView-1.0.7-x64-setup.exe
-->
-
+
diff --git a/packaging/build-msi.ps1 b/packaging/build-msi.ps1
index 3266be6..1b4ae22 100644
--- a/packaging/build-msi.ps1
+++ b/packaging/build-msi.ps1
@@ -27,7 +27,7 @@
Build configuration. Default: Release.
.PARAMETER Version
- Product version. Default: 1.0.6. Must match AndroidTreeView.Core.AppInfo.Version.
+ Product version. Default: 1.0.7. Must match AndroidTreeView.Core.AppInfo.Version.
.EXAMPLE
./build-msi.ps1 -Product App -Arch x64
@@ -49,7 +49,7 @@ param(
[string]$Configuration = 'Release',
- [string]$Version = '1.0.6'
+ [string]$Version = '1.0.7'
)
$ErrorActionPreference = 'Stop'
diff --git a/packaging/build-update-zip.ps1 b/packaging/build-update-zip.ps1
index e5f807f..f710f71 100644
--- a/packaging/build-update-zip.ps1
+++ b/packaging/build-update-zip.ps1
@@ -27,7 +27,7 @@ param(
[string]$Configuration = 'Release',
- [string]$Version = '1.0.6'
+ [string]$Version = '1.0.7'
)
$ErrorActionPreference = 'Stop'
@@ -35,7 +35,11 @@ $ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Split-Path -Parent $scriptDir
$artifacts = Join-Path $repoRoot 'artifacts'
-$scrcpyVersion = '4.0'
+$scrcpyVersion = '4.1'
+$platformToolsVersion = '37.0.0'
+$magiskVersion = '30.7'
+$magiskSha256 = 'e0d32d2123532860f97123d927b1bb86c4e08e6fd8a48bfc6b5bee0afae9ebd5'
+$payloadDumperVersion = '1.3.0'
function Assert-UnderDirectory {
param(
@@ -106,6 +110,7 @@ function Get-ProductConfig {
ArtifactName = 'AndroidTreeView-Mini'
Executable = if ($RidInfo.IsWindows) { 'AndroidTreeView.App.mini.exe' } else { 'AndroidTreeView.App.mini' }
BundleFastboot = $false
+ BundleRootTools = $false
}
}
default {
@@ -116,6 +121,7 @@ function Get-ProductConfig {
ArtifactName = 'AndroidTreeView'
Executable = if ($RidInfo.IsWindows) { 'AndroidTreeView.App.exe' } else { 'AndroidTreeView.App' }
BundleFastboot = $true
+ BundleRootTools = $true
}
}
}
@@ -148,6 +154,28 @@ function Invoke-Download {
Invoke-WebRequest @params
}
+function Assert-FileSha256 {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Path,
+
+ [Parameter(Mandatory = $true)]
+ [string]$ExpectedSha256,
+
+ [Parameter(Mandatory = $true)]
+ [string]$AssetName
+ )
+
+ if (-not (Test-Path -LiteralPath $Path)) {
+ throw "Cannot verify missing asset '$AssetName' at '$Path'."
+ }
+
+ $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant()
+ if ($actual -ne $ExpectedSha256.ToLowerInvariant()) {
+ throw "SHA-256 mismatch for '$AssetName': expected '$ExpectedSha256', got '$actual'."
+ }
+}
+
function Expand-ToolArchive {
param(
[Parameter(Mandatory = $true)]
@@ -272,8 +300,8 @@ function New-IcnsFromPng {
}
New-Item -ItemType Directory -Force -Path $iconsetDir | Out-Null
- # name => pixel size for the standard iconset slots (1x + @2x). Upscaled slots beyond the
- # 256px source are slightly soft but valid; Dock/Finder common sizes stay crisp.
+ # name => pixel size for the standard iconset slots (1x + @2x). macOS app artwork occupies
+ # about 81% of each icon canvas; the transparent safe area keeps it aligned with system icons.
$slots = [ordered]@{
'icon_16x16.png' = 16
'icon_16x16@2x.png' = 32
@@ -289,10 +317,20 @@ function New-IcnsFromPng {
foreach ($entry in $slots.GetEnumerator()) {
$target = Join-Path $iconsetDir $entry.Key
- & sips -z $entry.Value $entry.Value $SourcePng --out $target *> $null
+ $contentSize = [Math]::Max(1, [Math]::Round($entry.Value * 0.8125))
+ $resizedTarget = "$target.resized.png"
+
+ & sips -z $contentSize $contentSize $SourcePng --out $resizedTarget *> $null
if ($LASTEXITCODE -ne 0) {
- throw "sips failed to render '$($entry.Key)' from '$SourcePng'."
+ throw "sips failed to resize '$($entry.Key)' from '$SourcePng'."
}
+
+ & sips -p $entry.Value $entry.Value $resizedTarget --out $target *> $null
+ if ($LASTEXITCODE -ne 0) {
+ throw "sips failed to add the macOS safe area to '$($entry.Key)'."
+ }
+
+ Remove-Item -Force -LiteralPath $resizedTarget
}
& iconutil -c icns $iconsetDir -o $OutputIcns
@@ -414,6 +452,29 @@ $iconPlistEntry CFBundleIdentifier
Set-ExecutableBits -RidInfo $RidInfo -Directory $macOSDir -Names @($ProductConfig.Executable)
Set-ExecutableBits -RidInfo $RidInfo -Directory (Join-Path $macOSDir 'scrcpy') -Names @('scrcpy', 'adb', 'fastboot')
+ if ($ProductConfig.BundleRootTools) {
+ Set-ExecutableBits `
+ -RidInfo $RidInfo `
+ -Directory (Join-Path (Join-Path $macOSDir 'root-tools') 'payload-dumper') `
+ -Names @('payload-dumper-go')
+ }
+
+ if (-not (Get-Command codesign -ErrorAction SilentlyContinue)) {
+ throw 'codesign is required to produce a valid macOS app bundle.'
+ }
+
+ # dotnet signs the apphost before it is wrapped in the final bundle. Sign again after adding
+ # Info.plist and Resources so the bundle resource envelope matches the shipped contents.
+ # New-PackageArchive uses ditto to preserve the extended-attribute signatures on managed DLLs.
+ & codesign --force --deep --sign - --timestamp=none $bundleRoot
+ if ($LASTEXITCODE -ne 0) {
+ throw "codesign failed for '$bundleRoot'."
+ }
+
+ & codesign --verify --deep --strict $bundleRoot
+ if ($LASTEXITCODE -ne 0) {
+ throw "codesign verification failed for '$bundleRoot'."
+ }
return $BundleStageDir
}
@@ -473,15 +534,28 @@ function Ensure-Fastboot {
)
$fastbootPath = Join-Path $ScrcpyRoot $RidInfo.FastbootExecutable
+ $fastbootSha256 = switch ($RidInfo.Rid) {
+ 'win-x64' { 'dd55fef77ab2753b6423f37f39d91cb00ce53ab4539a2431577f07c4abcaa32a' }
+ 'osx-arm64' { '549420b5b6b843efd78bfcd47765bb4b581fa23093693bb65648fab9eaa5de7a' }
+ default { throw "No fastboot checksum is mapped for RID '$($RidInfo.Rid)'." }
+ }
if (Test-Path -LiteralPath $fastbootPath) {
- return
+ $actualFastbootSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $fastbootPath).Hash.ToLowerInvariant()
+ if ($actualFastbootSha256 -eq $fastbootSha256) {
+ return
+ }
}
$assetName = switch ($RidInfo.Rid) {
- 'win-x64' { 'platform-tools-latest-windows.zip' }
- 'osx-arm64' { 'platform-tools-latest-darwin.zip' }
+ 'win-x64' { "platform-tools_r$platformToolsVersion-win.zip" }
+ 'osx-arm64' { "platform-tools_r$platformToolsVersion-darwin.zip" }
default { throw "No platform-tools asset is mapped for RID '$($RidInfo.Rid)'." }
}
+ $archiveSha256 = switch ($RidInfo.Rid) {
+ 'win-x64' { '4fe305812db074cea32903a489d061eb4454cbc90a49e8fea677f4b7af764918' }
+ 'osx-arm64' { '094a1395683c509fd4d48667da0d8b5ef4d42b2abfcd29f2e8149e2f989357c7' }
+ default { throw "No platform-tools archive checksum is mapped for RID '$($RidInfo.Rid)'." }
+ }
$downloadDir = Join-Path (Join-Path (Join-Path $artifacts 'downloads') 'platform-tools') $RidInfo.Rid
$archivePath = Join-Path $downloadDir $assetName
@@ -491,6 +565,7 @@ function Ensure-Fastboot {
if (-not (Test-Path -LiteralPath $archivePath)) {
Invoke-Download -Uri $url -OutFile $archivePath
}
+ Assert-FileSha256 -Path $archivePath -ExpectedSha256 $archiveSha256 -AssetName $assetName
Expand-ToolArchive -ArchivePath $archivePath -Destination $extractDir
@@ -501,6 +576,10 @@ function Ensure-Fastboot {
}
Copy-Item -LiteralPath $sourceFastboot -Destination $fastbootPath -Force
+ Assert-FileSha256 `
+ -Path $fastbootPath `
+ -ExpectedSha256 $fastbootSha256 `
+ -AssetName $RidInfo.FastbootExecutable
if ($RidInfo.IsWindows) {
$winPthread = Join-Path $platformTools 'libwinpthread-1.dll'
@@ -512,6 +591,84 @@ function Ensure-Fastboot {
}
}
+function Ensure-RootTools {
+ param(
+ [Parameter(Mandatory = $true)]
+ [pscustomobject]$RidInfo
+ )
+
+ $payloadAssetName = switch ($RidInfo.Rid) {
+ 'win-x64' { "payload-dumper-go_${payloadDumperVersion}_windows_amd64.tar.gz" }
+ 'osx-arm64' { "payload-dumper-go_${payloadDumperVersion}_darwin_arm64.tar.gz" }
+ default { throw "No payload-dumper-go asset is mapped for RID '$($RidInfo.Rid)'." }
+ }
+ $payloadSha256 = switch ($RidInfo.Rid) {
+ 'win-x64' { '0f96e07477963327f7f50a03bf2aa9dac5c76dba110ab332dc759321ae345d52' }
+ 'osx-arm64' { 'e6b95df4b08e4bf452077e35cc2c0d644ce8fd454696d1aceedde6887ef0df84' }
+ default { throw "No payload-dumper-go checksum is mapped for RID '$($RidInfo.Rid)'." }
+ }
+ $payloadExecutableSha256 = switch ($RidInfo.Rid) {
+ 'win-x64' { 'cd017857a28d029e80b0830531bcc960be5bbd4b8c937b122024285197012cd7' }
+ 'osx-arm64' { '56d5dd0f402cecc2548a563d840f3fd9e707521b5bd398430f59894c79d08450' }
+ default { throw "No payload-dumper-go executable checksum is mapped for RID '$($RidInfo.Rid)'." }
+ }
+ $payloadExecutable = if ($RidInfo.IsWindows) { 'payload-dumper-go.exe' } else { 'payload-dumper-go' }
+ $magiskAssetName = "Magisk-v$magiskVersion.apk"
+
+ $downloadDir = Join-Path (Join-Path (Join-Path $artifacts 'downloads') 'root-tools') $RidInfo.Rid
+ $magiskDownload = Join-Path $downloadDir $magiskAssetName
+ $payloadDownload = Join-Path $downloadDir $payloadAssetName
+ $extractDir = Join-Path $downloadDir 'payload-extract'
+
+ if (-not (Test-Path -LiteralPath $magiskDownload)) {
+ Invoke-Download `
+ -Uri "https://github.com/topjohnwu/Magisk/releases/download/v$magiskVersion/$magiskAssetName" `
+ -OutFile $magiskDownload
+ }
+ Assert-FileSha256 -Path $magiskDownload -ExpectedSha256 $magiskSha256 -AssetName $magiskAssetName
+
+ if (-not (Test-Path -LiteralPath $payloadDownload)) {
+ Invoke-Download `
+ -Uri "https://github.com/ssut/payload-dumper-go/releases/download/$payloadDumperVersion/$payloadAssetName" `
+ -OutFile $payloadDownload
+ }
+ Assert-FileSha256 -Path $payloadDownload -ExpectedSha256 $payloadSha256 -AssetName $payloadAssetName
+
+ Expand-ToolArchive -ArchivePath $payloadDownload -Destination $extractDir
+ $payloadSourceRoot = Resolve-ExtractedToolRoot `
+ -ExtractDir $extractDir `
+ -ExecutableName $payloadExecutable
+
+ $rootToolsDir = Join-Path (Join-Path (Join-Path $artifacts 'tools') 'root-tools') $RidInfo.Rid
+ Assert-UnderDirectory -Path $rootToolsDir -Parent $artifacts
+ if (Test-Path -LiteralPath $rootToolsDir) {
+ Remove-Item -Recurse -Force -LiteralPath $rootToolsDir
+ }
+
+ $magiskDir = Join-Path $rootToolsDir 'magisk'
+ $payloadDir = Join-Path $rootToolsDir 'payload-dumper'
+ New-Item -ItemType Directory -Force -Path $magiskDir, $payloadDir | Out-Null
+ Copy-Item -LiteralPath $magiskDownload -Destination (Join-Path $magiskDir $magiskAssetName) -Force
+ Copy-Item `
+ -LiteralPath (Join-Path $payloadSourceRoot $payloadExecutable) `
+ -Destination (Join-Path $payloadDir $payloadExecutable) `
+ -Force
+ Assert-FileSha256 `
+ -Path (Join-Path $payloadDir $payloadExecutable) `
+ -ExpectedSha256 $payloadExecutableSha256 `
+ -AssetName $payloadExecutable
+ Set-ExecutableBits -RidInfo $RidInfo -Directory $payloadDir -Names @($payloadExecutable)
+
+ if (-not (Test-Path -LiteralPath (Join-Path $magiskDir $magiskAssetName))) {
+ throw "Root tools staging did not produce '$magiskAssetName'."
+ }
+ if (-not (Test-Path -LiteralPath (Join-Path $payloadDir $payloadExecutable))) {
+ throw "Root tools staging did not produce '$payloadExecutable'."
+ }
+
+ return $rootToolsDir
+}
+
function New-PackageArchive {
param(
[Parameter(Mandatory = $true)]
@@ -529,16 +686,18 @@ function New-PackageArchive {
}
if ($RidInfo.IsMacOS) {
- $zipFullPath = [System.IO.Path]::GetFullPath($ZipPath)
- Push-Location $SourceDir
- try {
- & zip -qry $zipFullPath .
- if ($LASTEXITCODE -ne 0) {
- throw "zip failed with exit code $LASTEXITCODE."
- }
+ if (-not (Get-Command ditto -ErrorAction SilentlyContinue)) {
+ throw 'ditto is required to preserve macOS app bundle signatures in ZIP packages.'
+ }
+
+ $appBundles = @(Get-ChildItem -LiteralPath $SourceDir -Directory -Filter '*.app')
+ if ($appBundles.Count -ne 1) {
+ throw "Expected exactly one .app bundle under '$SourceDir', found $($appBundles.Count)."
}
- finally {
- Pop-Location
+
+ & ditto -c -k --sequesterRsrc --keepParent $appBundles[0].FullName $ZipPath
+ if ($LASTEXITCODE -ne 0) {
+ throw "ditto failed with exit code $LASTEXITCODE."
}
return
@@ -564,6 +723,8 @@ $zipPath = Join-Path $artifacts "$baseName.zip"
$zipChecksumPath = "$zipPath.sha256"
$packageKind = if ($ridInfo.IsWindows) { 'portable-x64' } else { "portable-$($ridInfo.Rid)" }
$bundleFastbootValue = if ($productConfig.BundleFastboot) { 'true' } else { 'false' }
+$bundleRootToolsValue = if ($productConfig.BundleRootTools) { 'true' } else { 'false' }
+$enableWindowsTargetingValue = if ($ridInfo.IsWindows) { 'true' } else { 'false' }
# macOS has no system-level "install .NET runtime" prompt like Windows, so a framework-dependent
# .app silently fails to launch on machines without the runtime. Bundle the runtime into the macOS
# .app (self-contained). Windows stays framework-dependent: its apphost shows the OS download prompt.
@@ -583,6 +744,10 @@ $scrcpyDir = Ensure-ScrcpyBundle -RidInfo $ridInfo
if ($productConfig.BundleFastboot) {
Ensure-Fastboot -RidInfo $ridInfo -ScrcpyRoot $scrcpyDir
}
+$rootToolsDir = ''
+if ($productConfig.BundleRootTools) {
+ $rootToolsDir = Ensure-RootTools -RidInfo $ridInfo
+}
Write-Host "==> Publishing $($productConfig.ProductName) ($($ridInfo.Rid))..." -ForegroundColor Cyan
$publishArgs = @(
@@ -600,6 +765,9 @@ $publishArgs = @(
"-p:ScrcpyExecutableName=$($ridInfo.ScrcpyExecutable)",
"-p:FastbootExecutableName=$($ridInfo.FastbootExecutable)",
"-p:AndroidTreeViewBundleFastboot=$bundleFastbootValue",
+ "-p:AndroidTreeViewBundleRootTools=$bundleRootToolsValue",
+ "-p:EnableWindowsTargeting=$enableWindowsTargetingValue",
+ "-p:RootToolsDir=$rootToolsDir",
'-p:DebugType=None',
'-p:DebugSymbols=false',
'--output', $publishDir
@@ -626,6 +794,22 @@ if (-not $productConfig.BundleFastboot) {
Set-ExecutableBits -RidInfo $ridInfo -Directory $publishDir -Names @($productConfig.Executable)
Set-ExecutableBits -RidInfo $ridInfo -Directory $publishedScrcpyDir -Names @('scrcpy', 'adb', 'fastboot')
+$publishedRootToolsDir = Join-Path $publishDir 'root-tools'
+if ($productConfig.BundleRootTools) {
+ Assert-UnderDirectory -Path $publishedRootToolsDir -Parent $publishDir
+ if (Test-Path -LiteralPath $publishedRootToolsDir) {
+ Remove-Item -Recurse -Force -LiteralPath $publishedRootToolsDir
+ }
+ Copy-DirectoryContents -Source $rootToolsDir -Destination $publishedRootToolsDir
+ $publishedPayloadDumper = if ($ridInfo.IsWindows) { 'payload-dumper-go.exe' } else { 'payload-dumper-go' }
+ Set-ExecutableBits `
+ -RidInfo $ridInfo `
+ -Directory (Join-Path $publishedRootToolsDir 'payload-dumper') `
+ -Names @($publishedPayloadDumper)
+} elseif (Test-Path -LiteralPath $publishedRootToolsDir) {
+ throw "Mini publish unexpectedly contains App-only Root tools at '$publishedRootToolsDir'."
+}
+
$manifest = [ordered]@{
packageKind = $packageKind
product = $Product
diff --git a/src/AndroidTreeView.Adb/Commands/FastbootArgs.cs b/src/AndroidTreeView.Adb/Commands/FastbootArgs.cs
new file mode 100644
index 0000000..af2ad06
--- /dev/null
+++ b/src/AndroidTreeView.Adb/Commands/FastbootArgs.cs
@@ -0,0 +1,38 @@
+using AndroidTreeView.Models.Rooting;
+
+namespace AndroidTreeView.Adb.Commands;
+
+/// Argument arrays for strict root-workflow fastboot invocations.
+public static class FastbootArgs
+{
+ public static readonly string[] DevicesLong = { "devices", "-l" };
+
+ public static string[] GetVar(string serial, string variable)
+ => new[] { "-s", Require(serial, nameof(serial)), "getvar", Require(variable, nameof(variable)) };
+
+ public static string[] Flash(string serial, string partition, string imagePath)
+ => new[]
+ {
+ "-s",
+ Require(serial, nameof(serial)),
+ "flash",
+ Require(partition, nameof(partition)),
+ Require(imagePath, nameof(imagePath))
+ };
+
+ public static string[] Reboot(string serial)
+ => new[] { "-s", Require(serial, nameof(serial)), "reboot" };
+
+ public static string PartitionName(BootPartitionTarget target) => target switch
+ {
+ BootPartitionTarget.Boot => "boot",
+ BootPartitionTarget.InitBoot => "init_boot",
+ _ => throw new ArgumentOutOfRangeException(nameof(target), target, "Unknown targets are not flashable.")
+ };
+
+ private static string Require(string value, string parameterName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(value, parameterName);
+ return value;
+ }
+}
diff --git a/src/AndroidTreeView.Adb/Parsers/AndroidBootImageHeaderParser.cs b/src/AndroidTreeView.Adb/Parsers/AndroidBootImageHeaderParser.cs
new file mode 100644
index 0000000..d913ccc
--- /dev/null
+++ b/src/AndroidTreeView.Adb/Parsers/AndroidBootImageHeaderParser.cs
@@ -0,0 +1,119 @@
+using System.Buffers.Binary;
+
+namespace AndroidTreeView.Adb.Parsers;
+
+/// Reads ramdisk evidence from standard Android boot image headers (v0-v4).
+public static class AndroidBootImageHeaderParser
+{
+ public const int MaximumHeaderSize = 1660;
+
+ private const int BootHeaderV0Size = 1632;
+ private const int BootHeaderV1Size = 1648;
+ private const int BootHeaderV2Size = 1660;
+ private const int BootHeaderV3Size = 1580;
+ private const int BootHeaderV4Size = 1584;
+ private const uint ModernPageSize = 4096;
+ private static ReadOnlySpan BootMagic => "ANDROID!"u8;
+
+ public static BootRamdiskEvidence Parse(ReadOnlySpan header, long fileLength)
+ {
+ if (fileLength < 0 || header.Length < 44 || !header[..BootMagic.Length].SequenceEqual(BootMagic))
+ {
+ return BootRamdiskEvidence.Unknown;
+ }
+
+ var headerVersion = ReadUInt32(header, 40);
+ return headerVersion switch
+ {
+ <= 2 => ParseLegacy(header, fileLength, headerVersion),
+ 3 => ParseModern(header, fileLength, headerVersion, BootHeaderV3Size),
+ 4 => ParseModern(header, fileLength, headerVersion, BootHeaderV4Size),
+ _ => BootRamdiskEvidence.Unknown
+ };
+ }
+
+ private static BootRamdiskEvidence ParseLegacy(
+ ReadOnlySpan header,
+ long fileLength,
+ uint headerVersion)
+ {
+ var requiredHeaderSize = headerVersion switch
+ {
+ 0 => BootHeaderV0Size,
+ 1 => BootHeaderV1Size,
+ _ => BootHeaderV2Size
+ };
+ if (header.Length < requiredHeaderSize)
+ {
+ return BootRamdiskEvidence.Unknown;
+ }
+
+ if (headerVersion >= 1 && ReadUInt32(header, 1644) != requiredHeaderSize)
+ {
+ return BootRamdiskEvidence.Unknown;
+ }
+
+ var pageSize = ReadUInt32(header, 36);
+ if (pageSize is < 2048 or > 65536 || !IsPowerOfTwo(pageSize))
+ {
+ return BootRamdiskEvidence.Unknown;
+ }
+
+ var kernelSize = ReadUInt32(header, 8);
+ var ramdiskSize = ReadUInt32(header, 16);
+ if (!TryAdd(pageSize, Align(kernelSize, pageSize), out var ramdiskOffset)
+ || !TryAdd(ramdiskOffset, ramdiskSize, out var requiredLength)
+ || requiredLength > (ulong)fileLength)
+ {
+ return BootRamdiskEvidence.Unknown;
+ }
+
+ return ramdiskSize == 0 ? BootRamdiskEvidence.Absent : BootRamdiskEvidence.Present;
+ }
+
+ private static BootRamdiskEvidence ParseModern(
+ ReadOnlySpan header,
+ long fileLength,
+ uint headerVersion,
+ int requiredHeaderSize)
+ {
+ if (header.Length < requiredHeaderSize
+ || ReadUInt32(header, 20) != requiredHeaderSize
+ || ReadUInt32(header, 40) != headerVersion)
+ {
+ return BootRamdiskEvidence.Unknown;
+ }
+
+ var kernelSize = ReadUInt32(header, 8);
+ var ramdiskSize = ReadUInt32(header, 12);
+ if (!TryAdd(ModernPageSize, Align(kernelSize, ModernPageSize), out var ramdiskOffset)
+ || !TryAdd(ramdiskOffset, ramdiskSize, out var requiredLength)
+ || requiredLength > (ulong)fileLength)
+ {
+ return BootRamdiskEvidence.Unknown;
+ }
+
+ return ramdiskSize == 0 ? BootRamdiskEvidence.Absent : BootRamdiskEvidence.Present;
+ }
+
+ private static uint ReadUInt32(ReadOnlySpan value, int offset)
+ => BinaryPrimitives.ReadUInt32LittleEndian(value.Slice(offset, sizeof(uint)));
+
+ private static bool IsPowerOfTwo(uint value) => (value & (value - 1)) == 0;
+
+ private static ulong Align(uint value, uint alignment)
+ => ((ulong)value + alignment - 1) / alignment * alignment;
+
+ private static bool TryAdd(ulong left, ulong right, out ulong result)
+ {
+ result = left + right;
+ return result >= left;
+ }
+}
+
+public enum BootRamdiskEvidence
+{
+ Unknown = 0,
+ Present = 1,
+ Absent = 2
+}
diff --git a/src/AndroidTreeView.Adb/Parsers/BootPartitionTargetDetector.cs b/src/AndroidTreeView.Adb/Parsers/BootPartitionTargetDetector.cs
new file mode 100644
index 0000000..dfd5a1b
--- /dev/null
+++ b/src/AndroidTreeView.Adb/Parsers/BootPartitionTargetDetector.cs
@@ -0,0 +1,105 @@
+using System.Globalization;
+using AndroidTreeView.Models.Rooting;
+
+namespace AndroidTreeView.Adb.Parsers;
+
+/// Combines package contents and device evidence without guessing a flash partition.
+public static class BootPartitionTargetDetector
+{
+ private static readonly Version MinimumAndroid13GkiKernel = new(5, 10);
+
+ public static BootPartitionDetection Detect(
+ bool packageHasBoot,
+ bool packageHasInitBoot,
+ bool deviceHasInitBoot,
+ int? androidSdk,
+ Version? kernelVersion)
+ {
+ if (!packageHasBoot && !packageHasInitBoot)
+ {
+ return BootPartitionDetection.Blocked(RootErrorCode.TargetImageMissing);
+ }
+
+ var initBootEvidenceIncomplete = deviceHasInitBoot
+ && (androidSdk is null or <= 0 || kernelVersion is null);
+ if (initBootEvidenceIncomplete)
+ {
+ return BootPartitionDetection.Blocked(RootErrorCode.TargetEvidenceConflict);
+ }
+
+ var initBootRequired = deviceHasInitBoot
+ && androidSdk >= 33
+ && kernelVersion is not null
+ && kernelVersion >= MinimumAndroid13GkiKernel;
+
+ if (initBootRequired)
+ {
+ return packageHasInitBoot
+ ? BootPartitionDetection.Selected(BootPartitionTarget.InitBoot)
+ : BootPartitionDetection.Blocked(RootErrorCode.TargetImageMissing);
+ }
+
+ if (packageHasBoot)
+ {
+ return BootPartitionDetection.Selected(BootPartitionTarget.Boot);
+ }
+
+ return BootPartitionDetection.Blocked(RootErrorCode.TargetEvidenceConflict);
+ }
+
+ public static int? ParseAndroidSdk(string? output)
+ => int.TryParse(output?.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var sdk)
+ ? sdk
+ : null;
+
+ public static Version? ParseKernelVersion(string? output)
+ {
+ var token = (output ?? string.Empty)
+ .Trim()
+ .Split(new[] { '-', ' ', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
+ .FirstOrDefault();
+ if (token is null)
+ {
+ return null;
+ }
+
+ var components = token.Split('.');
+ if (components.Length < 2
+ || !int.TryParse(components[0], NumberStyles.None, CultureInfo.InvariantCulture, out var major)
+ || !int.TryParse(components[1], NumberStyles.None, CultureInfo.InvariantCulture, out var minor))
+ {
+ return null;
+ }
+
+ return new Version(major, minor);
+ }
+
+ public static bool ParsePartitionExists(string? output)
+ => (output ?? string.Empty).Trim().Equals("yes", StringComparison.OrdinalIgnoreCase);
+
+ public static BootPartitionDetection ValidateRamdisk(
+ BootPartitionTarget target,
+ BootRamdiskEvidence evidence)
+ => evidence switch
+ {
+ BootRamdiskEvidence.Present => BootPartitionDetection.Selected(target),
+ BootRamdiskEvidence.Absent when target == BootPartitionTarget.Boot
+ => BootPartitionDetection.Blocked(RootErrorCode.RecoveryOnlyUnsupported),
+ _ => BootPartitionDetection.Blocked(RootErrorCode.TargetEvidenceConflict)
+ };
+}
+
+public sealed record BootPartitionDetection
+{
+ public BootPartitionTarget Target { get; init; }
+
+ public RootErrorCode ErrorCode { get; init; }
+
+ public bool IsSupported => Target != BootPartitionTarget.Unknown && ErrorCode == RootErrorCode.None;
+
+ public static BootPartitionDetection Selected(BootPartitionTarget target)
+ => new() { Target = target };
+
+ public static BootPartitionDetection Blocked(RootErrorCode errorCode)
+ => new() { ErrorCode = errorCode };
+}
diff --git a/src/AndroidTreeView.Adb/Parsers/CpuAbiParser.cs b/src/AndroidTreeView.Adb/Parsers/CpuAbiParser.cs
new file mode 100644
index 0000000..277cac5
--- /dev/null
+++ b/src/AndroidTreeView.Adb/Parsers/CpuAbiParser.cs
@@ -0,0 +1,21 @@
+namespace AndroidTreeView.Adb.Parsers;
+
+/// Normalizes Android ABI output to the directory names used by official Magisk APKs.
+public static class CpuAbiParser
+{
+ private static readonly HashSet SupportedAbis = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "arm64-v8a",
+ "armeabi-v7a",
+ "x86",
+ "x86_64"
+ };
+
+ public static string? Parse(string? output)
+ {
+ var first = (output ?? string.Empty)
+ .Split(new[] { '\r', '\n', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .FirstOrDefault();
+ return first is not null && SupportedAbis.Contains(first) ? first.ToLowerInvariant() : null;
+ }
+}
diff --git a/src/AndroidTreeView.Adb/Parsers/FastbootVarParser.cs b/src/AndroidTreeView.Adb/Parsers/FastbootVarParser.cs
new file mode 100644
index 0000000..f1df146
--- /dev/null
+++ b/src/AndroidTreeView.Adb/Parsers/FastbootVarParser.cs
@@ -0,0 +1,246 @@
+using AndroidTreeView.Models.Rooting;
+
+namespace AndroidTreeView.Adb.Parsers;
+
+/// Pure parsers for fastboot variable and long device-list output.
+public static class FastbootVarParser
+{
+ public static IReadOnlyDictionary ParseVariables(string? output)
+ {
+ var variables = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var raw in (output ?? string.Empty).Split('\n'))
+ {
+ var line = raw.Trim();
+ if (line.StartsWith("(bootloader)", StringComparison.OrdinalIgnoreCase))
+ {
+ line = line["(bootloader)".Length..].Trim();
+ }
+
+ var separator = line.LastIndexOf(':');
+ if (separator <= 0)
+ {
+ continue;
+ }
+
+ var key = line[..separator].Trim();
+ var value = line[(separator + 1)..].Trim();
+ if (key.Length == 0
+ || key.Equals("all", StringComparison.OrdinalIgnoreCase)
+ || key.StartsWith("finished", StringComparison.OrdinalIgnoreCase)
+ || key.StartsWith("total time", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ variables[key] = value;
+ }
+
+ return variables;
+ }
+
+ public static string? ParseValue(string? output, string variable)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(variable);
+ var variables = ParseVariables(output);
+ return variables.TryGetValue(variable, out var value) ? value : null;
+ }
+
+ public static bool? ParseBoolean(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return null;
+ }
+
+ return value.Trim().ToLowerInvariant() switch
+ {
+ "yes" or "true" or "1" => true,
+ "no" or "false" or "0" => false,
+ _ => null
+ };
+ }
+
+ public static IReadOnlyList ParseDevices(string? output)
+ {
+ var devices = new List();
+ var seenSerials = new HashSet(StringComparer.Ordinal);
+
+ foreach (var raw in (output ?? string.Empty).Split('\n'))
+ {
+ var fields = raw.Trim().Split(
+ new[] { ' ', '\t' },
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (fields.Length < 2
+ || !fields[1].Equals("fastboot", StringComparison.OrdinalIgnoreCase)
+ || !seenSerials.Add(fields[0]))
+ {
+ continue;
+ }
+
+ var descriptors = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (var i = 2; i < fields.Length; i++)
+ {
+ var separator = fields[i].IndexOf(':');
+ if (separator > 0 && separator < fields[i].Length - 1)
+ {
+ descriptors[fields[i][..separator]] = fields[i][(separator + 1)..];
+ }
+ }
+
+ descriptors.TryGetValue("usb", out var usbPath);
+ descriptors.TryGetValue("product", out var product);
+ devices.Add(new FastbootDeviceIdentity
+ {
+ Serial = fields[0],
+ UsbPath = usbPath,
+ Product = product,
+ Descriptors = descriptors
+ });
+ }
+
+ return devices;
+ }
+
+ public static FastbootIdentityMatch MatchIdentity(
+ RootDeviceIdentity target,
+ IReadOnlyList candidates)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ ArgumentNullException.ThrowIfNull(candidates);
+
+ var matches = new List();
+ var conflictFound = false;
+ foreach (var candidate in candidates)
+ {
+ var serialMatches = EqualsValue(target.Serial, candidate.Serial);
+ var usbMatches = UsbPathEquals(target.UsbPath, candidate.UsbPath);
+ var productMatches = EqualsValue(target.Product, candidate.Product)
+ || EqualsValue(target.Device, candidate.Product);
+ var usbConflicts = UsbPathConflicts(target.UsbPath, candidate.UsbPath);
+ var productConflicts = HasProductIdentity(target) && !string.IsNullOrWhiteSpace(candidate.Product)
+ && !productMatches;
+
+ // ADB and bootloader product names are not guaranteed to use the same namespace. A stable
+ // serial remains primary evidence, while a conflicting physical USB path still blocks it.
+ if ((serialMatches && usbConflicts)
+ || (!serialMatches && usbMatches && productConflicts))
+ {
+ conflictFound = true;
+ continue;
+ }
+
+ var evidence = FastbootIdentityEvidence.None;
+ if (serialMatches)
+ {
+ evidence |= FastbootIdentityEvidence.Serial;
+ }
+
+ if (usbMatches)
+ {
+ evidence |= FastbootIdentityEvidence.UsbPath;
+ }
+
+ if (productMatches)
+ {
+ evidence |= FastbootIdentityEvidence.Product;
+ }
+
+ // A stable serial is sufficient if no available field conflicts. When fastboot changes the
+ // serial, require independent USB and product evidence together.
+ if (serialMatches || (usbMatches && productMatches))
+ {
+ matches.Add(new FastbootIdentityMatch
+ {
+ Status = FastbootIdentityMatchStatus.Verified,
+ Device = candidate,
+ Evidence = evidence
+ });
+ }
+ }
+
+ if (matches.Count == 1)
+ {
+ return matches[0];
+ }
+
+ if (matches.Count > 1)
+ {
+ return new FastbootIdentityMatch { Status = FastbootIdentityMatchStatus.Ambiguous };
+ }
+
+ return new FastbootIdentityMatch
+ {
+ Status = conflictFound
+ ? FastbootIdentityMatchStatus.ConflictingEvidence
+ : FastbootIdentityMatchStatus.Unverified
+ };
+ }
+
+ private static bool HasProductIdentity(RootDeviceIdentity target)
+ => !string.IsNullOrWhiteSpace(target.Product) || !string.IsNullOrWhiteSpace(target.Device);
+
+ private static bool UsbPathConflicts(string? left, string? right)
+ => !string.IsNullOrWhiteSpace(left)
+ && !string.IsNullOrWhiteSpace(right)
+ && !UsbPathEquals(left, right);
+
+ private static bool UsbPathEquals(string? left, string? right)
+ {
+ if (EqualsValue(left, right))
+ {
+ return true;
+ }
+
+ return TryParseMacUsbLocation(left, out var leftLocation)
+ && TryParseMacUsbLocation(right, out var rightLocation)
+ && leftLocation == rightLocation;
+ }
+
+ private static bool TryParseMacUsbLocation(string? value, out uint location)
+ {
+ location = 0;
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return false;
+ }
+
+ var text = value.Trim();
+ if (text.EndsWith('X')
+ && uint.TryParse(text[..^1], out location))
+ {
+ return true;
+ }
+
+ var separator = text.IndexOf('-');
+ if (separator <= 0
+ || !byte.TryParse(text[..separator], out var bus))
+ {
+ return false;
+ }
+
+ var ports = text[(separator + 1)..].Split('.');
+ if (ports.Length is 0 or > 6)
+ {
+ return false;
+ }
+
+ location = (uint)bus << 24;
+ for (var i = 0; i < ports.Length; i++)
+ {
+ if (!byte.TryParse(ports[i], out var port) || port is 0 or > 15)
+ {
+ location = 0;
+ return false;
+ }
+
+ location |= (uint)port << (20 - (i * 4));
+ }
+
+ return true;
+ }
+
+ private static bool EqualsValue(string? left, string? right)
+ => !string.IsNullOrWhiteSpace(left)
+ && !string.IsNullOrWhiteSpace(right)
+ && left.Trim().Equals(right.Trim(), StringComparison.OrdinalIgnoreCase);
+}
diff --git a/src/AndroidTreeView.Adb/Parsers/FirmwarePackageMetadataParser.cs b/src/AndroidTreeView.Adb/Parsers/FirmwarePackageMetadataParser.cs
new file mode 100644
index 0000000..52ee6ad
--- /dev/null
+++ b/src/AndroidTreeView.Adb/Parsers/FirmwarePackageMetadataParser.cs
@@ -0,0 +1,131 @@
+using AndroidTreeView.Models.Rooting;
+
+namespace AndroidTreeView.Adb.Parsers;
+
+/// Parses Android OTA and Pixel factory package identity metadata.
+public static class FirmwarePackageMetadataParser
+{
+ public static FirmwarePackageMetadata Parse(
+ string packagePath,
+ FirmwarePackageType packageType,
+ RootDeviceIdentity device,
+ string? otaMetadata,
+ string? androidInfo)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
+ ArgumentNullException.ThrowIfNull(device);
+
+ var products = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var devices = new HashSet(StringComparer.OrdinalIgnoreCase);
+ ParseOtaMetadata(otaMetadata, products, devices);
+ ParseAndroidInfo(androidInfo, products, devices);
+
+ var declared = products.Concat(devices).ToArray();
+ var targetValues = new[] { device.Product, device.Device }
+ .Where(static value => !string.IsNullOrWhiteSpace(value))
+ .Select(static value => value!.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ var matching = declared
+ .Where(value => targetValues.Contains(value, StringComparer.OrdinalIgnoreCase))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ var status = declared.Length == 0 || targetValues.Length == 0
+ ? FirmwarePackageMatchStatus.Unverified
+ : matching.Length > 0
+ ? FirmwarePackageMatchStatus.Matched
+ : FirmwarePackageMatchStatus.Mismatched;
+
+ return new FirmwarePackageMetadata
+ {
+ PackagePath = Path.GetFullPath(packagePath),
+ OriginalPackageName = Path.GetFileName(packagePath),
+ PackageType = packageType,
+ DeclaredProducts = products.Order(StringComparer.OrdinalIgnoreCase).ToArray(),
+ DeclaredDevices = devices.Order(StringComparer.OrdinalIgnoreCase).ToArray(),
+ MatchStatus = status,
+ MatchingValues = matching
+ };
+ }
+
+ private static void ParseOtaMetadata(
+ string? text,
+ ISet products,
+ ISet devices)
+ {
+ foreach (var (key, value) in ParseKeyValues(text))
+ {
+ if (key.Equals("pre-device", StringComparison.OrdinalIgnoreCase))
+ {
+ AddAlternatives(value, devices);
+ continue;
+ }
+
+ if (key.Equals("post-build", StringComparison.OrdinalIgnoreCase))
+ {
+ var fingerprintHead = value.Split(':', 2)[0];
+ var parts = fingerprintHead.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (parts.Length >= 3)
+ {
+ products.Add(parts[1]);
+ devices.Add(parts[2]);
+ }
+ }
+ }
+ }
+
+ private static void ParseAndroidInfo(
+ string? text,
+ ISet products,
+ ISet devices)
+ {
+ foreach (var raw in (text ?? string.Empty).Split('\n'))
+ {
+ var line = raw.Trim();
+ if (line.StartsWith("require ", StringComparison.OrdinalIgnoreCase))
+ {
+ line = line["require ".Length..].Trim();
+ }
+
+ var separator = line.IndexOf('=');
+ if (separator <= 0)
+ {
+ continue;
+ }
+
+ var key = line[..separator].Trim();
+ var value = line[(separator + 1)..].Trim();
+ // "board" is a hardware platform (for example slider) and is not comparable with
+ // ro.product.device/product (for example oriole), so it is intentionally ignored.
+ if (key.Equals("product", StringComparison.OrdinalIgnoreCase))
+ {
+ AddAlternatives(value, products);
+ }
+ else if (key.Equals("device", StringComparison.OrdinalIgnoreCase))
+ {
+ AddAlternatives(value, devices);
+ }
+ }
+ }
+
+ private static IEnumerable<(string Key, string Value)> ParseKeyValues(string? text)
+ {
+ foreach (var raw in (text ?? string.Empty).Split('\n'))
+ {
+ var separator = raw.IndexOf('=');
+ if (separator > 0)
+ {
+ yield return (raw[..separator].Trim(), raw[(separator + 1)..].Trim());
+ }
+ }
+ }
+
+ private static void AddAlternatives(string value, ISet destination)
+ {
+ foreach (var item in value.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ destination.Add(item);
+ }
+ }
+}
diff --git a/src/AndroidTreeView.Adb/Parsers/MagiskFlagsParser.cs b/src/AndroidTreeView.Adb/Parsers/MagiskFlagsParser.cs
new file mode 100644
index 0000000..a562591
--- /dev/null
+++ b/src/AndroidTreeView.Adb/Parsers/MagiskFlagsParser.cs
@@ -0,0 +1,96 @@
+namespace AndroidTreeView.Adb.Parsers;
+
+///
+/// The patch flags that official Magisk derives on the device itself (via app_functions.sh 's
+/// app_init ) and then passes to boot_patch.sh as environment variables.
+///
+public sealed record MagiskPatchFlags
+{
+ public required bool KeepVerity { get; init; }
+ public required bool KeepForceEncrypt { get; init; }
+ public required bool PatchVbmetaFlag { get; init; }
+ public required bool RecoveryMode { get; init; }
+ public required bool LegacySar { get; init; }
+}
+
+///
+/// Parses the KEY=VALUE lines that app_init prints via its printvar helper.
+///
+///
+/// Every flag is required. boot_patch.sh silently defaults a missing flag to false , and on a
+/// system-as-root device KEEPVERITY=false strips logical and first_stage_mount off the
+/// /system fstab entry, which leaves first-stage init unable to mount the root filesystem. A missing or
+/// unparsable flag must therefore abort the patch rather than fall back to a default.
+///
+public static class MagiskFlagsParser
+{
+ ///
+ /// Parses the flags recorded inside a patched ramdisk's .backup/.magisk . That config only carries
+ /// the flags Magisk needs at runtime — PATCHVBMETAFLAG and LEGACYSAR are patch-time only and
+ /// are absent — so it cannot be read with .
+ ///
+ public static (bool KeepVerity, bool KeepForceEncrypt)? ParsePatchedConfig(string? output)
+ {
+ var values = ReadPairs(output);
+ var keepVerity = ReadBool(values, "KEEPVERITY");
+ var keepForceEncrypt = ReadBool(values, "KEEPFORCEENCRYPT");
+ return keepVerity is null || keepForceEncrypt is null
+ ? null
+ : (keepVerity.Value, keepForceEncrypt.Value);
+ }
+
+ public static MagiskPatchFlags? Parse(string? output)
+ {
+ var values = ReadPairs(output);
+ var keepVerity = ReadBool(values, "KEEPVERITY");
+ var keepForceEncrypt = ReadBool(values, "KEEPFORCEENCRYPT");
+ var patchVbmetaFlag = ReadBool(values, "PATCHVBMETAFLAG");
+ var recoveryMode = ReadBool(values, "RECOVERYMODE");
+ var legacySar = ReadBool(values, "LEGACYSAR");
+ if (keepVerity is null || keepForceEncrypt is null || patchVbmetaFlag is null
+ || recoveryMode is null || legacySar is null)
+ {
+ return null;
+ }
+
+ return new MagiskPatchFlags
+ {
+ KeepVerity = keepVerity.Value,
+ KeepForceEncrypt = keepForceEncrypt.Value,
+ PatchVbmetaFlag = patchVbmetaFlag.Value,
+ RecoveryMode = recoveryMode.Value,
+ LegacySar = legacySar.Value
+ };
+ }
+
+ private static Dictionary ReadPairs(string? output)
+ {
+ var values = new Dictionary(StringComparer.Ordinal);
+ if (string.IsNullOrWhiteSpace(output))
+ {
+ return values;
+ }
+
+ foreach (var line in output.Split('\n'))
+ {
+ var trimmed = line.Trim();
+ var separator = trimmed.IndexOf('=');
+ if (separator > 0)
+ {
+ values[trimmed[..separator]] = trimmed[(separator + 1)..].Trim();
+ }
+ }
+
+ return values;
+ }
+
+ private static bool? ReadBool(IReadOnlyDictionary values, string key) =>
+ values.TryGetValue(key, out var value)
+ ? value switch
+ {
+ "true" => true,
+ "false" => false,
+ _ => null
+ }
+ : null;
+}
diff --git a/src/AndroidTreeView.Adb/Parsers/PackageTypeDetector.cs b/src/AndroidTreeView.Adb/Parsers/PackageTypeDetector.cs
new file mode 100644
index 0000000..845cb05
--- /dev/null
+++ b/src/AndroidTreeView.Adb/Parsers/PackageTypeDetector.cs
@@ -0,0 +1,72 @@
+using AndroidTreeView.Models.Rooting;
+
+namespace AndroidTreeView.Adb.Parsers;
+
+/// Classifies supported firmware containers from file headers and ZIP entry names.
+public static class PackageTypeDetector
+{
+ private static readonly byte[] ZipHeader = { 0x50, 0x4b };
+ private static readonly byte[] PayloadHeader = { 0x43, 0x72, 0x41, 0x55 };
+
+ public static bool IsZipHeader(ReadOnlySpan header)
+ => header.StartsWith(ZipHeader);
+
+ public static bool IsPayloadHeader(ReadOnlySpan header)
+ => header.StartsWith(PayloadHeader);
+
+ public static FirmwarePackageType DetectZipEntries(IEnumerable entryNames)
+ {
+ ArgumentNullException.ThrowIfNull(entryNames);
+ var names = entryNames
+ .Select(Normalize)
+ .Where(static name => name.Length > 0)
+ .ToArray();
+
+ if (names.Any(IsTopLevelBootImage))
+ {
+ return FirmwarePackageType.PlainZip;
+ }
+
+ if (names.Any(IsTopLevelPixelImageZip))
+ {
+ return FirmwarePackageType.NestedZip;
+ }
+
+ if (names.Any(static name => FileName(name).Equals("payload.bin", StringComparison.OrdinalIgnoreCase)))
+ {
+ return FirmwarePackageType.Payload;
+ }
+
+ return FirmwarePackageType.Unknown;
+ }
+
+ public static bool IsBootImage(string entryName)
+ {
+ var fileName = FileName(Normalize(entryName));
+ return fileName.Equals("boot.img", StringComparison.OrdinalIgnoreCase)
+ || fileName.Equals("init_boot.img", StringComparison.OrdinalIgnoreCase);
+ }
+
+ public static bool IsTopLevelBootImage(string entryName)
+ {
+ var normalized = Normalize(entryName);
+ return !normalized.Contains('/') && IsBootImage(normalized);
+ }
+
+ public static bool IsTopLevelPixelImageZip(string entryName)
+ {
+ var normalized = Normalize(entryName);
+ var fileName = FileName(normalized);
+ return !normalized.Contains('/')
+ && fileName.StartsWith("image-", StringComparison.OrdinalIgnoreCase)
+ && fileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string Normalize(string value) => value.Replace('\\', '/').Trim('/');
+
+ private static string FileName(string value)
+ {
+ var slash = value.LastIndexOf('/');
+ return slash < 0 ? value : value[(slash + 1)..];
+ }
+}
diff --git a/src/AndroidTreeView.Adb/Services/BootImageExtractor.cs b/src/AndroidTreeView.Adb/Services/BootImageExtractor.cs
new file mode 100644
index 0000000..0b1573f
--- /dev/null
+++ b/src/AndroidTreeView.Adb/Services/BootImageExtractor.cs
@@ -0,0 +1,571 @@
+using System.IO.Compression;
+using AndroidTreeView.Adb.Commands;
+using AndroidTreeView.Adb.Parsers;
+using AndroidTreeView.Core.Exceptions;
+using AndroidTreeView.Core.Interfaces;
+using AndroidTreeView.Core.Services;
+using AndroidTreeView.Models.Rooting;
+
+namespace AndroidTreeView.Adb.Services;
+
+/// Safely extracts an evidence-backed boot image from ZIP, Pixel nested ZIP, or payload.bin.
+public sealed class BootImageExtractor : IBootImageExtractor
+{
+ private const long MaxBootImageBytes = 512L * 1024 * 1024;
+ private const long MaxNestedZipBytes = 8L * 1024 * 1024 * 1024;
+ private const long MaxPayloadBytes = 16L * 1024 * 1024 * 1024;
+ private const long MaxArchiveExpandedBytes = 20L * 1024 * 1024 * 1024;
+ private const int MaxMetadataBytes = 256 * 1024;
+ private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(8);
+ private static readonly TimeSpan PayloadTimeout = TimeSpan.FromMinutes(10);
+
+ private readonly IAdbCommandExecutor _adb;
+ private readonly IExternalCommandRunner _runner;
+ private readonly RootToolPaths _paths;
+
+ public BootImageExtractor(
+ IAdbCommandExecutor adb,
+ IExternalCommandRunner runner,
+ RootToolPaths paths)
+ {
+ _adb = adb;
+ _runner = runner;
+ _paths = paths;
+ }
+
+ public async Task ExtractAsync(
+ string packagePath,
+ RootDeviceIdentity device,
+ CancellationToken ct = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
+ ArgumentNullException.ThrowIfNull(device);
+ var fullPackagePath = Path.GetFullPath(packagePath);
+ if (!File.Exists(fullPackagePath))
+ {
+ throw new RootWorkflowException(RootErrorCode.PackageNotFound, "The selected firmware package does not exist.");
+ }
+
+ var workDirectory = Path.Combine(_paths.WorkRootDirectory, Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(workDirectory);
+ try
+ {
+ ct.ThrowIfCancellationRequested();
+ var header = new byte[4];
+ await using (var stream = new FileStream(fullPackagePath, FileMode.Open, FileAccess.Read, FileShare.Read,
+ bufferSize: 4096, useAsync: true))
+ {
+ var read = await stream.ReadAsync(header, ct).ConfigureAwait(false);
+ if (read < header.Length)
+ {
+ throw new RootWorkflowException(RootErrorCode.PackageUnsupported, "The selected firmware package is too short.");
+ }
+ }
+
+ if (PackageTypeDetector.IsPayloadHeader(header))
+ {
+ var payloadLength = new FileInfo(fullPackagePath).Length;
+ if (payloadLength <= 0 || payloadLength > MaxPayloadBytes)
+ {
+ throw new RootWorkflowException(RootErrorCode.PackageSizeLimitExceeded,
+ "The payload exceeds the safety limit.");
+ }
+
+ var probe = await ProbeDeviceAsync(device.Serial, ct).ConfigureAwait(false);
+ var detection = BootPartitionTargetDetector.Detect(
+ packageHasBoot: true,
+ packageHasInitBoot: true,
+ probe.HasInitBoot,
+ probe.AndroidSdk,
+ probe.KernelVersion);
+ EnsureSupported(detection);
+ var metadata = FirmwarePackageMetadataParser.Parse(
+ fullPackagePath,
+ FirmwarePackageType.Payload,
+ device,
+ otaMetadata: null,
+ androidInfo: null);
+ var extracted = await ExtractPayloadAsync(
+ fullPackagePath,
+ workDirectory,
+ detection.Target,
+ ct).ConfigureAwait(false);
+ await EnsureRamdiskEvidenceAsync(extracted, detection.Target, ct).ConfigureAwait(false);
+ return BuildInfo(extracted, workDirectory, fullPackagePath, detection.Target,
+ BootImageSource.Payload, metadata);
+ }
+
+ if (!PackageTypeDetector.IsZipHeader(header))
+ {
+ throw new RootWorkflowException(RootErrorCode.PackageUnsupported, "The selected package is not a supported ZIP or payload.bin.");
+ }
+
+ return await ExtractZipAsync(fullPackagePath, workDirectory, device, ct).ConfigureAwait(false);
+ }
+ catch
+ {
+ TryDeleteDirectory(workDirectory);
+ throw;
+ }
+ }
+
+ private async Task ExtractZipAsync(
+ string packagePath,
+ string workDirectory,
+ RootDeviceIdentity device,
+ CancellationToken ct)
+ {
+ try
+ {
+ using var outer = ZipFile.OpenRead(packagePath);
+ ValidateArchive(outer, workDirectory);
+ var type = PackageTypeDetector.DetectZipEntries(outer.Entries.Select(static entry => entry.FullName));
+ if (type == FirmwarePackageType.Unknown)
+ {
+ throw new RootWorkflowException(RootErrorCode.PackageUnsupported, "The ZIP contains no supported boot image or payload.");
+ }
+
+ var otaMetadata = await ReadOptionalTextAsync(outer, "META-INF/com/android/metadata", ct).ConfigureAwait(false);
+ var androidInfo = await ReadOptionalTextAsync(outer, "android-info.txt", ct).ConfigureAwait(false);
+ ZipArchive imageArchive = outer;
+ string? nestedPath = null;
+ if (type == FirmwarePackageType.NestedZip)
+ {
+ var nestedEntries = outer.Entries.Where(entry => PackageTypeDetector.IsTopLevelPixelImageZip(entry.FullName)).ToArray();
+ if (nestedEntries.Length != 1)
+ {
+ throw new RootWorkflowException(RootErrorCode.PackageCorrupt, "The package contains duplicate or ambiguous Pixel image ZIPs.");
+ }
+
+ nestedPath = Path.Combine(workDirectory, "pixel-image.zip");
+ await CopyEntryAsync(nestedEntries[0], nestedPath, MaxNestedZipBytes, ct).ConfigureAwait(false);
+ imageArchive = ZipFile.OpenRead(nestedPath);
+ ValidateArchive(imageArchive, workDirectory);
+ if (imageArchive.Entries.Any(entry => PackageTypeDetector.IsTopLevelPixelImageZip(entry.FullName)))
+ {
+ throw new RootWorkflowException(RootErrorCode.PackageUnsupported, "Only one Pixel nested ZIP level is supported.");
+ }
+
+ androidInfo ??= await ReadOptionalTextAsync(imageArchive, "android-info.txt", ct).ConfigureAwait(false);
+ }
+
+ try
+ {
+ var effectiveType = type == FirmwarePackageType.NestedZip
+ ? FirmwarePackageType.NestedZip
+ : type;
+ var metadata = FirmwarePackageMetadataParser.Parse(
+ packagePath,
+ effectiveType,
+ device,
+ otaMetadata,
+ androidInfo);
+ if (metadata.MatchStatus == FirmwarePackageMatchStatus.Mismatched)
+ {
+ throw new RootWorkflowException(RootErrorCode.PackageMetadataMismatch,
+ "Firmware package metadata identifies a different device.");
+ }
+
+ var bootEntries = imageArchive.Entries
+ .Where(entry => PackageTypeDetector.IsTopLevelBootImage(entry.FullName))
+ .ToArray();
+ var boot = SingleEntry(bootEntries, "boot.img");
+ var initBoot = SingleEntry(bootEntries, "init_boot.img");
+ var payload = imageArchive.Entries
+ .Where(entry => Path.GetFileName(entry.FullName).Equals("payload.bin", StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+
+ var probe = await ProbeDeviceAsync(device.Serial, ct).ConfigureAwait(false);
+ var detection = BootPartitionTargetDetector.Detect(
+ boot is not null || type == FirmwarePackageType.Payload,
+ initBoot is not null || type == FirmwarePackageType.Payload,
+ probe.HasInitBoot,
+ probe.AndroidSdk,
+ probe.KernelVersion);
+ EnsureSupported(detection);
+
+ string outputPath;
+ BootImageSource source;
+ if (type == FirmwarePackageType.Payload)
+ {
+ if (payload.Length != 1)
+ {
+ throw new RootWorkflowException(RootErrorCode.PackageCorrupt, "The package contains duplicate or missing payload.bin entries.");
+ }
+
+ var payloadPath = Path.Combine(workDirectory, "payload.bin");
+ await CopyEntryAsync(payload[0], payloadPath, MaxPayloadBytes, ct).ConfigureAwait(false);
+ outputPath = await ExtractPayloadAsync(payloadPath, workDirectory, detection.Target, ct).ConfigureAwait(false);
+ source = BootImageSource.Payload;
+ }
+ else
+ {
+ var selected = detection.Target == BootPartitionTarget.InitBoot ? initBoot : boot;
+ if (selected is null)
+ {
+ throw new RootWorkflowException(RootErrorCode.TargetImageMissing, "The selected target image is absent from the package.");
+ }
+
+ outputPath = Path.Combine(workDirectory, FastbootArgs.PartitionName(detection.Target) + "-original.img");
+ await CopyEntryAsync(selected, outputPath, MaxBootImageBytes, ct).ConfigureAwait(false);
+ source = type == FirmwarePackageType.NestedZip ? BootImageSource.NestedZip : BootImageSource.PlainZip;
+ }
+
+ await EnsureRamdiskEvidenceAsync(outputPath, detection.Target, ct).ConfigureAwait(false);
+ return BuildInfo(outputPath, workDirectory, packagePath, detection.Target, source, metadata);
+ }
+ finally
+ {
+ if (!ReferenceEquals(imageArchive, outer))
+ {
+ imageArchive.Dispose();
+ }
+
+ if (nestedPath is not null)
+ {
+ TryDeleteFile(nestedPath);
+ }
+ }
+ }
+ catch (InvalidDataException ex)
+ {
+ throw new RootWorkflowException(RootErrorCode.PackageCorrupt, "The selected ZIP is corrupt.", ex);
+ }
+ }
+
+ private async Task ExtractPayloadAsync(
+ string payloadPath,
+ string workDirectory,
+ BootPartitionTarget target,
+ CancellationToken ct)
+ {
+ if (!File.Exists(_paths.PayloadDumperPath))
+ {
+ throw new RootWorkflowException(RootErrorCode.PayloadToolUnavailable, "The bundled payload extraction tool is unavailable.");
+ }
+
+ var outputDirectory = Path.Combine(workDirectory, "payload-output");
+ Directory.CreateDirectory(outputDirectory);
+ var partition = FastbootArgs.PartitionName(target);
+ ExternalCommandResult result;
+ try
+ {
+ result = await _runner.RunAsync(new ExternalCommandRequest
+ {
+ FileName = _paths.PayloadDumperPath,
+ Arguments = new[] { "-p", partition, "-o", outputDirectory, Path.GetFullPath(payloadPath) },
+ Timeout = PayloadTimeout
+ }, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new RootWorkflowException(RootErrorCode.PayloadExtractionFailed,
+ "Payload extraction could not be started.", ex);
+ }
+
+ if (!result.IsSuccess)
+ {
+ throw new RootWorkflowException(RootErrorCode.PayloadExtractionFailed, "Payload extraction failed.")
+ {
+ DiagnosticSummary = Sanitize(result.StandardError, result.TimedOut)
+ };
+ }
+
+ var source = Path.Combine(outputDirectory, partition + ".img");
+ if (!File.Exists(source) || new FileInfo(source).Length == 0 || new FileInfo(source).Length > MaxBootImageBytes)
+ {
+ throw new RootWorkflowException(RootErrorCode.TargetImageMissing, "Payload extraction did not produce a valid target image.");
+ }
+
+ var destination = Path.Combine(workDirectory, partition + "-original.img");
+ File.Move(source, destination, overwrite: true);
+ return destination;
+ }
+
+ private async Task ProbeDeviceAsync(string serial, CancellationToken ct)
+ {
+ var initTask = ExecuteShellAsync(serial,
+ new[] { "test", "-e", "/dev/block/by-name/init_boot" },
+ ct);
+ var sdkTask = ExecuteShellAsync(serial, new[] { "getprop", "ro.build.version.sdk" }, ct);
+ var kernelTask = ExecuteShellAsync(serial, new[] { "uname", "-r" }, ct);
+ try
+ {
+ await Task.WhenAll(initTask, sdkTask, kernelTask).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new RootWorkflowException(RootErrorCode.DeviceUnavailable,
+ "Required device boot-partition evidence could not be read.", ex);
+ }
+ var initResult = await initTask.ConfigureAwait(false);
+ var sdkResult = await sdkTask.ConfigureAwait(false);
+ var kernelResult = await kernelTask.ConfigureAwait(false);
+ var sdk = BootPartitionTargetDetector.ParseAndroidSdk(sdkResult.StandardOutput);
+ var kernel = BootPartitionTargetDetector.ParseKernelVersion(kernelResult.StandardOutput);
+ var initBootExists = ParseFileExistsResult(initResult);
+ if (initBootExists is null
+ || !sdkResult.IsSuccess
+ || !kernelResult.IsSuccess
+ || sdk is null
+ || kernel is null)
+ {
+ throw new RootWorkflowException(RootErrorCode.DeviceUnavailable,
+ "Required device boot-partition evidence could not be read.");
+ }
+
+ return new DeviceProbe(
+ initBootExists.Value,
+ sdk,
+ kernel);
+ }
+
+ private static bool? ParseFileExistsResult(AdbCommandResult result)
+ {
+ if (result.IsSuccess)
+ {
+ return true;
+ }
+
+ return result.ExitCode == 1
+ && !result.TimedOut
+ && string.IsNullOrWhiteSpace(result.StandardOutput)
+ && string.IsNullOrWhiteSpace(result.StandardError)
+ ? false
+ : null;
+ }
+
+ private Task ExecuteShellAsync(
+ string serial,
+ IReadOnlyList arguments,
+ CancellationToken ct)
+ => _adb.ExecuteAsync(new AdbCommandRequest
+ {
+ Serial = serial,
+ Arguments = arguments,
+ RunInShell = true,
+ Timeout = ProbeTimeout
+ }, ct);
+
+ private static async Task EnsureRamdiskEvidenceAsync(
+ string imagePath,
+ BootPartitionTarget target,
+ CancellationToken ct)
+ {
+ var header = new byte[AndroidBootImageHeaderParser.MaximumHeaderSize];
+ int read;
+ long length;
+ await using (var stream = new FileStream(imagePath, FileMode.Open, FileAccess.Read, FileShare.Read,
+ bufferSize: 4096, useAsync: true))
+ {
+ length = stream.Length;
+ read = await stream.ReadAtLeastAsync(header, header.Length, throwOnEndOfStream: false, ct)
+ .ConfigureAwait(false);
+ }
+
+ var evidence = AndroidBootImageHeaderParser.Parse(header.AsSpan(0, read), length);
+ var validation = BootPartitionTargetDetector.ValidateRamdisk(target, evidence);
+ if (!validation.IsSupported)
+ {
+ throw new RootWorkflowException(validation.ErrorCode,
+ validation.ErrorCode == RootErrorCode.RecoveryOnlyUnsupported
+ ? "The selected boot image has no ramdisk and requires unsupported recovery-mode installation."
+ : "The selected image did not provide reliable boot ramdisk evidence.");
+ }
+ }
+
+ private static void ValidateArchive(ZipArchive archive, string extractionRoot)
+ {
+ long total = 0;
+ foreach (var entry in archive.Entries)
+ {
+ ValidateEntryPath(extractionRoot, entry.FullName);
+ try
+ {
+ total = checked(total + entry.Length);
+ }
+ catch (OverflowException)
+ {
+ throw new RootWorkflowException(RootErrorCode.PackageSizeLimitExceeded, "ZIP expanded size exceeds the safety limit.");
+ }
+
+ if (total > MaxArchiveExpandedBytes)
+ {
+ throw new RootWorkflowException(RootErrorCode.PackageSizeLimitExceeded, "ZIP expanded size exceeds the safety limit.");
+ }
+ }
+ }
+
+ private static void ValidateEntryPath(string extractionRoot, string entryName)
+ {
+ if (string.IsNullOrWhiteSpace(entryName) || entryName.IndexOf('\0') >= 0)
+ {
+ throw new RootWorkflowException(RootErrorCode.PackagePathUnsafe, "ZIP contains an invalid entry path.");
+ }
+
+ try
+ {
+ var root = Path.GetFullPath(extractionRoot) + Path.DirectorySeparatorChar;
+ var normalizedEntry = entryName
+ .Replace('/', Path.DirectorySeparatorChar)
+ .Replace('\\', Path.DirectorySeparatorChar);
+ var candidate = Path.GetFullPath(Path.Combine(extractionRoot, normalizedEntry));
+ var comparison = OperatingSystem.IsWindows()
+ ? StringComparison.OrdinalIgnoreCase
+ : StringComparison.Ordinal;
+ if (!candidate.StartsWith(root, comparison))
+ {
+ throw new RootWorkflowException(RootErrorCode.PackagePathUnsafe,
+ "ZIP contains a path outside the extraction directory.");
+ }
+ }
+ catch (RootWorkflowException)
+ {
+ throw;
+ }
+ catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException)
+ {
+ throw new RootWorkflowException(RootErrorCode.PackagePathUnsafe,
+ "ZIP contains an invalid entry path.", ex);
+ }
+ }
+
+ private static ZipArchiveEntry? SingleEntry(IEnumerable entries, string fileName)
+ {
+ var matches = entries
+ .Where(entry => Path.GetFileName(entry.FullName).Equals(fileName, StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+ if (matches.Length > 1)
+ {
+ throw new RootWorkflowException(RootErrorCode.PackageCorrupt, $"The package contains duplicate {fileName} entries.");
+ }
+
+ return matches.SingleOrDefault();
+ }
+
+ private static async Task CopyEntryAsync(
+ ZipArchiveEntry entry,
+ string destination,
+ long limit,
+ CancellationToken ct)
+ {
+ if (entry.Length <= 0 || entry.Length > limit)
+ {
+ throw new RootWorkflowException(RootErrorCode.PackageSizeLimitExceeded, "ZIP entry exceeds its safety limit.");
+ }
+
+ await using var source = entry.Open();
+ await using var target = new FileStream(destination, FileMode.CreateNew, FileAccess.Write, FileShare.None,
+ bufferSize: 81920, useAsync: true);
+ var buffer = new byte[81920];
+ long written = 0;
+ int read;
+ while ((read = await source.ReadAsync(buffer, ct).ConfigureAwait(false)) > 0)
+ {
+ written += read;
+ if (written > limit)
+ {
+ throw new RootWorkflowException(RootErrorCode.PackageSizeLimitExceeded, "ZIP entry exceeded its safety limit while streaming.");
+ }
+
+ await target.WriteAsync(buffer.AsMemory(0, read), ct).ConfigureAwait(false);
+ }
+ }
+
+ private static async Task ReadOptionalTextAsync(
+ ZipArchive archive,
+ string name,
+ CancellationToken ct)
+ {
+ var matches = archive.Entries
+ .Where(entry => entry.FullName.Replace('\\', '/').Equals(name, StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+ if (matches.Length == 0)
+ {
+ return null;
+ }
+
+ if (matches.Length > 1 || matches[0].Length > MaxMetadataBytes)
+ {
+ throw new RootWorkflowException(RootErrorCode.PackageCorrupt, "Package metadata is duplicated or too large.");
+ }
+
+ using var reader = new StreamReader(matches[0].Open());
+ ct.ThrowIfCancellationRequested();
+ return await reader.ReadToEndAsync(ct).ConfigureAwait(false);
+ }
+
+ private static BootImageInfo BuildInfo(
+ string path,
+ string workDirectory,
+ string packagePath,
+ BootPartitionTarget target,
+ BootImageSource source,
+ FirmwarePackageMetadata metadata)
+ => new()
+ {
+ Path = Path.GetFullPath(path),
+ WorkDirectory = Path.GetFullPath(workDirectory),
+ OriginalPackageName = Path.GetFileName(packagePath),
+ TargetPartition = target,
+ Source = source,
+ PackageMetadata = metadata
+ };
+
+ private static void EnsureSupported(BootPartitionDetection detection)
+ {
+ if (!detection.IsSupported)
+ {
+ throw new RootWorkflowException(detection.ErrorCode, "Device and package evidence did not establish a supported boot target.");
+ }
+ }
+
+ private static string Sanitize(string? value, bool timedOut)
+ {
+ var normalized = string.Join(' ', (value ?? string.Empty)
+ .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
+ if (normalized.Length > 300)
+ {
+ normalized = normalized[..300];
+ }
+
+ return timedOut ? $"Command timed out. {normalized}".Trim() : normalized;
+ }
+
+ private static void TryDeleteDirectory(string path)
+ {
+ try
+ {
+ if (Directory.Exists(path))
+ {
+ Directory.Delete(path, recursive: true);
+ }
+ }
+ catch
+ {
+ // Best effort after the primary error; callers still receive the original stable failure.
+ }
+ }
+
+ private static void TryDeleteFile(string path)
+ {
+ try
+ {
+ File.Delete(path);
+ }
+ catch
+ {
+ // Best effort for the transient nested archive.
+ }
+ }
+
+ private sealed record DeviceProbe(bool HasInitBoot, int? AndroidSdk, Version? KernelVersion);
+}
diff --git a/src/AndroidTreeView.Adb/Services/ExternalCommandRunner.cs b/src/AndroidTreeView.Adb/Services/ExternalCommandRunner.cs
new file mode 100644
index 0000000..155ee52
--- /dev/null
+++ b/src/AndroidTreeView.Adb/Services/ExternalCommandRunner.cs
@@ -0,0 +1,31 @@
+using AndroidTreeView.Adb.Internal;
+using AndroidTreeView.Core.Interfaces;
+using AndroidTreeView.Core.Services;
+
+namespace AndroidTreeView.Adb.Services;
+
+/// adapter over the shared process runner.
+public sealed class ExternalCommandRunner : IExternalCommandRunner
+{
+ public async Task RunAsync(
+ ExternalCommandRequest request,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var result = await ProcessRunner.RunAsync(
+ request.FileName,
+ request.Arguments,
+ request.Timeout,
+ ct).ConfigureAwait(false);
+
+ return new ExternalCommandResult
+ {
+ ExitCode = result.ExitCode,
+ StandardOutput = result.StandardOutput,
+ StandardError = result.StandardError,
+ TimedOut = result.TimedOut,
+ Duration = result.Duration
+ };
+ }
+}
diff --git a/src/AndroidTreeView.Adb/Services/FastbootService.cs b/src/AndroidTreeView.Adb/Services/FastbootService.cs
index 0c15257..67f616b 100644
--- a/src/AndroidTreeView.Adb/Services/FastbootService.cs
+++ b/src/AndroidTreeView.Adb/Services/FastbootService.cs
@@ -1,5 +1,5 @@
-using AndroidTreeView.Adb.Internal;
using AndroidTreeView.Core.Interfaces;
+using AndroidTreeView.Core.Services;
using Microsoft.Extensions.Logging;
namespace AndroidTreeView.Adb.Services;
@@ -15,11 +15,16 @@ public sealed class FastbootService : IFastbootService
private static readonly TimeSpan ActionTimeout = TimeSpan.FromSeconds(8);
private readonly IAdbEnvironment _environment;
+ private readonly IExternalCommandRunner _runner;
private readonly ILogger _logger;
- public FastbootService(IAdbEnvironment environment, ILogger logger)
+ public FastbootService(
+ IAdbEnvironment environment,
+ IExternalCommandRunner runner,
+ ILogger logger)
{
_environment = environment;
+ _runner = runner;
_logger = logger;
}
@@ -55,8 +60,9 @@ public async Task> ListSerialsAsync(CancellationToken ct =
try
{
- var result = await ProcessRunner.RunAsync(exe, new[] { "devices" }, ListTimeout, ct).ConfigureAwait(false);
- return result.TimedOut || result.ExitCode != 0
+ var result = await _runner.RunAsync(CreateRequest(exe, ["devices"], ListTimeout), ct)
+ .ConfigureAwait(false);
+ return !result.IsSuccess
? Array.Empty()
: ParseSerials(result.StandardOutput);
}
@@ -82,10 +88,15 @@ public async Task> GetVariablesAsync(string
try
{
- var result = await ProcessRunner
- .RunAsync(exe, new[] { "-s", serial, "getvar", "all" }, ListTimeout, ct)
+ var result = await _runner
+ .RunAsync(CreateRequest(exe, ["-s", serial, "getvar", "all"], ListTimeout), ct)
.ConfigureAwait(false);
+ if (!result.IsSuccess)
+ {
+ return empty;
+ }
+
// fastboot writes getvar output to STDERR, so parse both streams.
return ParseVariables(result.StandardOutput + "\n" + result.StandardError);
}
@@ -124,7 +135,7 @@ private async Task RunAsync(string[] args, CancellationToken ct)
try
{
- await ProcessRunner.RunAsync(exe, args, ActionTimeout, ct).ConfigureAwait(false);
+ await _runner.RunAsync(CreateRequest(exe, args, ActionTimeout), ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -136,6 +147,17 @@ private async Task RunAsync(string[] args, CancellationToken ct)
}
}
+ private static ExternalCommandRequest CreateRequest(
+ string executablePath,
+ IReadOnlyList arguments,
+ TimeSpan timeout)
+ => new()
+ {
+ FileName = executablePath,
+ Arguments = arguments,
+ Timeout = timeout
+ };
+
// Lines look like "SERIAL\tfastboot" (tabs or spaces). Keep the serial of any line that names fastboot.
private static IReadOnlyList ParseSerials(string output)
{
diff --git a/src/AndroidTreeView.Adb/Services/MagiskPatcher.cs b/src/AndroidTreeView.Adb/Services/MagiskPatcher.cs
new file mode 100644
index 0000000..073468b
--- /dev/null
+++ b/src/AndroidTreeView.Adb/Services/MagiskPatcher.cs
@@ -0,0 +1,425 @@
+using System.IO.Compression;
+using AndroidTreeView.Adb.Parsers;
+using AndroidTreeView.Core.Exceptions;
+using AndroidTreeView.Core.Interfaces;
+using AndroidTreeView.Core.Services;
+using AndroidTreeView.Models.Rooting;
+using Microsoft.Extensions.Logging;
+
+namespace AndroidTreeView.Adb.Services;
+
+///
+/// Wraps the fixed official Magisk APK patch components on the explicitly selected ADB device.
+/// This command sequence remains diagnosable because the M0 real-device matrix has not been verified.
+///
+public sealed class MagiskPatcher : IMagiskPatcher
+{
+ private const long MaxComponentBytes = 128L * 1024 * 1024;
+ private const long MaxAllComponentsBytes = 512L * 1024 * 1024;
+ private static readonly TimeSpan InstallTimeout = TimeSpan.FromMinutes(2);
+ private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(30);
+ private static readonly TimeSpan PatchTimeout = TimeSpan.FromMinutes(5);
+
+ private readonly IAdbCommandExecutor _adb;
+ private readonly RootToolPaths _paths;
+ private readonly ILogger _logger;
+
+ public MagiskPatcher(
+ IAdbCommandExecutor adb,
+ RootToolPaths paths,
+ ILogger logger)
+ {
+ _adb = adb;
+ _paths = paths;
+ _logger = logger;
+ }
+
+ public async Task PatchAsync(
+ string serial,
+ BootImageInfo bootImage,
+ CancellationToken ct = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(serial);
+ ArgumentNullException.ThrowIfNull(bootImage);
+ if (!File.Exists(_paths.MagiskApkPath))
+ {
+ throw new RootWorkflowException(RootErrorCode.MagiskToolUnavailable, "The fixed official Magisk APK is unavailable.");
+ }
+
+ if (!File.Exists(bootImage.Path) || new FileInfo(bootImage.Path).Length == 0)
+ {
+ throw new RootWorkflowException(RootErrorCode.TargetImageMissing, "The original boot image is missing or empty.");
+ }
+
+ var session = Guid.NewGuid().ToString("N");
+ var remoteRoot = $"/data/local/tmp/atv_root/{session}";
+ var localComponentRoot = Path.Combine(bootImage.WorkDirectory, $".magisk-components-{session}");
+ var patchedPath = Path.Combine(bootImage.WorkDirectory, "boot-patched.img");
+ try
+ {
+ TryDeleteLocal(patchedPath);
+ var installResult = await ExecuteRequiredAsync(
+ serial,
+ new[] { "install", "-r", _paths.MagiskApkPath },
+ runInShell: false,
+ InstallTimeout,
+ RootErrorCode.MagiskInstallFailed,
+ ct).ConfigureAwait(false);
+ if (!HasInstallSuccess(installResult))
+ {
+ throw new RootWorkflowException(RootErrorCode.MagiskInstallFailed,
+ "ADB did not confirm that the Magisk APK was installed.")
+ {
+ DiagnosticSummary = Sanitize(
+ installResult.StandardOutput + "\n" + installResult.StandardError,
+ installResult.TimedOut)
+ };
+ }
+
+ var abiResult = await ExecuteRequiredAsync(
+ serial,
+ new[] { "getprop", "ro.product.cpu.abi" },
+ runInShell: true,
+ CommandTimeout,
+ RootErrorCode.DeviceAbiUnsupported,
+ ct).ConfigureAwait(false);
+ var abi = CpuAbiParser.Parse(abiResult.StandardOutput);
+ if (abi is null)
+ {
+ throw new RootWorkflowException(RootErrorCode.DeviceAbiUnsupported, "The device ABI is not supported by the bundled Magisk APK.");
+ }
+
+ await ExecuteRequiredAsync(serial, new[] { "mkdir", "-p", remoteRoot }, true,
+ CommandTimeout, RootErrorCode.ImagePushFailed, ct).ConfigureAwait(false);
+ await ExecuteRequiredAsync(serial, new[] { "push", bootImage.Path, $"{remoteRoot}/boot.img" }, false,
+ CommandTimeout, RootErrorCode.ImagePushFailed, ct).ConfigureAwait(false);
+
+ // Extract a fixed allowlist from the verified official APK on the desktop. This avoids
+ // depending on an optional device-side unzip binary; the M0 patch execution is still unverified.
+ var componentEntries = new (string Entry, string RemoteName)[]
+ {
+ ("assets/boot_patch.sh", "boot_patch.sh"),
+ ("assets/util_functions.sh", "util_functions.sh"),
+ ("assets/app_functions.sh", "app_functions.sh"),
+ ("assets/stub.apk", "stub.apk"),
+ ($"lib/{abi}/libmagiskboot.so", "magiskboot"),
+ ($"lib/{abi}/libmagiskinit.so", "magiskinit"),
+ ($"lib/{abi}/libmagisk.so", "magisk"),
+ ($"lib/{abi}/libinit-ld.so", "init-ld")
+ };
+ var localComponents = await ExtractOfficialComponentsAsync(
+ _paths.MagiskApkPath,
+ localComponentRoot,
+ componentEntries,
+ ct).ConfigureAwait(false);
+ foreach (var component in localComponents)
+ {
+ await ExecuteRequiredAsync(serial,
+ new[] { "push", component.LocalPath, $"{remoteRoot}/{component.RemoteName}" },
+ false,
+ CommandTimeout,
+ RootErrorCode.ImagePushFailed,
+ ct).ConfigureAwait(false);
+ }
+
+ await ExecuteRequiredAsync(serial,
+ new[] { "chmod", "0700", $"{remoteRoot}/boot_patch.sh", $"{remoteRoot}/util_functions.sh",
+ $"{remoteRoot}/app_functions.sh",
+ $"{remoteRoot}/magiskboot", $"{remoteRoot}/magiskinit", $"{remoteRoot}/magisk", $"{remoteRoot}/init-ld" },
+ true,
+ CommandTimeout,
+ RootErrorCode.MagiskPatchFailed,
+ ct).ConfigureAwait(false);
+
+ var flags = await ProbePatchFlagsAsync(serial, remoteRoot, ct).ConfigureAwait(false);
+
+ await ExecuteRequiredAsync(serial,
+ new[]
+ {
+ $"KEEPVERITY={Sh(flags.KeepVerity)}",
+ $"KEEPFORCEENCRYPT={Sh(flags.KeepForceEncrypt)}",
+ $"PATCHVBMETAFLAG={Sh(flags.PatchVbmetaFlag)}",
+ $"RECOVERYMODE={Sh(flags.RecoveryMode)}",
+ $"LEGACYSAR={Sh(flags.LegacySar)}",
+ "sh", $"{remoteRoot}/boot_patch.sh", $"{remoteRoot}/boot.img"
+ },
+ true,
+ PatchTimeout,
+ RootErrorCode.MagiskPatchFailed,
+ ct).ConfigureAwait(false);
+
+ await VerifyPatchedFlagsAsync(serial, remoteRoot, flags, ct).ConfigureAwait(false);
+
+ await ExecuteRequiredAsync(serial,
+ new[] { "pull", $"{remoteRoot}/new-boot.img", patchedPath },
+ false,
+ CommandTimeout,
+ RootErrorCode.PatchedImagePullFailed,
+ ct).ConfigureAwait(false);
+ if (!File.Exists(patchedPath) || new FileInfo(patchedPath).Length == 0)
+ {
+ throw new RootWorkflowException(RootErrorCode.PatchedImageInvalid,
+ "Magisk patching did not produce a non-empty local image.");
+ }
+
+ return Path.GetFullPath(patchedPath);
+ }
+ finally
+ {
+ TryDeleteDirectory(localComponentRoot);
+ try
+ {
+ await _adb.ExecuteAsync(new AdbCommandRequest
+ {
+ Serial = serial,
+ Arguments = new[] { "rm", "-rf", remoteRoot },
+ RunInShell = true,
+ Timeout = CommandTimeout
+ }, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Magisk temporary directory cleanup failed for the selected device.");
+ }
+ }
+ }
+
+ private static string Sh(bool value) => value ? "true" : "false";
+
+ ///
+ /// Runs official Magisk's own app_init probe on the device to derive the patch flags, exactly as the
+ /// Magisk app does before invoking boot_patch.sh .
+ ///
+ ///
+ /// This must be a separate shell invocation from boot_patch.sh : app_functions.sh replaces
+ /// grep_prop with an empty stub, which would break the SHA1 lookup inside boot_patch.sh .
+ ///
+ private async Task ProbePatchFlagsAsync(
+ string serial,
+ string remoteRoot,
+ CancellationToken ct)
+ {
+ var result = await ExecuteRequiredAsync(serial,
+ new[]
+ {
+ "cd", remoteRoot, "&&",
+ ".", "./util_functions.sh", "&&",
+ ".", "./app_functions.sh", "&&",
+ "api_level_arch_detect", "&&",
+ "app_init"
+ },
+ true,
+ CommandTimeout,
+ RootErrorCode.MagiskFlagProbeFailed,
+ ct).ConfigureAwait(false);
+
+ var flags = MagiskFlagsParser.Parse(result.StandardOutput);
+ if (flags is null)
+ {
+ throw new RootWorkflowException(RootErrorCode.MagiskFlagProbeFailed,
+ "The device probe did not report a complete set of Magisk patch flags.")
+ {
+ DiagnosticSummary = Sanitize(result.StandardOutput + "\n" + result.StandardError, result.TimedOut)
+ };
+ }
+
+ _logger.LogInformation(
+ "Magisk patch flags probed: KEEPVERITY={KeepVerity} KEEPFORCEENCRYPT={KeepForceEncrypt} " +
+ "PATCHVBMETAFLAG={PatchVbmeta} RECOVERYMODE={RecoveryMode} LEGACYSAR={LegacySar}",
+ flags.KeepVerity, flags.KeepForceEncrypt, flags.PatchVbmetaFlag, flags.RecoveryMode, flags.LegacySar);
+ return flags;
+ }
+
+ ///
+ /// Reads the config back out of the patched ramdisk and asserts it matches the probed flags.
+ ///
+ ///
+ /// boot_patch.sh ends in a bare true , so its exit code proves nothing, and a wrongly-flagged
+ /// image is structurally valid — neither the exit code nor a header check can catch it. Reading back the
+ /// config that Magisk itself recorded is the only gate that can.
+ ///
+ private async Task VerifyPatchedFlagsAsync(
+ string serial,
+ string remoteRoot,
+ MagiskPatchFlags expected,
+ CancellationToken ct)
+ {
+ var result = await ExecuteRequiredAsync(serial,
+ // magiskboot restores the cpio entry's own mode (0000), so the extracted file has to be
+ // chmod-ed before it can be read back.
+ new[]
+ {
+ "cd", remoteRoot, "&&",
+ "rm", "-f", "config.verify", "&&",
+ "./magiskboot", "cpio", "ramdisk.cpio", "'extract .backup/.magisk config.verify'", "&&",
+ "chmod", "0600", "config.verify", "&&",
+ "cat", "config.verify"
+ },
+ true,
+ CommandTimeout,
+ RootErrorCode.PatchedImageFlagMismatch,
+ ct).ConfigureAwait(false);
+
+ var actual = MagiskFlagsParser.ParsePatchedConfig(result.StandardOutput);
+ if (actual is null)
+ {
+ throw new RootWorkflowException(RootErrorCode.PatchedImageFlagMismatch,
+ "The patched image did not carry a readable Magisk config.")
+ {
+ DiagnosticSummary = Sanitize(result.StandardOutput + "\n" + result.StandardError, result.TimedOut)
+ };
+ }
+
+ if (actual.Value.KeepVerity != expected.KeepVerity
+ || actual.Value.KeepForceEncrypt != expected.KeepForceEncrypt)
+ {
+ throw new RootWorkflowException(RootErrorCode.PatchedImageFlagMismatch,
+ $"The patched image recorded KEEPVERITY={Sh(actual.Value.KeepVerity)} " +
+ $"KEEPFORCEENCRYPT={Sh(actual.Value.KeepForceEncrypt)} but the device requires " +
+ $"KEEPVERITY={Sh(expected.KeepVerity)} KEEPFORCEENCRYPT={Sh(expected.KeepForceEncrypt)}. " +
+ "Flashing it would leave the device unable to mount its system partition.");
+ }
+ }
+
+ private async Task ExecuteRequiredAsync(
+ string serial,
+ IReadOnlyList arguments,
+ bool runInShell,
+ TimeSpan timeout,
+ RootErrorCode errorCode,
+ CancellationToken ct)
+ {
+ AdbCommandResult result;
+ try
+ {
+ result = await _adb.ExecuteAsync(new AdbCommandRequest
+ {
+ Serial = serial,
+ Arguments = arguments,
+ RunInShell = runInShell,
+ Timeout = timeout
+ }, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new RootWorkflowException(errorCode, "A required Magisk patch command could not be executed.", ex);
+ }
+
+ if (!result.IsSuccess)
+ {
+ throw new RootWorkflowException(errorCode, "A required Magisk patch command failed.")
+ {
+ DiagnosticSummary = Sanitize(result.StandardError, result.TimedOut)
+ };
+ }
+
+ return result;
+ }
+
+ private static async Task> ExtractOfficialComponentsAsync(
+ string apkPath,
+ string destinationDirectory,
+ IReadOnlyList<(string Entry, string RemoteName)> requested,
+ CancellationToken ct)
+ {
+ try
+ {
+ Directory.CreateDirectory(destinationDirectory);
+ using var archive = ZipFile.OpenRead(apkPath);
+ var extracted = new List(requested.Count);
+ long total = 0;
+ foreach (var component in requested)
+ {
+ var matches = archive.Entries
+ .Where(entry => entry.FullName.Equals(component.Entry, StringComparison.Ordinal))
+ .ToArray();
+ if (matches.Length != 1 || matches[0].Length <= 0 || matches[0].Length > MaxComponentBytes)
+ {
+ throw new RootWorkflowException(RootErrorCode.MagiskToolUnavailable,
+ "The official Magisk APK is missing a required component or contains an invalid component.");
+ }
+
+ total = checked(total + matches[0].Length);
+ if (total > MaxAllComponentsBytes)
+ {
+ throw new RootWorkflowException(RootErrorCode.MagiskToolUnavailable,
+ "The official Magisk component set exceeds the safety limit.");
+ }
+
+ var destination = Path.Combine(destinationDirectory, component.RemoteName);
+ await using var source = matches[0].Open();
+ await using var target = new FileStream(destination, FileMode.CreateNew, FileAccess.Write,
+ FileShare.None, bufferSize: 81920, useAsync: true);
+ await source.CopyToAsync(target, ct).ConfigureAwait(false);
+ if (target.Length != matches[0].Length)
+ {
+ throw new RootWorkflowException(RootErrorCode.MagiskToolUnavailable,
+ "A Magisk component could not be extracted completely.");
+ }
+
+ extracted.Add(new ExtractedComponent(destination, component.RemoteName));
+ }
+
+ return extracted;
+ }
+ catch (RootWorkflowException)
+ {
+ throw;
+ }
+ catch (Exception ex) when (ex is InvalidDataException or IOException or OverflowException)
+ {
+ throw new RootWorkflowException(RootErrorCode.MagiskToolUnavailable,
+ "The official Magisk APK could not be read safely.", ex);
+ }
+ }
+
+ private static string Sanitize(string? value, bool timedOut)
+ {
+ var normalized = string.Join(' ', (value ?? string.Empty)
+ .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
+ if (normalized.Length > 300)
+ {
+ normalized = normalized[..300];
+ }
+
+ return timedOut ? $"Command timed out. {normalized}".Trim() : normalized;
+ }
+
+ private static bool HasInstallSuccess(AdbCommandResult result)
+ => (result.StandardOutput + "\n" + result.StandardError)
+ .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Any(static line => line.Equals("Success", StringComparison.OrdinalIgnoreCase));
+
+ private static void TryDeleteLocal(string path)
+ {
+ try
+ {
+ File.Delete(path);
+ }
+ catch
+ {
+ // A subsequent pull reports the stable failure if the destination cannot be replaced.
+ }
+ }
+
+ private static void TryDeleteDirectory(string path)
+ {
+ try
+ {
+ if (Directory.Exists(path))
+ {
+ Directory.Delete(path, recursive: true);
+ }
+ }
+ catch
+ {
+ // Cleanup is best effort and must not hide the primary patch failure.
+ }
+ }
+
+ private sealed record ExtractedComponent(string LocalPath, string RemoteName);
+}
diff --git a/src/AndroidTreeView.Adb/Services/RootFastbootService.cs b/src/AndroidTreeView.Adb/Services/RootFastbootService.cs
new file mode 100644
index 0000000..ff1ff30
--- /dev/null
+++ b/src/AndroidTreeView.Adb/Services/RootFastbootService.cs
@@ -0,0 +1,533 @@
+using System.Diagnostics;
+using AndroidTreeView.Adb.Commands;
+using AndroidTreeView.Adb.Parsers;
+using AndroidTreeView.Core.Exceptions;
+using AndroidTreeView.Core.Interfaces;
+using AndroidTreeView.Core.Services;
+using AndroidTreeView.Models.Rooting;
+using Microsoft.Extensions.Logging;
+
+namespace AndroidTreeView.Adb.Services;
+
+/// Strict, identity-preserving fastboot operations for the root workflow.
+public sealed class RootFastbootService : IRootFastbootService
+{
+ private static readonly TimeSpan ListTimeout = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan GetVarTimeout = TimeSpan.FromSeconds(8);
+ private static readonly TimeSpan FlashTimeout = TimeSpan.FromMinutes(3);
+ private static readonly TimeSpan RebootTimeout = TimeSpan.FromSeconds(15);
+ private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(250);
+
+ private readonly IAdbEnvironment _environment;
+ private readonly IAdbCommandExecutor _adb;
+ private readonly IExternalCommandRunner _runner;
+ private readonly RootToolPaths _rootToolPaths;
+ private readonly ILogger _logger;
+
+ public RootFastbootService(
+ IAdbEnvironment environment,
+ IAdbCommandExecutor adb,
+ IExternalCommandRunner runner,
+ RootToolPaths rootToolPaths,
+ ILogger logger)
+ {
+ _environment = environment;
+ _adb = adb;
+ _runner = runner;
+ _rootToolPaths = rootToolPaths;
+ _logger = logger;
+ }
+
+ public string? ExecutablePath
+ {
+ get
+ {
+ var name = OperatingSystem.IsWindows() ? "fastboot.exe" : "fastboot";
+ var bundled = Path.Combine(AppContext.BaseDirectory, "scrcpy", name);
+ if (File.Exists(bundled))
+ {
+ return bundled;
+ }
+
+ var adbPath = _environment.Location?.ExecutablePath;
+ var adbDirectory = string.IsNullOrWhiteSpace(adbPath) ? null : Path.GetDirectoryName(adbPath);
+ if (!string.IsNullOrWhiteSpace(adbDirectory))
+ {
+ var sibling = Path.Combine(adbDirectory, name);
+ if (File.Exists(sibling))
+ {
+ return sibling;
+ }
+ }
+
+ return null;
+ }
+ }
+
+ public async Task CaptureBaselineAsync(CancellationToken ct = default)
+ {
+ var devices = await ListDevicesStrictAsync(RootErrorCode.FastbootBaselineFailed, ct).ConfigureAwait(false);
+ return new FastbootBaseline
+ {
+ CapturedAtUtc = DateTimeOffset.UtcNow,
+ Devices = devices
+ };
+ }
+
+ public async Task RebootToBootloaderAsync(string adbSerial, CancellationToken ct = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(adbSerial);
+ AdbCommandResult result;
+ try
+ {
+ result = await _adb.ExecuteAsync(new AdbCommandRequest
+ {
+ Serial = adbSerial,
+ Arguments = new[] { "reboot", "bootloader" },
+ Timeout = RebootTimeout
+ }, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new RootWorkflowException(RootErrorCode.RebootToBootloaderFailed,
+ "ADB could not reboot the selected device to bootloader.", ex);
+ }
+
+ if (!result.IsSuccess)
+ {
+ throw Failure(
+ RootErrorCode.RebootToBootloaderFailed,
+ "ADB could not reboot the selected device to bootloader.",
+ result.StandardError,
+ result.TimedOut);
+ }
+ }
+
+ public async Task WaitForMatchingDeviceAsync(
+ RootDeviceIdentity device,
+ FastbootBaseline baseline,
+ TimeSpan timeout,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(device);
+ ArgumentNullException.ThrowIfNull(baseline);
+ if (timeout <= TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(timeout));
+ }
+
+ var baselineSerials = baseline.Devices
+ .Select(static item => item.Serial)
+ .ToHashSet(StringComparer.Ordinal);
+ var stopwatch = Stopwatch.StartNew();
+ FastbootIdentityMatch? lastResult = null;
+
+ while (stopwatch.Elapsed < timeout)
+ {
+ ct.ThrowIfCancellationRequested();
+ IReadOnlyList listed;
+ try
+ {
+ listed = await ListDevicesStrictAsync(RootErrorCode.FastbootDeviceNotFound, ct).ConfigureAwait(false);
+ }
+ catch (RootWorkflowException ex) when (ex.ErrorCode == RootErrorCode.FastbootDeviceNotFound)
+ {
+ listed = Array.Empty();
+ }
+
+ var candidates = listed.Where(item => !baselineSerials.Contains(item.Serial)).ToArray();
+ var enriched = await EnrichProductsAsync(candidates, ct).ConfigureAwait(false);
+ lastResult = FastbootVarParser.MatchIdentity(device, enriched);
+ if (lastResult.IsVerified
+ || lastResult.Status is FastbootIdentityMatchStatus.Ambiguous
+ or FastbootIdentityMatchStatus.ConflictingEvidence)
+ {
+ return lastResult;
+ }
+
+ var remaining = timeout - stopwatch.Elapsed;
+ if (remaining > TimeSpan.Zero)
+ {
+ await Task.Delay(remaining < PollInterval ? remaining : PollInterval, ct).ConfigureAwait(false);
+ }
+ }
+
+ return lastResult is { Status: FastbootIdentityMatchStatus.ConflictingEvidence }
+ ? lastResult
+ : new FastbootIdentityMatch { Status = FastbootIdentityMatchStatus.TargetNotFound };
+ }
+
+ public async Task IsBootloaderUnlockedAsync(string fastbootSerial, CancellationToken ct = default)
+ {
+ var value = await GetVariableStrictAsync(fastbootSerial, "unlocked", ct).ConfigureAwait(false);
+ return FastbootVarParser.ParseBoolean(value) == true;
+ }
+
+ public async Task VerifyCurrentIdentityAsync(
+ RootDeviceIdentity device,
+ string expectedSerial,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(device);
+ ArgumentException.ThrowIfNullOrWhiteSpace(expectedSerial);
+
+ var listed = await ListDevicesStrictAsync(RootErrorCode.FastbootDeviceNotFound, ct).ConfigureAwait(false);
+ var expected = listed
+ .Where(candidate => candidate.Serial.Equals(expectedSerial, StringComparison.Ordinal))
+ .ToArray();
+ if (expected.Length == 0)
+ {
+ return new FastbootIdentityMatch { Status = FastbootIdentityMatchStatus.TargetNotFound };
+ }
+
+ var enriched = await EnrichProductsAsync(expected, ct).ConfigureAwait(false);
+ return FastbootVarParser.MatchIdentity(device, enriched);
+ }
+
+ public async Task GetBootLayoutAsync(
+ string fastbootSerial,
+ BootPartitionTarget target,
+ CancellationToken ct = default)
+ {
+ var partition = FastbootArgs.PartitionName(target);
+ var slotCountValue = await GetVariableOptionalAsync(fastbootSerial, "slot-count", ct).ConfigureAwait(false);
+ var hasSlotValue = await GetVariableOptionalAsync(fastbootSerial, $"has-slot:{partition}", ct).ConfigureAwait(false);
+ var currentSlotValue = await GetVariableOptionalAsync(fastbootSerial, "current-slot", ct).ConfigureAwait(false);
+
+ int? slotCount = int.TryParse(slotCountValue, out var count) && count >= 0 ? count : null;
+ var hasSlot = FastbootVarParser.ParseBoolean(hasSlotValue);
+ var currentSlot = NormalizeSlot(currentSlotValue);
+
+ // Legacy bootloaders predate A/B and answer "GetVar Variable Not found" for has-slot and
+ // current-slot, so an explicit slot-count of 0 or 1 is their only affirmative way to report
+ // a single-slot layout. Absent variables still count as no evidence, never as a slot.
+ var singleSlotEvidence = hasSlot == false || slotCount is 0 or 1;
+ var abSlotEvidence = hasSlot == true || slotCount > 1 || currentSlot is not null;
+
+ var kind = FastbootBootLayoutKind.Unknown;
+ if (singleSlotEvidence && !abSlotEvidence)
+ {
+ kind = FastbootBootLayoutKind.Single;
+ }
+ else if (hasSlot == true && slotCount == 2 && currentSlot is not null)
+ {
+ kind = FastbootBootLayoutKind.Ab;
+ }
+
+ return new FastbootBootLayout
+ {
+ Kind = kind,
+ CurrentSlot = currentSlot,
+ SlotCount = slotCount,
+ TargetHasSlot = hasSlot
+ };
+ }
+
+ public async Task FlashAsync(
+ string fastbootSerial,
+ BootPartitionTarget target,
+ string imagePath,
+ FastbootBootLayout layout,
+ IReadOnlyCollection? alreadySucceededPartitions = null,
+ CancellationToken ct = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(fastbootSerial);
+ ArgumentException.ThrowIfNullOrWhiteSpace(imagePath);
+ ArgumentNullException.ThrowIfNull(layout);
+ if (!File.Exists(imagePath) || new FileInfo(imagePath).Length == 0)
+ {
+ throw new RootWorkflowException(RootErrorCode.PatchedImageInvalid, "The patched boot image is missing or empty.");
+ }
+
+ var targetName = FastbootArgs.PartitionName(target);
+ var requested = layout.Kind switch
+ {
+ FastbootBootLayoutKind.Single => new[] { targetName },
+ FastbootBootLayoutKind.Ab => new[] { $"{targetName}_a", $"{targetName}_b" },
+ _ => Array.Empty()
+ };
+ if (requested.Length == 0)
+ {
+ return FailedResult(requested, Array.Empty(), null, FlashOutcome.FailedBeforeWrite,
+ RootErrorCode.BootLayoutUnknown, "Boot layout is unknown.");
+ }
+
+ var succeeded = (alreadySucceededPartitions ?? Array.Empty())
+ .Where(partition => requested.Contains(partition, StringComparer.Ordinal))
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+
+ foreach (var partition in requested.Where(partition => !succeeded.Contains(partition, StringComparer.Ordinal)))
+ {
+ if (ct.IsCancellationRequested && succeeded.Count > 0)
+ {
+ return FailedResult(requested, succeeded, partition, FlashOutcome.PartiallyWritten,
+ RootErrorCode.FlashPartiallyWritten,
+ "Flash was cancelled before the next partition write started.");
+ }
+
+ ct.ThrowIfCancellationRequested();
+ ExternalCommandResult result;
+ try
+ {
+ result = await RunFastbootAsync(
+ FastbootArgs.Flash(fastbootSerial, partition, Path.GetFullPath(imagePath)),
+ FlashTimeout,
+ ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ return FailedResult(requested, succeeded, partition, FlashOutcome.Unknown,
+ RootErrorCode.FlashOutcomeUnknown, "Flash command was cancelled; device write status is unknown.");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Fastboot flash process failed for partition {Partition}.", partition);
+ return FailedResult(requested, succeeded, partition, FlashOutcome.Unknown,
+ RootErrorCode.FlashOutcomeUnknown, "Fastboot process ended unexpectedly; device write status is unknown.");
+ }
+
+ if (result.TimedOut)
+ {
+ return FailedResult(requested, succeeded, partition, FlashOutcome.Unknown,
+ RootErrorCode.FlashOutcomeUnknown, Diagnostic(result.StandardError, timedOut: true));
+ }
+
+ if (!result.IsSuccess)
+ {
+ return FailedResult(requested, succeeded, partition, FlashOutcome.Unknown,
+ RootErrorCode.FlashOutcomeUnknown, Diagnostic(result.StandardError));
+ }
+
+ succeeded.Add(partition);
+ }
+
+ return new FlashResult
+ {
+ RequestedPartitions = requested,
+ SucceededPartitions = succeeded,
+ Outcome = FlashOutcome.Succeeded,
+ ErrorCode = RootErrorCode.None
+ };
+ }
+
+ public async Task RebootAsync(string fastbootSerial, CancellationToken ct = default)
+ {
+ ExternalCommandResult result;
+ try
+ {
+ result = await RunFastbootAsync(FastbootArgs.Reboot(fastbootSerial), RebootTimeout, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (RootWorkflowException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new RootWorkflowException(RootErrorCode.RebootFailed,
+ "Fastboot could not reboot the selected device.", ex);
+ }
+
+ if (!result.IsSuccess)
+ {
+ throw Failure(RootErrorCode.RebootFailed, "Fastboot could not reboot the selected device.",
+ result.StandardError, result.TimedOut);
+ }
+ }
+
+ private async Task> ListDevicesStrictAsync(
+ RootErrorCode errorCode,
+ CancellationToken ct)
+ {
+ ExternalCommandResult result;
+ try
+ {
+ result = await RunFastbootAsync(FastbootArgs.DevicesLong, ListTimeout, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (RootWorkflowException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new RootWorkflowException(errorCode, "Fastboot device enumeration failed.", ex);
+ }
+
+ if (!result.IsSuccess)
+ {
+ throw Failure(errorCode, "Fastboot device enumeration failed.", result.StandardError, result.TimedOut);
+ }
+
+ return FastbootVarParser.ParseDevices(result.StandardOutput + "\n" + result.StandardError);
+ }
+
+ private async Task> EnrichProductsAsync(
+ IReadOnlyList candidates,
+ CancellationToken ct)
+ {
+ var enriched = new List(candidates.Count);
+ foreach (var candidate in candidates)
+ {
+ if (!string.IsNullOrWhiteSpace(candidate.Product))
+ {
+ enriched.Add(candidate);
+ continue;
+ }
+
+ string? product = null;
+ try
+ {
+ product = await GetVariableStrictAsync(candidate.Serial, "product", ct).ConfigureAwait(false);
+ }
+ catch (RootWorkflowException)
+ {
+ // Missing product evidence keeps the candidate unverified; it must never be guessed.
+ }
+
+ enriched.Add(candidate with { Product = product });
+ }
+
+ return enriched;
+ }
+
+ private async Task GetVariableStrictAsync(string serial, string variable, CancellationToken ct)
+ {
+ ExternalCommandResult result;
+ try
+ {
+ result = await RunFastbootAsync(FastbootArgs.GetVar(serial, variable), GetVarTimeout, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (RootWorkflowException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new RootWorkflowException(RootErrorCode.FastbootDeviceNotFound,
+ $"Fastboot getvar {variable} failed.", ex);
+ }
+
+ if (!result.IsSuccess)
+ {
+ throw Failure(RootErrorCode.FastbootDeviceNotFound, $"Fastboot getvar {variable} failed.",
+ result.StandardError, result.TimedOut);
+ }
+
+ var combined = result.StandardOutput + "\n" + result.StandardError;
+ return FastbootVarParser.ParseValue(combined, variable);
+ }
+
+ private async Task GetVariableOptionalAsync(string serial, string variable, CancellationToken ct)
+ {
+ try
+ {
+ var result = await RunFastbootAsync(
+ FastbootArgs.GetVar(serial, variable),
+ GetVarTimeout,
+ ct).ConfigureAwait(false);
+ if (!result.IsSuccess)
+ {
+ return null;
+ }
+
+ return FastbootVarParser.ParseValue(
+ result.StandardOutput + "\n" + result.StandardError,
+ variable);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (RootWorkflowException ex) when (ex.ErrorCode == RootErrorCode.FastbootUnavailable)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Optional fastboot variable {Variable} could not be read.", variable);
+ return null;
+ }
+ }
+
+ private async Task RunFastbootAsync(
+ IReadOnlyList arguments,
+ TimeSpan timeout,
+ CancellationToken ct)
+ {
+ var executable = ExecutablePath;
+ if (executable is null)
+ {
+ throw new RootWorkflowException(RootErrorCode.FastbootUnavailable, "The fastboot executable is unavailable.");
+ }
+
+ return await _runner.RunAsync(new ExternalCommandRequest
+ {
+ FileName = executable,
+ Arguments = arguments,
+ Timeout = timeout
+ }, ct).ConfigureAwait(false);
+ }
+
+ private static FlashResult FailedResult(
+ IReadOnlyList requested,
+ IReadOnlyList succeeded,
+ string? failedPartition,
+ FlashOutcome outcome,
+ RootErrorCode errorCode,
+ string diagnostic)
+ => new()
+ {
+ RequestedPartitions = requested,
+ SucceededPartitions = succeeded.ToArray(),
+ FailedPartition = failedPartition,
+ Outcome = outcome,
+ ErrorCode = errorCode,
+ DiagnosticSummary = diagnostic
+ };
+
+ private static RootWorkflowException Failure(
+ RootErrorCode code,
+ string message,
+ string? standardError,
+ bool timedOut)
+ => new(code, message) { DiagnosticSummary = Diagnostic(standardError, timedOut) };
+
+ private static string Diagnostic(string? value, bool timedOut = false)
+ {
+ var normalized = string.Join(' ', (value ?? string.Empty)
+ .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
+ if (normalized.Length > 300)
+ {
+ normalized = normalized[..300];
+ }
+
+ if (timedOut)
+ {
+ return normalized.Length == 0 ? "Command timed out." : $"Command timed out. {normalized}";
+ }
+
+ return normalized.Length == 0 ? "Command failed without diagnostic output." : normalized;
+ }
+
+ private static string? NormalizeSlot(string? value)
+ {
+ var normalized = value?.Trim().TrimStart('_').ToLowerInvariant();
+ return normalized is "a" or "b" ? normalized : null;
+ }
+}
diff --git a/src/AndroidTreeView.Adb/Services/RootToolPaths.cs b/src/AndroidTreeView.Adb/Services/RootToolPaths.cs
new file mode 100644
index 0000000..54b5796
--- /dev/null
+++ b/src/AndroidTreeView.Adb/Services/RootToolPaths.cs
@@ -0,0 +1,32 @@
+namespace AndroidTreeView.Adb.Services;
+
+/// Resolved locations for fixed-version root assets and per-session working data.
+public sealed class RootToolPaths
+{
+ public RootToolPaths(
+ string? applicationBaseDirectory = null,
+ string? workRootDirectory = null)
+ {
+ var appBase = Path.GetFullPath(applicationBaseDirectory ?? AppContext.BaseDirectory);
+ RootToolsDirectory = Path.Combine(appBase, "root-tools");
+ MagiskApkPath = Path.Combine(RootToolsDirectory, "magisk", "Magisk-v30.7.apk");
+ PayloadDumperPath = Path.Combine(
+ RootToolsDirectory,
+ "payload-dumper",
+ OperatingSystem.IsWindows() ? "payload-dumper-go.exe" : "payload-dumper-go");
+
+ var defaultWorkRoot = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".androidtreeview",
+ "root-work");
+ WorkRootDirectory = Path.GetFullPath(workRootDirectory ?? defaultWorkRoot);
+ }
+
+ public string RootToolsDirectory { get; }
+
+ public string MagiskApkPath { get; }
+
+ public string PayloadDumperPath { get; }
+
+ public string WorkRootDirectory { get; }
+}
diff --git a/src/AndroidTreeView.App/AndroidTreeView.App.csproj b/src/AndroidTreeView.App/AndroidTreeView.App.csproj
index de7dc78..5cb0bab 100644
--- a/src/AndroidTreeView.App/AndroidTreeView.App.csproj
+++ b/src/AndroidTreeView.App/AndroidTreeView.App.csproj
@@ -8,10 +8,10 @@
Assets/atv-icon.ico
true
true
- 1.0.6
- 1.0.6.0
- 1.0.6.0
- 1.0.6
+ 1.0.7
+ 1.0.7.0
+ 1.0.7.0
+ 1.0.7
false
@@ -48,5 +48,6 @@
+
diff --git a/src/AndroidTreeView.App/AppServices.cs b/src/AndroidTreeView.App/AppServices.cs
index a9e5021..97acaee 100644
--- a/src/AndroidTreeView.App/AppServices.cs
+++ b/src/AndroidTreeView.App/AppServices.cs
@@ -36,6 +36,7 @@ public static IServiceCollection ConfigureAppServices(this IServiceCollection se
// Shell + device grid view models (singletons).
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
// Page + detail view models (transient).
services.AddTransient();
diff --git a/src/AndroidTreeView.App/Resources/Strings.resx b/src/AndroidTreeView.App/Resources/Strings.resx
index 62acc49..04a1fc0 100644
--- a/src/AndroidTreeView.App/Resources/Strings.resx
+++ b/src/AndroidTreeView.App/Resources/Strings.resx
@@ -661,7 +661,7 @@
Could not start the update process
- Ready to check the internal update channel
+ Ready to check GitHub Releases
Update Source
@@ -670,13 +670,13 @@
Package Key
- No build has been uploaded for this channel yet
+ No GitHub Release has been published yet
- Could not reach the internal update server
+ Could not reach GitHub Releases
- The update server rejected the request
+ GitHub rejected the update request
The update metadata is incomplete
@@ -714,5 +714,117 @@
Failed to load {0}
+ Root wizard
+ Semi-automatic Root
+ Target device
+ Firmware package
+ Workflow status
+ {0} | product: {1}
+ ADB is unavailable. Configure ADB before starting.
+ No online, authorized ADB device is available.
+ Select the exact device you intend to modify.
+ The target identity is locked for this session. Cancel before choosing another device.
+ Could not refresh connected devices.
+ Supported: firmware ZIP (including Pixel nested ZIP) or payload.bin.
+ Package metadata matches the selected device
+ Package metadata identifies another device
+ Package metadata could not be verified automatically
+ Verified fastboot device {0} ({1})
+ Fastboot identity is not verified
+ Waiting for ADB-to-fastboot verification
+ serial
+ USB path
+ product
+ no identity evidence
+ ,
+ Single partition: {0}
+ Both slots: {0}_a and {1}_b
+ Not detected yet
+ Target partition
+ Package check
+ Device identity
+ Write targets
+ Original image backup
+ Patched image
+ Refresh devices
+ Extract and patch
+ Enter bootloader
+ Recheck fastboot
+ Flash patched image
+ Open backup folder
+ Enter bootloader?
+ The selected device will reboot into fastboot. Keep the USB cable connected and do not switch devices.
+ Final write confirmation
+ Writing the wrong image can stop the device from booting. Verify the device, package, partition, and backup shown below.
+ Both A/B slots will receive the same image. If the slots contain different system versions, switching slots later may cause a boot loop.
+ The package does not contain enough metadata to verify that it belongs to this device. Treat the package-device match as unverified.
+ Experimental write path
+ Real Magisk patching and fastboot flashing remain disabled by default until the M0 and M1 device-validation milestones are recorded.
+ I understand the risk and have verified the target device and firmware package.
+ Workflow blocked
+ The bootloader is locked. Unlock it using the device manufacturer's procedure, which may erase all data, then recheck. This app will not unlock it for you.
+ The fastboot device cannot be proven to be the selected ADB device. Reconnect only the intended device and recheck; this safety check cannot be bypassed.
+ The target slot layout is missing or contradictory. Flashing remains disabled.
+ The package or device does not provide an unambiguous supported boot target. Choose a matching package; recovery patching is not supported.
+ Dangerous intermediate state
+ At least one write may have completed, but the full result is not safe. Do not disconnect the device. Review the status and retry only the failed partition.
+ Root image flashed
+ The verified fastboot device was rebooted. Keep the original-image backup until the device has booted successfully.
+ Select
+ Extract
+ Patch and back up
+ Verify fastboot
+ Flash
+ Complete
+ Done
+ Current step
+ Pending
+ Root workflow error
+ The root workflow could not continue.
+ The selected device is missing, offline, or unauthorized.
+ The selected package is missing, corrupt, unsafe, or unsupported.
+ The firmware package explicitly targets a different device.
+ The payload extraction tool is unavailable or extraction failed.
+ A safe boot or init_boot target could not be established.
+ The selected boot image has no ramdisk. Recovery-mode Magisk installation is not supported.
+ The original image could not be backed up and verified. Flashing is disabled.
+ Magisk installation or image patching failed.
+ The patched image was rejected: its Magisk verity/encryption flags do not match what this device requires. Flashing it would leave the device unable to mount its system partition.
+ Fastboot is unavailable or the device did not enter bootloader mode.
+ The fastboot device identity could not be verified.
+ The device bootloader is locked.
+ The boot partition slot layout is unknown or contradictory.
+ One partition was written but a later partition failed.
+ The flash command was interrupted and its write outcome is unknown.
+ The image was not confirmed written.
+ The image was written, but fastboot could not reboot the device.
+ Acknowledge the write risk before flashing.
+ Select an online device and its matching firmware package.
+ Ready to extract and patch the selected package.
+ Inspecting the package and extracting the target image...
+ Target image extracted.
+ Backing up the original and patching with Magisk...
+ Patched image and verified backup are ready.
+ Confirm before rebooting the selected device into bootloader.
+ Waiting for the same device in fastboot...
+ Fastboot identity verified; checking unlock and slot layout...
+ The package has no safe supported target.
+ Fastboot identity verification failed.
+ The bootloader is locked.
+ The boot slot layout is not safe to use.
+ Review every write target and acknowledge the risk.
+ Writing the patched image. Do not disconnect the device...
+ Write confirmed; rebooting the device...
+ Root workflow completed.
+ The current step failed and may be retried safely.
+ A later A/B write failed after an earlier partition succeeded.
+ The flash outcome is unknown. Do not disconnect the device.
+ The workflow was cancelled; original backups were retained.
+ Select adb executable
+ Select APKs or files
+ Select firmware ZIP or payload.bin
+ Android package (APK)
+ Supported firmware package
+ Android Debug Bridge
+ Executable files
-
diff --git a/src/AndroidTreeView.App/Resources/Strings.zh-Hans.resx b/src/AndroidTreeView.App/Resources/Strings.zh-Hans.resx
index ee83efa..49b80ce 100644
--- a/src/AndroidTreeView.App/Resources/Strings.zh-Hans.resx
+++ b/src/AndroidTreeView.App/Resources/Strings.zh-Hans.resx
@@ -661,7 +661,7 @@
无法启动更新流程
- 可以检查内网更新通道
+ 可以检查 GitHub Releases
更新源
@@ -670,13 +670,13 @@
包标识
- 这个通道还没有上传版本
+ GitHub 仓库还没有发布版本
- 无法连接内网更新服务
+ 无法连接 GitHub Releases
- 更新服务拒绝了本次请求
+ GitHub 拒绝了本次更新请求
更新信息不完整
@@ -714,5 +714,117 @@
加载 {0} 失败
+ Root 向导
+ 半自动 Root
+ 目标设备
+ 刷机包
+ 流程状态
+ {0} | 产品:{1}
+ ADB 不可用,请先完成 ADB 配置。
+ 没有在线且已授权的 ADB 设备。
+ 请明确选择要修改的设备。
+ 本次会话已锁定目标设备;如需更换,请先取消当前流程。
+ 无法刷新已连接设备。
+ 支持刷机包 ZIP(含 Pixel 嵌套 ZIP)或 payload.bin。
+ 包元数据与所选设备匹配
+ 包元数据指向其他设备
+ 无法自动验证包元数据
+ 已验证 fastboot 设备 {0}({1})
+ 未验证 fastboot 设备身份
+ 等待验证 ADB 到 fastboot 身份连续性
+ 序列号
+ USB 路径
+ 产品代号
+ 无身份凭据
+ 、
+ 单分区:{0}
+ 双槽:{0}_a 和 {1}_b
+ 尚未检测
+ 目标分区
+ 刷机包检查
+ 设备身份
+ 写入目标
+ 原始镜像备份
+ 修补后镜像
+ 刷新设备
+ 提取并修补
+ 进入 Bootloader
+ 重新检测 Fastboot
+ 刷入修补镜像
+ 打开备份目录
+ 进入 Bootloader?
+ 所选设备将重启进入 fastboot。请保持 USB 数据线稳定,不要切换设备。
+ 最终写入确认
+ 刷错镜像可能导致设备无法启动。请核对下方设备、刷机包、分区和备份。
+ A/B 两个槽都会写入同一镜像。若两槽系统版本不同,日后切换槽位可能导致无法启动。
+ 刷机包没有提供足够元数据,无法确认其属于当前设备。请将包与设备的匹配状态视为未验证。
+ 实验性写入链路
+ 在 M0 与 M1 实机验证里程碑留下记录前,真实 Magisk 修补和 fastboot 刷写默认禁用。
+ 我已理解风险,并已核对目标设备和刷机包。
+ 流程已阻止
+ Bootloader 尚未解锁。请按厂商流程自行解锁(通常会清除全部数据)后重新检测;本工具不会代为解锁。
+ 无法证明 fastboot 设备就是所选 ADB 设备。请仅连接目标设备后重新检测;此安全检查不可绕过。
+ 目标槽位信息缺失或互相矛盾,刷写保持禁用。
+ 刷机包或设备没有提供唯一且受支持的启动目标。请选择匹配包;首版不支持 recovery 修补。
+ 危险的中间状态
+ 至少一次写入可能已经完成,但整体结果不安全。请勿断开设备,并仅重试失败的分区。
+ Root 镜像已刷入
+ 已验证的 fastboot 设备已重启。确认设备成功开机前,请保留原始镜像备份。
+ 选择
+ 提取
+ 修补与备份
+ 验证 Fastboot
+ 刷入
+ 完成
+ 已完成
+ 当前步骤
+ 待执行
+ Root 流程错误
+ Root 流程无法继续。
+ 所选设备缺失、离线或未授权。
+ 所选刷机包缺失、损坏、不安全或不受支持。
+ 刷机包明确指向其他设备。
+ payload 提取工具不可用或提取失败。
+ 无法确定安全的 boot 或 init_boot 目标。
+ 所选 boot 镜像不含 ramdisk,暂不支持 Recovery 模式的 Magisk 安装。
+ 无法备份并验证原始镜像,刷写已禁用。
+ Magisk 安装或镜像修补失败。
+ 修补后的镜像未通过校验:其 Magisk verity/加密标志与本机要求不符。刷入会导致设备无法挂载系统分区。
+ Fastboot 不可用,或设备未进入 Bootloader。
+ 无法验证 fastboot 设备身份。
+ 设备 Bootloader 尚未解锁。
+ 启动分区槽位未知或互相矛盾。
+ 一个分区已写入,但后续分区写入失败。
+ 刷写命令被中断,无法确定写入结果。
+ 未能确认镜像写入成功。
+ 镜像已写入,但 fastboot 无法重启设备。
+ 必须确认写入风险后才能刷入。
+ 请选择在线设备及与其匹配的刷机包。
+ 已准备提取并修补所选刷机包。
+ 正在检查刷机包并提取目标镜像...
+ 目标镜像已提取。
+ 正在备份原始镜像并用 Magisk 修补...
+ 修补镜像和已验证备份均已就绪。
+ 请确认后再将所选设备重启进入 Bootloader。
+ 正在等待同一设备进入 fastboot...
+ Fastboot 身份已验证,正在检查解锁和槽位...
+ 刷机包没有安全且受支持的目标。
+ Fastboot 身份验证失败。
+ Bootloader 尚未解锁。
+ 启动槽位信息不满足安全要求。
+ 请核对全部写入目标并确认风险。
+ 正在写入修补镜像,请勿断开设备...
+ 已确认写入,正在重启设备...
+ Root 流程已完成。
+ 当前步骤失败,可安全重试。
+ A/B 前一分区成功后,后续分区写入失败。
+ 刷写结果未知,请勿断开设备。
+ 流程已取消;原始镜像备份已保留。
+ 选择 adb 可执行文件
+ 选择 APK 或文件
+ 选择刷机包 ZIP 或 payload.bin
+ Android 安装包(APK)
+ 受支持的刷机包
+ Android 调试桥
+ 可执行文件
-
diff --git a/src/AndroidTreeView.App/Services/FilePickerService.cs b/src/AndroidTreeView.App/Services/FilePickerService.cs
index 5da3017..a171983 100644
--- a/src/AndroidTreeView.App/Services/FilePickerService.cs
+++ b/src/AndroidTreeView.App/Services/FilePickerService.cs
@@ -1,4 +1,5 @@
using System.Diagnostics;
+using AndroidTreeView.Core.Interfaces;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
@@ -14,11 +15,12 @@ namespace AndroidTreeView.App.Services;
public sealed class FilePickerService : IFilePickerService
{
private readonly ILogger _logger;
+ private readonly ILocalizationService _localization;
- public FilePickerService(ILogger logger)
+ public FilePickerService(ILogger logger, ILocalizationService localization)
{
- ArgumentNullException.ThrowIfNull(logger);
- _logger = logger;
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _localization = localization ?? throw new ArgumentNullException(nameof(localization));
}
///
@@ -33,7 +35,7 @@ public FilePickerService(ILogger logger)
var options = new FilePickerOpenOptions
{
- Title = "Select adb executable",
+ Title = _localization.Get("picker.adb.title"),
AllowMultiple = false,
FileTypeFilter = BuildFilters()
};
@@ -50,12 +52,37 @@ public FilePickerService(ILogger logger)
///
public Task> PickTransferFilesAsync() =>
PickFilesAsync(
- "Select APKs or files",
+ _localization.Get("picker.transfer.title"),
[
- new FilePickerFileType("APK") { Patterns = ["*.apk"] },
+ new FilePickerFileType(_localization.Get("picker.type.apk")) { Patterns = ["*.apk"] },
FilePickerFileTypes.All
]);
+ ///
+ public async Task PickRootPackageAsync()
+ {
+ var window = GetMainWindow();
+ if (window?.StorageProvider is not { CanOpen: true } provider)
+ {
+ _logger.LogWarning("No storage provider is available for the root package picker.");
+ return null;
+ }
+
+ var files = await provider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = _localization.Get("picker.root.title"),
+ AllowMultiple = false,
+ FileTypeFilter =
+ [
+ new FilePickerFileType(_localization.Get("picker.type.firmware"))
+ {
+ Patterns = ["*.zip", "payload.bin"]
+ }
+ ]
+ }).ConfigureAwait(true);
+ return files.Count == 0 ? null : files[0].TryGetLocalPath();
+ }
+
///
public Task OpenUrlAsync(string url)
{
@@ -76,21 +103,21 @@ public Task OpenUrlAsync(string url)
return Task.CompletedTask;
}
- private static IReadOnlyList BuildFilters()
+ private IReadOnlyList BuildFilters()
{
if (OperatingSystem.IsWindows())
{
return new[]
{
- new FilePickerFileType("adb") { Patterns = new[] { "adb.exe" } },
- new FilePickerFileType("Executables") { Patterns = new[] { "*.exe" } },
+ new FilePickerFileType(_localization.Get("picker.type.adb")) { Patterns = new[] { "adb.exe" } },
+ new FilePickerFileType(_localization.Get("picker.type.executable")) { Patterns = new[] { "*.exe" } },
FilePickerFileTypes.All
};
}
return new[]
{
- new FilePickerFileType("adb") { Patterns = new[] { "adb" } },
+ new FilePickerFileType(_localization.Get("picker.type.adb")) { Patterns = new[] { "adb" } },
FilePickerFileTypes.All
};
}
diff --git a/src/AndroidTreeView.App/Services/IFilePickerService.cs b/src/AndroidTreeView.App/Services/IFilePickerService.cs
index a459496..e054f95 100644
--- a/src/AndroidTreeView.App/Services/IFilePickerService.cs
+++ b/src/AndroidTreeView.App/Services/IFilePickerService.cs
@@ -11,6 +11,9 @@ public interface IFilePickerService
/// Prompts the user to pick one or more files for device transfer or APK install.
Task> PickTransferFilesAsync();
+ /// Prompts for one supported firmware ZIP or payload.bin file.
+ Task PickRootPackageAsync();
+
/// Opens in the default browser.
Task OpenUrlAsync(string url);
}
diff --git a/src/AndroidTreeView.App/ViewModels/AppEnums.cs b/src/AndroidTreeView.App/ViewModels/AppEnums.cs
index f877f5b..98f6a8c 100644
--- a/src/AndroidTreeView.App/ViewModels/AppEnums.cs
+++ b/src/AndroidTreeView.App/ViewModels/AppEnums.cs
@@ -17,6 +17,7 @@ public enum AppLayoutMode
public enum NavSection
{
Devices,
+ Root,
Settings,
About
}
diff --git a/src/AndroidTreeView.App/ViewModels/MainWindowViewModel.cs b/src/AndroidTreeView.App/ViewModels/MainWindowViewModel.cs
index 156d965..df63ad5 100644
--- a/src/AndroidTreeView.App/ViewModels/MainWindowViewModel.cs
+++ b/src/AndroidTreeView.App/ViewModels/MainWindowViewModel.cs
@@ -127,6 +127,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
public MainWindowViewModel(
DevicesViewModel devices,
+ RootWizardViewModel rootWizard,
SettingsViewModel settings,
SetupViewModel setup,
Func aboutFactory,
@@ -148,6 +149,7 @@ public MainWindowViewModel(
ILogger logger)
{
Devices = devices;
+ RootWizard = rootWizard;
Dialog = dialog;
_cli = cli;
_fastboot = fastboot;
@@ -185,6 +187,9 @@ public MainWindowViewModel(
/// The device grid view model (also the default content).
public DevicesViewModel Devices { get; }
+ /// The persistent semi-automatic root workflow page.
+ public RootWizardViewModel RootWizard { get; }
+
/// The settings page view model.
public SettingsViewModel Settings { get; }
@@ -315,10 +320,15 @@ public void SetWindowWidth(double width)
? AppLayoutMode.Medium
: AppLayoutMode.Narrow;
+ var enteringNarrow = mode == AppLayoutMode.Narrow && LayoutMode != AppLayoutMode.Narrow;
LayoutMode = mode;
IsWide = mode == AppLayoutMode.Wide;
IsMedium = mode == AppLayoutMode.Medium;
IsNarrow = mode == AppLayoutMode.Narrow;
+ if (enteringNarrow)
+ {
+ IsSidebarOpen = false;
+ }
}
[RelayCommand]
@@ -326,6 +336,16 @@ private void NavigateDevices()
{
CurrentSection = NavSection.Devices;
CurrentContent = Devices;
+ CloseSidebarOnNarrow();
+ }
+
+ [RelayCommand]
+ private void NavigateRoot()
+ {
+ CurrentSection = NavSection.Root;
+ CurrentContent = RootWizard;
+ CloseSidebarOnNarrow();
+ _ = RootWizard.RefreshDevicesAsync();
}
[RelayCommand]
@@ -333,6 +353,7 @@ private void NavigateSettings()
{
CurrentSection = NavSection.Settings;
CurrentContent = Settings;
+ CloseSidebarOnNarrow();
}
[RelayCommand]
@@ -340,10 +361,13 @@ private void NavigateAbout()
{
CurrentSection = NavSection.About;
CurrentContent = _aboutFactory();
+ CloseSidebarOnNarrow();
}
[RelayCommand]
- private Task RefreshAsync() => RefreshDevicesAsync();
+ private Task RefreshAsync() => CurrentSection == NavSection.Root
+ ? RootWizard.RefreshDevicesAsync()
+ : RefreshDevicesAsync();
private async Task RefreshDevicesAsync()
{
@@ -360,6 +384,14 @@ private async Task RefreshDevicesAsync()
[RelayCommand]
private void ToggleSidebar() => IsSidebarOpen = !IsSidebarOpen;
+ private void CloseSidebarOnNarrow()
+ {
+ if (IsNarrow)
+ {
+ IsSidebarOpen = false;
+ }
+ }
+
[RelayCommand]
private void Back()
{
@@ -491,6 +523,7 @@ partial void OnCurrentSectionChanged(NavSection value)
private string TitleFor(NavSection section) => section switch
{
NavSection.Settings => _localization.Get("settings.title"),
+ NavSection.Root => _localization.Get("root.wizard.title"),
NavSection.About => _localization.Get("about.title"),
_ => _localization.Get("devices.title"),
};
@@ -550,6 +583,11 @@ private async Task EnrichOneAsync(string serial)
}
var battery = await _deviceService.GetBatteryAsync(serial).ConfigureAwait(true);
+ if (!IsCurrentOnlineCard(serial, card))
+ {
+ return;
+ }
+
card.ApplyBattery(battery);
var due = !_staticEnrichedAt.TryGetValue(serial, out var last)
@@ -557,12 +595,26 @@ private async Task EnrichOneAsync(string serial)
if (due)
{
var overview = await _deviceService.GetOverviewAsync(serial).ConfigureAwait(true);
+ if (!IsCurrentOnlineCard(serial, card))
+ {
+ return;
+ }
+
card.ApplyOverview(overview);
var root = await _deviceService.GetRootStatusAsync(serial).ConfigureAwait(true);
+ if (!IsCurrentOnlineCard(serial, card))
+ {
+ return;
+ }
+
card.ApplyRoot(root);
await card.RefreshActionStateAsync().ConfigureAwait(true);
+ if (!IsCurrentOnlineCard(serial, card))
+ {
+ return;
+ }
_staticEnrichedAt[serial] = DateTimeOffset.Now;
}
@@ -577,6 +629,12 @@ private async Task EnrichOneAsync(string serial)
}
}
+ private bool IsCurrentOnlineCard(string serial, DeviceCardViewModel card)
+ {
+ var current = Devices.FindCard(serial);
+ return ReferenceEquals(current, card) && current.IsOnline && !current.IsFastboot;
+ }
+
// Fetch fastboot getvar facts once per fastboot device (until it has them or is unplugged).
private void MaybeEnrichFastboot(string serial)
{
diff --git a/src/AndroidTreeView.App/ViewModels/RootWizardViewModel.cs b/src/AndroidTreeView.App/ViewModels/RootWizardViewModel.cs
new file mode 100644
index 0000000..bea865e
--- /dev/null
+++ b/src/AndroidTreeView.App/ViewModels/RootWizardViewModel.cs
@@ -0,0 +1,634 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using AndroidTreeView.App.Services;
+using AndroidTreeView.Core.Interfaces;
+using AndroidTreeView.Models.Devices;
+using AndroidTreeView.Models.Rooting;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Extensions.Logging;
+
+namespace AndroidTreeView.App.ViewModels;
+
+/// Presentation and user-confirmation layer for the semi-automatic root workflow.
+public sealed partial class RootWizardViewModel : ViewModelBase
+{
+ private readonly IRootWizardService _wizard;
+ private readonly IDeviceMonitor _monitor;
+ private readonly IFilePickerService _filePicker;
+ private readonly IDialogService _dialog;
+ private readonly ILocalizationService _localization;
+ private readonly ILogger _logger;
+ private IReadOnlyList _latestDevices = Array.Empty();
+ private bool _adbAvailable;
+ private CancellationTokenSource? _operationCts;
+
+ [ObservableProperty]
+ private RootDeviceOptionViewModel? _selectedDevice;
+
+ [ObservableProperty]
+ private RootWizardState _state;
+
+ [ObservableProperty]
+ private string _stateText = string.Empty;
+
+ [ObservableProperty]
+ private string _deviceListMessage = string.Empty;
+
+ [ObservableProperty]
+ private string? _selectedPackagePath;
+
+ [ObservableProperty]
+ private string _packageDisplayName = string.Empty;
+
+ [ObservableProperty]
+ private string _targetPartitionText = string.Empty;
+
+ [ObservableProperty]
+ private string _packageMatchText = string.Empty;
+
+ [ObservableProperty]
+ private string _identityText = string.Empty;
+
+ [ObservableProperty]
+ private string _slotText = string.Empty;
+
+ [ObservableProperty]
+ private string? _backupPath;
+
+ [ObservableProperty]
+ private string? _patchedImagePath;
+
+ [ObservableProperty]
+ private bool _isBusy;
+
+ [ObservableProperty]
+ private bool _hasError;
+
+ [ObservableProperty]
+ private string _errorMessage = string.Empty;
+
+ [ObservableProperty]
+ private bool _isSelectionLocked;
+
+ [ObservableProperty]
+ private bool _hasAcknowledgedRisk;
+
+ [ObservableProperty]
+ private bool _isAbLayout;
+
+ [ObservableProperty]
+ private bool _isPackageMatchUnverified;
+
+ [ObservableProperty]
+ private bool _showFlashConfirmation;
+
+ [ObservableProperty]
+ private bool _showCompleted;
+
+ [ObservableProperty]
+ private bool _showBlockedGuidance;
+
+ [ObservableProperty]
+ private string _blockedGuidanceText = string.Empty;
+
+ [ObservableProperty]
+ private bool _showPartialFlashWarning;
+
+ public RootWizardViewModel(
+ IRootWizardService wizard,
+ IDeviceMonitor monitor,
+ IFilePickerService filePicker,
+ IDialogService dialog,
+ ILocalizationService localization,
+ ILogger logger)
+ {
+ _wizard = wizard ?? throw new ArgumentNullException(nameof(wizard));
+ _monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
+ _filePicker = filePicker ?? throw new ArgumentNullException(nameof(filePicker));
+ _dialog = dialog ?? throw new ArgumentNullException(nameof(dialog));
+ _localization = localization ?? throw new ArgumentNullException(nameof(localization));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+
+ Steps = new ObservableCollection(Enumerable.Range(0, 6).Select(i => new RootWizardStepItem(i)));
+ _wizard.Changed += OnWizardChanged;
+ _monitor.DevicesChanged += OnDevicesChanged;
+ _localization.LanguageChanged += OnLanguageChanged;
+ ApplySnapshot(_wizard.Snapshot);
+ }
+
+ public ObservableCollection Devices { get; } = new();
+
+ public ObservableCollection Steps { get; }
+
+ public bool HasDevices => Devices.Count > 0;
+
+ public bool IsDeviceSelectionEnabled => !IsSelectionLocked && !IsBusy;
+
+ public bool CanPickPackage => !IsSelectionLocked && !IsBusy;
+
+ public bool CanStart =>
+ State == RootWizardState.PackageSelected
+ && SelectedDevice is not null
+ && !string.IsNullOrWhiteSpace(SelectedPackagePath)
+ && !IsBusy;
+
+ public bool CanConfirmBootloader => State == RootWizardState.AwaitingBootloaderConfirm && !IsBusy;
+
+ public bool CanRecheckFastboot =>
+ State is RootWizardState.RebootingToBootloader
+ or RootWizardState.BlockedFastbootIdentity
+ or RootWizardState.BlockedLocked
+ or RootWizardState.BlockedBootLayout
+ && !IsBusy;
+
+ public bool CanRetry => State == RootWizardState.Failed && !IsRetryBlocked(_wizard.Snapshot.ErrorCode) && !IsBusy;
+
+ public bool CanCancel => State is not (
+ RootWizardState.Idle
+ or RootWizardState.FlashOutcomeUnknown
+ or RootWizardState.Cancelled
+ or RootWizardState.Completed);
+
+ public bool CanFlash =>
+ State is RootWizardState.AwaitingFlashConfirm or RootWizardState.FailedPartialFlash
+ && _wizard.Snapshot.FastbootIdentityMatch?.IsVerified == true
+ && _wizard.Snapshot.BootLayout?.IsKnown == true
+ && _wizard.Snapshot.TargetPartition != BootPartitionTarget.Unknown
+ && !string.IsNullOrWhiteSpace(PatchedImagePath)
+ && !string.IsNullOrWhiteSpace(BackupPath)
+ && HasAcknowledgedRisk
+ && !IsBusy;
+
+ public async Task RefreshDevicesAsync(CancellationToken ct = default)
+ {
+ try
+ {
+ var snapshot = await _monitor.RefreshNowAsync(ct).ConfigureAwait(false);
+ RunOnUiThread(() => ApplyDevices(snapshot.Devices, snapshot.AdbAvailable));
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Refreshing root target devices failed.");
+ RunOnUiThread(() => DeviceListMessage = _localization.Get("root.device.refresh.failed"));
+ }
+ }
+
+ partial void OnSelectedDeviceChanged(RootDeviceOptionViewModel? value)
+ {
+ if (value is null || IsSelectionLocked)
+ {
+ return;
+ }
+
+ try
+ {
+ _wizard.SelectDevice(value.Identity);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Selecting root target device failed.");
+ }
+ }
+
+ partial void OnHasAcknowledgedRiskChanged(bool value) => FlashCommand.NotifyCanExecuteChanged();
+
+ [RelayCommand]
+ private Task RefreshTargetsAsync(CancellationToken ct) => RefreshDevicesAsync(ct);
+
+ [RelayCommand(CanExecute = nameof(CanPickPackage))]
+ private async Task PickPackageAsync()
+ {
+ var path = await _filePicker.PickRootPackageAsync().ConfigureAwait(true);
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return;
+ }
+
+ try
+ {
+ _wizard.SelectPackage(path);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Selecting root firmware package failed.");
+ }
+ }
+
+ [RelayCommand(CanExecute = nameof(CanStart))]
+ private Task StartAsync() => RunOperationAsync(ct => _wizard.ExtractAndPatchAsync(ct));
+
+ [RelayCommand(CanExecute = nameof(CanConfirmBootloader))]
+ private async Task ConfirmBootloaderAsync()
+ {
+ var confirmed = await _dialog.ConfirmAsync(
+ _localization.Get("root.confirm.bootloader.title"),
+ _localization.Get("root.confirm.bootloader.message"),
+ _localization.Get("root.action.enter.bootloader"),
+ _localization.Get("common.cancel")).ConfigureAwait(true);
+ if (!confirmed)
+ {
+ return;
+ }
+
+ await RunOperationAsync(async ct =>
+ {
+ await _wizard.ConfirmBootloaderAsync(ct).ConfigureAwait(false);
+ if (_wizard.Snapshot.State != RootWizardState.RebootingToBootloader)
+ {
+ return;
+ }
+
+ await _wizard.DetectFastbootAsync(ct).ConfigureAwait(false);
+ }).ConfigureAwait(true);
+ }
+
+ [RelayCommand(CanExecute = nameof(CanRecheckFastboot))]
+ private Task RecheckFastbootAsync() => RunOperationAsync(ct => _wizard.DetectFastbootAsync(ct));
+
+ [RelayCommand(CanExecute = nameof(CanFlash))]
+ private Task FlashAsync() => RunOperationAsync(ct => _wizard.ConfirmFlashAsync(HasAcknowledgedRisk, ct));
+
+ [RelayCommand(CanExecute = nameof(CanRetry))]
+ private Task RetryAsync() => RunOperationAsync(ct => _wizard.RetryAsync(ct));
+
+ [RelayCommand(CanExecute = nameof(CanCancel))]
+ private async Task CancelAsync()
+ {
+ _operationCts?.Cancel();
+ await _wizard.CancelAsync().ConfigureAwait(true);
+ }
+
+ [RelayCommand]
+ private Task OpenBackupFolderAsync()
+ {
+ var directory = string.IsNullOrWhiteSpace(BackupPath) ? null : Path.GetDirectoryName(BackupPath);
+ return string.IsNullOrWhiteSpace(directory) ? Task.CompletedTask : _filePicker.OpenUrlAsync(directory);
+ }
+
+ private async Task RunOperationAsync(Func operation)
+ {
+ if (IsBusy)
+ {
+ return;
+ }
+
+ _operationCts?.Dispose();
+ _operationCts = new CancellationTokenSource();
+ try
+ {
+ IsBusy = true;
+ NotifyCommandStates();
+ await operation(_operationCts.Token).ConfigureAwait(true);
+ }
+ catch (OperationCanceledException) when (_operationCts.IsCancellationRequested)
+ {
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Root workflow UI operation failed.");
+ }
+ finally
+ {
+ IsBusy = IsBusyState(_wizard.Snapshot.State);
+ NotifyCommandStates();
+ }
+ }
+
+ private void OnWizardChanged(object? sender, RootWizardSnapshot snapshot) =>
+ RunOnUiThread(() => ApplySnapshot(snapshot));
+
+ private void OnDevicesChanged(object? sender, DeviceListChangedEventArgs e) =>
+ RunOnUiThread(() => ApplyDevices(e.Devices, e.AdbAvailable));
+
+ private void OnLanguageChanged(object? sender, EventArgs e) => RunOnUiThread(() =>
+ {
+ ApplyDevices(_latestDevices, _adbAvailable);
+ ApplyPresentation(_wizard.Snapshot);
+ });
+
+ private void ApplyDevices(IReadOnlyList devices, bool adbAvailable)
+ {
+ _latestDevices = devices;
+ _adbAvailable = adbAvailable;
+ var online = devices.Where(device => device.IsOnline).ToArray();
+ var lockedIdentity = IsSelectionLocked ? _wizard.Snapshot.DeviceIdentity : null;
+ var selectedSerial = lockedIdentity?.Serial
+ ?? SelectedDevice?.Identity.Serial
+ ?? _wizard.Snapshot.DeviceIdentity?.Serial;
+
+ var desired = new List(online.Length);
+ foreach (var device in online)
+ {
+ desired.Add(new RootDeviceOptionViewModel(
+ new RootDeviceIdentity
+ {
+ Serial = device.Serial,
+ UsbPath = device.UsbPath,
+ Product = device.Product,
+ Device = device.Device,
+ Model = device.Model,
+ TransportId = device.TransportId,
+ },
+ device.DisplayName,
+ _localization.Format("root.device.details", device.Serial, device.Product ?? device.Device ?? _localization.Get("common.unavailable"))));
+ }
+
+ if (lockedIdentity is not null
+ && desired.All(device => !string.Equals(
+ device.Identity.Serial,
+ lockedIdentity.Serial,
+ StringComparison.Ordinal)))
+ {
+ desired.Insert(0, CreateDeviceOption(lockedIdentity));
+ }
+
+ // The device poll ticks every second. Rebuilding an unchanged collection makes the ListBox
+ // drop and restore its selection, which drags the surrounding ScrollViewer back to the
+ // device card while the user is reading further down the wizard.
+ if (!Devices.SequenceEqual(desired))
+ {
+ Devices.Clear();
+ foreach (var device in desired)
+ {
+ Devices.Add(device);
+ }
+ }
+
+ SelectedDevice = Devices.FirstOrDefault(device => string.Equals(device.Identity.Serial, selectedSerial, StringComparison.Ordinal));
+ if (SelectedDevice is null && Devices.Count == 1 && !IsSelectionLocked && _wizard.Snapshot.DeviceIdentity is null)
+ {
+ SelectedDevice = Devices[0];
+ }
+
+ DeviceListMessage = !adbAvailable
+ ? _localization.Get("root.device.adb.missing")
+ : Devices.Count == 0
+ ? _localization.Get("root.device.empty")
+ : Devices.Count > 1 && SelectedDevice is null
+ ? _localization.Get("root.device.choose")
+ : string.Empty;
+ OnPropertyChanged(nameof(HasDevices));
+ }
+
+ private void ApplySnapshot(RootWizardSnapshot snapshot)
+ {
+ var previousState = State;
+ State = snapshot.State;
+ SelectedPackagePath = snapshot.PackagePath;
+ PackageDisplayName = string.IsNullOrWhiteSpace(snapshot.PackagePath) ? string.Empty : Path.GetFileName(snapshot.PackagePath);
+ BackupPath = snapshot.BackupPath;
+ PatchedImagePath = snapshot.PatchedImagePath;
+ IsBusy = IsBusyState(snapshot.State);
+ IsSelectionLocked = !IsSelectionState(snapshot.State);
+ IsAbLayout = snapshot.BootLayout?.Kind == FastbootBootLayoutKind.Ab;
+ IsPackageMatchUnverified = snapshot.PackageMetadata?.MatchStatus == FirmwarePackageMatchStatus.Unverified;
+ ShowFlashConfirmation = snapshot.State is RootWizardState.AwaitingFlashConfirm or RootWizardState.FailedPartialFlash;
+ ShowCompleted = snapshot.State == RootWizardState.Completed;
+ ShowPartialFlashWarning = snapshot.State is RootWizardState.FailedPartialFlash or RootWizardState.FlashOutcomeUnknown;
+
+ if (previousState != snapshot.State && ShowFlashConfirmation)
+ {
+ HasAcknowledgedRisk = false;
+ }
+
+ var lockedSerial = snapshot.DeviceIdentity?.Serial;
+ if (!string.IsNullOrWhiteSpace(lockedSerial))
+ {
+ var lockedDevice = Devices.FirstOrDefault(device => string.Equals(
+ device.Identity.Serial,
+ lockedSerial,
+ StringComparison.Ordinal));
+ if (lockedDevice is null && IsSelectionLocked && snapshot.DeviceIdentity is not null)
+ {
+ lockedDevice = CreateDeviceOption(snapshot.DeviceIdentity);
+ Devices.Insert(0, lockedDevice);
+ }
+
+ SelectedDevice = lockedDevice;
+ }
+
+ ApplyPresentation(snapshot);
+ NotifyCommandStates();
+ }
+
+ private RootDeviceOptionViewModel CreateDeviceOption(RootDeviceIdentity identity)
+ => new(
+ identity,
+ identity.Model ?? identity.Serial,
+ _localization.Format(
+ "root.device.details",
+ identity.Serial,
+ identity.Product ?? identity.Device ?? _localization.Get("common.unavailable")));
+
+ private void ApplyPresentation(RootWizardSnapshot snapshot)
+ {
+ StateText = _localization.Get(StateKey(snapshot.State));
+ TargetPartitionText = snapshot.TargetPartition switch
+ {
+ BootPartitionTarget.Boot => "boot",
+ BootPartitionTarget.InitBoot => "init_boot",
+ _ => _localization.Get("common.unavailable"),
+ };
+ PackageMatchText = snapshot.PackageMetadata?.MatchStatus switch
+ {
+ FirmwarePackageMatchStatus.Matched => _localization.Get("root.package.match.matched"),
+ FirmwarePackageMatchStatus.Mismatched => _localization.Get("root.package.match.mismatched"),
+ FirmwarePackageMatchStatus.Unverified => _localization.Get("root.package.match.unverified"),
+ _ => _localization.Get("common.unavailable"),
+ };
+ IdentityText = snapshot.FastbootIdentityMatch is { } match
+ ? match.IsVerified
+ ? _localization.Format("root.identity.verified", match.Device!.Serial, LocalizeEvidence(match.Evidence))
+ : _localization.Get("root.identity.unverified")
+ : _localization.Get("root.identity.pending");
+ SlotText = snapshot.BootLayout?.Kind switch
+ {
+ FastbootBootLayoutKind.Single => _localization.Format("root.slot.single", TargetPartitionText),
+ FastbootBootLayoutKind.Ab => _localization.Format("root.slot.ab", TargetPartitionText, TargetPartitionText),
+ _ => _localization.Get("root.slot.pending"),
+ };
+
+ HasError = snapshot.ErrorCode != RootErrorCode.None && snapshot.ErrorCode != RootErrorCode.OperationCancelled;
+ ErrorMessage = HasError ? _localization.Get(ErrorKey(snapshot.ErrorCode)) : string.Empty;
+ ShowBlockedGuidance = snapshot.State is RootWizardState.BlockedUnsupportedTarget
+ or RootWizardState.BlockedFastbootIdentity
+ or RootWizardState.BlockedLocked
+ or RootWizardState.BlockedBootLayout;
+ BlockedGuidanceText = snapshot.State switch
+ {
+ RootWizardState.BlockedLocked => _localization.Get("root.blocked.locked.guide"),
+ RootWizardState.BlockedFastbootIdentity => _localization.Get("root.blocked.identity.guide"),
+ RootWizardState.BlockedBootLayout => _localization.Get("root.blocked.layout.guide"),
+ RootWizardState.BlockedUnsupportedTarget => _localization.Get("root.blocked.target.guide"),
+ _ => string.Empty,
+ };
+ UpdateSteps(snapshot.State);
+ }
+
+ private void UpdateSteps(RootWizardState state)
+ {
+ var current = StepIndex(state);
+ var completed = state == RootWizardState.Completed;
+ var titles = new[]
+ {
+ "root.step.select", "root.step.extract", "root.step.patch",
+ "root.step.fastboot", "root.step.flash", "root.step.complete",
+ };
+ for (var i = 0; i < Steps.Count; i++)
+ {
+ Steps[i].Title = _localization.Get(titles[i]);
+ Steps[i].IsCurrent = !completed && i == current;
+ Steps[i].IsComplete = completed || i < current;
+ Steps[i].StatusText = Steps[i].IsComplete
+ ? _localization.Get("root.step.done")
+ : Steps[i].IsCurrent
+ ? _localization.Get("root.step.current")
+ : _localization.Get("root.step.pending");
+ }
+ }
+
+ private void NotifyCommandStates()
+ {
+ OnPropertyChanged(nameof(IsDeviceSelectionEnabled));
+ OnPropertyChanged(nameof(CanPickPackage));
+ OnPropertyChanged(nameof(CanStart));
+ OnPropertyChanged(nameof(CanConfirmBootloader));
+ OnPropertyChanged(nameof(CanRecheckFastboot));
+ OnPropertyChanged(nameof(CanRetry));
+ OnPropertyChanged(nameof(CanCancel));
+ OnPropertyChanged(nameof(CanFlash));
+ PickPackageCommand.NotifyCanExecuteChanged();
+ StartCommand.NotifyCanExecuteChanged();
+ ConfirmBootloaderCommand.NotifyCanExecuteChanged();
+ RecheckFastbootCommand.NotifyCanExecuteChanged();
+ RetryCommand.NotifyCanExecuteChanged();
+ CancelCommand.NotifyCanExecuteChanged();
+ FlashCommand.NotifyCanExecuteChanged();
+ }
+
+ private static bool IsBusyState(RootWizardState state) => state is
+ RootWizardState.Extracting or RootWizardState.BootExtracted or RootWizardState.Patching
+ or RootWizardState.RebootingToBootloader or RootWizardState.InFastboot
+ or RootWizardState.Flashing or RootWizardState.Rebooting;
+
+ private static bool IsSelectionState(RootWizardState state) => state is
+ RootWizardState.Idle or RootWizardState.PackageSelected or RootWizardState.Cancelled
+ or RootWizardState.Completed;
+
+ private static bool IsRetryBlocked(RootErrorCode code) => code is
+ RootErrorCode.RiskNotAcknowledged or RootErrorCode.FlashPartiallyWritten
+ or RootErrorCode.FlashOutcomeUnknown;
+
+ private string LocalizeEvidence(FastbootIdentityEvidence evidence)
+ {
+ var values = new List(3);
+ if (evidence.HasFlag(FastbootIdentityEvidence.Serial))
+ {
+ values.Add(_localization.Get("root.identity.evidence.serial"));
+ }
+
+ if (evidence.HasFlag(FastbootIdentityEvidence.UsbPath))
+ {
+ values.Add(_localization.Get("root.identity.evidence.usb"));
+ }
+
+ if (evidence.HasFlag(FastbootIdentityEvidence.Product))
+ {
+ values.Add(_localization.Get("root.identity.evidence.product"));
+ }
+
+ return values.Count == 0
+ ? _localization.Get("root.identity.evidence.none")
+ : string.Join(_localization.Get("root.identity.evidence.separator"), values);
+ }
+
+ private static int StepIndex(RootWizardState state) => state switch
+ {
+ RootWizardState.Idle or RootWizardState.PackageSelected => 0,
+ RootWizardState.Extracting or RootWizardState.BootExtracted or RootWizardState.BlockedUnsupportedTarget => 1,
+ RootWizardState.Patching or RootWizardState.BootPatched or RootWizardState.AwaitingBootloaderConfirm => 2,
+ RootWizardState.RebootingToBootloader or RootWizardState.InFastboot or RootWizardState.BlockedFastbootIdentity
+ or RootWizardState.BlockedLocked or RootWizardState.BlockedBootLayout => 3,
+ RootWizardState.AwaitingFlashConfirm or RootWizardState.Flashing or RootWizardState.FailedPartialFlash
+ or RootWizardState.FlashOutcomeUnknown => 4,
+ RootWizardState.Rebooting or RootWizardState.Completed => 5,
+ _ => 0,
+ };
+
+ private static string StateKey(RootWizardState state) => $"root.state.{state.ToString().ToLowerInvariant()}";
+
+ private static string ErrorKey(RootErrorCode code) => code switch
+ {
+ RootErrorCode.DeviceNotSelected or RootErrorCode.DeviceUnavailable or RootErrorCode.DeviceUnauthorized => "root.error.device",
+ RootErrorCode.PackageNotFound or RootErrorCode.PackageUnsupported or RootErrorCode.PackageCorrupt
+ or RootErrorCode.PackageExtractionFailed or RootErrorCode.PackageSizeLimitExceeded
+ or RootErrorCode.PackagePathUnsafe => "root.error.package",
+ RootErrorCode.PackageMetadataMismatch => "root.error.package.mismatch",
+ RootErrorCode.PayloadToolUnavailable or RootErrorCode.PayloadExtractionFailed => "root.error.payload",
+ RootErrorCode.TargetImageMissing or RootErrorCode.TargetPartitionUnknown
+ or RootErrorCode.TargetEvidenceConflict => "root.error.target",
+ RootErrorCode.RecoveryOnlyUnsupported => "root.error.recoveryonly",
+ RootErrorCode.BackupSourceMissing or RootErrorCode.BackupFailed or RootErrorCode.BackupVerificationFailed => "root.error.backup",
+ RootErrorCode.MagiskToolUnavailable or RootErrorCode.MagiskInstallFailed or RootErrorCode.DeviceAbiUnsupported
+ or RootErrorCode.ImagePushFailed or RootErrorCode.MagiskPatchFailed
+ or RootErrorCode.PatchedImagePullFailed or RootErrorCode.PatchedImageInvalid
+ or RootErrorCode.MagiskFlagProbeFailed => "root.error.magisk",
+ RootErrorCode.PatchedImageFlagMismatch => "root.error.patched.flags",
+ RootErrorCode.FastbootUnavailable or RootErrorCode.FastbootBaselineFailed
+ or RootErrorCode.FastbootDeviceNotFound or RootErrorCode.RebootToBootloaderFailed => "root.error.fastboot",
+ RootErrorCode.FastbootIdentityUnverified or RootErrorCode.FastbootIdentityConflict => "root.error.identity",
+ RootErrorCode.BootloaderLocked => "root.error.locked",
+ RootErrorCode.BootLayoutUnknown or RootErrorCode.BootLayoutConflict => "root.error.layout",
+ RootErrorCode.FlashPartiallyWritten => "root.error.flash.partial",
+ RootErrorCode.FlashOutcomeUnknown => "root.error.flash.unknown",
+ RootErrorCode.FlashFailed => "root.error.flash",
+ RootErrorCode.RebootFailed => "root.error.reboot",
+ RootErrorCode.RiskNotAcknowledged => "root.error.risk",
+ _ => "root.error.generic",
+ };
+
+ private static void RunOnUiThread(Action action)
+ {
+ if (Dispatcher.UIThread.CheckAccess())
+ {
+ action();
+ }
+ else
+ {
+ Dispatcher.UIThread.Post(action);
+ }
+ }
+}
+
+public sealed record RootDeviceOptionViewModel(RootDeviceIdentity Identity, string DisplayName, string Details);
+
+public sealed partial class RootWizardStepItem : ObservableObject
+{
+ public RootWizardStepItem(int index) => Index = index;
+
+ public int Index { get; }
+
+ public int Number => Index + 1;
+
+ [ObservableProperty]
+ private string _title = string.Empty;
+
+ [ObservableProperty]
+ private string _statusText = string.Empty;
+
+ [ObservableProperty]
+ private bool _isCurrent;
+
+ [ObservableProperty]
+ private bool _isComplete;
+}
diff --git a/src/AndroidTreeView.App/Views/MainWindow.axaml b/src/AndroidTreeView.App/Views/MainWindow.axaml
index 7bdbee2..be15eee 100644
--- a/src/AndroidTreeView.App/Views/MainWindow.axaml
+++ b/src/AndroidTreeView.App/Views/MainWindow.axaml
@@ -41,6 +41,12 @@
Command="{Binding NavigateDevicesCommand}"
Classes.active="{Binding CurrentSection, Converter={StaticResource EnumEquals}, ConverterParameter=Devices}"
Content="{loc:Localize Key=nav.devices}" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AndroidTreeView.App/Views/RootWizardView.axaml.cs b/src/AndroidTreeView.App/Views/RootWizardView.axaml.cs
new file mode 100644
index 0000000..29f6ee1
--- /dev/null
+++ b/src/AndroidTreeView.App/Views/RootWizardView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace AndroidTreeView.App.Views;
+
+public partial class RootWizardView : UserControl
+{
+ public RootWizardView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/AndroidTreeView.App/app.manifest b/src/AndroidTreeView.App/app.manifest
index 41e62b4..1f85d29 100644
--- a/src/AndroidTreeView.App/app.manifest
+++ b/src/AndroidTreeView.App/app.manifest
@@ -1,6 +1,6 @@
-
+
AndroidTreeView.App.mini
- 1.0.6
- 1.0.6.0
- 1.0.6.0
- 1.0.6
+ 1.0.7
+ 1.0.7.0
+ 1.0.7.0
+ 1.0.7
false
Assets\atv-icon.ico
diff --git a/src/AndroidTreeView.Models/Rooting/BootImageInfo.cs b/src/AndroidTreeView.Models/Rooting/BootImageInfo.cs
new file mode 100644
index 0000000..be38084
--- /dev/null
+++ b/src/AndroidTreeView.Models/Rooting/BootImageInfo.cs
@@ -0,0 +1,17 @@
+namespace AndroidTreeView.Models.Rooting;
+
+/// An extracted original boot image and the evidence-backed partition it belongs to.
+public sealed record BootImageInfo
+{
+ public required string Path { get; init; }
+
+ public required string WorkDirectory { get; init; }
+
+ public required string OriginalPackageName { get; init; }
+
+ public BootPartitionTarget TargetPartition { get; init; }
+
+ public BootImageSource Source { get; init; }
+
+ public FirmwarePackageMetadata? PackageMetadata { get; init; }
+}
diff --git a/src/AndroidTreeView.Models/Rooting/BootImageSource.cs b/src/AndroidTreeView.Models/Rooting/BootImageSource.cs
new file mode 100644
index 0000000..0dc757e
--- /dev/null
+++ b/src/AndroidTreeView.Models/Rooting/BootImageSource.cs
@@ -0,0 +1,19 @@
+namespace AndroidTreeView.Models.Rooting;
+
+/// Physical source from which a boot image was extracted.
+public enum BootImageSource
+{
+ Unknown = 0,
+ PlainZip = 1,
+ NestedZip = 2,
+ Payload = 3
+}
+
+/// Supported firmware package containers.
+public enum FirmwarePackageType
+{
+ Unknown = 0,
+ PlainZip = 1,
+ NestedZip = 2,
+ Payload = 3
+}
diff --git a/src/AndroidTreeView.Models/Rooting/BootPartitionTarget.cs b/src/AndroidTreeView.Models/Rooting/BootPartitionTarget.cs
new file mode 100644
index 0000000..18ed9d5
--- /dev/null
+++ b/src/AndroidTreeView.Models/Rooting/BootPartitionTarget.cs
@@ -0,0 +1,11 @@
+namespace AndroidTreeView.Models.Rooting;
+
+///
+/// Boot partition selected from device and package evidence. is never flashable.
+///
+public enum BootPartitionTarget
+{
+ Unknown = 0,
+ Boot = 1,
+ InitBoot = 2
+}
diff --git a/src/AndroidTreeView.Models/Rooting/FastbootBootLayout.cs b/src/AndroidTreeView.Models/Rooting/FastbootBootLayout.cs
new file mode 100644
index 0000000..4a0442f
--- /dev/null
+++ b/src/AndroidTreeView.Models/Rooting/FastbootBootLayout.cs
@@ -0,0 +1,24 @@
+namespace AndroidTreeView.Models.Rooting;
+
+public enum FastbootBootLayoutKind
+{
+ Unknown = 0,
+ Single = 1,
+ Ab = 2
+}
+
+/// Evidence-backed layout for the selected boot partition.
+public sealed record FastbootBootLayout
+{
+ public FastbootBootLayoutKind Kind { get; init; }
+
+ /// Active slot reported by fastboot, normally a or b .
+ public string? CurrentSlot { get; init; }
+
+ public int? SlotCount { get; init; }
+
+ /// Value reported by has-slot:<target> , when available.
+ public bool? TargetHasSlot { get; init; }
+
+ public bool IsKnown => Kind != FastbootBootLayoutKind.Unknown;
+}
diff --git a/src/AndroidTreeView.Models/Rooting/FastbootIdentity.cs b/src/AndroidTreeView.Models/Rooting/FastbootIdentity.cs
new file mode 100644
index 0000000..450aa82
--- /dev/null
+++ b/src/AndroidTreeView.Models/Rooting/FastbootIdentity.cs
@@ -0,0 +1,57 @@
+namespace AndroidTreeView.Models.Rooting;
+
+/// Identity fields reported for one device by fastboot.
+public sealed record FastbootDeviceIdentity
+{
+ public required string Serial { get; init; }
+
+ public string? UsbPath { get; init; }
+
+ public string? Product { get; init; }
+
+ public IReadOnlyDictionary Descriptors { get; init; }
+ = new Dictionary();
+}
+
+/// Fastboot devices already present before the selected ADB device is rebooted.
+public sealed record FastbootBaseline
+{
+ public DateTimeOffset CapturedAtUtc { get; init; } = DateTimeOffset.UtcNow;
+
+ public IReadOnlyList Devices { get; init; }
+ = Array.Empty();
+}
+
+public enum FastbootIdentityMatchStatus
+{
+ Unverified = 0,
+ Verified = 1,
+ TargetNotFound = 2,
+ Ambiguous = 3,
+ ConflictingEvidence = 4
+}
+
+/// Independent evidence used to correlate ADB and fastboot identities.
+[Flags]
+public enum FastbootIdentityEvidence
+{
+ None = 0,
+ Serial = 1,
+ UsbPath = 2,
+ Product = 4
+}
+
+///
+/// Correlation result after reboot. A device may be used for writes only when is
+/// .
+///
+public sealed record FastbootIdentityMatch
+{
+ public FastbootIdentityMatchStatus Status { get; init; }
+
+ public FastbootDeviceIdentity? Device { get; init; }
+
+ public FastbootIdentityEvidence Evidence { get; init; }
+
+ public bool IsVerified => Status == FastbootIdentityMatchStatus.Verified && Device is not null;
+}
diff --git a/src/AndroidTreeView.Models/Rooting/FirmwarePackageMetadata.cs b/src/AndroidTreeView.Models/Rooting/FirmwarePackageMetadata.cs
new file mode 100644
index 0000000..dc40dac
--- /dev/null
+++ b/src/AndroidTreeView.Models/Rooting/FirmwarePackageMetadata.cs
@@ -0,0 +1,33 @@
+namespace AndroidTreeView.Models.Rooting;
+
+/// Result of comparing package metadata with the locked device identity.
+public enum FirmwarePackageMatchStatus
+{
+ /// The package did not provide enough metadata for a comparison.
+ Unverified = 0,
+
+ /// At least one declared package identity matches and none conflict.
+ Matched = 1,
+
+ /// Package metadata explicitly identifies a different device.
+ Mismatched = 2
+}
+
+/// Parsed, UI-independent identity metadata for a selected firmware package.
+public sealed record FirmwarePackageMetadata
+{
+ public required string PackagePath { get; init; }
+
+ public required string OriginalPackageName { get; init; }
+
+ public FirmwarePackageType PackageType { get; init; }
+
+ public IReadOnlyList DeclaredProducts { get; init; } = Array.Empty();
+
+ public IReadOnlyList DeclaredDevices { get; init; } = Array.Empty();
+
+ public FirmwarePackageMatchStatus MatchStatus { get; init; }
+
+ /// Machine-readable values that established a match, never localized display text.
+ public IReadOnlyList MatchingValues { get; init; } = Array.Empty();
+}
diff --git a/src/AndroidTreeView.Models/Rooting/FlashResult.cs b/src/AndroidTreeView.Models/Rooting/FlashResult.cs
new file mode 100644
index 0000000..f7a48e3
--- /dev/null
+++ b/src/AndroidTreeView.Models/Rooting/FlashResult.cs
@@ -0,0 +1,38 @@
+namespace AndroidTreeView.Models.Rooting;
+
+/// Safety-relevant outcome of one single-slot or A/B flash operation.
+public enum FlashOutcome
+{
+ /// Every requested partition was confirmed written.
+ Succeeded = 0,
+
+ /// The command failed before any partition was confirmed written.
+ FailedBeforeWrite = 1,
+
+ /// At least one requested partition was written and a later write failed.
+ PartiallyWritten = 2,
+
+ /// A command was interrupted or disconnected, so its write result cannot be established.
+ Unknown = 3
+}
+
+/// Detailed flash result retained by the wizard for recovery guidance and retry decisions.
+public sealed record FlashResult
+{
+ public required IReadOnlyList RequestedPartitions { get; init; }
+
+ public IReadOnlyList SucceededPartitions { get; init; } = Array.Empty();
+
+ public string? FailedPartition { get; init; }
+
+ public FlashOutcome Outcome { get; init; }
+
+ public RootErrorCode ErrorCode { get; init; }
+
+ /// Sanitized process detail for logs; callers must not use it as localized display text.
+ public string? DiagnosticSummary { get; init; }
+
+ public bool HasWrittenAnyPartition => SucceededPartitions.Count > 0;
+
+ public bool OutcomeUnknown => Outcome == FlashOutcome.Unknown;
+}
diff --git a/src/AndroidTreeView.Models/Rooting/RootDeviceIdentity.cs b/src/AndroidTreeView.Models/Rooting/RootDeviceIdentity.cs
new file mode 100644
index 0000000..a0a4a34
--- /dev/null
+++ b/src/AndroidTreeView.Models/Rooting/RootDeviceIdentity.cs
@@ -0,0 +1,17 @@
+namespace AndroidTreeView.Models.Rooting;
+
+/// ADB identity locked at the start of a root session.
+public sealed record RootDeviceIdentity
+{
+ public required string Serial { get; init; }
+
+ public string? UsbPath { get; init; }
+
+ public string? Product { get; init; }
+
+ public string? Device { get; init; }
+
+ public string? Model { get; init; }
+
+ public string? TransportId { get; init; }
+}
diff --git a/src/AndroidTreeView.Models/Rooting/RootErrorCode.cs b/src/AndroidTreeView.Models/Rooting/RootErrorCode.cs
new file mode 100644
index 0000000..3a9e27b
--- /dev/null
+++ b/src/AndroidTreeView.Models/Rooting/RootErrorCode.cs
@@ -0,0 +1,63 @@
+namespace AndroidTreeView.Models.Rooting;
+
+///
+/// Stable, non-localized error identifiers used across services, tests, logs and UI resource lookup.
+/// Numeric values are grouped by workflow stage and must not be repurposed.
+///
+public enum RootErrorCode
+{
+ None = 0,
+
+ InvalidState = 100,
+ DeviceNotSelected = 101,
+ DeviceUnavailable = 102,
+ DeviceUnauthorized = 103,
+ OperationCancelled = 104,
+
+ PackageNotFound = 200,
+ PackageUnsupported = 201,
+ PackageCorrupt = 202,
+ PackageMetadataMismatch = 203,
+ PackageMetadataUnverified = 204,
+ PackageExtractionFailed = 205,
+ PackageSizeLimitExceeded = 206,
+ PackagePathUnsafe = 207,
+ PayloadToolUnavailable = 208,
+ PayloadExtractionFailed = 209,
+ TargetImageMissing = 210,
+ TargetPartitionUnknown = 211,
+ TargetEvidenceConflict = 212,
+ RecoveryOnlyUnsupported = 213,
+
+ BackupSourceMissing = 300,
+ BackupFailed = 301,
+ BackupVerificationFailed = 302,
+
+ MagiskToolUnavailable = 400,
+ MagiskInstallFailed = 401,
+ DeviceAbiUnsupported = 402,
+ ImagePushFailed = 403,
+ MagiskPatchFailed = 404,
+ PatchedImagePullFailed = 405,
+ PatchedImageInvalid = 406,
+ MagiskFlagProbeFailed = 407,
+ PatchedImageFlagMismatch = 408,
+
+ FastbootUnavailable = 500,
+ FastbootBaselineFailed = 501,
+ FastbootDeviceNotFound = 502,
+ FastbootIdentityUnverified = 503,
+ FastbootIdentityConflict = 504,
+ BootloaderLocked = 505,
+ BootLayoutUnknown = 506,
+ BootLayoutConflict = 507,
+ RebootToBootloaderFailed = 508,
+
+ RiskNotAcknowledged = 600,
+ FlashFailed = 601,
+ FlashPartiallyWritten = 602,
+ FlashOutcomeUnknown = 603,
+ RebootFailed = 604,
+
+ UnexpectedFailure = 900
+}
diff --git a/src/AndroidTreeView.Models/Rooting/RootWizardSnapshot.cs b/src/AndroidTreeView.Models/Rooting/RootWizardSnapshot.cs
new file mode 100644
index 0000000..e126362
--- /dev/null
+++ b/src/AndroidTreeView.Models/Rooting/RootWizardSnapshot.cs
@@ -0,0 +1,47 @@
+namespace AndroidTreeView.Models.Rooting;
+
+///
+/// Immutable observable state for a root session. It contains machine data only; presentation and localization
+/// belong to the App layer.
+///
+public sealed record RootWizardSnapshot
+{
+ public RootWizardState State { get; init; } = RootWizardState.Idle;
+
+ public Guid SessionId { get; init; } = Guid.NewGuid();
+
+ public DateTimeOffset UpdatedAtUtc { get; init; } = DateTimeOffset.UtcNow;
+
+ public RootDeviceIdentity? DeviceIdentity { get; init; }
+
+ public string? PackagePath { get; init; }
+
+ public FirmwarePackageMetadata? PackageMetadata { get; init; }
+
+ public BootImageInfo? BootImage { get; init; }
+
+ public string? WorkDirectory { get; init; }
+
+ public string? PatchedImagePath { get; init; }
+
+ public string? BackupPath { get; init; }
+
+ public FastbootBaseline? FastbootBaseline { get; init; }
+
+ public FastbootIdentityMatch? FastbootIdentityMatch { get; init; }
+
+ public FastbootBootLayout? BootLayout { get; init; }
+
+ public FlashResult? FlashResult { get; init; }
+
+ public RootErrorCode ErrorCode { get; init; }
+
+ /// Sanitized technical detail for diagnostics, not user-facing localized text.
+ public string? DiagnosticSummary { get; init; }
+
+ public BootPartitionTarget TargetPartition =>
+ BootImage?.TargetPartition ?? BootPartitionTarget.Unknown;
+
+ public string? MatchedFastbootSerial =>
+ FastbootIdentityMatch?.IsVerified == true ? FastbootIdentityMatch.Device!.Serial : null;
+}
diff --git a/src/AndroidTreeView.Models/Rooting/RootWizardState.cs b/src/AndroidTreeView.Models/Rooting/RootWizardState.cs
new file mode 100644
index 0000000..2230f16
--- /dev/null
+++ b/src/AndroidTreeView.Models/Rooting/RootWizardState.cs
@@ -0,0 +1,27 @@
+namespace AndroidTreeView.Models.Rooting;
+
+/// Explicit states of the semi-automatic root workflow.
+public enum RootWizardState
+{
+ Idle = 0,
+ PackageSelected = 1,
+ Extracting = 2,
+ BootExtracted = 3,
+ Patching = 4,
+ BootPatched = 5,
+ AwaitingBootloaderConfirm = 6,
+ RebootingToBootloader = 7,
+ InFastboot = 8,
+ BlockedUnsupportedTarget = 9,
+ BlockedFastbootIdentity = 10,
+ BlockedLocked = 11,
+ BlockedBootLayout = 12,
+ AwaitingFlashConfirm = 13,
+ Flashing = 14,
+ Rebooting = 15,
+ Completed = 16,
+ Failed = 17,
+ FailedPartialFlash = 18,
+ FlashOutcomeUnknown = 19,
+ Cancelled = 20
+}
diff --git a/src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs b/src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs
index 5ca6ec0..92b9de2 100644
--- a/src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs
+++ b/src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs
@@ -1,55 +1,63 @@
-using AndroidTreeView.Adb.Services;
-using AndroidTreeView.Core.Interfaces;
-using AndroidTreeView.Core.Options;
-using AndroidTreeView.Core.Services;
-using AndroidTreeView.Infrastructure.Settings;
-using AndroidTreeView.Infrastructure.Update;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.DependencyInjection.Extensions;
-
-namespace AndroidTreeView.Shared;
-
-///
-/// Shared composition root for everything the full app and Mini should run identically: ADB discovery,
-/// device monitoring, scrcpy launching, settings, update checks and update installation.
-///
-public static class AndroidTreeViewSharedServices
-{
- public static IServiceCollection AddAndroidTreeViewSharedServices(
- this IServiceCollection services,
- UpdateProductOptions? updateProduct = null)
- {
- ArgumentNullException.ThrowIfNull(services);
-
- updateProduct ??= UpdateProductOptions.ForMainApp();
-
- services.AddLogging();
- services.TryAddSingleton(updateProduct);
- services.TryAddSingleton(sp => CreateHttpClient(sp.GetRequiredService()));
- services.TryAddSingleton();
-
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
-
- return services;
- }
-
- private static HttpClient CreateHttpClient(UpdateProductOptions product)
- {
- var client = new HttpClient();
- client.DefaultRequestHeaders.UserAgent.ParseAdd($"{product.Name.Replace(' ', '-')}/{product.Version}");
- return client;
- }
-}
+using AndroidTreeView.Adb.Services;
+using AndroidTreeView.Core.Interfaces;
+using AndroidTreeView.Core.Options;
+using AndroidTreeView.Core.Services;
+using AndroidTreeView.Infrastructure.Rooting;
+using AndroidTreeView.Infrastructure.Settings;
+using AndroidTreeView.Infrastructure.Update;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace AndroidTreeView.Shared;
+
+///
+/// Shared composition root for everything the full app and Mini should run identically: ADB discovery,
+/// device monitoring, scrcpy launching, settings, update checks and update installation.
+///
+public static class AndroidTreeViewSharedServices
+{
+ public static IServiceCollection AddAndroidTreeViewSharedServices(
+ this IServiceCollection services,
+ UpdateProductOptions? updateProduct = null)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ updateProduct ??= UpdateProductOptions.ForMainApp();
+
+ services.AddLogging();
+ services.TryAddSingleton(updateProduct);
+ services.TryAddSingleton(sp => CreateHttpClient(sp.GetRequiredService()));
+ services.TryAddSingleton();
+
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+
+ return services;
+ }
+
+ private static HttpClient CreateHttpClient(UpdateProductOptions product)
+ {
+ var client = new HttpClient();
+ client.DefaultRequestHeaders.UserAgent.ParseAdd($"{product.Name.Replace(' ', '-')}/{product.Version}");
+ return client;
+ }
+}
diff --git a/tests/AndroidTreeView.Adb.Tests/Commands/FastbootArgsTests.cs b/tests/AndroidTreeView.Adb.Tests/Commands/FastbootArgsTests.cs
new file mode 100644
index 0000000..dab62cf
--- /dev/null
+++ b/tests/AndroidTreeView.Adb.Tests/Commands/FastbootArgsTests.cs
@@ -0,0 +1,23 @@
+using AndroidTreeView.Adb.Commands;
+using AndroidTreeView.Models.Rooting;
+using Xunit;
+
+namespace AndroidTreeView.Adb.Tests.Commands;
+
+public sealed class FastbootArgsTests
+{
+ [Fact]
+ public void DeviceCommands_AlwaysCarryExplicitSerial()
+ {
+ Assert.Equal(new[] { "-s", "SERIAL", "getvar", "unlocked" }, FastbootArgs.GetVar("SERIAL", "unlocked"));
+ Assert.Equal(new[] { "-s", "SERIAL", "flash", "init_boot_a", "/tmp/patched.img" },
+ FastbootArgs.Flash("SERIAL", "init_boot_a", "/tmp/patched.img"));
+ Assert.Equal(new[] { "-s", "SERIAL", "reboot" }, FastbootArgs.Reboot("SERIAL"));
+ }
+
+ [Theory]
+ [InlineData(BootPartitionTarget.Boot, "boot")]
+ [InlineData(BootPartitionTarget.InitBoot, "init_boot")]
+ public void PartitionName_MapsOnlySupportedTargets(BootPartitionTarget target, string expected)
+ => Assert.Equal(expected, FastbootArgs.PartitionName(target));
+}
diff --git a/tests/AndroidTreeView.Adb.Tests/Parsers/AndroidBootImageHeaderParserTests.cs b/tests/AndroidTreeView.Adb.Tests/Parsers/AndroidBootImageHeaderParserTests.cs
new file mode 100644
index 0000000..40bff05
--- /dev/null
+++ b/tests/AndroidTreeView.Adb.Tests/Parsers/AndroidBootImageHeaderParserTests.cs
@@ -0,0 +1,113 @@
+using System.Buffers.Binary;
+using AndroidTreeView.Adb.Parsers;
+using Xunit;
+
+namespace AndroidTreeView.Adb.Tests.Parsers;
+
+public sealed class AndroidBootImageHeaderParserTests
+{
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void Parse_StandardHeaderWithRamdisk_ReturnsPresent(uint version)
+ {
+ var image = CreateImage(version, ramdiskSize: 17);
+
+ var result = AndroidBootImageHeaderParser.Parse(image, image.Length);
+
+ Assert.Equal(BootRamdiskEvidence.Present, result);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void Parse_StandardHeaderWithoutRamdisk_ReturnsAbsent(uint version)
+ {
+ var image = CreateImage(version, ramdiskSize: 0);
+
+ var result = AndroidBootImageHeaderParser.Parse(image, image.Length);
+
+ Assert.Equal(BootRamdiskEvidence.Absent, result);
+ }
+
+ [Fact]
+ public void Parse_InvalidMagic_ReturnsUnknown()
+ {
+ var image = CreateImage(4, ramdiskSize: 1);
+ image[0] = (byte)'X';
+
+ Assert.Equal(BootRamdiskEvidence.Unknown, AndroidBootImageHeaderParser.Parse(image, image.Length));
+ }
+
+ [Fact]
+ public void Parse_UnknownVersion_ReturnsUnknown()
+ {
+ var image = CreateImage(4, ramdiskSize: 1);
+ Write(image, 40, 5);
+
+ Assert.Equal(BootRamdiskEvidence.Unknown, AndroidBootImageHeaderParser.Parse(image, image.Length));
+ }
+
+ [Fact]
+ public void Parse_TruncatedHeader_ReturnsUnknown()
+ {
+ var image = CreateImage(2, ramdiskSize: 1);
+
+ Assert.Equal(BootRamdiskEvidence.Unknown,
+ AndroidBootImageHeaderParser.Parse(image.AsSpan(0, 100), image.Length));
+ }
+
+ [Fact]
+ public void Parse_RamdiskOutsideFile_ReturnsUnknown()
+ {
+ var image = CreateImage(3, ramdiskSize: 17);
+
+ Assert.Equal(BootRamdiskEvidence.Unknown,
+ AndroidBootImageHeaderParser.Parse(image, image.Length - 17));
+ }
+
+ internal static byte[] CreateImage(uint version, uint ramdiskSize, uint kernelSize = 1)
+ {
+ const uint pageSize = 4096;
+ var headerSize = version switch
+ {
+ 0 => 1632u,
+ 1 => 1648u,
+ 2 => 1660u,
+ 3 => 1580u,
+ 4 => 1584u,
+ _ => throw new ArgumentOutOfRangeException(nameof(version))
+ };
+ var alignedKernel = ((ulong)kernelSize + pageSize - 1) / pageSize * pageSize;
+ var imageLength = checked((int)((ulong)pageSize + alignedKernel + ramdiskSize));
+ var image = new byte[imageLength];
+ "ANDROID!"u8.CopyTo(image);
+ Write(image, 8, kernelSize);
+ Write(image, 40, version);
+ if (version <= 2)
+ {
+ Write(image, 16, ramdiskSize);
+ Write(image, 36, pageSize);
+ if (version >= 1)
+ {
+ Write(image, 1644, headerSize);
+ }
+ }
+ else
+ {
+ Write(image, 12, ramdiskSize);
+ Write(image, 20, headerSize);
+ }
+
+ return image;
+ }
+
+ private static void Write(Span destination, int offset, uint value)
+ => BinaryPrimitives.WriteUInt32LittleEndian(destination.Slice(offset, sizeof(uint)), value);
+}
diff --git a/tests/AndroidTreeView.Adb.Tests/Parsers/BootPartitionTargetDetectorTests.cs b/tests/AndroidTreeView.Adb.Tests/Parsers/BootPartitionTargetDetectorTests.cs
new file mode 100644
index 0000000..4ea3eda
--- /dev/null
+++ b/tests/AndroidTreeView.Adb.Tests/Parsers/BootPartitionTargetDetectorTests.cs
@@ -0,0 +1,71 @@
+using AndroidTreeView.Adb.Parsers;
+using AndroidTreeView.Models.Rooting;
+using Xunit;
+
+namespace AndroidTreeView.Adb.Tests.Parsers;
+
+public sealed class BootPartitionTargetDetectorTests
+{
+ [Fact]
+ public void Detect_Android13GkiWithPartition_SelectsInitBoot()
+ {
+ var result = BootPartitionTargetDetector.Detect(true, true, true, 33, new Version(5, 10));
+ Assert.Equal(BootPartitionTarget.InitBoot, result.Target);
+ }
+
+ [Fact]
+ public void Detect_Android12Kernel510_StillSelectsBoot()
+ {
+ var result = BootPartitionTargetDetector.Detect(true, true, true, 32, new Version(5, 10));
+ Assert.Equal(BootPartitionTarget.Boot, result.Target);
+ }
+
+ [Fact]
+ public void Detect_RequiredInitBootMissing_DoesNotFallback()
+ {
+ var result = BootPartitionTargetDetector.Detect(true, false, true, 34, new Version(6, 1));
+ Assert.False(result.IsSupported);
+ Assert.Equal(RootErrorCode.TargetImageMissing, result.ErrorCode);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void Detect_InitBootWithMissingOrInvalidSdk_DoesNotFallbackToBoot(int? androidSdk)
+ {
+ var result = BootPartitionTargetDetector.Detect(true, true, true, androidSdk, new Version(5, 10));
+
+ Assert.False(result.IsSupported);
+ Assert.Equal(RootErrorCode.TargetEvidenceConflict, result.ErrorCode);
+ }
+
+ [Fact]
+ public void Detect_InitBootWithMissingKernel_DoesNotFallbackToBoot()
+ {
+ var result = BootPartitionTargetDetector.Detect(true, true, true, 32, kernelVersion: null);
+
+ Assert.False(result.IsSupported);
+ Assert.Equal(RootErrorCode.TargetEvidenceConflict, result.ErrorCode);
+ }
+
+ [Fact]
+ public void ValidateRamdisk_BootWithoutRamdisk_BlocksRecoveryOnly()
+ {
+ var result = BootPartitionTargetDetector.ValidateRamdisk(
+ BootPartitionTarget.Boot,
+ BootRamdiskEvidence.Absent);
+
+ Assert.Equal(RootErrorCode.RecoveryOnlyUnsupported, result.ErrorCode);
+ }
+
+ [Theory]
+ [InlineData(BootPartitionTarget.Boot)]
+ [InlineData(BootPartitionTarget.InitBoot)]
+ public void ValidateRamdisk_UnknownEvidence_BlocksWithoutGuessing(BootPartitionTarget target)
+ {
+ var result = BootPartitionTargetDetector.ValidateRamdisk(target, BootRamdiskEvidence.Unknown);
+
+ Assert.Equal(RootErrorCode.TargetEvidenceConflict, result.ErrorCode);
+ }
+}
diff --git a/tests/AndroidTreeView.Adb.Tests/Parsers/CpuAbiParserTests.cs b/tests/AndroidTreeView.Adb.Tests/Parsers/CpuAbiParserTests.cs
new file mode 100644
index 0000000..c23d7b0
--- /dev/null
+++ b/tests/AndroidTreeView.Adb.Tests/Parsers/CpuAbiParserTests.cs
@@ -0,0 +1,14 @@
+using AndroidTreeView.Adb.Parsers;
+using Xunit;
+
+namespace AndroidTreeView.Adb.Tests.Parsers;
+
+public sealed class CpuAbiParserTests
+{
+ [Theory]
+ [InlineData("arm64-v8a\n", "arm64-v8a")]
+ [InlineData("x86_64,armeabi-v7a", "x86_64")]
+ [InlineData("mips", null)]
+ public void Parse_NormalizesSupportedAbi(string output, string? expected)
+ => Assert.Equal(expected, CpuAbiParser.Parse(output));
+}
diff --git a/tests/AndroidTreeView.Adb.Tests/Parsers/FastbootVarParserTests.cs b/tests/AndroidTreeView.Adb.Tests/Parsers/FastbootVarParserTests.cs
new file mode 100644
index 0000000..a4b0653
--- /dev/null
+++ b/tests/AndroidTreeView.Adb.Tests/Parsers/FastbootVarParserTests.cs
@@ -0,0 +1,113 @@
+using AndroidTreeView.Adb.Parsers;
+using AndroidTreeView.Models.Rooting;
+using Xunit;
+
+namespace AndroidTreeView.Adb.Tests.Parsers;
+
+public sealed class FastbootVarParserTests
+{
+ [Fact]
+ public void ParseVariables_ReadsStderrStyleOutputAndIgnoresNoise()
+ {
+ var values = FastbootVarParser.ParseVariables(
+ "(bootloader) unlocked: yes\n(bootloader) has-slot:boot: yes\nFinished. Total time: 0.01s");
+
+ Assert.Equal("yes", values["unlocked"]);
+ Assert.Equal("yes", values["has-slot:boot"]);
+ Assert.Equal(2, values.Count);
+ }
+
+ [Fact]
+ public void ParseDevices_ReadsLongDescriptors()
+ {
+ var device = Assert.Single(FastbootVarParser.ParseDevices(
+ "FB123 fastboot usb:1-2 product:oriole transport_id:9\n"));
+
+ Assert.Equal("FB123", device.Serial);
+ Assert.Equal("1-2", device.UsbPath);
+ Assert.Equal("oriole", device.Product);
+ }
+
+ [Fact]
+ public void MatchIdentity_SerialChanged_RequiresUsbAndProduct()
+ {
+ var target = new RootDeviceIdentity { Serial = "ADB", UsbPath = "1-2", Product = "oriole" };
+ var verified = FastbootVarParser.MatchIdentity(target,
+ new[] { new FastbootDeviceIdentity { Serial = "FB", UsbPath = "1-2", Product = "oriole" } });
+ var unverified = FastbootVarParser.MatchIdentity(target,
+ new[] { new FastbootDeviceIdentity { Serial = "FB", Product = "oriole" } });
+
+ Assert.True(verified.IsVerified);
+ Assert.Equal(FastbootIdentityEvidence.UsbPath | FastbootIdentityEvidence.Product, verified.Evidence);
+ Assert.Equal(FastbootIdentityMatchStatus.Unverified, unverified.Status);
+ }
+
+ [Fact]
+ public void MatchIdentity_OnlyCandidateWithoutEvidence_IsNeverSelected()
+ {
+ var result = FastbootVarParser.MatchIdentity(
+ new RootDeviceIdentity { Serial = "ADB" },
+ new[] { new FastbootDeviceIdentity { Serial = "OTHER" } });
+
+ Assert.Equal(FastbootIdentityMatchStatus.Unverified, result.Status);
+ Assert.False(result.IsVerified);
+ }
+
+ [Fact]
+ public void MatchIdentity_StableSerial_AllowsDifferentBootloaderProductName()
+ {
+ var result = FastbootVarParser.MatchIdentity(
+ new RootDeviceIdentity { Serial = "SERIAL", Product = "adb_product" },
+ new[] { new FastbootDeviceIdentity { Serial = "SERIAL", Product = "fastboot-product" } });
+
+ Assert.True(result.IsVerified);
+ Assert.Equal(FastbootIdentityEvidence.Serial, result.Evidence);
+ }
+
+ [Fact]
+ public void MatchIdentity_MacUsbLocationFormats_AreEquivalent()
+ {
+ var result = FastbootVarParser.MatchIdentity(
+ new RootDeviceIdentity
+ {
+ Serial = "ADB-SERIAL",
+ UsbPath = "3-1.2.3.4",
+ Product = "cannon"
+ },
+ new[]
+ {
+ new FastbootDeviceIdentity
+ {
+ Serial = "FASTBOOT-SERIAL",
+ UsbPath = "51524608X",
+ Product = "cannon"
+ }
+ });
+
+ Assert.True(result.IsVerified);
+ Assert.Equal(
+ FastbootIdentityEvidence.UsbPath | FastbootIdentityEvidence.Product,
+ result.Evidence);
+ }
+
+ [Fact]
+ public void MatchIdentity_DifferentMacUsbLocations_RemainConflicting()
+ {
+ var result = FastbootVarParser.MatchIdentity(
+ new RootDeviceIdentity
+ {
+ Serial = "SAME-SERIAL",
+ UsbPath = "3-1.2.3.2.1"
+ },
+ new[]
+ {
+ new FastbootDeviceIdentity
+ {
+ Serial = "SAME-SERIAL",
+ UsbPath = "51524608X"
+ }
+ });
+
+ Assert.Equal(FastbootIdentityMatchStatus.ConflictingEvidence, result.Status);
+ }
+}
diff --git a/tests/AndroidTreeView.Adb.Tests/Parsers/FirmwarePackageMetadataParserTests.cs b/tests/AndroidTreeView.Adb.Tests/Parsers/FirmwarePackageMetadataParserTests.cs
new file mode 100644
index 0000000..a5975a3
--- /dev/null
+++ b/tests/AndroidTreeView.Adb.Tests/Parsers/FirmwarePackageMetadataParserTests.cs
@@ -0,0 +1,35 @@
+using AndroidTreeView.Adb.Parsers;
+using AndroidTreeView.Models.Rooting;
+using Xunit;
+
+namespace AndroidTreeView.Adb.Tests.Parsers;
+
+public sealed class FirmwarePackageMetadataParserTests
+{
+ [Fact]
+ public void Parse_OtaPreDevice_MatchesLockedDevice()
+ {
+ var metadata = FirmwarePackageMetadataParser.Parse(
+ "/tmp/ota.zip",
+ FirmwarePackageType.Payload,
+ new RootDeviceIdentity { Serial = "S", Device = "oriole" },
+ "pre-device=oriole|raven\npost-build=google/oriole/oriole:16/test",
+ null);
+
+ Assert.Equal(FirmwarePackageMatchStatus.Matched, metadata.MatchStatus);
+ Assert.Contains("oriole", metadata.MatchingValues);
+ }
+
+ [Fact]
+ public void Parse_ExplicitOtherProduct_IsMismatch()
+ {
+ var metadata = FirmwarePackageMetadataParser.Parse(
+ "/tmp/factory.zip",
+ FirmwarePackageType.NestedZip,
+ new RootDeviceIdentity { Serial = "S", Product = "oriole" },
+ null,
+ "require product=panther");
+
+ Assert.Equal(FirmwarePackageMatchStatus.Mismatched, metadata.MatchStatus);
+ }
+}
diff --git a/tests/AndroidTreeView.Adb.Tests/Parsers/MagiskFlagsParserTests.cs b/tests/AndroidTreeView.Adb.Tests/Parsers/MagiskFlagsParserTests.cs
new file mode 100644
index 0000000..3f7af01
--- /dev/null
+++ b/tests/AndroidTreeView.Adb.Tests/Parsers/MagiskFlagsParserTests.cs
@@ -0,0 +1,102 @@
+using AndroidTreeView.Adb.Parsers;
+using Xunit;
+
+namespace AndroidTreeView.Adb.Tests.Parsers;
+
+public sealed class MagiskFlagsParserTests
+{
+ // Verbatim app_init output captured from a real cannon (M2007J22C, Android 12, MIUI 14).
+ private const string RealProbeOutput = """
+ SLOT=
+ SYSTEM_AS_ROOT=true
+ RAMDISKEXIST=true
+ ISAB=false
+ CRYPTOTYPE=file
+ PATCHVBMETAFLAG=false
+ LEGACYSAR=false
+ RECOVERYMODE=false
+ KEEPVERITY=true
+ KEEPFORCEENCRYPT=true
+ VENDORBOOT=false
+ """;
+
+ // Verbatim .backup/.magisk from the official Magisk v30.7 output for the same device. Note it carries
+ // neither PATCHVBMETAFLAG nor LEGACYSAR.
+ private const string RealPatchedConfig = """
+ KEEPVERITY=true
+ KEEPFORCEENCRYPT=true
+ RECOVERYMODE=false
+ VENDORBOOT=false
+ PREINITDEVICE=cache
+ SHA1=161a5ed94f06974120cc8f0985cbd3fc430f350e
+ """;
+
+ [Fact]
+ public void Parse_reads_every_flag_from_real_device_probe_output()
+ {
+ var flags = MagiskFlagsParser.Parse(RealProbeOutput);
+
+ Assert.NotNull(flags);
+ Assert.True(flags.KeepVerity);
+ Assert.True(flags.KeepForceEncrypt);
+ Assert.False(flags.PatchVbmetaFlag);
+ Assert.False(flags.RecoveryMode);
+ Assert.False(flags.LegacySar);
+ }
+
+ [Fact]
+ public void Parse_tolerates_magiskboot_noise_around_the_pairs()
+ {
+ var flags = MagiskFlagsParser.Parse("Loading cpio: [ramdisk.cpio]\n" + RealProbeOutput + "\nDumping cpio: [x]");
+
+ Assert.NotNull(flags);
+ Assert.True(flags.KeepVerity);
+ }
+
+ [Theory]
+ [InlineData("KEEPVERITY")]
+ [InlineData("KEEPFORCEENCRYPT")]
+ [InlineData("PATCHVBMETAFLAG")]
+ [InlineData("RECOVERYMODE")]
+ [InlineData("LEGACYSAR")]
+ public void Parse_rejects_output_missing_any_required_flag(string dropped)
+ {
+ var lines = RealProbeOutput.Split('\n')
+ .Where(line => !line.TrimStart().StartsWith(dropped + "=", StringComparison.Ordinal));
+
+ Assert.Null(MagiskFlagsParser.Parse(string.Join('\n', lines)));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ [InlineData("KEEPVERITY=yes\nKEEPFORCEENCRYPT=true\nPATCHVBMETAFLAG=false\nRECOVERYMODE=false\nLEGACYSAR=false")]
+ public void Parse_rejects_empty_or_non_boolean_output(string? output) =>
+ Assert.Null(MagiskFlagsParser.Parse(output));
+
+ [Fact]
+ public void ParsePatchedConfig_reads_a_real_patched_config_that_lacks_patch_time_only_flags()
+ {
+ var config = MagiskFlagsParser.ParsePatchedConfig(RealPatchedConfig);
+
+ Assert.NotNull(config);
+ Assert.True(config.Value.KeepVerity);
+ Assert.True(config.Value.KeepForceEncrypt);
+ }
+
+ [Fact]
+ public void ParsePatchedConfig_reads_the_unpatched_default_that_bootloops_this_device()
+ {
+ var config = MagiskFlagsParser.ParsePatchedConfig(
+ RealPatchedConfig.Replace("=true", "=false", StringComparison.Ordinal));
+
+ Assert.NotNull(config);
+ Assert.False(config.Value.KeepVerity);
+ Assert.False(config.Value.KeepForceEncrypt);
+ }
+
+ [Fact]
+ public void ParsePatchedConfig_rejects_a_config_without_the_verity_flags() =>
+ Assert.Null(MagiskFlagsParser.ParsePatchedConfig("PREINITDEVICE=cache\nSHA1=abc"));
+}
diff --git a/tests/AndroidTreeView.Adb.Tests/Parsers/PackageTypeDetectorTests.cs b/tests/AndroidTreeView.Adb.Tests/Parsers/PackageTypeDetectorTests.cs
new file mode 100644
index 0000000..05a6e2e
--- /dev/null
+++ b/tests/AndroidTreeView.Adb.Tests/Parsers/PackageTypeDetectorTests.cs
@@ -0,0 +1,24 @@
+using AndroidTreeView.Adb.Parsers;
+using AndroidTreeView.Models.Rooting;
+using Xunit;
+
+namespace AndroidTreeView.Adb.Tests.Parsers;
+
+public sealed class PackageTypeDetectorTests
+{
+ [Theory]
+ [InlineData("boot.img", FirmwarePackageType.PlainZip)]
+ [InlineData("INIT_BOOT.IMG", FirmwarePackageType.PlainZip)]
+ [InlineData("image-oriole.zip", FirmwarePackageType.NestedZip)]
+ [InlineData("payload.bin", FirmwarePackageType.Payload)]
+ [InlineData("system.img", FirmwarePackageType.Unknown)]
+ public void DetectZipEntries_ClassifiesSupportedContainer(string entry, FirmwarePackageType expected)
+ => Assert.Equal(expected, PackageTypeDetector.DetectZipEntries(new[] { entry }));
+
+ [Fact]
+ public void HeaderDetection_DoesNotTrustExtension()
+ {
+ Assert.True(PackageTypeDetector.IsZipHeader(new byte[] { 0x50, 0x4b, 3, 4 }));
+ Assert.True(PackageTypeDetector.IsPayloadHeader("CrAU"u8));
+ }
+}
diff --git a/tests/AndroidTreeView.Adb.Tests/Services/BootImageExtractorTests.cs b/tests/AndroidTreeView.Adb.Tests/Services/BootImageExtractorTests.cs
new file mode 100644
index 0000000..9d7d7db
--- /dev/null
+++ b/tests/AndroidTreeView.Adb.Tests/Services/BootImageExtractorTests.cs
@@ -0,0 +1,200 @@
+using System.IO.Compression;
+using AndroidTreeView.Adb.Parsers;
+using AndroidTreeView.Adb.Services;
+using AndroidTreeView.Adb.Tests.Parsers;
+using AndroidTreeView.Adb.Tests.TestDoubles;
+using AndroidTreeView.Core.Exceptions;
+using AndroidTreeView.Core.Services;
+using AndroidTreeView.Models.Rooting;
+using Xunit;
+
+namespace AndroidTreeView.Adb.Tests.Services;
+
+public sealed class BootImageExtractorTests : IDisposable
+{
+ private readonly string _directory = Path.Combine(Path.GetTempPath(), $"atv-extractor-{Guid.NewGuid():N}");
+
+ public BootImageExtractorTests() => Directory.CreateDirectory(_directory);
+
+ [Fact]
+ public async Task ExtractAsync_PlainZip_SelectsBootForAndroid12()
+ {
+ var bootImage = AndroidBootImageHeaderParserTests.CreateImage(2, ramdiskSize: 2);
+ var initBootImage = AndroidBootImageHeaderParserTests.CreateImage(4, ramdiskSize: 2, kernelSize: 0);
+ var package = CreateZip(("boot.img", bootImage), ("init_boot.img", initBootImage));
+ var adb = SuccessfulProbe("no", "32", "5.10.1-test");
+ var extractor = Build(adb);
+
+ var result = await extractor.ExtractAsync(package, new RootDeviceIdentity { Serial = "S" });
+
+ Assert.Equal(BootPartitionTarget.Boot, result.TargetPartition);
+ Assert.Equal(BootImageSource.PlainZip, result.Source);
+ Assert.Equal(bootImage, await File.ReadAllBytesAsync(result.Path));
+ }
+
+ [Fact]
+ public async Task ExtractAsync_PixelNestedZip_ExtractsExactlyOneLevel()
+ {
+ var bootImage = AndroidBootImageHeaderParserTests.CreateImage(1, ramdiskSize: 2);
+ var inner = CreateZip(("boot.img", bootImage));
+ var package = CreateZip(("image-oriole.zip", await File.ReadAllBytesAsync(inner)));
+ var extractor = Build(SuccessfulProbe("no", "32", "5.10.1"));
+
+ var result = await extractor.ExtractAsync(package, new RootDeviceIdentity { Serial = "S" });
+
+ Assert.Equal(BootImageSource.NestedZip, result.Source);
+ Assert.Equal(bootImage, await File.ReadAllBytesAsync(result.Path));
+ }
+
+ [Fact]
+ public async Task ExtractAsync_ZipPayload_UsesArgvAndRequiresProducedImage()
+ {
+ var package = CreateZip(("payload.bin", "CrAUtest"u8.ToArray()));
+ var workRoot = Path.Combine(_directory, "payload-work");
+ var tools = Path.Combine(_directory, "payload-tools");
+ var paths = new RootToolPaths(tools, workRoot);
+ Directory.CreateDirectory(Path.GetDirectoryName(paths.PayloadDumperPath)!);
+ await File.WriteAllTextAsync(paths.PayloadDumperPath, "test");
+ var runner = new RootTestExternalCommandRunner
+ {
+ Handler = (request, _) =>
+ {
+ var outputDirectory = request.Arguments[3];
+ Directory.CreateDirectory(outputDirectory);
+ File.WriteAllBytes(
+ Path.Combine(outputDirectory, "boot.img"),
+ AndroidBootImageHeaderParserTests.CreateImage(2, ramdiskSize: 2));
+ return Task.FromResult(new ExternalCommandResult());
+ }
+ };
+ var extractor = new BootImageExtractor(SuccessfulProbe("no", "32", "5.10.1"), runner, paths);
+
+ var result = await extractor.ExtractAsync(package, new RootDeviceIdentity { Serial = "S" });
+
+ Assert.Equal(BootImageSource.Payload, result.Source);
+ var request = Assert.Single(runner.Requests);
+ Assert.Equal(paths.PayloadDumperPath, request.FileName);
+ Assert.Equal(new[] { "-p", "boot", "-o" }, request.Arguments.Take(3));
+ Assert.Equal(BootRamdiskEvidence.Present,
+ AndroidBootImageHeaderParser.Parse(await File.ReadAllBytesAsync(result.Path), new FileInfo(result.Path).Length));
+ }
+
+ [Fact]
+ public async Task ExtractAsync_BootWithoutRamdisk_BlocksRecoveryOnlyAndCleansWork()
+ {
+ var package = CreateZip(("boot.img", AndroidBootImageHeaderParserTests.CreateImage(2, ramdiskSize: 0)));
+ var workRoot = Path.Combine(_directory, "recovery-only-work");
+ var extractor = Build(SuccessfulProbe("no", "32", "5.10.1"), workRoot);
+
+ var error = await Assert.ThrowsAsync(
+ () => extractor.ExtractAsync(package, new RootDeviceIdentity { Serial = "S" }));
+
+ Assert.Equal(RootErrorCode.RecoveryOnlyUnsupported, error.ErrorCode);
+ Assert.False(Directory.Exists(workRoot) && Directory.EnumerateFileSystemEntries(workRoot).Any());
+ }
+
+ [Fact]
+ public async Task ExtractAsync_UnrecognizedBootHeader_BlocksWithoutGuessing()
+ {
+ var package = CreateZip(("boot.img", new byte[4096]));
+ var extractor = Build(SuccessfulProbe("no", "32", "5.10.1"));
+
+ var error = await Assert.ThrowsAsync(
+ () => extractor.ExtractAsync(package, new RootDeviceIdentity { Serial = "S" }));
+
+ Assert.Equal(RootErrorCode.TargetEvidenceConflict, error.ErrorCode);
+ }
+
+ [Fact]
+ public async Task ExtractAsync_Android13InitBoot_RequiresRamdiskEvidence()
+ {
+ var bootImage = AndroidBootImageHeaderParserTests.CreateImage(4, ramdiskSize: 0);
+ var initBootImage = AndroidBootImageHeaderParserTests.CreateImage(4, ramdiskSize: 3, kernelSize: 0);
+ var package = CreateZip(("boot.img", bootImage), ("init_boot.img", initBootImage));
+ var extractor = Build(SuccessfulProbe("yes", "33", "5.10.1"));
+
+ var result = await extractor.ExtractAsync(package, new RootDeviceIdentity { Serial = "S" });
+
+ Assert.Equal(BootPartitionTarget.InitBoot, result.TargetPartition);
+ Assert.Equal(initBootImage, await File.ReadAllBytesAsync(result.Path));
+ }
+
+ [Fact]
+ public async Task ExtractAsync_PathTraversal_IsRejectedAndWorkDirectoryCleaned()
+ {
+ var package = CreateZip(("boot.img", new byte[] { 1 }), ("../escape.txt", new byte[] { 2 }));
+ var workRoot = Path.Combine(_directory, "work");
+ var extractor = Build(SuccessfulProbe("no", "32", "5.10.1"), workRoot);
+
+ var error = await Assert.ThrowsAsync(
+ () => extractor.ExtractAsync(package, new RootDeviceIdentity { Serial = "S" }));
+
+ Assert.Equal(RootErrorCode.PackagePathUnsafe, error.ErrorCode);
+ Assert.False(Directory.Exists(workRoot) && Directory.EnumerateFileSystemEntries(workRoot).Any());
+ }
+
+ [Fact]
+ public async Task ExtractAsync_ProbeFailure_BlocksInsteadOfAssumingBoot()
+ {
+ var package = CreateZip(("boot.img", new byte[] { 1 }));
+ var adb = new RootTestAdbCommandExecutor
+ {
+ Handler = static (request, _) => Task.FromResult(request.Arguments.Contains("getprop")
+ ? new AdbCommandResult { ExitCode = 1, StandardError = "device offline" }
+ : new AdbCommandResult { StandardOutput = request.Arguments.Contains("uname") ? "5.10.1" : "no" })
+ };
+ var extractor = Build(adb);
+
+ var error = await Assert.ThrowsAsync(
+ () => extractor.ExtractAsync(package, new RootDeviceIdentity { Serial = "S" }));
+
+ Assert.Equal(RootErrorCode.DeviceUnavailable, error.ErrorCode);
+ }
+
+ [Fact]
+ public async Task ExtractAsync_ProbeUsesDirectTestCommandWithoutNestedShellQuoting()
+ {
+ var package = CreateZip(("boot.img", AndroidBootImageHeaderParserTests.CreateImage(2, ramdiskSize: 2)));
+ var adb = SuccessfulProbe("no", "31", "4.14.186");
+ var extractor = Build(adb);
+
+ await extractor.ExtractAsync(package, new RootDeviceIdentity { Serial = "S" });
+
+ var request = Assert.Single(adb.Requests, request => request.Arguments.Contains("test"));
+ Assert.True(request.RunInShell);
+ Assert.Equal(new[] { "test", "-e", "/dev/block/by-name/init_boot" }, request.Arguments);
+ }
+
+ public void Dispose() => Directory.Delete(_directory, recursive: true);
+
+ private BootImageExtractor Build(RootTestAdbCommandExecutor adb, string? workRoot = null)
+ {
+ var tools = Path.Combine(_directory, "tools");
+ var paths = new RootToolPaths(tools, workRoot ?? Path.Combine(_directory, "work"));
+ return new BootImageExtractor(adb, new RootTestExternalCommandRunner(), paths);
+ }
+
+ private RootTestAdbCommandExecutor SuccessfulProbe(string initBoot, string sdk, string kernel)
+ => new()
+ {
+ Handler = (request, _) => Task.FromResult(request.Arguments.Contains("getprop")
+ ? new AdbCommandResult { StandardOutput = sdk }
+ : request.Arguments.Contains("uname")
+ ? new AdbCommandResult { StandardOutput = kernel }
+ : new AdbCommandResult { ExitCode = initBoot == "yes" ? 0 : 1 })
+ };
+
+ private string CreateZip(params (string Name, byte[] Content)[] entries)
+ {
+ var path = Path.Combine(_directory, $"package-{Guid.NewGuid():N}.zip");
+ using var archive = ZipFile.Open(path, ZipArchiveMode.Create);
+ foreach (var item in entries)
+ {
+ var entry = archive.CreateEntry(item.Name);
+ using var stream = entry.Open();
+ stream.Write(item.Content);
+ }
+
+ return path;
+ }
+}
diff --git a/tests/AndroidTreeView.Adb.Tests/Services/FastbootServiceTests.cs b/tests/AndroidTreeView.Adb.Tests/Services/FastbootServiceTests.cs
new file mode 100644
index 0000000..c504f99
--- /dev/null
+++ b/tests/AndroidTreeView.Adb.Tests/Services/FastbootServiceTests.cs
@@ -0,0 +1,138 @@
+using AndroidTreeView.Adb.Services;
+using AndroidTreeView.Adb.Tests.TestDoubles;
+using AndroidTreeView.Core.Interfaces;
+using AndroidTreeView.Core.Services;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace AndroidTreeView.Adb.Tests.Services;
+
+public sealed class FastbootServiceTests : IDisposable
+{
+ private readonly string _toolsDirectory = Path.Combine(
+ Path.GetTempPath(),
+ $"AndroidTreeView-fastboot-tests-{Guid.NewGuid():N}");
+
+ [Fact]
+ public async Task ListSerialsAsync_WhenFastbootIsMissing_ReturnsEmptyWithoutRunningCommand()
+ {
+ Directory.CreateDirectory(_toolsDirectory);
+ var runner = new FakeExternalCommandRunner();
+ var service = BuildService(runner);
+
+ var serials = await service.ListSerialsAsync();
+
+ Assert.Empty(serials);
+ Assert.Empty(runner.Requests);
+ }
+
+ [Fact]
+ public async Task ListSerialsAsync_WhenCommandTimesOut_ReturnsEmptyAndPreservesRequestArguments()
+ {
+ var fastbootPath = CreateFastbootFile();
+ var runner = new FakeExternalCommandRunner();
+ runner.Enqueue(new ExternalCommandResult { ExitCode = -1, TimedOut = true });
+ var service = BuildService(runner);
+
+ var serials = await service.ListSerialsAsync();
+
+ Assert.Empty(serials);
+ var request = Assert.Single(runner.Requests);
+ Assert.Equal(fastbootPath, request.FileName);
+ Assert.Equal(new[] { "devices" }, request.Arguments);
+ Assert.Equal(TimeSpan.FromSeconds(4), request.Timeout);
+ }
+
+ [Fact]
+ public async Task ListSerialsAsync_WhenCommandExitsNonZero_ReturnsEmpty()
+ {
+ CreateFastbootFile();
+ var runner = new FakeExternalCommandRunner();
+ runner.Enqueue(new ExternalCommandResult
+ {
+ ExitCode = 1,
+ StandardOutput = "serial-ignored\tfastboot\n",
+ StandardError = "failed"
+ });
+ var service = BuildService(runner);
+
+ var serials = await service.ListSerialsAsync();
+
+ Assert.Empty(serials);
+ }
+
+ [Fact]
+ public async Task GetVariablesAsync_ParsesFastbootStderrAndTargetsRequestedSerial()
+ {
+ CreateFastbootFile();
+ var runner = new FakeExternalCommandRunner();
+ runner.Enqueue(new ExternalCommandResult
+ {
+ ExitCode = 0,
+ StandardError = "(bootloader) product: devon\n(bootloader) unlocked: yes\nFinished. Total time: 0.001s"
+ });
+ var service = BuildService(runner);
+
+ var variables = await service.GetVariablesAsync("device-123");
+
+ Assert.Equal("devon", variables["product"]);
+ Assert.Equal("yes", variables["unlocked"]);
+ var request = Assert.Single(runner.Requests);
+ Assert.Equal(new[] { "-s", "device-123", "getvar", "all" }, request.Arguments);
+ }
+
+ [Fact]
+ public async Task ListSerialsAsync_WhenCancelled_PropagatesCancellation()
+ {
+ CreateFastbootFile();
+ var runner = new FakeExternalCommandRunner();
+ var service = BuildService(runner);
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ await Assert.ThrowsAnyAsync(
+ () => service.ListSerialsAsync(cts.Token));
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_toolsDirectory))
+ {
+ Directory.Delete(_toolsDirectory, recursive: true);
+ }
+ }
+
+ private FastbootService BuildService(FakeExternalCommandRunner runner)
+ {
+ var adbPath = Path.Combine(_toolsDirectory, OperatingSystem.IsWindows() ? "adb.exe" : "adb");
+ var environment = new TestAdbEnvironment(adbPath);
+ return new FastbootService(environment, runner, NullLogger.Instance);
+ }
+
+ private string CreateFastbootFile()
+ {
+ Directory.CreateDirectory(_toolsDirectory);
+ var path = Path.Combine(
+ _toolsDirectory,
+ OperatingSystem.IsWindows() ? "fastboot.exe" : "fastboot");
+ File.WriteAllText(path, "test executable placeholder");
+ return path;
+ }
+
+ private sealed class TestAdbEnvironment(string executablePath) : IAdbEnvironment
+ {
+ public bool IsAvailable => true;
+
+ public AdbLocation? Location { get; } = new()
+ {
+ ExecutablePath = executablePath,
+ Source = AdbLocationSource.Configured
+ };
+
+ public string ExecutablePath => executablePath;
+
+ public event EventHandler? Changed { add { } remove { } }
+
+ public void Set(AdbLocation? location) => throw new NotSupportedException();
+ }
+}
diff --git a/tests/AndroidTreeView.Adb.Tests/Services/MagiskPatcherTests.cs b/tests/AndroidTreeView.Adb.Tests/Services/MagiskPatcherTests.cs
new file mode 100644
index 0000000..c3ed5dc
--- /dev/null
+++ b/tests/AndroidTreeView.Adb.Tests/Services/MagiskPatcherTests.cs
@@ -0,0 +1,241 @@
+using System.IO.Compression;
+using AndroidTreeView.Adb.Services;
+using AndroidTreeView.Adb.Tests.TestDoubles;
+using AndroidTreeView.Core.Services;
+using AndroidTreeView.Models.Rooting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace AndroidTreeView.Adb.Tests.Services;
+
+public sealed class MagiskPatcherTests : IDisposable
+{
+ // Verbatim app_init output from a real cannon (M2007J22C, Android 12, MIUI 14).
+ private const string ProbeOutput = """
+ SYSTEM_AS_ROOT=true
+ PATCHVBMETAFLAG=false
+ LEGACYSAR=false
+ RECOVERYMODE=false
+ KEEPVERITY=true
+ KEEPFORCEENCRYPT=true
+ """;
+
+ // Verbatim .backup/.magisk shape: no PATCHVBMETAFLAG, no LEGACYSAR.
+ private const string PatchedConfig = """
+ KEEPVERITY=true
+ KEEPFORCEENCRYPT=true
+ RECOVERYMODE=false
+ PREINITDEVICE=cache
+ """;
+
+ private readonly string _directory = Path.Combine(Path.GetTempPath(), $"atv-magisk-{Guid.NewGuid():N}");
+
+ public MagiskPatcherTests() => Directory.CreateDirectory(_directory);
+
+ private static RootTestAdbCommandExecutor HealthyAdb(string? probe = null, string? config = null) => new()
+ {
+ Handler = (request, _) =>
+ {
+ var command = string.Join(' ', request.Arguments);
+ if (command.Contains("app_init", StringComparison.Ordinal))
+ {
+ return Task.FromResult(new AdbCommandResult { StandardOutput = probe ?? ProbeOutput });
+ }
+
+ if (command.Contains("config.verify", StringComparison.Ordinal))
+ {
+ return Task.FromResult(new AdbCommandResult { StandardOutput = config ?? PatchedConfig });
+ }
+
+ if (request.Arguments.Contains("getprop"))
+ {
+ return Task.FromResult(new AdbCommandResult { StandardOutput = "arm64-v8a\n" });
+ }
+
+ if (request.Arguments.FirstOrDefault() == "install")
+ {
+ return Task.FromResult(new AdbCommandResult { StandardOutput = "Success\n" });
+ }
+
+ if (request.Arguments.FirstOrDefault() == "pull")
+ {
+ File.WriteAllBytes(request.Arguments[2], new byte[] { 9, 8, 7 });
+ }
+
+ return Task.FromResult(new AdbCommandResult());
+ }
+ };
+
+ // Naming boot_patch.sh is not invoking it: `push` and `chmod` both carry its path as an argument.
+ private static bool IsPatchInvocation(AdbCommandRequest r) =>
+ r.RunInShell
+ && r.Arguments.Contains("sh")
+ && r.Arguments.Any(a => a.EndsWith("boot_patch.sh", StringComparison.Ordinal));
+
+ private static bool IsFlagProbe(AdbCommandRequest r) =>
+ r.RunInShell && r.Arguments.Contains("app_init");
+
+ private async Task<(MagiskPatcher Patcher, BootImageInfo Boot)> ArrangeAsync(
+ RootTestAdbCommandExecutor adb,
+ string name)
+ {
+ var work = Path.Combine(_directory, name);
+ Directory.CreateDirectory(work);
+ var bootPath = Path.Combine(work, "boot.img");
+ await File.WriteAllBytesAsync(bootPath, new byte[] { 1, 2, 3 });
+ return (new MagiskPatcher(adb, CreateTools("arm64-v8a"), NullLogger.Instance),
+ new BootImageInfo
+ {
+ Path = bootPath,
+ WorkDirectory = work,
+ OriginalPackageName = "factory.zip",
+ TargetPartition = BootPartitionTarget.Boot
+ });
+ }
+
+ [Fact]
+ public async Task PatchAsync_PassesProbedFlagsToBootPatchScript()
+ {
+ var adb = HealthyAdb();
+ var (patcher, boot) = await ArrangeAsync(adb, "flags-work");
+
+ await patcher.PatchAsync("SERIAL", boot);
+
+ var patch = string.Join(' ', adb.Requests.First(IsPatchInvocation).Arguments);
+ Assert.Contains("KEEPVERITY=true", patch);
+ Assert.Contains("KEEPFORCEENCRYPT=true", patch);
+ Assert.Contains("PATCHVBMETAFLAG=false", patch);
+ Assert.Contains("RECOVERYMODE=false", patch);
+ Assert.Contains("LEGACYSAR=false", patch);
+ }
+
+ [Fact]
+ public async Task PatchAsync_ProbesFlagsBeforePatching()
+ {
+ var adb = HealthyAdb();
+ var (patcher, boot) = await ArrangeAsync(adb, "order-work");
+
+ await patcher.PatchAsync("SERIAL", boot);
+
+ Assert.InRange(adb.Requests.FindIndex(IsFlagProbe), 0, adb.Requests.FindIndex(IsPatchInvocation) - 1);
+ }
+
+ [Fact]
+ public async Task PatchAsync_IncompleteProbe_AbortsBeforePatching()
+ {
+ var adb = HealthyAdb(probe: "KEEPVERITY=true");
+ var (patcher, boot) = await ArrangeAsync(adb, "probe-work");
+
+ var error = await Assert.ThrowsAsync(
+ () => patcher.PatchAsync("SERIAL", boot));
+
+ Assert.Equal(RootErrorCode.MagiskFlagProbeFailed, error.ErrorCode);
+ Assert.DoesNotContain(adb.Requests, IsPatchInvocation);
+ }
+
+ [Fact]
+ public async Task PatchAsync_PatchedConfigContradictsProbe_IsRejectedBeforePull()
+ {
+ // The exact shape of the real bootloop: device needs KEEPVERITY=true, image recorded false.
+ var adb = HealthyAdb(config: "KEEPVERITY=false\nKEEPFORCEENCRYPT=false");
+ var (patcher, boot) = await ArrangeAsync(adb, "mismatch-work");
+
+ var error = await Assert.ThrowsAsync(
+ () => patcher.PatchAsync("SERIAL", boot));
+
+ Assert.Equal(RootErrorCode.PatchedImageFlagMismatch, error.ErrorCode);
+ Assert.DoesNotContain(adb.Requests, r => r.Arguments.FirstOrDefault() == "pull");
+ }
+
+ [Fact]
+ public async Task PatchAsync_UnreadablePatchedConfig_IsRejected()
+ {
+ var adb = HealthyAdb(config: "Loading cpio: [ramdisk.cpio]");
+ var (patcher, boot) = await ArrangeAsync(adb, "unreadable-work");
+
+ var error = await Assert.ThrowsAsync(
+ () => patcher.PatchAsync("SERIAL", boot));
+
+ Assert.Equal(RootErrorCode.PatchedImageFlagMismatch, error.ErrorCode);
+ }
+
+ [Fact]
+ public async Task PatchAsync_ExtractsOfficialComponentsLocally_AndTargetsEveryAdbCommand()
+ {
+ var adb = HealthyAdb();
+ var (patcher, boot) = await ArrangeAsync(adb, "work");
+
+ var patched = await patcher.PatchAsync("SERIAL", boot);
+
+ Assert.True(File.Exists(patched));
+ Assert.All(adb.Requests, request => Assert.Equal("SERIAL", request.Serial));
+ Assert.DoesNotContain(adb.Requests, request => request.Arguments.Contains("unzip") || request.Arguments.Contains("cp"));
+ // 8 components (app_functions.sh included) plus the boot image itself.
+ Assert.Equal(9, adb.Requests.Count(request => request.Arguments.FirstOrDefault() == "push"));
+ var script = Assert.Single(adb.Requests, IsPatchInvocation);
+ Assert.Equal("sh", script.Arguments[5]);
+ Assert.False(Directory.EnumerateDirectories(boot.WorkDirectory, ".magisk-components-*").Any());
+ Assert.Equal("rm", adb.Requests[^1].Arguments[0]);
+ }
+
+ [Fact]
+ public async Task PatchAsync_InstallExitZeroWithFailureOutput_IsRejected()
+ {
+ var work = Path.Combine(_directory, "failure-work");
+ Directory.CreateDirectory(work);
+ var bootPath = Path.Combine(work, "boot.img");
+ await File.WriteAllBytesAsync(bootPath, new byte[] { 1, 2, 3 });
+ var paths = CreateTools("arm64-v8a");
+ var adb = new RootTestAdbCommandExecutor
+ {
+ Handler = static (request, _) => Task.FromResult(
+ request.Arguments.FirstOrDefault() == "install"
+ ? new AdbCommandResult { StandardOutput = "Failure [INSTALL_FAILED_VERSION_DOWNGRADE]\n" }
+ : new AdbCommandResult())
+ };
+ var patcher = new MagiskPatcher(adb, paths, NullLogger.Instance);
+
+ var error = await Assert.ThrowsAsync(
+ () => patcher.PatchAsync("SERIAL", new BootImageInfo
+ {
+ Path = bootPath,
+ WorkDirectory = work,
+ OriginalPackageName = "factory.zip",
+ TargetPartition = BootPartitionTarget.Boot
+ }));
+
+ Assert.Equal(RootErrorCode.MagiskInstallFailed, error.ErrorCode);
+ Assert.Contains("Failure", error.DiagnosticSummary);
+ Assert.Equal(2, adb.Requests.Count);
+ Assert.Equal("install", adb.Requests[0].Arguments[0]);
+ Assert.Equal("rm", adb.Requests[1].Arguments[0]);
+ }
+
+ public void Dispose() => Directory.Delete(_directory, recursive: true);
+
+ private RootToolPaths CreateTools(string abi)
+ {
+ var appBase = Path.Combine(_directory, "app");
+ var paths = new RootToolPaths(appBase, Path.Combine(_directory, "root-work"));
+ Directory.CreateDirectory(Path.GetDirectoryName(paths.MagiskApkPath)!);
+ using var archive = ZipFile.Open(paths.MagiskApkPath, ZipArchiveMode.Create);
+ foreach (var entryName in new[]
+ {
+ "assets/boot_patch.sh",
+ "assets/util_functions.sh",
+ "assets/app_functions.sh",
+ "assets/stub.apk",
+ $"lib/{abi}/libmagiskboot.so",
+ $"lib/{abi}/libmagiskinit.so",
+ $"lib/{abi}/libmagisk.so",
+ $"lib/{abi}/libinit-ld.so"
+ })
+ {
+ var entry = archive.CreateEntry(entryName);
+ using var stream = entry.Open();
+ stream.WriteByte(1);
+ }
+
+ return paths;
+ }
+}
diff --git a/tests/AndroidTreeView.Adb.Tests/Services/RootFastbootServiceTests.cs b/tests/AndroidTreeView.Adb.Tests/Services/RootFastbootServiceTests.cs
new file mode 100644
index 0000000..19873d6
--- /dev/null
+++ b/tests/AndroidTreeView.Adb.Tests/Services/RootFastbootServiceTests.cs
@@ -0,0 +1,282 @@
+using AndroidTreeView.Adb.Services;
+using AndroidTreeView.Adb.Tests.TestDoubles;
+using AndroidTreeView.Core.Interfaces;
+using AndroidTreeView.Core.Services;
+using AndroidTreeView.Models.Rooting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace AndroidTreeView.Adb.Tests.Services;
+
+public sealed class RootFastbootServiceTests : IDisposable
+{
+ private readonly string _directory = Path.Combine(Path.GetTempPath(), $"atv-root-fastboot-{Guid.NewGuid():N}");
+ private readonly string _imagePath;
+
+ public RootFastbootServiceTests()
+ {
+ Directory.CreateDirectory(_directory);
+ File.WriteAllText(Path.Combine(_directory, OperatingSystem.IsWindows() ? "fastboot.exe" : "fastboot"), "test");
+ _imagePath = Path.Combine(_directory, "patched.img");
+ File.WriteAllBytes(_imagePath, new byte[] { 1, 2, 3 });
+ }
+
+ [Fact]
+ public async Task WaitForMatchingDevice_SerialChanged_VerifiesUsbAndProduct()
+ {
+ var runner = new RootTestExternalCommandRunner
+ {
+ Handler = static (request, _) => Task.FromResult(request.Arguments.Contains("devices")
+ ? new ExternalCommandResult { StandardOutput = "FB fastboot usb:1-2\n" }
+ : new ExternalCommandResult { StandardError = "(bootloader) product: oriole" })
+ };
+ var service = Build(runner);
+
+ var match = await service.WaitForMatchingDeviceAsync(
+ new RootDeviceIdentity { Serial = "ADB", UsbPath = "1-2", Product = "oriole" },
+ new FastbootBaseline(),
+ TimeSpan.FromSeconds(1));
+
+ Assert.True(match.IsVerified);
+ Assert.All(runner.Requests.Where(request => request.Arguments.Contains("getvar")),
+ request => Assert.Equal(new[] { "-s", "FB" }, request.Arguments.Take(2)));
+ }
+
+ [Fact]
+ public async Task WaitForMatchingDevice_StableSerial_VerifiesWhenProductNameChanges()
+ {
+ var runner = new RootTestExternalCommandRunner
+ {
+ Handler = static (request, _) => Task.FromResult(request.Arguments.Contains("devices")
+ ? new ExternalCommandResult { StandardOutput = "SERIAL fastboot\n" }
+ : new ExternalCommandResult { StandardError = "(bootloader) product: bootloader-product" })
+ };
+ var service = Build(runner);
+
+ var match = await service.WaitForMatchingDeviceAsync(
+ new RootDeviceIdentity { Serial = "SERIAL", Product = "adb_product" },
+ new FastbootBaseline(),
+ TimeSpan.FromSeconds(1));
+
+ Assert.True(match.IsVerified);
+ Assert.Equal(FastbootIdentityEvidence.Serial, match.Evidence);
+ }
+
+ [Fact]
+ public async Task FlashAsync_SecondSlotCancelledBeforeStart_ReturnsPartialResult()
+ {
+ using var cts = new CancellationTokenSource();
+ var runner = new RootTestExternalCommandRunner
+ {
+ Handler = (_, _) =>
+ {
+ cts.Cancel();
+ return Task.FromResult(new ExternalCommandResult());
+ }
+ };
+ var service = Build(runner);
+
+ var result = await service.FlashAsync(
+ "FB",
+ BootPartitionTarget.Boot,
+ _imagePath,
+ new FastbootBootLayout { Kind = FastbootBootLayoutKind.Ab, SlotCount = 2, TargetHasSlot = true, CurrentSlot = "a" },
+ ct: cts.Token);
+
+ Assert.Equal(FlashOutcome.PartiallyWritten, result.Outcome);
+ Assert.Equal(RootErrorCode.FlashPartiallyWritten, result.ErrorCode);
+ Assert.Equal(new[] { "boot_a" }, result.SucceededPartitions);
+ Assert.Equal("boot_b", result.FailedPartition);
+ Assert.Single(runner.Requests);
+ }
+
+ [Fact]
+ public async Task FlashAsync_SecondSlotFails_ReturnsUnknownAndPreservesConfirmedFirstSlot()
+ {
+ var call = 0;
+ var runner = new RootTestExternalCommandRunner
+ {
+ Handler = (_, _) => Task.FromResult(++call == 1
+ ? new ExternalCommandResult()
+ : new ExternalCommandResult { ExitCode = 1, StandardError = "FAILED remote failure" })
+ };
+ var service = Build(runner);
+
+ var result = await service.FlashAsync(
+ "FB",
+ BootPartitionTarget.InitBoot,
+ _imagePath,
+ new FastbootBootLayout { Kind = FastbootBootLayoutKind.Ab, SlotCount = 2, TargetHasSlot = true, CurrentSlot = "b" });
+
+ Assert.Equal(FlashOutcome.Unknown, result.Outcome);
+ Assert.Equal(RootErrorCode.FlashOutcomeUnknown, result.ErrorCode);
+ Assert.Equal(new[] { "init_boot_a" }, result.SucceededPartitions);
+ Assert.Equal("init_boot_b", result.FailedPartition);
+ Assert.Equal(2, runner.Requests.Count);
+ Assert.All(runner.Requests, request => Assert.Equal(new[] { "-s", "FB" }, request.Arguments.Take(2)));
+ }
+
+ [Fact]
+ public async Task FlashAsync_SingleSlotLayout_WritesTargetPartition()
+ {
+ var runner = new RootTestExternalCommandRunner();
+ var service = Build(runner);
+
+ var result = await service.FlashAsync(
+ "FB",
+ BootPartitionTarget.Boot,
+ _imagePath,
+ new FastbootBootLayout { Kind = FastbootBootLayoutKind.Single, TargetHasSlot = false });
+
+ Assert.Equal(FlashOutcome.Succeeded, result.Outcome);
+ Assert.Equal(RootErrorCode.None, result.ErrorCode);
+ Assert.Equal(new[] { "boot" }, result.SucceededPartitions);
+ Assert.Single(runner.Requests);
+ }
+
+ [Fact]
+ public async Task VerifyCurrentIdentity_OnlyExpectedSerialWithUsbAndProduct_IsVerified()
+ {
+ var runner = new RootTestExternalCommandRunner
+ {
+ Handler = static (request, _) => Task.FromResult(request.Arguments.Contains("devices")
+ ? new ExternalCommandResult
+ {
+ StandardOutput = "OTHER fastboot usb:9-9 product:raven\nFB fastboot usb:1-2\n"
+ }
+ : new ExternalCommandResult { StandardError = "(bootloader) product: oriole" })
+ };
+ var service = Build(runner);
+
+ var result = await service.VerifyCurrentIdentityAsync(
+ new RootDeviceIdentity { Serial = "ADB", UsbPath = "1-2", Product = "oriole" },
+ "FB");
+
+ Assert.True(result.IsVerified);
+ Assert.Equal("FB", result.Device!.Serial);
+ Assert.DoesNotContain(runner.Requests,
+ request => request.Arguments.Contains("getvar") && request.Arguments.Contains("OTHER"));
+ }
+
+ [Fact]
+ public async Task VerifyCurrentIdentity_UniqueExpectedSerialWithoutEvidence_RemainsUnverified()
+ {
+ var runner = new RootTestExternalCommandRunner
+ {
+ Handler = static (request, _) => Task.FromResult(request.Arguments.Contains("devices")
+ ? new ExternalCommandResult { StandardOutput = "FB fastboot\n" }
+ : new ExternalCommandResult { ExitCode = 1 })
+ };
+ var service = Build(runner);
+
+ var result = await service.VerifyCurrentIdentityAsync(
+ new RootDeviceIdentity { Serial = "ADB" },
+ "FB");
+
+ Assert.Equal(FastbootIdentityMatchStatus.Unverified, result.Status);
+ Assert.False(result.IsVerified);
+ }
+
+ [Fact]
+ public async Task GetBootLayout_HasSlotNo_IsSingleWhenSlotVariablesAreUnsupported()
+ {
+ var runner = new RootTestExternalCommandRunner
+ {
+ Handler = static (request, _) => Task.FromResult(request.Arguments[^1] == "has-slot:boot"
+ ? new ExternalCommandResult { StandardError = "(bootloader) has-slot:boot: no" }
+ : new ExternalCommandResult { ExitCode = 1, StandardError = "unknown variable" })
+ };
+ var service = Build(runner);
+
+ var layout = await service.GetBootLayoutAsync("FB", BootPartitionTarget.Boot);
+
+ Assert.Equal(FastbootBootLayoutKind.Single, layout.Kind);
+ Assert.False(layout.TargetHasSlot);
+ Assert.All(runner.Requests, request => Assert.Equal(new[] { "-s", "FB" }, request.Arguments.Take(2)));
+ }
+
+ [Fact]
+ public async Task GetBootLayout_LegacyBootloaderReportingZeroSlots_IsSingle()
+ {
+ // Verified against a Xiaomi "cannon" bootloader: slot-count answers 0, while has-slot and
+ // current-slot fail with "GetVar Variable Not found".
+ var runner = new RootTestExternalCommandRunner
+ {
+ Handler = static (request, _) => Task.FromResult(request.Arguments[^1] == "slot-count"
+ ? new ExternalCommandResult { StandardOutput = "slot-count: 0" }
+ : new ExternalCommandResult
+ {
+ ExitCode = 1,
+ StandardError = $"getvar:{request.Arguments[^1]} FAILED (remote: 'GetVar Variable Not found')"
+ })
+ };
+ var service = Build(runner);
+
+ var layout = await service.GetBootLayoutAsync("FB", BootPartitionTarget.Boot);
+
+ Assert.Equal(FastbootBootLayoutKind.Single, layout.Kind);
+ Assert.Equal(0, layout.SlotCount);
+ Assert.Null(layout.TargetHasSlot);
+ Assert.Null(layout.CurrentSlot);
+ }
+
+ [Fact]
+ public async Task GetBootLayout_ZeroSlotsButActiveSlotReported_StaysUnknown()
+ {
+ var runner = new RootTestExternalCommandRunner
+ {
+ Handler = static (request, _) => Task.FromResult(request.Arguments[^1] switch
+ {
+ "slot-count" => new ExternalCommandResult { StandardOutput = "slot-count: 0" },
+ "current-slot" => new ExternalCommandResult { StandardOutput = "current-slot: a" },
+ _ => new ExternalCommandResult { ExitCode = 1, StandardError = "unknown variable" }
+ })
+ };
+ var service = Build(runner);
+
+ var layout = await service.GetBootLayoutAsync("FB", BootPartitionTarget.Boot);
+
+ Assert.Equal(FastbootBootLayoutKind.Unknown, layout.Kind);
+ }
+
+ [Fact]
+ public async Task GetBootLayout_NoSlotEvidenceAtAll_StaysUnknown()
+ {
+ var runner = new RootTestExternalCommandRunner
+ {
+ Handler = static (_, _) => Task.FromResult(
+ new ExternalCommandResult { ExitCode = 1, StandardError = "unknown variable" })
+ };
+ var service = Build(runner);
+
+ var layout = await service.GetBootLayoutAsync("FB", BootPartitionTarget.Boot);
+
+ Assert.Equal(FastbootBootLayoutKind.Unknown, layout.Kind);
+ }
+
+ public void Dispose()
+ {
+ Directory.Delete(_directory, recursive: true);
+ }
+
+ private RootFastbootService Build(RootTestExternalCommandRunner runner)
+ => new(
+ new TestEnvironment(Path.Combine(_directory, OperatingSystem.IsWindows() ? "adb.exe" : "adb")),
+ new RootTestAdbCommandExecutor(),
+ runner,
+ new RootToolPaths(_directory, Path.Combine(_directory, "work")),
+ NullLogger.Instance);
+
+ private sealed class TestEnvironment(string executablePath) : IAdbEnvironment
+ {
+ public bool IsAvailable => true;
+ public AdbLocation? Location { get; } = new()
+ {
+ ExecutablePath = executablePath,
+ Source = AdbLocationSource.Configured
+ };
+ public string ExecutablePath => executablePath;
+ public event EventHandler? Changed { add { } remove { } }
+ public void Set(AdbLocation? location) => throw new NotSupportedException();
+ }
+}
diff --git a/tests/AndroidTreeView.Adb.Tests/TestDoubles/FakeExternalCommandRunner.cs b/tests/AndroidTreeView.Adb.Tests/TestDoubles/FakeExternalCommandRunner.cs
new file mode 100644
index 0000000..48a0a29
--- /dev/null
+++ b/tests/AndroidTreeView.Adb.Tests/TestDoubles/FakeExternalCommandRunner.cs
@@ -0,0 +1,35 @@
+using AndroidTreeView.Core.Interfaces;
+using AndroidTreeView.Core.Services;
+
+namespace AndroidTreeView.Adb.Tests.TestDoubles;
+
+internal sealed class FakeExternalCommandRunner : IExternalCommandRunner
+{
+ private readonly Queue _results = new();
+ private readonly List _requests = [];
+
+ public IReadOnlyList Requests => _requests;
+
+ public Exception? Exception { get; set; }
+
+ public void Enqueue(ExternalCommandResult result) => _results.Enqueue(result);
+
+ public Task RunAsync(
+ ExternalCommandRequest request,
+ CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+ _requests.Add(request);
+
+ if (Exception is not null)
+ {
+ return Task.FromException(Exception);
+ }
+
+ var result = _results.Count > 0
+ ? _results.Dequeue()
+ : new ExternalCommandResult { ExitCode = 0 };
+
+ return Task.FromResult(result);
+ }
+}
diff --git a/tests/AndroidTreeView.Adb.Tests/TestDoubles/RootWorkflowCommandDoubles.cs b/tests/AndroidTreeView.Adb.Tests/TestDoubles/RootWorkflowCommandDoubles.cs
new file mode 100644
index 0000000..815b521
--- /dev/null
+++ b/tests/AndroidTreeView.Adb.Tests/TestDoubles/RootWorkflowCommandDoubles.cs
@@ -0,0 +1,40 @@
+using AndroidTreeView.Core.Interfaces;
+using AndroidTreeView.Core.Services;
+
+namespace AndroidTreeView.Adb.Tests.TestDoubles;
+
+internal sealed class RootTestExternalCommandRunner : IExternalCommandRunner
+{
+ public List Requests { get; } = [];
+
+ public Func> Handler { get; set; }
+ = static (_, _) => Task.FromResult(new ExternalCommandResult());
+
+ public Task RunAsync(ExternalCommandRequest request, CancellationToken ct = default)
+ {
+ Requests.Add(request);
+ return Handler(request, ct);
+ }
+}
+
+internal sealed class RootTestAdbCommandExecutor : IAdbCommandExecutor
+{
+ public List Requests { get; } = [];
+
+ public Func> Handler { get; set; }
+ = static (_, _) => Task.FromResult(new AdbCommandResult());
+
+ public Task ExecuteAsync(AdbCommandRequest request, CancellationToken ct = default)
+ {
+ Requests.Add(request);
+ return Handler(request, ct);
+ }
+
+ public async IAsyncEnumerable StreamAsync(
+ AdbCommandRequest request,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
+ {
+ await Task.CompletedTask;
+ yield break;
+ }
+}
diff --git a/tests/AndroidTreeView.App.Tests/BootSmokeTests.cs b/tests/AndroidTreeView.App.Tests/BootSmokeTests.cs
index 0146ff9..247ff8a 100644
--- a/tests/AndroidTreeView.App.Tests/BootSmokeTests.cs
+++ b/tests/AndroidTreeView.App.Tests/BootSmokeTests.cs
@@ -58,7 +58,7 @@ public async Task Main_window_boots_with_resolved_shell_view_model()
}
[AvaloniaFact]
- public async Task ViewLocator_resolves_devices_and_setup_views()
+ public async Task ViewLocator_resolves_devices_setup_and_root_views()
{
var services = new ServiceCollection();
services.ConfigureAppServices();
@@ -71,5 +71,8 @@ public async Task ViewLocator_resolves_devices_and_setup_views()
var setupView = locator.Build(provider.GetRequiredService());
Assert.IsType(setupView);
+
+ var rootView = locator.Build(provider.GetRequiredService());
+ Assert.IsType(rootView);
}
}
diff --git a/tests/AndroidTreeView.App.Tests/Fakes.cs b/tests/AndroidTreeView.App.Tests/Fakes.cs
index b79f359..d2e4551 100644
--- a/tests/AndroidTreeView.App.Tests/Fakes.cs
+++ b/tests/AndroidTreeView.App.Tests/Fakes.cs
@@ -224,11 +224,21 @@ internal sealed class FakeFilePickerService : IFilePickerService
{
public IReadOnlyList TransferFiles { get; set; } = [];
+ public string? RootPackagePath { get; set; }
+
+ public List OpenedUrls { get; } = [];
+
public Task PickAdbExecutableAsync() => Task.FromResult(null);
public Task> PickTransferFilesAsync() => Task.FromResult(TransferFiles);
- public Task OpenUrlAsync(string url) => Task.CompletedTask;
+ public Task PickRootPackageAsync() => Task.FromResult(RootPackagePath);
+
+ public Task OpenUrlAsync(string url)
+ {
+ OpenedUrls.Add(url);
+ return Task.CompletedTask;
+ }
}
/// Auto-confirming double so action commands proceed in tests.
@@ -265,6 +275,94 @@ public event EventHandler? CanExecuteChanged { add { } remove { } }
}
}
+internal sealed class ConfigurableDialogService : IDialogService
+{
+ public bool ConfirmResult { get; set; } = true;
+
+ public bool IsOpen => false;
+
+ public string Title => string.Empty;
+
+ public string Message => string.Empty;
+
+ public string ConfirmText => string.Empty;
+
+ public string CancelText => string.Empty;
+
+ public ICommand ConfirmCommand { get; } = new NoopCommand();
+
+ public ICommand CancelCommand { get; } = new NoopCommand();
+
+ public event PropertyChangedEventHandler? PropertyChanged { add { } remove { } }
+
+ public Task ConfirmAsync(string title, string message, string confirmText, string cancelText) =>
+ Task.FromResult(ConfirmResult);
+
+ private sealed class NoopCommand : ICommand
+ {
+ public bool CanExecute(object? parameter) => true;
+
+ public void Execute(object? parameter)
+ {
+ }
+
+ public event EventHandler? CanExecuteChanged { add { } remove { } }
+ }
+}
+
+internal sealed class LanguageAwareLocalizationService : ILocalizationService
+{
+ public AppLanguage CurrentLanguage { get; private set; } = AppLanguage.English;
+
+ public CultureInfo CurrentCulture => CultureInfo.InvariantCulture;
+
+ public event EventHandler? LanguageChanged;
+
+ public string Get(string key) => $"{CurrentLanguage}:{key}";
+
+ public string Format(string key, params object[] args) => Get(key);
+
+ public string this[string key] => Get(key);
+
+ public void SetLanguage(AppLanguage language)
+ {
+ CurrentLanguage = language;
+ LanguageChanged?.Invoke(this, EventArgs.Empty);
+ }
+}
+
+internal sealed class FakeRootDeviceMonitor : IDeviceMonitor
+{
+ private DeviceListChangedEventArgs _snapshot = new()
+ {
+ Devices = Array.Empty(),
+ AdbAvailable = true
+ };
+
+ public event EventHandler? DevicesChanged;
+
+ public bool IsRunning => false;
+
+ public void Publish(params AdbDevice[] devices)
+ {
+ _snapshot = new DeviceListChangedEventArgs { Devices = devices, AdbAvailable = true };
+ DevicesChanged?.Invoke(this, _snapshot);
+ }
+
+ public void Start()
+ {
+ }
+
+ public Task StopAsync() => Task.CompletedTask;
+
+ public Task RefreshNowAsync(CancellationToken ct = default) =>
+ Task.FromResult(_snapshot);
+
+ public void UpdateInterval(TimeSpan interval)
+ {
+ }
+}
+
/// No-op double (no fastboot devices) for view-model tests.
internal sealed class FakeFastbootService : IFastbootService
{
diff --git a/tests/AndroidTreeView.App.Tests/RootLocalizationTests.cs b/tests/AndroidTreeView.App.Tests/RootLocalizationTests.cs
new file mode 100644
index 0000000..553d9e1
--- /dev/null
+++ b/tests/AndroidTreeView.App.Tests/RootLocalizationTests.cs
@@ -0,0 +1,70 @@
+using System.Xml.Linq;
+using Xunit;
+
+namespace AndroidTreeView.App.Tests;
+
+public sealed class RootLocalizationTests
+{
+ [Fact]
+ public void English_and_simplified_chinese_resources_have_identical_keys()
+ {
+ var repositoryRoot = FindRepositoryRoot();
+ var resources = Path.Combine(repositoryRoot, "src", "AndroidTreeView.App", "Resources");
+ var english = ReadKeys(Path.Combine(resources, "Strings.resx"));
+ var chinese = ReadKeys(Path.Combine(resources, "Strings.zh-Hans.resx"));
+
+ Assert.Equal(english, chinese);
+ Assert.Contains("root.wizard.title", english);
+ Assert.Contains("root.confirm.flash.warning", english);
+ Assert.Contains("root.error.flash.unknown", english);
+ }
+
+ [Fact]
+ public void Flash_warning_message_is_translated_in_both_languages()
+ {
+ var repositoryRoot = FindRepositoryRoot();
+ var resources = Path.Combine(repositoryRoot, "src", "AndroidTreeView.App", "Resources");
+ const string key = "root.confirm.flash.warning";
+
+ var english = ReadValue(Path.Combine(resources, "Strings.resx"), key);
+ var chinese = ReadValue(Path.Combine(resources, "Strings.zh-Hans.resx"), key);
+
+ Assert.False(string.IsNullOrWhiteSpace(english));
+ Assert.False(string.IsNullOrWhiteSpace(chinese));
+ Assert.NotEqual(key, english);
+ Assert.NotEqual(key, chinese);
+ }
+
+ private static string[] ReadKeys(string path) =>
+ XDocument.Load(path)
+ .Root!
+ .Elements("data")
+ .Select(element => (string?)element.Attribute("name"))
+ .Where(name => !string.IsNullOrWhiteSpace(name))
+ .OrderBy(name => name, StringComparer.Ordinal)
+ .ToArray()!;
+
+ private static string? ReadValue(string path, string key) =>
+ XDocument.Load(path)
+ .Root!
+ .Elements("data")
+ .Single(element => string.Equals((string?)element.Attribute("name"), key, StringComparison.Ordinal))
+ .Element("value")
+ ?.Value;
+
+ private static string FindRepositoryRoot()
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory is not null)
+ {
+ if (File.Exists(Path.Combine(directory.FullName, "AndroidTreeView.sln")))
+ {
+ return directory.FullName;
+ }
+
+ directory = directory.Parent;
+ }
+
+ throw new DirectoryNotFoundException("Could not locate the AndroidTreeView repository root.");
+ }
+}
diff --git a/tests/AndroidTreeView.App.Tests/RootWizardViewModelTests.cs b/tests/AndroidTreeView.App.Tests/RootWizardViewModelTests.cs
new file mode 100644
index 0000000..d7eec14
--- /dev/null
+++ b/tests/AndroidTreeView.App.Tests/RootWizardViewModelTests.cs
@@ -0,0 +1,476 @@
+using AndroidTreeView.App.ViewModels;
+using AndroidTreeView.Core.Interfaces;
+using AndroidTreeView.Core.Options;
+using AndroidTreeView.Models.Devices;
+using AndroidTreeView.Models.Rooting;
+using Avalonia.Headless.XUnit;
+using Avalonia.VisualTree;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace AndroidTreeView.App.Tests;
+
+public sealed class RootWizardViewModelTests
+{
+ [AvaloniaFact]
+ public void Repeated_polls_with_an_unchanged_device_list_do_not_rebuild_the_collection()
+ {
+ var context = CreateContext();
+ context.Monitor.Publish(Device("one"), Device("two"));
+
+ var changes = 0;
+ context.ViewModel.Devices.CollectionChanged += (_, _) => changes++;
+ context.Monitor.Publish(Device("one"), Device("two"));
+ context.Monitor.Publish(Device("one"), Device("two"));
+
+ // A rebuild would clear the ListBox selection and yank the surrounding ScrollViewer
+ // back to the device card on every poll tick.
+ Assert.Equal(0, changes);
+ Assert.Equal(2, context.ViewModel.Devices.Count);
+ }
+
+ [AvaloniaFact]
+ public void Repeated_polls_keep_the_selection_instance_stable()
+ {
+ var context = CreateContext();
+ context.Monitor.Publish(Device("only", product: "akita"));
+ var selected = context.ViewModel.SelectedDevice;
+
+ var selectionChanges = 0;
+ context.ViewModel.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName == nameof(RootWizardViewModel.SelectedDevice))
+ {
+ selectionChanges++;
+ }
+ };
+ context.Monitor.Publish(Device("only", product: "akita"));
+
+ Assert.Equal(0, selectionChanges);
+ Assert.Same(selected, context.ViewModel.SelectedDevice);
+ }
+
+ [AvaloniaFact]
+ public void A_changed_device_list_still_rebuilds_the_collection()
+ {
+ var context = CreateContext();
+ context.Monitor.Publish(Device("one"), Device("two"));
+
+ context.Monitor.Publish(Device("one"), Device("three"));
+
+ Assert.Equal(2, context.ViewModel.Devices.Count);
+ Assert.Contains(context.ViewModel.Devices, device => device.Identity.Serial == "three");
+ Assert.DoesNotContain(context.ViewModel.Devices, device => device.Identity.Serial == "two");
+ }
+
+ [AvaloniaFact]
+ public void Multiple_online_devices_are_not_preselected()
+ {
+ var context = CreateContext();
+
+ context.Monitor.Publish(Device("one"), Device("two"));
+
+ Assert.Equal(2, context.ViewModel.Devices.Count);
+ Assert.Null(context.ViewModel.SelectedDevice);
+ Assert.False(context.ViewModel.CanStart);
+ Assert.Equal("English:root.device.choose", context.ViewModel.DeviceListMessage);
+ }
+
+ [AvaloniaFact]
+ public void One_online_device_is_preselected_and_sent_to_the_wizard()
+ {
+ var context = CreateContext();
+
+ context.Monitor.Publish(Device("only", product: "akita"));
+
+ Assert.Equal("only", context.ViewModel.SelectedDevice?.Identity.Serial);
+ Assert.Equal("only", context.Wizard.Snapshot.DeviceIdentity?.Serial);
+ Assert.Equal(1, context.Wizard.SelectDeviceCallCount);
+ }
+
+ [AvaloniaFact]
+ public void Offline_unauthorized_and_fastboot_devices_are_filtered_out()
+ {
+ var context = CreateContext();
+
+ context.Monitor.Publish(
+ Device("online"),
+ Device("offline", DeviceConnectionState.Offline),
+ Device("unauthorized", DeviceConnectionState.Unauthorized),
+ Device("fastboot", DeviceConnectionState.Bootloader));
+
+ var selected = Assert.Single(context.ViewModel.Devices);
+ Assert.Equal("online", selected.Identity.Serial);
+ }
+
+ [AvaloniaFact]
+ public void Workflow_locks_the_selected_device_identity()
+ {
+ var context = CreateContext();
+ context.Monitor.Publish(Device("one"), Device("two"));
+ context.ViewModel.SelectedDevice = context.ViewModel.Devices[0];
+ var callsBeforeLock = context.Wizard.SelectDeviceCallCount;
+ context.Wizard.Publish(FlashReadySnapshot(RootWizardState.AwaitingBootloaderConfirm) with
+ {
+ DeviceIdentity = Identity("one")
+ });
+
+ context.ViewModel.SelectedDevice = context.ViewModel.Devices[1];
+
+ Assert.True(context.ViewModel.IsSelectionLocked);
+ Assert.False(context.ViewModel.IsDeviceSelectionEnabled);
+ Assert.Equal(callsBeforeLock, context.Wizard.SelectDeviceCallCount);
+ Assert.Equal("one", context.Wizard.Snapshot.DeviceIdentity?.Serial);
+ }
+
+ [AvaloniaFact]
+ public void Locked_fastboot_target_is_not_replaced_by_another_online_adb_device()
+ {
+ var target = new RootDeviceIdentity
+ {
+ Serial = "fastboot-target",
+ Product = "cannon",
+ Model = "Redmi Note 9"
+ };
+ var context = CreateContext(FlashReadySnapshot(RootWizardState.BlockedFastbootIdentity) with
+ {
+ DeviceIdentity = target,
+ FastbootIdentityMatch = new FastbootIdentityMatch
+ {
+ Status = FastbootIdentityMatchStatus.Unverified
+ }
+ });
+
+ context.Monitor.Publish(Device("other-adb-device", product: "cannon"));
+
+ Assert.Equal("fastboot-target", context.ViewModel.SelectedDevice?.Identity.Serial);
+ Assert.Contains(context.ViewModel.Devices, device => device.Identity.Serial == "other-adb-device");
+ Assert.Contains(context.ViewModel.Devices, device => device.Identity.Serial == "fastboot-target");
+ Assert.Equal(0, context.Wizard.SelectDeviceCallCount);
+ }
+
+ [AvaloniaTheory]
+ [InlineData(RootWizardState.Failed)]
+ [InlineData(RootWizardState.BlockedUnsupportedTarget)]
+ public void Failed_or_blocked_session_keeps_its_target_locked_until_cancel(RootWizardState state)
+ {
+ var context = CreateContext(FlashReadySnapshot(state) with
+ {
+ ErrorCode = RootErrorCode.TargetEvidenceConflict
+ });
+
+ Assert.True(context.ViewModel.IsSelectionLocked);
+ Assert.False(context.ViewModel.IsDeviceSelectionEnabled);
+ Assert.False(context.ViewModel.PickPackageCommand.CanExecute(null));
+ }
+
+ [AvaloniaFact]
+ public async Task Cancelling_the_file_picker_keeps_the_current_snapshot()
+ {
+ var context = CreateContext();
+ context.Picker.RootPackagePath = null;
+
+ await context.ViewModel.PickPackageCommand.ExecuteAsync(null);
+
+ Assert.Equal(0, context.Wizard.SelectPackageCallCount);
+ Assert.Equal(RootWizardState.Idle, context.ViewModel.State);
+ Assert.Null(context.ViewModel.SelectedPackagePath);
+ }
+
+ [AvaloniaFact]
+ public async Task Bootloader_confirmation_is_a_hard_gate()
+ {
+ var declined = CreateContext(FlashReadySnapshot(RootWizardState.AwaitingBootloaderConfirm));
+ declined.Dialog.ConfirmResult = false;
+
+ await declined.ViewModel.ConfirmBootloaderCommand.ExecuteAsync(null);
+
+ Assert.Equal(0, declined.Wizard.ConfirmBootloaderCallCount);
+ Assert.Equal(0, declined.Wizard.DetectFastbootCallCount);
+
+ var accepted = CreateContext(FlashReadySnapshot(RootWizardState.AwaitingBootloaderConfirm));
+ accepted.Dialog.ConfirmResult = true;
+
+ await accepted.ViewModel.ConfirmBootloaderCommand.ExecuteAsync(null);
+
+ Assert.Equal(1, accepted.Wizard.ConfirmBootloaderCallCount);
+ Assert.Equal(1, accepted.Wizard.DetectFastbootCallCount);
+ }
+
+ [AvaloniaFact]
+ public async Task Bootloader_reboot_failure_does_not_start_fastboot_detection()
+ {
+ var context = CreateContext(FlashReadySnapshot(RootWizardState.AwaitingBootloaderConfirm));
+ context.Dialog.ConfirmResult = true;
+ context.Wizard.ConfirmBootloaderResultState = RootWizardState.Failed;
+
+ await context.ViewModel.ConfirmBootloaderCommand.ExecuteAsync(null);
+
+ Assert.Equal(1, context.Wizard.ConfirmBootloaderCallCount);
+ Assert.Equal(0, context.Wizard.DetectFastbootCallCount);
+ Assert.Equal(RootWizardState.Failed, context.ViewModel.State);
+ }
+
+ [AvaloniaFact]
+ public async Task Flash_requires_explicit_risk_acknowledgement()
+ {
+ var context = CreateContext(FlashReadySnapshot(RootWizardState.AwaitingFlashConfirm));
+
+ Assert.False(context.ViewModel.FlashCommand.CanExecute(null));
+ context.ViewModel.HasAcknowledgedRisk = true;
+ Assert.True(context.ViewModel.FlashCommand.CanExecute(null));
+
+ await context.ViewModel.FlashCommand.ExecuteAsync(null);
+
+ Assert.Equal(1, context.Wizard.ConfirmFlashCallCount);
+ Assert.True(context.Wizard.LastRiskAcknowledged);
+ }
+
+ [AvaloniaTheory]
+ [InlineData(RootWizardState.BlockedFastbootIdentity, RootErrorCode.FastbootIdentityUnverified)]
+ [InlineData(RootWizardState.BlockedLocked, RootErrorCode.BootloaderLocked)]
+ [InlineData(RootWizardState.BlockedBootLayout, RootErrorCode.BootLayoutUnknown)]
+ public void Safety_blocks_disable_flash_and_allow_recheck(RootWizardState state, RootErrorCode errorCode)
+ {
+ var context = CreateContext(FlashReadySnapshot(state) with { ErrorCode = errorCode });
+ context.ViewModel.HasAcknowledgedRisk = true;
+
+ Assert.False(context.ViewModel.FlashCommand.CanExecute(null));
+ Assert.True(context.ViewModel.RecheckFastbootCommand.CanExecute(null));
+ Assert.True(context.ViewModel.ShowBlockedGuidance);
+ Assert.True(context.ViewModel.HasError);
+ }
+
+ [AvaloniaTheory]
+ [InlineData(RootWizardState.FailedPartialFlash, RootErrorCode.FlashPartiallyWritten)]
+ [InlineData(RootWizardState.FlashOutcomeUnknown, RootErrorCode.FlashOutcomeUnknown)]
+ public void Partial_and_unknown_flash_outcomes_are_shown_as_dangerous(
+ RootWizardState state,
+ RootErrorCode errorCode)
+ {
+ var context = CreateContext(FlashReadySnapshot(state) with { ErrorCode = errorCode });
+
+ Assert.True(context.ViewModel.ShowPartialFlashWarning);
+ Assert.True(context.ViewModel.HasError);
+ Assert.False(context.ViewModel.FlashCommand.CanExecute(null));
+ Assert.Equal(state != RootWizardState.FlashOutcomeUnknown, context.ViewModel.CancelCommand.CanExecute(null));
+
+ context.ViewModel.HasAcknowledgedRisk = true;
+ Assert.Equal(
+ state == RootWizardState.FailedPartialFlash,
+ context.ViewModel.FlashCommand.CanExecute(null));
+ }
+
+ [AvaloniaTheory]
+ [InlineData(RootErrorCode.FlashFailed)]
+ [InlineData(RootErrorCode.RebootFailed)]
+ public async Task Failed_flash_or_reboot_can_retry(RootErrorCode errorCode)
+ {
+ var context = CreateContext(FlashReadySnapshot(RootWizardState.Failed) with { ErrorCode = errorCode });
+
+ Assert.True(context.ViewModel.RetryCommand.CanExecute(null));
+ await context.ViewModel.RetryCommand.ExecuteAsync(null);
+ Assert.Equal(1, context.Wizard.RetryCallCount);
+ }
+
+ [AvaloniaFact]
+ public async Task Cancel_stops_the_active_wizard_session()
+ {
+ var context = CreateContext(FlashReadySnapshot(RootWizardState.AwaitingBootloaderConfirm));
+
+ await context.ViewModel.CancelCommand.ExecuteAsync(null);
+
+ Assert.Equal(1, context.Wizard.CancelCallCount);
+ Assert.Equal(RootWizardState.Cancelled, context.ViewModel.State);
+ }
+
+ [AvaloniaFact]
+ public void Language_change_recomputes_state_device_and_step_text()
+ {
+ var context = CreateContext(FlashReadySnapshot(RootWizardState.AwaitingFlashConfirm));
+ context.Monitor.Publish(Device("one"));
+ var englishState = context.ViewModel.StateText;
+ var englishStep = context.ViewModel.Steps[4].Title;
+
+ context.Localization.SetLanguage(AppLanguage.ChineseSimplified);
+
+ Assert.NotEqual(englishState, context.ViewModel.StateText);
+ Assert.Equal("ChineseSimplified:root.state.awaitingflashconfirm", context.ViewModel.StateText);
+ Assert.NotEqual(englishStep, context.ViewModel.Steps[4].Title);
+ Assert.StartsWith("ChineseSimplified:", context.ViewModel.Devices[0].Details);
+ }
+
+ [AvaloniaTheory]
+ [InlineData(null, false)]
+ [InlineData(FirmwarePackageMatchStatus.Unverified, true)]
+ [InlineData(FirmwarePackageMatchStatus.Matched, false)]
+ public void Package_match_warning_is_only_visible_for_unverified_metadata(
+ FirmwarePackageMatchStatus? status,
+ bool expected)
+ {
+ var metadata = status is null ? null : new FirmwarePackageMetadata
+ {
+ PackagePath = "/firmware/device.zip",
+ OriginalPackageName = "device.zip",
+ MatchStatus = status.Value
+ };
+ var context = CreateContext(FlashReadySnapshot(RootWizardState.AwaitingFlashConfirm) with
+ {
+ PackageMetadata = metadata
+ });
+
+ Assert.Equal(expected, context.ViewModel.IsPackageMatchUnverified);
+ }
+
+ [AvaloniaFact]
+ public void Recovery_only_error_maps_to_specific_localized_guidance()
+ {
+ var context = CreateContext(FlashReadySnapshot(RootWizardState.BlockedUnsupportedTarget) with
+ {
+ ErrorCode = RootErrorCode.RecoveryOnlyUnsupported
+ });
+
+ Assert.Equal("English:root.error.recoveryonly", context.ViewModel.ErrorMessage);
+ }
+
+ private static TestContext CreateContext(RootWizardSnapshot? snapshot = null)
+ {
+ var wizard = new FakeRootWizardService(snapshot ?? new RootWizardSnapshot());
+ var monitor = new FakeRootDeviceMonitor();
+ var picker = new FakeFilePickerService();
+ var dialog = new ConfigurableDialogService();
+ var localization = new LanguageAwareLocalizationService();
+ var viewModel = new RootWizardViewModel(
+ wizard,
+ monitor,
+ picker,
+ dialog,
+ localization,
+ NullLogger.Instance);
+ return new TestContext(viewModel, wizard, monitor, picker, dialog, localization);
+ }
+
+ private static RootWizardSnapshot FlashReadySnapshot(RootWizardState state) => new()
+ {
+ State = state,
+ DeviceIdentity = Identity("device-1"),
+ PackagePath = "/firmware/device.zip",
+ BootImage = new BootImageInfo
+ {
+ Path = "/work/boot.img",
+ WorkDirectory = "/work",
+ OriginalPackageName = "device.zip",
+ TargetPartition = BootPartitionTarget.Boot,
+ Source = BootImageSource.PlainZip
+ },
+ PatchedImagePath = "/work/boot-patched.img",
+ BackupPath = "/backups/boot-original.img",
+ FastbootIdentityMatch = new FastbootIdentityMatch
+ {
+ Status = FastbootIdentityMatchStatus.Verified,
+ Device = new FastbootDeviceIdentity { Serial = "device-1" },
+ Evidence = FastbootIdentityEvidence.Serial
+ },
+ BootLayout = new FastbootBootLayout
+ {
+ Kind = FastbootBootLayoutKind.Single,
+ TargetHasSlot = false
+ }
+ };
+
+ private static RootDeviceIdentity Identity(string serial) => new() { Serial = serial };
+
+ private static AdbDevice Device(
+ string serial,
+ DeviceConnectionState state = DeviceConnectionState.Online,
+ string? product = null) => new()
+ {
+ Serial = serial,
+ State = state,
+ Product = product,
+ Device = product,
+ Model = serial
+ };
+
+ private sealed record TestContext(
+ RootWizardViewModel ViewModel,
+ FakeRootWizardService Wizard,
+ FakeRootDeviceMonitor Monitor,
+ FakeFilePickerService Picker,
+ ConfigurableDialogService Dialog,
+ LanguageAwareLocalizationService Localization);
+
+ private sealed class FakeRootWizardService : IRootWizardService
+ {
+ public FakeRootWizardService(RootWizardSnapshot snapshot) => Snapshot = snapshot;
+
+ public RootWizardSnapshot Snapshot { get; private set; }
+
+ public int SelectDeviceCallCount { get; private set; }
+ public int SelectPackageCallCount { get; private set; }
+ public int ConfirmBootloaderCallCount { get; private set; }
+ public int DetectFastbootCallCount { get; private set; }
+ public int ConfirmFlashCallCount { get; private set; }
+ public int RetryCallCount { get; private set; }
+ public int CancelCallCount { get; private set; }
+ public bool LastRiskAcknowledged { get; private set; }
+ public RootWizardState ConfirmBootloaderResultState { get; set; } =
+ RootWizardState.RebootingToBootloader;
+
+ public event EventHandler? Changed;
+
+ public void Publish(RootWizardSnapshot snapshot)
+ {
+ Snapshot = snapshot;
+ Changed?.Invoke(this, snapshot);
+ }
+
+ public void SelectDevice(RootDeviceIdentity device)
+ {
+ SelectDeviceCallCount++;
+ Snapshot = Snapshot with { DeviceIdentity = device };
+ }
+
+ public void SelectPackage(string packagePath)
+ {
+ SelectPackageCallCount++;
+ Publish(Snapshot with { State = RootWizardState.PackageSelected, PackagePath = packagePath });
+ }
+
+ public Task ExtractAndPatchAsync(CancellationToken ct = default) => Task.CompletedTask;
+
+ public Task ConfirmBootloaderAsync(CancellationToken ct = default)
+ {
+ ConfirmBootloaderCallCount++;
+ Publish(Snapshot with { State = ConfirmBootloaderResultState });
+ return Task.CompletedTask;
+ }
+
+ public Task DetectFastbootAsync(CancellationToken ct = default)
+ {
+ DetectFastbootCallCount++;
+ return Task.CompletedTask;
+ }
+
+ public Task ConfirmFlashAsync(bool riskAcknowledged, CancellationToken ct = default)
+ {
+ ConfirmFlashCallCount++;
+ LastRiskAcknowledged = riskAcknowledged;
+ return Task.CompletedTask;
+ }
+
+ public Task RetryAsync(CancellationToken ct = default)
+ {
+ RetryCallCount++;
+ return Task.CompletedTask;
+ }
+
+ public Task CancelAsync()
+ {
+ CancelCallCount++;
+ Publish(Snapshot with { State = RootWizardState.Cancelled, ErrorCode = RootErrorCode.OperationCancelled });
+ return Task.CompletedTask;
+ }
+ }
+
+}
diff --git a/tests/AndroidTreeView.App.Tests/ServiceGraphTests.cs b/tests/AndroidTreeView.App.Tests/ServiceGraphTests.cs
index 80343d7..f7adb6a 100644
--- a/tests/AndroidTreeView.App.Tests/ServiceGraphTests.cs
+++ b/tests/AndroidTreeView.App.Tests/ServiceGraphTests.cs
@@ -44,6 +44,7 @@ public void ConfigureAppServices_returns_same_collection_for_chaining()
[InlineData(typeof(ILogcatService))]
[InlineData(typeof(IAdbLocator))]
[InlineData(typeof(IAdbEnvironment))]
+ [InlineData(typeof(IRootWizardService))]
public async Task Resolves_every_registered_service(Type serviceType)
{
await using var provider = BuildProvider();
@@ -67,6 +68,7 @@ public async Task Resolves_every_registered_service(Type serviceType)
[InlineData(typeof(RootStatusViewModel))]
[InlineData(typeof(LogcatViewModel))]
[InlineData(typeof(RawPropertiesViewModel))]
+ [InlineData(typeof(RootWizardViewModel))]
public async Task Resolves_every_registered_view_model(Type viewModelType)
{
await using var provider = BuildProvider();
@@ -109,8 +111,26 @@ public async Task ViewModel_singletons_are_shared_and_pages_are_transient()
provider.GetRequiredService(),
provider.GetRequiredService());
+ Assert.Same(
+ provider.GetRequiredService(),
+ provider.GetRequiredService());
+
Assert.NotSame(
provider.GetRequiredService(),
provider.GetRequiredService());
}
+
+ [Fact]
+ public async Task Root_navigation_reuses_the_singleton_wizard_state()
+ {
+ await using var provider = BuildProvider();
+ var shell = provider.GetRequiredService();
+ var rootWizard = provider.GetRequiredService();
+
+ shell.NavigateRootCommand.Execute(null);
+
+ Assert.Equal(NavSection.Root, shell.CurrentSection);
+ Assert.Same(rootWizard, shell.CurrentContent);
+ Assert.Same(rootWizard, shell.RootWizard);
+ }
}
diff --git a/tests/AndroidTreeView.Core.Tests/RootWizardServiceTests.cs b/tests/AndroidTreeView.Core.Tests/RootWizardServiceTests.cs
new file mode 100644
index 0000000..d16a8df
--- /dev/null
+++ b/tests/AndroidTreeView.Core.Tests/RootWizardServiceTests.cs
@@ -0,0 +1,638 @@
+using AndroidTreeView.Core.Exceptions;
+using AndroidTreeView.Core.Interfaces;
+using AndroidTreeView.Core.Services;
+using AndroidTreeView.Models.Rooting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace AndroidTreeView.Core.Tests;
+
+public sealed class RootWizardServiceTests : IDisposable
+{
+ private readonly string _root = Path.Combine(
+ Path.GetTempPath(),
+ "AndroidTreeView-wizard-tests",
+ Guid.NewGuid().ToString("N"));
+
+ [Fact]
+ public async Task ExtractAndPatchAsync_ExtractsThenBacksUpThenPatches()
+ {
+ var fixture = CreateFixture();
+ var states = new List();
+ fixture.Wizard.Changed += (_, snapshot) => states.Add(snapshot.State);
+
+ await fixture.Wizard.ExtractAndPatchAsync();
+
+ Assert.Equal(["extract", "backup", "patch"], fixture.Calls.Take(3));
+ Assert.Equal(RootWizardState.AwaitingBootloaderConfirm, fixture.Wizard.Snapshot.State);
+ Assert.True(File.Exists(fixture.Wizard.Snapshot.BackupPath));
+ Assert.Contains(RootWizardState.BootExtracted, states);
+ Assert.Contains(RootWizardState.BootPatched, states);
+ }
+
+ [Fact]
+ public async Task RetryAsync_AfterPatchFailureReusesExtractionAndBackup()
+ {
+ var fixture = CreateFixture();
+ fixture.Patcher.FailuresRemaining = 1;
+
+ await fixture.Wizard.ExtractAndPatchAsync();
+ Assert.Equal(RootWizardState.Failed, fixture.Wizard.Snapshot.State);
+
+ await fixture.Wizard.RetryAsync();
+
+ Assert.Equal(RootWizardState.AwaitingBootloaderConfirm, fixture.Wizard.Snapshot.State);
+ Assert.Equal(1, fixture.Extractor.CallCount);
+ Assert.Equal(1, fixture.Backup.CallCount);
+ Assert.Equal(2, fixture.Patcher.CallCount);
+ }
+
+ [Fact]
+ public async Task RetryAsync_AfterExtractionFailureUsesLockedDeviceAndPackage()
+ {
+ var fixture = CreateFixture();
+ fixture.Extractor.FailuresRemaining = 1;
+
+ await fixture.Wizard.ExtractAndPatchAsync();
+ Assert.Equal(RootWizardState.Failed, fixture.Wizard.Snapshot.State);
+ await fixture.Wizard.RetryAsync();
+
+ Assert.Equal(RootWizardState.AwaitingBootloaderConfirm, fixture.Wizard.Snapshot.State);
+ Assert.Equal(2, fixture.Extractor.CallCount);
+ Assert.Equal("ADB-1", fixture.Wizard.Snapshot.DeviceIdentity?.Serial);
+ }
+
+ [Fact]
+ public async Task ConfirmBootloaderAsync_CapturesBaselineBeforeReboot()
+ {
+ var fixture = CreateFixture();
+ await fixture.Wizard.ExtractAndPatchAsync();
+ fixture.Calls.Clear();
+
+ await fixture.Wizard.ConfirmBootloaderAsync();
+
+ Assert.Equal(["baseline", "reboot-bootloader"], fixture.Calls);
+ Assert.NotNull(fixture.Wizard.Snapshot.FastbootBaseline);
+ }
+
+ [Fact]
+ public async Task DetectFastbootAsync_InvalidStateFailsClosedWithoutFastbootProbe()
+ {
+ var fixture = CreateFixture();
+ await AdvanceToFlashAsync(fixture);
+ var waitCalls = fixture.Fastboot.WaitCalls;
+ var unlockCalls = fixture.Fastboot.UnlockCalls;
+ var layoutCalls = fixture.Fastboot.LayoutCalls;
+
+ await fixture.Wizard.DetectFastbootAsync();
+
+ Assert.Equal(RootWizardState.Failed, fixture.Wizard.Snapshot.State);
+ Assert.Equal(RootErrorCode.InvalidState, fixture.Wizard.Snapshot.ErrorCode);
+ Assert.Equal(waitCalls, fixture.Fastboot.WaitCalls);
+ Assert.Equal(unlockCalls, fixture.Fastboot.UnlockCalls);
+ Assert.Equal(layoutCalls, fixture.Fastboot.LayoutCalls);
+ }
+
+ [Theory]
+ [InlineData(RootWizardState.BlockedFastbootIdentity)]
+ [InlineData(RootWizardState.BlockedLocked)]
+ [InlineData(RootWizardState.BlockedBootLayout)]
+ public async Task DetectFastbootAsync_ExplicitSafetyBlockCanBeRechecked(RootWizardState blockedState)
+ {
+ var fixture = CreateFixture();
+ switch (blockedState)
+ {
+ case RootWizardState.BlockedFastbootIdentity:
+ fixture.Fastboot.IdentityMatch = new FastbootIdentityMatch
+ {
+ Status = FastbootIdentityMatchStatus.Unverified,
+ };
+ break;
+ case RootWizardState.BlockedLocked:
+ fixture.Fastboot.Unlocked = false;
+ break;
+ case RootWizardState.BlockedBootLayout:
+ fixture.Fastboot.Layout = new FastbootBootLayout { Kind = FastbootBootLayoutKind.Unknown };
+ break;
+ }
+
+ await AdvanceToDetectionAsync(fixture);
+ await fixture.Wizard.DetectFastbootAsync();
+ Assert.Equal(blockedState, fixture.Wizard.Snapshot.State);
+
+ fixture.Fastboot.IdentityMatch = FakeRootFastbootService.VerifiedIdentity();
+ fixture.Fastboot.Unlocked = true;
+ fixture.Fastboot.Layout = new FastbootBootLayout { Kind = FastbootBootLayoutKind.Single };
+ await fixture.Wizard.DetectFastbootAsync();
+
+ Assert.Equal(RootWizardState.AwaitingFlashConfirm, fixture.Wizard.Snapshot.State);
+ }
+
+ [Fact]
+ public async Task RetryAsync_AfterFastbootProbeFailureCanDetectAgain()
+ {
+ var fixture = CreateFixture();
+ fixture.Fastboot.WaitFailuresRemaining = 1;
+ await AdvanceToDetectionAsync(fixture);
+
+ await fixture.Wizard.DetectFastbootAsync();
+ Assert.Equal(RootWizardState.Failed, fixture.Wizard.Snapshot.State);
+
+ await fixture.Wizard.RetryAsync();
+
+ Assert.Equal(2, fixture.Fastboot.WaitCalls);
+ Assert.Equal(RootWizardState.AwaitingFlashConfirm, fixture.Wizard.Snapshot.State);
+ }
+
+ [Fact]
+ public async Task DetectFastbootAsync_UnverifiedIdentityHardBlocksAllLaterProbes()
+ {
+ var fixture = CreateFixture();
+ fixture.Fastboot.IdentityMatch = new FastbootIdentityMatch
+ {
+ Status = FastbootIdentityMatchStatus.Unverified,
+ };
+ await AdvanceToDetectionAsync(fixture);
+
+ await fixture.Wizard.DetectFastbootAsync();
+
+ Assert.Equal(RootWizardState.BlockedFastbootIdentity, fixture.Wizard.Snapshot.State);
+ Assert.Equal(0, fixture.Fastboot.UnlockCalls);
+ Assert.Equal(0, fixture.Fastboot.LayoutCalls);
+ Assert.Equal(0, fixture.Fastboot.FlashCalls);
+ }
+
+ [Theory]
+ [InlineData(false, FastbootBootLayoutKind.Single, RootWizardState.BlockedLocked)]
+ [InlineData(true, FastbootBootLayoutKind.Unknown, RootWizardState.BlockedBootLayout)]
+ public async Task DetectFastbootAsync_UnlockAndLayoutAreHardGates(
+ bool unlocked,
+ FastbootBootLayoutKind layout,
+ RootWizardState expected)
+ {
+ var fixture = CreateFixture();
+ fixture.Fastboot.Unlocked = unlocked;
+ fixture.Fastboot.Layout = new FastbootBootLayout { Kind = layout };
+ await AdvanceToDetectionAsync(fixture);
+
+ await fixture.Wizard.DetectFastbootAsync();
+
+ Assert.Equal(expected, fixture.Wizard.Snapshot.State);
+ Assert.Equal(0, fixture.Fastboot.FlashCalls);
+ }
+
+ [Fact]
+ public async Task ConfirmFlashAsync_RequiresExplicitRiskAcknowledgement()
+ {
+ var fixture = CreateFixture();
+ await AdvanceToFlashAsync(fixture);
+
+ await fixture.Wizard.ConfirmFlashAsync(riskAcknowledged: false);
+
+ Assert.Equal(RootWizardState.AwaitingFlashConfirm, fixture.Wizard.Snapshot.State);
+ Assert.Equal(RootErrorCode.RiskNotAcknowledged, fixture.Wizard.Snapshot.ErrorCode);
+ Assert.Equal(0, fixture.Fastboot.FlashCalls);
+ }
+
+ [Fact]
+ public async Task RetryAsync_PartialFlashSkipsAlreadyWrittenPartition()
+ {
+ var fixture = CreateFixture();
+ fixture.Fastboot.Layout = new FastbootBootLayout
+ {
+ Kind = FastbootBootLayoutKind.Ab,
+ SlotCount = 2,
+ TargetHasSlot = true,
+ };
+ fixture.Fastboot.Results.Enqueue(new FlashResult
+ {
+ RequestedPartitions = ["boot_a", "boot_b"],
+ SucceededPartitions = ["boot_a"],
+ FailedPartition = "boot_b",
+ Outcome = FlashOutcome.PartiallyWritten,
+ ErrorCode = RootErrorCode.FlashPartiallyWritten,
+ });
+ fixture.Fastboot.Results.Enqueue(new FlashResult
+ {
+ RequestedPartitions = ["boot_a", "boot_b"],
+ SucceededPartitions = ["boot_a", "boot_b"],
+ Outcome = FlashOutcome.Succeeded,
+ });
+ await AdvanceToFlashAsync(fixture);
+
+ await fixture.Wizard.ConfirmFlashAsync(riskAcknowledged: true);
+ Assert.Equal(RootWizardState.FailedPartialFlash, fixture.Wizard.Snapshot.State);
+ await fixture.Wizard.RetryAsync();
+
+ Assert.Equal(RootWizardState.AwaitingFlashConfirm, fixture.Wizard.Snapshot.State);
+ Assert.Equal(1, fixture.Fastboot.FlashCalls);
+ await fixture.Wizard.ConfirmFlashAsync(riskAcknowledged: true);
+
+ Assert.Equal(RootWizardState.Completed, fixture.Wizard.Snapshot.State);
+ Assert.Equal(["boot_a"], fixture.Fastboot.AlreadySucceeded[1]);
+ Assert.True(File.Exists(fixture.Wizard.Snapshot.BackupPath));
+ Assert.False(Directory.Exists(fixture.WorkDirectory));
+ }
+
+ [Fact]
+ public async Task CancelAsync_DuringFlashMarksOutcomeUnknownAndPreservesBackup()
+ {
+ var fixture = CreateFixture();
+ fixture.Fastboot.BlockFlashUntilCancellation = true;
+ await AdvanceToFlashAsync(fixture);
+
+ var flashing = fixture.Wizard.ConfirmFlashAsync(riskAcknowledged: true);
+ await fixture.Fastboot.FlashStarted.Task.WaitAsync(TimeSpan.FromSeconds(2));
+ await fixture.Wizard.CancelAsync();
+ await flashing;
+
+ Assert.Equal(RootWizardState.FlashOutcomeUnknown, fixture.Wizard.Snapshot.State);
+ Assert.Equal(FlashOutcome.Unknown, fixture.Wizard.Snapshot.FlashResult?.Outcome);
+ Assert.True(File.Exists(fixture.Wizard.Snapshot.BackupPath));
+ Assert.False(Directory.Exists(fixture.WorkDirectory));
+
+ await fixture.Wizard.RetryAsync();
+ Assert.Equal(RootWizardState.FlashOutcomeUnknown, fixture.Wizard.Snapshot.State);
+ Assert.Equal(1, fixture.Fastboot.FlashCalls);
+
+ await fixture.Wizard.CancelAsync();
+ Assert.Equal(RootWizardState.FlashOutcomeUnknown, fixture.Wizard.Snapshot.State);
+ }
+
+ [Fact]
+ public async Task RetryAsync_FailedBeforeWriteRequiresFreshConfirmation()
+ {
+ var fixture = CreateFixture();
+ fixture.Fastboot.Results.Enqueue(new FlashResult
+ {
+ RequestedPartitions = ["boot"],
+ Outcome = FlashOutcome.FailedBeforeWrite,
+ ErrorCode = RootErrorCode.FlashFailed,
+ });
+ fixture.Fastboot.Results.Enqueue(FakeRootFastbootService.Success());
+ await AdvanceToFlashAsync(fixture);
+
+ await fixture.Wizard.ConfirmFlashAsync(riskAcknowledged: true);
+ Assert.Equal(RootWizardState.Failed, fixture.Wizard.Snapshot.State);
+ Assert.Equal(RootErrorCode.FlashFailed, fixture.Wizard.Snapshot.ErrorCode);
+ await fixture.Wizard.RetryAsync();
+
+ Assert.Equal(RootWizardState.AwaitingFlashConfirm, fixture.Wizard.Snapshot.State);
+ Assert.Equal(1, fixture.Fastboot.FlashCalls);
+ await fixture.Wizard.ConfirmFlashAsync(riskAcknowledged: true);
+
+ Assert.Equal(RootWizardState.Completed, fixture.Wizard.Snapshot.State);
+ Assert.Equal(2, fixture.Fastboot.FlashCalls);
+ }
+
+ [Fact]
+ public async Task ConfirmFlashAsync_RevalidatesIdentityImmediatelyBeforeWrite()
+ {
+ var fixture = CreateFixture();
+ await AdvanceToFlashAsync(fixture);
+ fixture.Fastboot.CurrentIdentityMatch = new FastbootIdentityMatch
+ {
+ Status = FastbootIdentityMatchStatus.ConflictingEvidence,
+ Device = new FastbootDeviceIdentity { Serial = "FASTBOOT-1", Product = "other" },
+ };
+
+ await fixture.Wizard.ConfirmFlashAsync(riskAcknowledged: true);
+
+ Assert.Equal(RootWizardState.BlockedFastbootIdentity, fixture.Wizard.Snapshot.State);
+ Assert.Equal(RootErrorCode.FastbootIdentityConflict, fixture.Wizard.Snapshot.ErrorCode);
+ Assert.Equal(1, fixture.Fastboot.VerifyIdentityCalls);
+ Assert.Equal(0, fixture.Fastboot.FlashCalls);
+ }
+
+ [Fact]
+ public async Task ConfirmFlashAsync_BlocksWhenUnlockStateChanges()
+ {
+ var fixture = CreateFixture();
+ await AdvanceToFlashAsync(fixture);
+ fixture.Fastboot.Unlocked = false;
+
+ await fixture.Wizard.ConfirmFlashAsync(riskAcknowledged: true);
+
+ Assert.Equal(RootWizardState.BlockedLocked, fixture.Wizard.Snapshot.State);
+ Assert.Equal(0, fixture.Fastboot.FlashCalls);
+ }
+
+ [Fact]
+ public async Task ConfirmFlashAsync_BlocksAndPublishesChangedLayout()
+ {
+ var fixture = CreateFixture();
+ await AdvanceToFlashAsync(fixture);
+ var changedLayout = new FastbootBootLayout
+ {
+ Kind = FastbootBootLayoutKind.Ab,
+ SlotCount = 2,
+ TargetHasSlot = true,
+ };
+ fixture.Fastboot.Layout = changedLayout;
+
+ await fixture.Wizard.ConfirmFlashAsync(riskAcknowledged: true);
+
+ Assert.Equal(RootWizardState.BlockedBootLayout, fixture.Wizard.Snapshot.State);
+ Assert.Equal(RootErrorCode.BootLayoutConflict, fixture.Wizard.Snapshot.ErrorCode);
+ Assert.Equal(changedLayout, fixture.Wizard.Snapshot.BootLayout);
+ Assert.Equal(0, fixture.Fastboot.FlashCalls);
+ }
+
+ [Fact]
+ public async Task SelectionAfterSuccessfulFlashAndFailedRebootRequiresCancel()
+ {
+ var fixture = CreateFixture();
+ fixture.Fastboot.RebootFailuresRemaining = 1;
+ await AdvanceToFlashAsync(fixture);
+
+ await fixture.Wizard.ConfirmFlashAsync(riskAcknowledged: true);
+ Assert.Equal(RootWizardState.Failed, fixture.Wizard.Snapshot.State);
+ Assert.Equal(FlashOutcome.Succeeded, fixture.Wizard.Snapshot.FlashResult?.Outcome);
+
+ var exception = Assert.Throws(() =>
+ fixture.Wizard.SelectDevice(new RootDeviceIdentity { Serial = "ADB-2" }));
+ Assert.Equal(RootErrorCode.InvalidState, exception.ErrorCode);
+ Assert.Equal("ADB-1", fixture.Wizard.Snapshot.DeviceIdentity?.Serial);
+
+ await fixture.Wizard.CancelAsync();
+ fixture.Wizard.SelectDevice(new RootDeviceIdentity { Serial = "ADB-2" });
+ Assert.Equal("ADB-2", fixture.Wizard.Snapshot.DeviceIdentity?.Serial);
+ }
+
+ [Fact]
+ public async Task CancelAsync_DuringRebootPreservesSuccessfulFlashForRebootRetry()
+ {
+ var fixture = CreateFixture();
+ fixture.Fastboot.BlockRebootUntilCancellation = true;
+ await AdvanceToFlashAsync(fixture);
+
+ var flashing = fixture.Wizard.ConfirmFlashAsync(riskAcknowledged: true);
+ await fixture.Fastboot.RebootStarted.Task.WaitAsync(TimeSpan.FromSeconds(2));
+ await fixture.Wizard.CancelAsync();
+ await flashing;
+
+ Assert.Equal(RootWizardState.Failed, fixture.Wizard.Snapshot.State);
+ Assert.Equal(RootErrorCode.RebootFailed, fixture.Wizard.Snapshot.ErrorCode);
+ Assert.Equal(FlashOutcome.Succeeded, fixture.Wizard.Snapshot.FlashResult?.Outcome);
+ Assert.True(Directory.Exists(fixture.WorkDirectory));
+
+ fixture.Fastboot.BlockRebootUntilCancellation = false;
+ await fixture.Wizard.RetryAsync();
+ Assert.Equal(RootWizardState.Completed, fixture.Wizard.Snapshot.State);
+ Assert.False(Directory.Exists(fixture.WorkDirectory));
+ Assert.Equal(1, fixture.Fastboot.FlashCalls);
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(_root))
+ {
+ Directory.Delete(_root, recursive: true);
+ }
+ }
+ catch (IOException)
+ {
+ // Best-effort cleanup for transient test runner file locks.
+ }
+ }
+
+ private Fixture CreateFixture()
+ {
+ var calls = new List();
+ var work = Path.Combine(_root, "work");
+ var backups = Path.Combine(_root, "backups");
+ Directory.CreateDirectory(work);
+ var original = Path.Combine(work, "boot.img");
+ File.WriteAllBytes(original, [1, 2, 3]);
+ var image = new BootImageInfo
+ {
+ Path = original,
+ WorkDirectory = work,
+ OriginalPackageName = "firmware.zip",
+ TargetPartition = BootPartitionTarget.Boot,
+ Source = BootImageSource.PlainZip,
+ };
+ var extractor = new FakeExtractor(image, calls);
+ var backup = new FakeBackup(backups, calls);
+ var patcher = new FakePatcher(work, calls);
+ var fastboot = new FakeRootFastbootService(calls);
+ var wizard = new RootWizardService(
+ extractor,
+ backup,
+ patcher,
+ fastboot,
+ NullLogger.Instance);
+ wizard.SelectDevice(new RootDeviceIdentity { Serial = "ADB-1", Product = "pixel" });
+ wizard.SelectPackage(Path.Combine(_root, "firmware.zip"));
+ return new Fixture(wizard, extractor, backup, patcher, fastboot, calls, work);
+ }
+
+ private static async Task AdvanceToDetectionAsync(Fixture fixture)
+ {
+ await fixture.Wizard.ExtractAndPatchAsync();
+ await fixture.Wizard.ConfirmBootloaderAsync();
+ }
+
+ private static async Task AdvanceToFlashAsync(Fixture fixture)
+ {
+ await AdvanceToDetectionAsync(fixture);
+ await fixture.Wizard.DetectFastbootAsync();
+ Assert.Equal(RootWizardState.AwaitingFlashConfirm, fixture.Wizard.Snapshot.State);
+ }
+
+ private sealed record Fixture(
+ RootWizardService Wizard,
+ FakeExtractor Extractor,
+ FakeBackup Backup,
+ FakePatcher Patcher,
+ FakeRootFastbootService Fastboot,
+ List Calls,
+ string WorkDirectory);
+
+ private sealed class FakeExtractor(BootImageInfo image, List calls) : IBootImageExtractor
+ {
+ public int CallCount { get; private set; }
+ public int FailuresRemaining { get; set; }
+
+ public Task ExtractAsync(
+ string packagePath,
+ RootDeviceIdentity device,
+ CancellationToken ct = default)
+ {
+ CallCount++;
+ calls.Add("extract");
+ if (FailuresRemaining-- > 0)
+ {
+ throw new InvalidOperationException("extract failed");
+ }
+
+ return Task.FromResult(image);
+ }
+ }
+
+ private sealed class FakeBackup(string directory, List calls) : IBootBackupService
+ {
+ public int CallCount { get; private set; }
+
+ public async Task BackupAsync(
+ string sourcePath,
+ string serial,
+ BootPartitionTarget target,
+ CancellationToken ct = default)
+ {
+ CallCount++;
+ calls.Add("backup");
+ Directory.CreateDirectory(directory);
+ var path = Path.Combine(directory, $"backup-{CallCount}.img");
+ await File.WriteAllBytesAsync(path, [1, 2, 3], ct);
+ return path;
+ }
+ }
+
+ private sealed class FakePatcher(string workDirectory, List calls) : IMagiskPatcher
+ {
+ public int CallCount { get; private set; }
+ public int FailuresRemaining { get; set; }
+
+ public async Task PatchAsync(
+ string serial,
+ BootImageInfo bootImage,
+ CancellationToken ct = default)
+ {
+ CallCount++;
+ calls.Add("patch");
+ if (FailuresRemaining-- > 0)
+ {
+ throw new InvalidOperationException("patch failed");
+ }
+
+ var path = Path.Combine(workDirectory, "patched.img");
+ await File.WriteAllBytesAsync(path, [4, 5, 6], ct);
+ return path;
+ }
+ }
+
+ private sealed class FakeRootFastbootService(List calls) : IRootFastbootService
+ {
+ public string? ExecutablePath => "/tools/fastboot";
+ public bool Unlocked { get; set; } = true;
+ public FastbootBootLayout Layout { get; set; } = new() { Kind = FastbootBootLayoutKind.Single };
+ public FastbootIdentityMatch IdentityMatch { get; set; } = VerifiedIdentity();
+ public FastbootIdentityMatch? CurrentIdentityMatch { get; set; }
+ public Queue Results { get; } = new();
+ public List> AlreadySucceeded { get; } = [];
+ public int UnlockCalls { get; private set; }
+ public int LayoutCalls { get; private set; }
+ public int FlashCalls { get; private set; }
+ public int VerifyIdentityCalls { get; private set; }
+ public int WaitCalls { get; private set; }
+ public int WaitFailuresRemaining { get; set; }
+ public int RebootFailuresRemaining { get; set; }
+ public bool BlockFlashUntilCancellation { get; set; }
+ public bool BlockRebootUntilCancellation { get; set; }
+ public TaskCompletionSource FlashStarted { get; } =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public TaskCompletionSource RebootStarted { get; } =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public Task CaptureBaselineAsync(CancellationToken ct = default)
+ {
+ calls.Add("baseline");
+ return Task.FromResult(new FastbootBaseline());
+ }
+
+ public Task RebootToBootloaderAsync(string adbSerial, CancellationToken ct = default)
+ {
+ calls.Add("reboot-bootloader");
+ return Task.CompletedTask;
+ }
+
+ public Task WaitForMatchingDeviceAsync(
+ RootDeviceIdentity device,
+ FastbootBaseline baseline,
+ TimeSpan timeout,
+ CancellationToken ct = default)
+ {
+ WaitCalls++;
+ if (WaitFailuresRemaining-- > 0)
+ {
+ throw new InvalidOperationException("fastboot probe failed");
+ }
+
+ return Task.FromResult(IdentityMatch);
+ }
+
+ public Task VerifyCurrentIdentityAsync(
+ RootDeviceIdentity device,
+ string expectedSerial,
+ CancellationToken ct = default)
+ {
+ VerifyIdentityCalls++;
+ return Task.FromResult(CurrentIdentityMatch ?? IdentityMatch);
+ }
+
+ public Task IsBootloaderUnlockedAsync(string fastbootSerial, CancellationToken ct = default)
+ {
+ UnlockCalls++;
+ return Task.FromResult(Unlocked);
+ }
+
+ public Task GetBootLayoutAsync(
+ string fastbootSerial,
+ BootPartitionTarget target,
+ CancellationToken ct = default)
+ {
+ LayoutCalls++;
+ return Task.FromResult(Layout);
+ }
+
+ public async Task FlashAsync(
+ string fastbootSerial,
+ BootPartitionTarget target,
+ string imagePath,
+ FastbootBootLayout layout,
+ IReadOnlyCollection? alreadySucceededPartitions = null,
+ CancellationToken ct = default)
+ {
+ FlashCalls++;
+ AlreadySucceeded.Add(alreadySucceededPartitions?.ToArray() ?? []);
+ FlashStarted.TrySetResult(true);
+ if (BlockFlashUntilCancellation)
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, ct);
+ }
+
+ return Results.TryDequeue(out var result) ? result : Success();
+ }
+
+ public async Task RebootAsync(string fastbootSerial, CancellationToken ct = default)
+ {
+ RebootStarted.TrySetResult(true);
+ if (BlockRebootUntilCancellation)
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, ct);
+ }
+
+ if (RebootFailuresRemaining-- > 0)
+ {
+ throw new InvalidOperationException("reboot failed");
+ }
+ }
+
+ public static FlashResult Success() => new()
+ {
+ RequestedPartitions = ["boot"],
+ SucceededPartitions = ["boot"],
+ Outcome = FlashOutcome.Succeeded,
+ };
+
+ public static FastbootIdentityMatch VerifiedIdentity() => new()
+ {
+ Status = FastbootIdentityMatchStatus.Verified,
+ Device = new FastbootDeviceIdentity { Serial = "FASTBOOT-1", Product = "pixel" },
+ Evidence = FastbootIdentityEvidence.Product,
+ };
+ }
+}
diff --git a/tests/AndroidTreeView.Infrastructure.Tests/BootBackupServiceTests.cs b/tests/AndroidTreeView.Infrastructure.Tests/BootBackupServiceTests.cs
new file mode 100644
index 0000000..a07a096
--- /dev/null
+++ b/tests/AndroidTreeView.Infrastructure.Tests/BootBackupServiceTests.cs
@@ -0,0 +1,110 @@
+using AndroidTreeView.Core.Exceptions;
+using AndroidTreeView.Infrastructure.Rooting;
+using AndroidTreeView.Models.Rooting;
+using Xunit;
+
+namespace AndroidTreeView.Infrastructure.Tests;
+
+public sealed class BootBackupServiceTests : IDisposable
+{
+ private readonly string _directory = Path.Combine(
+ Path.GetTempPath(),
+ "AndroidTreeView-backup-tests",
+ Guid.NewGuid().ToString("N"));
+
+ [Fact]
+ public async Task BackupAsync_CopiesContentAndReturnsAbsoluteUniquePath()
+ {
+ var source = await CreateSourceAsync("boot.img", [1, 2, 3, 4, 5]);
+ var service = new BootBackupService(Path.Combine(_directory, "backups"));
+
+ var first = await service.BackupAsync(source, "SERIAL-1", BootPartitionTarget.Boot);
+ var second = await service.BackupAsync(source, "SERIAL-1", BootPartitionTarget.Boot);
+
+ Assert.True(Path.IsPathFullyQualified(first));
+ Assert.NotEqual(first, second);
+ Assert.Equal(await File.ReadAllBytesAsync(source), await File.ReadAllBytesAsync(first));
+ Assert.Contains("SERIAL-1-", Path.GetFileName(first), StringComparison.Ordinal);
+ Assert.Contains("-boot-", Path.GetFileName(first), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task BackupAsync_SanitizesSerialForEveryPlatform()
+ {
+ var source = await CreateSourceAsync("init_boot.img", [7, 8, 9]);
+ var service = new BootBackupService(Path.Combine(_directory, "backups"));
+
+ var backup = await service.BackupAsync(source, " ../USB:123\\name ", BootPartitionTarget.InitBoot);
+
+ var fileName = Path.GetFileName(backup);
+ Assert.StartsWith("_USB_123_name-", fileName, StringComparison.Ordinal);
+ Assert.Contains("-init_boot-", fileName, StringComparison.Ordinal);
+ Assert.DoesNotContain("..", fileName, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task BackupAsync_MissingSourceThrowsStableErrorCode()
+ {
+ var service = new BootBackupService(Path.Combine(_directory, "backups"));
+
+ var exception = await Assert.ThrowsAsync(() =>
+ service.BackupAsync(
+ Path.Combine(_directory, "missing.img"),
+ "SERIAL",
+ BootPartitionTarget.Boot));
+
+ Assert.Equal(RootErrorCode.BackupSourceMissing, exception.ErrorCode);
+ }
+
+ [Fact]
+ public async Task BackupAsync_PreCancelledDoesNotLeaveFiles()
+ {
+ var source = await CreateSourceAsync("boot.img", new byte[1024]);
+ var backupDirectory = Path.Combine(_directory, "backups");
+ var service = new BootBackupService(backupDirectory);
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+
+ await Assert.ThrowsAnyAsync(() =>
+ service.BackupAsync(source, "SERIAL", BootPartitionTarget.Boot, cancellation.Token));
+
+ Assert.False(Directory.Exists(backupDirectory));
+ }
+
+ [Fact]
+ public async Task BackupAsync_UnknownTargetDoesNotCopy()
+ {
+ var source = await CreateSourceAsync("boot.img", [1]);
+ var backupDirectory = Path.Combine(_directory, "backups");
+ var service = new BootBackupService(backupDirectory);
+
+ var exception = await Assert.ThrowsAsync(() =>
+ service.BackupAsync(source, "SERIAL", BootPartitionTarget.Unknown));
+
+ Assert.Equal(RootErrorCode.TargetPartitionUnknown, exception.ErrorCode);
+ Assert.False(Directory.Exists(backupDirectory));
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(_directory))
+ {
+ Directory.Delete(_directory, recursive: true);
+ }
+ }
+ catch (IOException)
+ {
+ // Best-effort cleanup for transient test runner file locks.
+ }
+ }
+
+ private async Task CreateSourceAsync(string fileName, byte[] content)
+ {
+ Directory.CreateDirectory(_directory);
+ var path = Path.Combine(_directory, fileName);
+ await File.WriteAllBytesAsync(path, content);
+ return path;
+ }
+}
diff --git a/tests/AndroidTreeView.Infrastructure.Tests/GitHubUpdateServiceTests.cs b/tests/AndroidTreeView.Infrastructure.Tests/GitHubUpdateServiceTests.cs
new file mode 100644
index 0000000..1731745
--- /dev/null
+++ b/tests/AndroidTreeView.Infrastructure.Tests/GitHubUpdateServiceTests.cs
@@ -0,0 +1,191 @@
+using System.Net;
+using System.Reflection;
+using System.Text;
+using AndroidTreeView.Core;
+using AndroidTreeView.Core.Options;
+using AndroidTreeView.Infrastructure.Update;
+using AndroidTreeView.Models;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace AndroidTreeView.Infrastructure.Tests;
+
+public sealed class GitHubUpdateServiceTests
+{
+ private static GitHubUpdateService CreateService(
+ FakeHttpMessageHandler handler,
+ out FakeSettingsService settings,
+ string version = "1.0.6",
+ string? rid = "win-x64",
+ string assetPrefix = "AndroidTreeView")
+ {
+ settings = new FakeSettingsService();
+ var product = new UpdateProductOptions
+ {
+ Version = version,
+ AppKey = assetPrefix == "AndroidTreeView-Mini" ? AppInfo.MiniUpdateKey : AppInfo.AppUpdateKey,
+ LatestReleaseApiUrl = AppInfo.LatestReleaseApiUrl,
+ ReleasesUrl = AppInfo.ReleasesUrl,
+ ReleaseAssetPrefix = assetPrefix,
+ ReleaseAssetRid = rid,
+ };
+ return new GitHubUpdateService(
+ new HttpClient(handler),
+ settings,
+ NullLogger.Instance,
+ product);
+ }
+
+ private static HttpResponseMessage JsonResponse(HttpStatusCode status, string body) =>
+ new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
+
+ private static string ReleaseJson(string tag, string prefix = "AndroidTreeView") =>
+ $$"""
+ {
+ "tag_name": "{{tag}}",
+ "html_url": "https://github.com/Birditch/AndroidTreeView/releases/tag/{{tag}}",
+ "body": "Release notes for {{tag}}.",
+ "assets": [
+ {
+ "name": "{{prefix}}-2.5.1-win-x64.zip",
+ "browser_download_url": "https://github.com/Birditch/AndroidTreeView/releases/download/v2.5.1/{{prefix}}-2.5.1-win-x64.zip"
+ },
+ {
+ "name": "{{prefix}}-2.5.1-win-x64.zip.sha256",
+ "browser_download_url": "https://github.com/Birditch/AndroidTreeView/releases/download/v2.5.1/{{prefix}}-2.5.1-win-x64.zip.sha256"
+ }
+ ]
+ }
+ """;
+
+ [Fact]
+ public void Version_ComesFromAssemblyInformationalVersion()
+ {
+ var assembly = typeof(AppInfo).Assembly;
+ var expected = assembly.GetCustomAttribute()!.InformationalVersion;
+ Assert.Equal(expected.Split('+')[0], AppInfo.GetVersion(assembly));
+ }
+
+ [Fact]
+ public async Task CheckForUpdates_SameVersion_ReturnsUpToDate()
+ {
+ var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, ReleaseJson("v1.0.6")));
+ var service = CreateService(handler, out _);
+
+ var result = await service.CheckForUpdatesAsync(userInitiated: true);
+
+ Assert.Equal(UpdateCheckStatus.UpToDate, result.Status);
+ Assert.False(result.UpdateAvailable);
+ Assert.Equal("1.0.6", result.LatestVersion);
+ }
+
+ [Fact]
+ public async Task CheckForUpdates_NewerVersion_UsesGitHubReleaseAssets()
+ {
+ var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, ReleaseJson("v2.5.1")));
+ var service = CreateService(handler, out _);
+
+ var result = await service.CheckForUpdatesAsync(userInitiated: true);
+
+ Assert.Equal(UpdateCheckStatus.UpdateAvailable, result.Status);
+ Assert.True(result.UpdateAvailable);
+ Assert.Equal("2.5.1", result.LatestVersion);
+ Assert.Equal("https://github.com/Birditch/AndroidTreeView/releases/tag/v2.5.1", result.ReleaseUrl);
+ Assert.Equal("https://github.com/Birditch/AndroidTreeView/releases/download/v2.5.1/AndroidTreeView-2.5.1-win-x64.zip", result.DownloadUrl);
+ Assert.Equal("https://github.com/Birditch/AndroidTreeView/releases/download/v2.5.1/AndroidTreeView-2.5.1-win-x64.zip.sha256", result.Sha256Url);
+ Assert.Equal("Release notes for v2.5.1.", result.ReleaseNotes);
+ }
+
+ [Fact]
+ public async Task CheckForUpdates_Mini_UsesMiniAsset()
+ {
+ var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, ReleaseJson("v2.5.1", "AndroidTreeView-Mini")));
+ var service = CreateService(handler, out _, assetPrefix: "AndroidTreeView-Mini");
+
+ var result = await service.CheckForUpdatesAsync(userInitiated: true);
+
+ Assert.Contains("AndroidTreeView-Mini-2.5.1-win-x64.zip", result.DownloadUrl);
+ }
+
+ [Fact]
+ public async Task CheckForUpdates_NonWindows_LeavesInstallDisabled()
+ {
+ var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, ReleaseJson("v2.5.1")));
+ var service = CreateService(handler, out _, rid: null);
+
+ var result = await service.CheckForUpdatesAsync(userInitiated: true);
+
+ Assert.True(result.UpdateAvailable);
+ Assert.Null(result.DownloadUrl);
+ Assert.Equal("https://github.com/Birditch/AndroidTreeView/releases/tag/v2.5.1", result.ReleaseUrl);
+ }
+
+ [Fact]
+ public async Task CheckForUpdates_SendsRequiredGitHubHeaders()
+ {
+ var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, ReleaseJson("v1.0.6")));
+ var service = CreateService(handler, out _);
+
+ await service.CheckForUpdatesAsync(userInitiated: true);
+
+ Assert.NotNull(handler.LastRequest);
+ Assert.True(handler.LastRequest!.Headers.UserAgent.Count > 0);
+ Assert.Contains(handler.LastRequest.Headers.Accept, h => h.MediaType == "application/vnd.github+json");
+ Assert.Equal(AppInfo.LatestReleaseApiUrl, handler.LastRequest.RequestUri!.ToString());
+ }
+
+ [Theory]
+ [InlineData(HttpStatusCode.NotFound, UpdateCheckStatus.NoRelease)]
+ [InlineData(HttpStatusCode.Forbidden, UpdateCheckStatus.RateLimited)]
+ [InlineData(HttpStatusCode.InternalServerError, UpdateCheckStatus.NetworkError)]
+ public async Task CheckForUpdates_HttpFailure_ReturnsExpectedStatus(HttpStatusCode status, UpdateCheckStatus expected)
+ {
+ var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(status));
+ var service = CreateService(handler, out _);
+
+ var result = await service.CheckForUpdatesAsync(userInitiated: true);
+
+ Assert.Equal(expected, result.Status);
+ Assert.False(result.UpdateAvailable);
+ }
+
+ [Fact]
+ public async Task CheckForUpdates_InvalidTag_ReturnsInvalidData()
+ {
+ var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, ReleaseJson("not-a-version")));
+ var service = CreateService(handler, out _);
+
+ var result = await service.CheckForUpdatesAsync(userInitiated: true);
+
+ Assert.Equal(UpdateCheckStatus.InvalidData, result.Status);
+ }
+
+ [Fact]
+ public async Task CheckForUpdates_MalformedJson_ReturnsInvalidData()
+ {
+ var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, "{ broken"));
+ var service = CreateService(handler, out _);
+
+ var result = await service.CheckForUpdatesAsync(userInitiated: true);
+
+ Assert.Equal(UpdateCheckStatus.InvalidData, result.Status);
+ }
+
+ [Fact]
+ public async Task CheckForUpdates_DisabledAndNotUserInitiated_SkipsRequest()
+ {
+ var called = false;
+ var handler = new FakeHttpMessageHandler(_ =>
+ {
+ called = true;
+ return JsonResponse(HttpStatusCode.OK, ReleaseJson("v2.5.1"));
+ });
+ var service = CreateService(handler, out var settings);
+ settings.Current = new() { AutoCheckUpdates = false };
+
+ var result = await service.CheckForUpdatesAsync(userInitiated: false);
+
+ Assert.Equal(UpdateCheckStatus.Disabled, result.Status);
+ Assert.False(called);
+ }
+}
diff --git a/tests/AndroidTreeView.Infrastructure.Tests/NekoIndexUpdateServiceTests.cs b/tests/AndroidTreeView.Infrastructure.Tests/NekoIndexUpdateServiceTests.cs
deleted file mode 100644
index e0ecca1..0000000
--- a/tests/AndroidTreeView.Infrastructure.Tests/NekoIndexUpdateServiceTests.cs
+++ /dev/null
@@ -1,233 +0,0 @@
-using System.Net;
-using System.Text;
-using AndroidTreeView.Core;
-using AndroidTreeView.Core.Options;
-using AndroidTreeView.Infrastructure.Update;
-using AndroidTreeView.Models;
-using Microsoft.Extensions.Logging.Abstractions;
-using Xunit;
-
-namespace AndroidTreeView.Infrastructure.Tests;
-
-public sealed class NekoIndexUpdateServiceTests
-{
- private static NekoIndexUpdateService CreateService(
- FakeHttpMessageHandler handler,
- out FakeSettingsService settings)
- {
- settings = new FakeSettingsService();
- var client = new HttpClient(handler);
- return new NekoIndexUpdateService(client, settings, NullLogger.Instance);
- }
-
- private static HttpResponseMessage JsonResponse(HttpStatusCode status, string body) =>
- new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
-
- private static string UpdateJson(
- string version,
- string downloadUrl = "/api/resources/android-tree-view-app/versions/latest/archive",
- string sha256 = "0123456789abcdef",
- string appKey = AppInfo.AppUpdateKey)
- => $$"""
- {
- "ok": true,
- "data": {
- "appKey": "{{appKey}}",
- "title": "AndroidTreeView",
- "version": "{{version}}",
- "releasedAt": "2026-07-08T07:40:04.829Z",
- "zip": {
- "size": 1344,
- "sha256": "{{sha256}}",
- "downloadUrl": "{{downloadUrl}}"
- },
- "files": []
- },
- "error": null
- }
- """;
-
- [Fact]
- public void CurrentVersion_MatchesAppInfo()
- {
- var service = CreateService(new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, UpdateJson("1.0.0"))), out _);
- Assert.Equal(AppInfo.Version, service.CurrentVersion);
- }
-
- [Fact]
- public async Task CheckForUpdates_SameVersion_ReturnsUpToDate()
- {
- var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, UpdateJson(AppInfo.Version)));
- var service = CreateService(handler, out _);
-
- var result = await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(UpdateCheckStatus.UpToDate, result.Status);
- Assert.False(result.UpdateAvailable);
- Assert.Equal(AppInfo.Version, result.LatestVersion);
- }
-
- [Fact]
- public async Task CheckForUpdates_NewerVersion_ReturnsUpdateAvailable()
- {
- var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, UpdateJson("v2.5.1")));
- var service = CreateService(handler, out _);
-
- var result = await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(UpdateCheckStatus.UpdateAvailable, result.Status);
- Assert.True(result.UpdateAvailable);
- Assert.Equal("2.5.1", result.LatestVersion);
- Assert.Equal("http://192.168.89.71:14000/api/resources/android-tree-view-app/versions/latest/archive", result.ReleaseUrl);
- Assert.Equal(result.ReleaseUrl, result.DownloadUrl);
- Assert.Equal("0123456789abcdef", result.Sha256);
- Assert.Equal("AndroidTreeView", result.ReleaseNotes);
- }
-
- [Fact]
- public async Task CheckForUpdates_SendsRequiredInternalApiHeaders()
- {
- var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, UpdateJson("1.0.0")));
- var service = CreateService(handler, out _);
-
- await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.NotNull(handler.LastRequest);
- Assert.True(handler.LastRequest!.Headers.UserAgent.Count > 0);
- Assert.Contains(handler.LastRequest.Headers.Accept, h => h.MediaType == "application/json");
- Assert.Equal(AppInfo.LatestReleaseApiUrl, handler.LastRequest.RequestUri!.ToString());
- }
-
- [Fact]
- public async Task CheckForUpdates_UsesConfiguredMiniUpdateChannel()
- {
- var handler = new FakeHttpMessageHandler(_ => JsonResponse(
- HttpStatusCode.OK,
- UpdateJson("2.0.0", appKey: AppInfo.MiniUpdateKey)));
- var settings = new FakeSettingsService();
- var product = UpdateProductOptions.ForMiniApp();
- var service = new NekoIndexUpdateService(
- new HttpClient(handler),
- settings,
- NullLogger.Instance,
- product);
-
- await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(product.LatestReleaseApiUrl, handler.LastRequest!.RequestUri!.ToString());
- }
-
- [Fact]
- public async Task CheckForUpdates_MismatchedAppKey_ReturnsInvalidData()
- {
- var handler = new FakeHttpMessageHandler(_ => JsonResponse(
- HttpStatusCode.OK,
- UpdateJson("2.0.0", appKey: AppInfo.MiniUpdateKey)));
- var service = CreateService(handler, out _);
-
- var result = await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(UpdateCheckStatus.InvalidData, result.Status);
- Assert.False(result.UpdateAvailable);
- }
-
- [Fact]
- public async Task CheckForUpdates_Forbidden_ReturnsRateLimited()
- {
- var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.Forbidden));
- var service = CreateService(handler, out _);
-
- var result = await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(UpdateCheckStatus.RateLimited, result.Status);
- Assert.False(result.UpdateAvailable);
- }
-
- [Fact]
- public async Task CheckForUpdates_NotFound_ReturnsNoRelease()
- {
- var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
- var service = CreateService(handler, out _);
-
- var result = await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(UpdateCheckStatus.NoRelease, result.Status);
- }
-
- [Fact]
- public async Task CheckForUpdates_NetworkFailure_ReturnsNetworkError()
- {
- var handler = new FakeHttpMessageHandler(_ => throw new HttpRequestException("connection refused"));
- var service = CreateService(handler, out _);
-
- var result = await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(UpdateCheckStatus.NetworkError, result.Status);
- }
-
- [Fact]
- public async Task CheckForUpdates_InvalidVersion_ReturnsInvalidData()
- {
- var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, UpdateJson("not-a-version")));
- var service = CreateService(handler, out _);
-
- var result = await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(UpdateCheckStatus.InvalidData, result.Status);
- Assert.False(result.UpdateAvailable);
- }
-
- [Fact]
- public async Task CheckForUpdates_OkFalse_ReturnsInvalidData()
- {
- var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, """{"ok":false,"data":null,"error":"missing"}"""));
- var service = CreateService(handler, out _);
-
- var result = await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(UpdateCheckStatus.InvalidData, result.Status);
- Assert.Equal("missing", result.ErrorMessage);
- }
-
- [Fact]
- public async Task CheckForUpdates_MalformedJson_ReturnsInvalidData()
- {
- var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, "{ broken"));
- var service = CreateService(handler, out _);
-
- var result = await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(UpdateCheckStatus.InvalidData, result.Status);
- }
-
- [Fact]
- public async Task CheckForUpdates_DisabledAndNotUserInitiated_ReturnsDisabledWithoutHttpCall()
- {
- var called = false;
- var handler = new FakeHttpMessageHandler(_ =>
- {
- called = true;
- return JsonResponse(HttpStatusCode.OK, UpdateJson("9.9.9"));
- });
- var service = CreateService(handler, out var settings);
- settings.Current = new AppSettings { AutoCheckUpdates = false };
-
- var result = await service.CheckForUpdatesAsync(userInitiated: false);
-
- Assert.Equal(UpdateCheckStatus.Disabled, result.Status);
- Assert.Equal(AppInfo.Version, result.CurrentVersion);
- Assert.False(called);
- }
-
- [Fact]
- public async Task CheckForUpdates_DisabledButUserInitiated_StillChecks()
- {
- var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, UpdateJson("3.0.0")));
- var service = CreateService(handler, out var settings);
- settings.Current = new AppSettings { AutoCheckUpdates = false };
-
- var result = await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(UpdateCheckStatus.UpdateAvailable, result.Status);
- }
-}
diff --git a/tools/verify-roottools-latest.ps1 b/tools/verify-roottools-latest.ps1
new file mode 100644
index 0000000..881e9af
--- /dev/null
+++ b/tools/verify-roottools-latest.ps1
@@ -0,0 +1,85 @@
+#Requires -Version 5.1
+<#
+.SYNOPSIS
+ Verifies the pinned Root tool releases, assets, and payload-dumper checksums.
+
+.DESCRIPTION
+ This is a metadata-only release check used by GitHub Actions. It does not
+ download the APK or binary archives. When a local Root tools directory is
+ supplied, the pinned Magisk APK hash is checked as well.
+#>
+[CmdletBinding()]
+param(
+ [string]$RootToolsDir = '',
+ [string]$MagiskVersion = '30.7',
+ [string]$MagiskSha256 = 'e0d32d2123532860f97123d927b1bb86c4e08e6fd8a48bfc6b5bee0afae9ebd5',
+ [string]$PayloadDumperVersion = '1.3.0',
+ [string]$PayloadDumperWindowsSha256 = '0f96e07477963327f7f50a03bf2aa9dac5c76dba110ab332dc759321ae345d52',
+ [string]$PayloadDumperMacSha256 = 'e6b95df4b08e4bf452077e35cc2c0d644ce8fd454696d1aceedde6887ef0df84'
+)
+
+$ErrorActionPreference = 'Stop'
+
+$headers = @{
+ 'User-Agent' = 'AndroidTreeView-release-check'
+ 'Accept' = 'application/vnd.github+json'
+}
+
+$magiskAsset = "Magisk-v$MagiskVersion.apk"
+$magiskRelease = Invoke-RestMethod `
+ -Uri "https://api.github.com/repos/topjohnwu/Magisk/releases/tags/v$MagiskVersion" `
+ -Headers $headers
+$magiskAssets = @($magiskRelease.assets | ForEach-Object { [string]$_.name })
+if ($magiskAssets -notcontains $magiskAsset) {
+ throw "Magisk v$MagiskVersion does not contain required asset '$magiskAsset'."
+}
+
+$payloadAssets = @(
+ "payload-dumper-go_${PayloadDumperVersion}_windows_amd64.tar.gz",
+ "payload-dumper-go_${PayloadDumperVersion}_darwin_arm64.tar.gz",
+ 'payload-dumper-go_sha256checksums.txt'
+)
+$payloadRelease = Invoke-RestMethod `
+ -Uri "https://api.github.com/repos/ssut/payload-dumper-go/releases/tags/$PayloadDumperVersion" `
+ -Headers $headers
+$payloadAssetNames = @($payloadRelease.assets | ForEach-Object { [string]$_.name })
+foreach ($asset in $payloadAssets) {
+ if ($payloadAssetNames -notcontains $asset) {
+ throw "payload-dumper-go $PayloadDumperVersion does not contain required asset '$asset'."
+ }
+}
+
+$checksums = Invoke-RestMethod `
+ -Uri "https://github.com/ssut/payload-dumper-go/releases/download/$PayloadDumperVersion/payload-dumper-go_sha256checksums.txt" `
+ -Headers $headers
+$expectedChecksums = @{
+ $payloadAssets[0] = $PayloadDumperWindowsSha256
+ $payloadAssets[1] = $PayloadDumperMacSha256
+}
+foreach ($entry in $expectedChecksums.GetEnumerator()) {
+ $escapedName = [regex]::Escape($entry.Key)
+ $match = [regex]::Match([string]$checksums, "(?im)^([a-f0-9]{64})\s+\*?$escapedName\s*$")
+ if (-not $match.Success) {
+ throw "Upstream checksum list does not contain '$($entry.Key)'."
+ }
+
+ $actual = $match.Groups[1].Value.ToLowerInvariant()
+ if ($actual -ne $entry.Value.ToLowerInvariant()) {
+ throw "Pinned SHA-256 for '$($entry.Key)' is '$($entry.Value)', upstream publishes '$actual'."
+ }
+}
+
+if (-not [string]::IsNullOrWhiteSpace($RootToolsDir)) {
+ $magiskPath = Join-Path (Join-Path $RootToolsDir 'magisk') $magiskAsset
+ if (-not (Test-Path -LiteralPath $magiskPath)) {
+ throw "Local Root tools directory is missing '$magiskPath'."
+ }
+
+ $actualMagiskSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $magiskPath).Hash.ToLowerInvariant()
+ if ($actualMagiskSha256 -ne $MagiskSha256.ToLowerInvariant()) {
+ throw "Local Magisk APK SHA-256 is '$actualMagiskSha256', expected '$MagiskSha256'."
+ }
+}
+
+Write-Host "Verified Magisk v$MagiskVersion asset '$magiskAsset'."
+Write-Host "Verified payload-dumper-go $PayloadDumperVersion assets and upstream SHA-256 values."
diff --git a/tools/verify-scrcpy-latest.ps1 b/tools/verify-scrcpy-latest.ps1
index 88a6b2a..ddb5972 100644
--- a/tools/verify-scrcpy-latest.ps1
+++ b/tools/verify-scrcpy-latest.ps1
@@ -13,7 +13,7 @@
[CmdletBinding()]
param(
[string]$ScrcpyExe = '',
- [string]$ExpectedVersion = '4.0',
+ [string]$ExpectedVersion = '4.1',
[string]$LatestReleaseApi = 'https://api.github.com/repos/Genymobile/scrcpy/releases/latest'
)