Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
172 changes: 172 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 27 additions & 8 deletions README-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ AndroidTreeView 是一个用于 Android 设备巡检、测试与管理的桌面

当前发布请查看上方 Release 徽章或 [GitHub Releases](https://github.com/Birditch/AndroidTreeView/releases/latest)。运行时版本、目标框架和打包配置以项目文件与发布工作流为准。

## 产品样式展示

![AndroidTreeView 设备总览](docs/images/product-devices-v1.0.5.png)

## 功能

- 设备卡片总览与详情页。
Expand All @@ -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`。
Expand All @@ -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
Expand All @@ -65,13 +91,6 @@ dotnet build src/AndroidTreeView.Mini.Mac/AndroidTreeView.Mini.Mac.csproj --no-r
dotnet test AndroidTreeView.sln --no-restore
```

## 许可证与致谢

<p>
<a href="LICENSE"><img alt="MIT License" src="https://img.shields.io/badge/license-MIT-0E7A5F.svg"></a>
<a href="https://www.jetbrains.com/rider/"><img alt="JetBrains Rider" src="https://img.shields.io/badge/JetBrains-Rider-000000.svg?logo=jetbrains&logoColor=white"></a>
<a href="https://dotnet.microsoft.com/"><img alt=".NET SDK" src="https://img.shields.io/badge/.NET-SDK-512BD4.svg?logo=dotnet&logoColor=white"></a>
<a href="https://avaloniaui.net/"><img alt="Avalonia UI" src="https://img.shields.io/badge/Avalonia-UI-663399.svg"></a>
</p>
## 许可证

AndroidTreeView 基于 [MIT License](LICENSE) 开源。
Loading