From 089fc9a3608af7d6ff34eb7303138caf4fa9ffc7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:23:20 +0000 Subject: [PATCH 1/6] Initial plan From 480f6d4b93e2e53eb653cdd2c537facda55f7a7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:29:30 +0000 Subject: [PATCH 2/6] Document source-based update lifecycle and production readiness review Co-authored-by: JusterZhu <11714536+JusterZhu@users.noreply.github.com> --- README-EN.md | 151 ++++++++++++++++++++++++++++++++++++++++++++++++++- README.md | 25 +++++++++ 2 files changed, 173 insertions(+), 3 deletions(-) diff --git a/README-EN.md b/README-EN.md index c98cb1a..7696519 100644 --- a/README-EN.md +++ b/README-EN.md @@ -11,7 +11,7 @@ ## Introduction -`GeneralUpdate.Avalonia` is a repository focused on update capabilities for Avalonia applications. Its current core module, `GeneralUpdate.Avalonia.Android`, provides a UI-free Android auto-update pipeline targeting `net8.0-android` for Avalonia 12+ apps. +`GeneralUpdate.Avalonia` is a repository focused on update capabilities for Avalonia applications. Its current core module, `GeneralUpdate.Avalonia.Android`, provides a UI-free Android auto-update pipeline targeting `net10.0-android` for Avalonia 12+ apps. The project uses composable abstractions so you can replace version comparison, downloading, hash validation, installer launching, logging, and event dispatching based on your application architecture. @@ -27,8 +27,8 @@ The project uses composable abstractions so you can replace version comparison, ### Prerequisites -- .NET SDK: `8.0+` -- Platform: `Android (net8.0-android)` +- .NET SDK: `10.0+` +- Platform: `Android (net10.0-android, API 26+)` - Avalonia: `12+` - Git: `2.30+` @@ -172,6 +172,151 @@ configures all four items below: finished installing. The process is killed on completion, so compare the installed version with the server again on the next launch to confirm the update actually took effect. +## Source Review and Production Readiness + +### Scope and evidence + +Review for issue #18, dated **2026-09-19**, against commit +[`c3d8751`](https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/commit/c3d87519de8fb97d3bebd1b1b0177b33cf0047e3). +Source links below are pinned to that revision; findings are not claims about other GeneralUpdate repositories or future releases. +This checkout contains an **Android-only, UI-free library**, not an Avalonia desktop updater. The [project target and dependencies][review-project] +specify `net10.0-android`, API 26+, AndroidX Core and build-time SourceLink; there is no Avalonia or GeneralUpdate.Core package reference. +References to GeneralUpdate.Core describe matching API semantics, not delegation to its implementation. + +**Verdict: not ready as a turnkey, cross-platform, closed-loop production updater.** +It is a useful foundation for a controlled Android integration, provided the host addresses the defects and release gates below. +This is an assessment, not a runtime remediation: the findings remain open. + +Classification: **bug** = source-supported incorrect behavior under the stated trigger; +**architecture risk** = missing guarantee or integration responsibility; +**experience improvement** = documentation, API or operational usability. +P1 means address before production in affected deployments; P2 means planned hardening. These priorities are not CVSS scores. + +### 1. Update process closed-loop + +| Stage | Implemented behavior and evidence | Breakpoint / recovery assessment | +|---|---|---| +| Version check | [Bootstrap, lines 73–215][review-bootstrap] queries the configured server, compares versions, and invokes the pre-check only for a newer non-forced update. [Package client][review-client] supports verification POST or JSON GET, filters full APKs in the POST protocol and validates metadata. | Request/protocol failures become failed results; request cancellation becomes `Canceled`. No automatic check retry or persisted check state. Forced updates only bypass pre-check; they do not force Android installation. | +| Download | [Downloader, lines 75–188 and 329–393][review-downloader] streams to `.part`, keeps a JSON sidecar, compares URL/hash/size/ETag/Last-Modified, and resumes using Range. A server returning 200 to a Range request restarts from zero. | Interrupted partial files are intentionally retained for a later call, not automatically resumed in the background. Missing/corrupt/inconsistent sidecars restart the download. Retry does not cover the GET/body (B1). A 416 response is returned as a network error without resetting the partial file; a repeated attempt can hit the same breakpoint. No `If-Range` or `Content-Range` validation; final SHA-256 still rejects incorrect bytes. | +| Verification | [Bootstrap, lines 236–285][review-bootstrap] checks size when supplied, then SHA-256; rejected files are deleted. [Validator, lines 37–72][review-hash] streams the file and disposes the hash and stream. | Detects corruption against the supplied digest, not independent publisher authenticity. Hash cancellation and cleanup failures can escape without a terminal snapshot/event (B2). There is no automatic retry after a corrupted package. | +| Installation | [Installer, lines 28–128][review-installer] checks authority, file presence, context and install permission, then grants read access through FileProvider and starts the Android installer. | Missing permission returns `InstallPermissionDenied`; URI/intent launch exceptions return `InstallLaunchFailed`. The host must obtain permission and retry. User rejection, signing mismatch, invalid APK contents and actual install completion are outside the returned result. | +| Restart and rollback | [Bootstrap, lines 293–320][review-bootstrap] remains in `Installing` after a successful handoff. It has no installed-version confirmation, relaunch, backup or rollback phase. | `AddListenerUpdateCompleted` also fires at `ReadyToInstall` and installer handoff: **neither means installation succeeded**. Persist the intended version in the host and reconcile it on the next launch. Plan recovery for a correctly signed but broken release and incompatible data migrations; Android installation checks are not an application-health rollback mechanism. | + +Network failures, disk-full and permission failures are not equivalent: the downloader maps `IOException` to `FileIoError`, +but `UnauthorizedAccessException` falls into `Unknown`. There is no free-space preflight or cache-retention policy. +Keep one coordinator per download directory and define bounded retry, user-visible recovery and cleanup policies. +Do not delete a verified APK while the external installer may still need it. + +### 2. Developer-friendliness + +- **Strengths:** the [module guide][review-guide] includes API/result tables, pre-check examples, manifest permission, + FileProvider XML and Activity-provider guidance. Three explicit async operations make progress and error presentation host-controlled. +- **Experience improvement — samples:** no runnable Avalonia host/sample is included. Add a minimal Android MVVM sample covering + permission return, cancellation, UI dispatch, subscription cleanup and next-launch version reconciliation; a code snippet alone + does not validate those integration steps. +- **API clarity:** `true` from pre-check means *skip*, and forced updates bypass it. `Completed` events describe a stage, not the + whole update. Cancellation sometimes returns a result and sometimes throws. Document these contracts together; consider + distinct package-ready and installer-launched notifications and a consistent cancellation contract. +- **UI customization:** there is no built-in UI to theme or replace, so customization is unrestricted but entirely the host's work. + The default dispatcher is not an Avalonia dispatcher (A1 below). Numeric versions use + [`System.Version`, not SemVer prerelease ordering][review-versions]; inject `IVersionComparer` when needed. +- **Cross-platform cost:** Android requires the workload, API 26+, installation permission, FileProvider paths and Activity lifecycle + handling. Windows/macOS/Linux need separate installers and orchestration; passing core tests on Linux is not desktop-update support. + The stale .NET 8 prerequisites in this English README are corrected by this review. +- **Distribution metadata defect:** [package metadata declares MIT][review-props], while the [repository license is Apache-2.0][review-license]. + The maintainer should reconcile the intended license before distributing a production package; this review does not choose a license. + +### 3. Potential bugs and security risks + +The following are source-confirmed findings, not failures reproduced by the existing test suite. + +| ID / priority / classification | Trigger, evidence and impact | Suggested correction / focused regression | +|---|---|---| +| B1 / P1 / reliability bug | `MaxRetryAttempts` suggests transient download retries, but [the only `WithRetryAsync` call wraps HEAD; GET/body are outside it][review-downloader] (101–123, 288–327). HEAD error status codes are converted to a tuple instead of thrown (239–247), and ordinary connection exceptions with no HTTP status are not considered transient. A transient GET/503 therefore fails immediately. | Define which requests/statuses retry, recreate each request, and resume safely after a body interruption. Test GET 503→success, a dropped stream and retry exhaustion. The catch filter does correctly propagate an exhausted exception; there is no infinite-retry-loop finding. | +| B2 / P1 / state/error-handling bug | [Hash cancellation is rethrown][review-hash] (57–59), while [download/verify orchestration only has `finally`][review-bootstrap] (217–290). Cancel during hashing and the snapshot remains `Verifying`, with no failure event. A size lookup or rejected-file deletion throwing also escapes and leaves a stale stage. | Normalize cancellation and storage failures at the orchestration boundary without hiding the original failure. Test hash cancellation and denied cleanup; assert terminal state, one notification and gate release. | +| B3 / P2 / resource ownership bug | With neither `httpClient` nor `httpOptions`, [the factory creates a download client][review-factory] (47–50), but [the receiving constructor marks it externally owned][review-downloader] (29–38) and disposal only closes owned clients (450–455). Disposing that default bootstrap never disposes this client/handler. | Track ownership of factory-created clients; continue leaving genuinely injected clients host-owned. Test repeated factory create/dispose and handler disposal. | +| B4 / P1 / lifecycle race bug | [Dispose destroys the semaphore immediately][review-bootstrap] (382–397), but in-flight methods still release it in `finally` (287–290, 316–319). Disposing during an operation can cause `ObjectDisposedException` and mask its outcome. | Until coordinated shutdown exists, cancel and await operations before disposal. Add an asynchronous shutdown/drain contract and a dispose-during-download regression. | +| B5 / P2 / diagnostic bug | [All download `OperationCanceledException`s map to user cancellation][review-downloader] (83–103, 190–199), including internally configured HEAD/download timeout expiry. This differs from version-check timeout handling, which reports `NetworkError`. | Distinguish caller cancellation from timeout; test both tokens independently. The overall timeout also does not bound storage writes, which use the caller token (140, 162). | +| S1 / P1 / security bug (medium severity) | [Global authentication is applied to metadata-selected download URLs][review-downloader] (239–243, 257–284) without origin restrictions; [metadata permits arbitrary HTTP(S) hosts][review-client] (194–208). If global reusable credentials are configured and an attacker controls a referenced host or published download URL, that host receives them on HEAD, before any hash check. | Separate verification/download credentials or require an explicit trusted-origin policy in the auth provider. Test that an off-origin metadata URL never receives credentials. This finding does not require installing a malicious APK. | + +Additional architecture/integration risks: + +- **A1 / P1 — UI thread and callbacks:** [the default dispatcher invokes inline][review-dispatcher], while bootstrap/download awaits use + `ConfigureAwait(false)`. Event handlers and the synchronous pre-check are not guaranteed to run on the Avalonia UI thread. + Supply an `IUpdateEventDispatcher` that posts to `Dispatcher.UIThread`; pre-check runs separately and must not directly access controls. + Callbacks execute while the operation gate is held: synchronously waiting for another update operation can deadlock, and a throwing + subscriber can escape or turn progress reporting into a download failure. Keep callbacks short/nonthrowing and throttle UI progress. + Normal C# events support `-=`; unsubscribe when a ViewModel is released if the bootstrap outlives it. There is no demonstrated + Avalonia-control leak because this library contains no controls. +- **A2 / P1 — trust boundary:** SHA-256 uses the checksum from the same metadata source, not a signed manifest. + [HTTP is accepted][review-client]; [permissive TLS is an explicit option][review-tls], not the default. + Require trusted HTTPS endpoints and certificate validation, consider signed metadata, and restrict credential origins. + The installer does not preflight package ID/signing certificate/version code, nor does `LaunchInstallerAsync` bind its file path to + a prior verification result. Preserve the verified handoff in app-private storage; consider a verified-package handle and identity + checks. Android still enforces its own installation/signature rules—this is **not** evidence of a signature bypass. +- **A3 / P2 — paths, locks and recovery:** [filenames are sanitized and joined with `Path.Combine`][review-downloader] (77–81, 427–447). + There is no archive extraction in this implementation; no Zip Slip or metadata-driven traversal was established. + [Storage uses exclusive write streams][review-storage], and the downloader closes its stream before renaming (149–175), so the old + rename-while-open defect is not present. The operation gate is per bootstrap, not per directory/process: two bootstraps targeting the + same filename can conflict or delete one another's files. Use a single coordinator or isolated staging paths. + Partial files enable resume, but stale releases need bounded retention rather than unconditional deletion on cancellation. + +### 4. Architecture design + +**Good separation:** constructor-injected downloader, validator, installer, storage, comparer, dispatcher and logger make the +[orchestrator][review-bootstrap] testable without UI. This is MVVM-compatible service design, not an MVVM implementation: +ViewModels, commands, bindings and dispatch remain in the host. There is no source evidence of UI/Core coupling through GeneralUpdate.Core. + +**Extensibility limits:** bootstrap constructs the concrete internal `HttpUpdatePackageClient` rather than accepting a discovery +interface; a nonstandard protocol needs an adapter endpoint or code changes. The default factory hard-wires storage/downloader/hash/installer; +advanced replacements require constructing `AndroidBootstrap` directly. `Sha256HashValidator` also opens physical files rather than using +`IFileStorage`, so a virtual storage implementation cannot be substituted throughout the pipeline. +Consider an injectable metadata provider and consistent storage abstraction only when those use cases are required. + +**State/operational limits:** the semaphore serializes individual calls, not an entire multi-call update transaction. +Snapshots are in-memory and do not establish verified-package provenance or crash recovery. A host coordinator should own the sequence, +shutdown and post-install reconciliation. The default logger is no-op; production hosts need stage/failure telemetry without credentials. + +**Dependency risks:** [AndroidX Core is the runtime package dependency; SourceLink is private build tooling][review-project]. +No dependency advisory audit or transitive inventory was performed for this assessment, so version age alone is not a vulnerability finding. +Validate the resolved dependency graph, Android workload/toolchain compatibility and packaged artifact on supported devices before release. + +### Validation and production release gates + +- **Observed:** the existing Release run passed all **3** `UpdateFlowEndToEndTests`, then all **48** core tests, without skips. + [Main CI run 35453852521](https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/actions/runs/35453852521) also passed 48 tests and Android build/pack. + The inspected earlier failed run 35446303238 exposed no jobs/logs; it is not evidence of a current source failure. +- **Limits:** [tests link platform-independent sources][review-tests], excluding the default factory and real Android installer. + [Flow tests use fake HTTP, random bytes, real storage/hash and a recording installer][review-flow-tests]—not a signed APK on a device. + No local Android workload/device was available; installer permissions, actual install/restart and Avalonia UI dispatch were not exercised. + This documentation-only change adds no runtime behavior or new test tooling. +- **Before production:** address S1 for authenticated deployments and B1/B2/B4; fix B3/B5 and reconcile package license metadata. + Exercise interruption/resume (200/206/416, changed ETag, corrupt sidecar), disk-full/permission/locked-file errors, + timeout versus cancellation, competing coordinators, subscriber exceptions and UI-thread dispatch. +- **On real Android devices:** test denied/granted install permission, bad FileProvider paths, user cancellation, wrong package/signature, + process death, installer success, next-launch version confirmation and failed-release/data-migration recovery. + Treat these as release acceptance criteria, not guarantees inferred from passing unit tests. + +**Bottom line:** use as an Android update building block only after application-specific hardening and device validation. +Do not advertise unattended install success, automatic rollback/restart, or desktop support from this repository's current implementation. + +[review-project]: https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/blob/c3d87519de8fb97d3bebd1b1b0177b33cf0047e3/src/GeneralUpdate.Avalonia.Android/GeneralUpdate.Avalonia.Android.csproj#L1-L43 +[review-bootstrap]: https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/blob/c3d87519de8fb97d3bebd1b1b0177b33cf0047e3/src/GeneralUpdate.Avalonia.Android/Services/AndroidBootstrap.cs#L8-L403 +[review-client]: https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/blob/c3d87519de8fb97d3bebd1b1b0177b33cf0047e3/src/GeneralUpdate.Avalonia.Android/Services/HttpUpdatePackageClient.cs#L16-L208 +[review-downloader]: https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/blob/c3d87519de8fb97d3bebd1b1b0177b33cf0047e3/src/GeneralUpdate.Avalonia.Android/Services/HttpResumableApkDownloader.cs#L29-L455 +[review-hash]: https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/blob/c3d87519de8fb97d3bebd1b1b0177b33cf0047e3/src/GeneralUpdate.Avalonia.Android/Services/Sha256HashValidator.cs#L9-L76 +[review-installer]: https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/blob/c3d87519de8fb97d3bebd1b1b0177b33cf0047e3/src/GeneralUpdate.Avalonia.Android/Services/AndroidApkInstaller.cs#L28-L128 +[review-guide]: https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/blob/c3d87519de8fb97d3bebd1b1b0177b33cf0047e3/src/GeneralUpdate.Avalonia.Android/README.en.md#L17-L187 +[review-versions]: https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/blob/c3d87519de8fb97d3bebd1b1b0177b33cf0047e3/src/GeneralUpdate.Avalonia.Android/Services/SystemVersionComparer.cs#L7-L25 +[review-props]: https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/blob/c3d87519de8fb97d3bebd1b1b0177b33cf0047e3/Directory.Build.props#L1-L5 +[review-license]: https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/blob/c3d87519de8fb97d3bebd1b1b0177b33cf0047e3/LICENSE#L1-L3 +[review-factory]: https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/blob/c3d87519de8fb97d3bebd1b1b0177b33cf0047e3/src/GeneralUpdate.Avalonia.Android/GeneralUpdateBootstrap.cs#L10-L69 +[review-dispatcher]: https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/blob/c3d87519de8fb97d3bebd1b1b0177b33cf0047e3/src/GeneralUpdate.Avalonia.Android/Services/ImmediateEventDispatcher.cs#L5-L8 +[review-tls]: https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/blob/c3d87519de8fb97d3bebd1b1b0177b33cf0047e3/src/GeneralUpdate.Avalonia.Android/Services/SslValidationPolicies.cs#L20-L32 +[review-storage]: https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/blob/c3d87519de8fb97d3bebd1b1b0177b33cf0047e3/src/GeneralUpdate.Avalonia.Android/Services/PhysicalFileStorage.cs#L7-L46 +[review-tests]: https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/blob/c3d87519de8fb97d3bebd1b1b0177b33cf0047e3/tests/GeneralUpdate.Avalonia.Android.Tests/GeneralUpdate.Avalonia.Android.Tests.csproj#L1-L58 +[review-flow-tests]: https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/blob/c3d87519de8fb97d3bebd1b1b0177b33cf0047e3/tests/GeneralUpdate.Avalonia.Android.Tests/UpdateFlowEndToEndTests.cs#L23-L207 + ## Directory Structure ```text diff --git a/README.md b/README.md index eda9510..ed8d0e3 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,31 @@ ZIP、差分包、驱动包不会交给 Android 安装器;`body` 为空数组 `LaunchInstallerAsync` 返回 `Success = true` 只表示安装器已拉起,**不代表用户已完成安装**:安装完成后进程会被 系统结束,下次启动时请自行比较本机版本与服务端版本,以确认这次更新是否真正生效。 +## 源码评审与生产可用性 + +针对 #18 的评审基于 **2026-09-19 / `c3d8751`**,完整证据、源码行号、问题优先级及验收建议见 +[英文评审正文](https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/blob/main/README-EN.md#source-review-and-production-readiness)。 +本次仅记录评审结论并修正英文环境说明,**不代表下述运行时问题已修复**。 + +| 维度 | 结论与风险 | 建议 | +|---|---|---| +| 更新闭环 | 当前仅实现 Android 版本查询、断点下载、大小/SHA-256 校验和安装器拉起。安装器拉起成功不等于安装完成;没有重启确认、持久化恢复或应用级回滚。 | 宿主持久化目标版本、下次启动核对实际版本,并制定失败版本及数据迁移恢复策略。分别处理弱网、文件权限、磁盘空间、损坏包和安装权限问题。 | +| 开发者友好性 | 有 API、服务端协议和 FileProvider 配置说明,但没有可运行的 Avalonia 示例。无内置 UI,定制自由但接入工作由宿主承担;不是桌面跨平台更新实现。 | 增加 MVVM 接入示例,覆盖 UI 调度、权限返回、取消、事件解绑及安装后确认;说明 pre-check 返回 `true` 表示跳过。 | +| 潜在 Bug | 完整正文列出下载重试范围不符(B1)、哈希取消/清理异常后的状态残留(B2)、默认下载 HttpClient 所有权遗漏(B3)、操作期间 Dispose 的竞争(B4)、超时被标成主动取消(B5)。全局认证还可能发送给元数据指定的其他下载域名(S1,需配置全局凭据且攻击者控制相关地址/主机)。 | 按触发条件补回归测试并修复;认证必须限定可信源。默认事件不保证 UI 线程,需注入 Avalonia 调度器;SHA-256 不等于独立签名,应使用可信 HTTPS,保留 Android 自身签名校验边界。 | +| 架构设计 | 核心无 UI,也没有引用 GeneralUpdate.Core 包;接口注入利于测试、适配 MVVM。但元数据客户端内置、默认哈希直接访问物理文件,操作锁只覆盖单实例的单次调用。 | 由宿主统一协调流程及生命周期;按需抽象元数据发现与存储,避免多实例共用下载路径,补充遥测和依赖兼容性验证。 | + +**分类说明:** B1–B5 是有明确源码触发路径的行为缺陷,S1 是有前提的凭据泄露风险; +无安装完成确认/回滚、无 UI 调度保证属于架构或接入责任,示例与 API 易用性属于体验优化。 +包元数据声明 MIT 而仓库许可证为 Apache-2.0,发布前还需维护者确认并统一,不能自行推定许可。 + +**验证边界:** 3 个现有流程测试和全部 48 个核心测试通过;主分支 CI 的 Android 构建/打包也通过。 +测试使用模拟 HTTP、真实文件/哈希及记录型安装器,不覆盖真机安装、签名拒绝、Avalonia UI 线程或重启恢复。 +没有证据支持把旧的“写流未关闭即重命名”问题、Zip Slip 或 Android 签名绕过列为当前缺陷。 + +**生产结论:不能直接作为开箱即用的跨平台、全闭环生产更新器。** +可在修复相关缺陷、限定可信更新源、补齐宿主协调及恢复逻辑,并通过 Android 真机故障场景验收后, +作为 Android 更新基础组件使用;详细上线门槛见完整评审。 + ## 目录结构 ```text From f1c1f1c37d5f26683d9cd67d3dbe1bd102f385a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:30:31 +0000 Subject: [PATCH 3/6] Align NuGet license metadata with repository Apache license Co-authored-by: JusterZhu <11714536+JusterZhu@users.noreply.github.com> --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 87979e6..dece50e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - MIT + Apache-2.0 git From 6b3ce295eadd97e62f767039597f83174bbbcc7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:41:53 +0000 Subject: [PATCH 4/6] Fix updater retries, lifecycle shutdown and credential origin isolation Co-authored-by: JusterZhu <11714536+JusterZhu@users.noreply.github.com> --- README-EN.md | 109 +++- README.md | 12 +- .../Abstractions/IAndroidBootstrap.cs | 8 + .../GeneralUpdateBootstrap.cs | 5 +- .../Models/HttpDownloadOptions.cs | 67 +- .../README.en.md | 25 + src/GeneralUpdate.Avalonia.Android/README.md | 25 + .../README.zh-CN.md | 23 + .../Services/AndroidBootstrap.cs | 320 ++++++++-- .../Services/HttpResumableApkDownloader.cs | 349 +++++++---- .../Services/HttpUpdatePackageClient.cs | 25 +- .../AuthenticationOriginTests.cs | 472 ++++++++++++++ .../BootstrapLifecycleTests.cs | 575 ++++++++++++++++++ .../HttpResumableApkDownloaderTests.cs | 556 +++++++++++++++++ .../HttpUpdatePackageClientTests.cs | 1 + 15 files changed, 2385 insertions(+), 187 deletions(-) create mode 100644 tests/GeneralUpdate.Avalonia.Android.Tests/AuthenticationOriginTests.cs create mode 100644 tests/GeneralUpdate.Avalonia.Android.Tests/BootstrapLifecycleTests.cs create mode 100644 tests/GeneralUpdate.Avalonia.Android.Tests/HttpResumableApkDownloaderTests.cs diff --git a/README-EN.md b/README-EN.md index 7696519..e512851 100644 --- a/README-EN.md +++ b/README-EN.md @@ -172,9 +172,104 @@ configures all four items below: finished installing. The process is killed on completion, so compare the installed version with the server again on the next launch to confirm the update actually took effect. +## Host UI and Recovery Responsibilities + +### Authentication and transport migration + +`HttpDownloadOptions.AuthProvider` authenticates the configured verification endpoint. For downloads it is now applied +only to that endpoint's origin (scheme, host and effective port) or an origin explicitly listed by the host in +`AllowedDownloadAuthenticationOrigins`. Paths do not narrow that trust. `TrustedAuthenticationOrigin` overrides the default +origin for both verification and downloads; without a valid trusted origin, downloads get no global credentials unless explicitly allowed. +An unlisted CDN remains anonymous; valid per-package credentials still take precedence at the original package URL. + +```csharp +var httpOptions = new HttpDownloadOptions +{ + AuthProvider = myAuthenticationProvider, + AllowedDownloadAuthenticationOrigins = new[] { new Uri("https://packages.example.com") }, + MaxRetryAttempts = 3, + DownloadTimeout = TimeSpan.FromMinutes(10) +}; +``` + +Only opt in a CDN if it is trusted to receive those credentials. Library-applied authentication requires HTTPS by default; +`AllowInsecureAuthentication = true` is an explicitly unsafe development-only escape hatch, not a production recommendation. +It does not disable origin checks. Anonymous HTTP remains supported for compatibility; use trusted HTTPS metadata and APK endpoints +in production, with system certificate validation (never `AllowAllSslValidationPolicy`). + +**Compatibility changes:** internally created clients no longer follow redirects, even within the same origin. +Configure the final verification/APK URLs rather than relying on 3xx responses. If you inject an `HttpClient`, you own its +handler/default headers: disable automatic redirects and avoid unrestricted credential headers yourself. The library cannot +prevent an injected handler from following redirects or transmitting its own headers. When `httpOptions` is provided, +the existing factory behavior still constructs internal clients instead of using the supplied client. +Retry limits apply to the complete download attempt (HEAD + GET/body); they do not add retry to metadata discovery. +With no `httpOptions`, downloads retain a single attempt. + +### UI and lifecycle + +Keep the core UI-free: implement this adapter in the **Avalonia host**, not in the Android library: + +```csharp +using Avalonia.Threading; +using GeneralUpdate.Avalonia.Android.Abstractions; + +public sealed class AvaloniaUpdateDispatcher : IUpdateEventDispatcher +{ + public void Dispatch(Action callback) => Dispatcher.UIThread.Post(callback); +} +``` + +Pass `eventDispatcher: new AvaloniaUpdateDispatcher()` to `CreateDefault`. Bind progress/results to your ViewModel +from the dispatched events, and retain the event delegates so the ViewModel can unsubscribe with `-=` when released. +Coalesce frequent progress updates in the host rather than enqueueing expensive rendering for every buffer. +Pre-check is synchronous and is **not** dispatched: read already-captured application policy, not controls, inside it. +Never call `.Wait()`/`.Result` on another update operation or on asynchronous disposal from a callback. +An `async void` event handler must catch its own exceptions after an `await`; synchronous subscriber isolation cannot catch them. + +`Dispose()` now requests shutdown without blocking and releases resources after current operations and gate waiters finish. +When deterministic cleanup is needed, the concrete bootstrap implements `IAsyncDisposable`; callers holding `IAndroidBootstrap` +can use `if (bootstrap is IAsyncDisposable asyncDisposable) await asyncDisposable.DisposeAsync(); else bootstrap.Dispose();`. +Await it outside callbacks. A custom dependency that ignores cancellation can delay cleanup. +Cancellation while waiting for the gate still throws `OperationCanceledException` without changing the active operation's state; +cancellation during verification returns a canceled result. Notification exceptions are logged and isolated, whereas a pre-check +exception produces a failed validation result. Installed-version confirmation is still not part of disposal or a completed event. + +Use a single host coordinator and a private staging directory for the full check → download/verify → install sequence. +Only hand the returned verified path to the installer; do not modify or remove the APK while installation may be reading it. +The public installer method also supports independent calls, so it does not establish verification provenance for arbitrary paths. +Persist the target version before handoff, reconcile the actual installed version on next launch, and clear obsolete staging files +only when no update/installer is using them. Keep resumable partial files for a bounded retention period. +Permission prompting, actual installation outcome, app relaunch and recovery from a bad release or data migration remain host/platform +responsibilities; they are not made reliable merely by a successful installer intent. + ## Source Review and Production Readiness -### Scope and evidence +### Remediation status + +The following fixes address the concrete defects identified in the original review. The historical assessment below is +retained with revision-pinned evidence; **its defect descriptions refer to the pre-fix revision**, not the current implementation. + +| Finding | Current behavior | Regression coverage | +|---|---|---| +| B1 — retry/resume | Configured attempts cover HEAD, GET and interrupted body reads with fresh requests. Only transport failures retry; local storage failures do not. Stale 416/invalid range responses discard unsafe partial state; retries restart from zero. Resume uses `If-Range` and validates `Content-Range`; absent validators cause a full download. | `HttpResumableApkDownloaderTests`: transient statuses, dropped bodies, retry exhaustion, 200/206/416, inconsistent ranges/validators, corrupt sidecars and storage errors. | +| B2 — terminal state | Hash cancellation returns `Canceled`; storage/permission failures return failed results. Rejected-file cleanup is best-effort and cannot replace the original failure. | `BootstrapLifecycleTests`: hash cancellation, size lookup and cleanup failures, one failure notification and released gate. | +| B3 — client ownership | Factory-created download clients are owned and released; supplied clients remain host-owned. | `HttpResumableApkDownloaderTests`: owned versus injected client/handler disposal. The Android factory itself still needs platform-build/device validation. | +| B4 — shutdown | `Dispose()` requests cancellation without blocking, rejects new calls, and defers resource release until active operations and waiters drain. Concrete `AndroidBootstrap.DisposeAsync()` waits for release. | `BootstrapLifecycleTests`: active operations, gate waiters, repeated disposal and disposal from callbacks. Noncooperative dependencies can still delay shutdown. | +| B5 — timeout | Internal probe/download timeout reports `NetworkError`; caller cancellation reports `Canceled`. The overall token covers asynchronous storage writes and flushes as well as network reads. | `HttpResumableApkDownloaderTests`: caller cancellation, probe/body timeout and storage-write timeout. | +| S1 — credential forwarding | Global download authentication is origin-scoped; additional CDN origins require host opt-in. Library-created clients do not follow redirects. | `AuthenticationOriginTests`: exact origin matching, off-origin and missing-trust cases, CDN opt-in, package-auth precedence and redirect rejection. | +| Callback errors | Synchronous notification subscriber, dispatcher and logger exceptions cannot replace operation outcomes. A throwing pre-check fails validation instead of bypassing host policy. | `BootstrapLifecycleTests`: throwing subscribers/loggers/dispatchers and fail-closed pre-check. | +| Package license | NuGet metadata now declares Apache-2.0, matching the existing LICENSE. | MSBuild property evaluation against LICENSE. | + +Validation after remediation: **160/160 core tests passed** (no failures or skips), including HEAD-rejection fallback and +authentication-policy failures returning terminal validation results before provider/network invocation. +The local Android build could not run because the `android` workload is missing (`NETSDK1147`); current PR CI requires approval. +Tests for these fixes use the existing .NET core test project; they are not Android device installation tests. +The fixes do **not** add desktop support, installer completion callbacks, automatic restart/rollback, independent manifest signing, +APK identity preflight, persisted workflow state, directory-wide coordination, or a runnable Avalonia sample. +UI dispatch, verified-path handoff, cache retention and next-launch reconciliation remain explicit host responsibilities described above. +Metadata-provider/storage extensibility and production dependency/device validation remain follow-up work, not silently resolved findings. + +### Historical scope and evidence (before remediation) Review for issue #18, dated **2026-09-19**, against commit [`c3d8751`](https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/commit/c3d87519de8fb97d3bebd1b1b0177b33cf0047e3). @@ -184,8 +279,8 @@ specify `net10.0-android`, API 26+, AndroidX Core and build-time SourceLink; the References to GeneralUpdate.Core describe matching API semantics, not delegation to its implementation. **Verdict: not ready as a turnkey, cross-platform, closed-loop production updater.** -It is a useful foundation for a controlled Android integration, provided the host addresses the defects and release gates below. -This is an assessment, not a runtime remediation: the findings remain open. +It is a useful foundation for a controlled Android integration. Concrete defects now have the fixes listed above; +the platform boundaries and application/device release gates still apply. Classification: **bug** = source-supported incorrect behavior under the stated trigger; **architecture risk** = missing guarantee or integration responsibility; @@ -228,7 +323,7 @@ Do not delete a verified APK while the external installer may still need it. ### 3. Potential bugs and security risks -The following are source-confirmed findings, not failures reproduced by the existing test suite. +The following were source-confirmed findings at the reviewed revision. See the remediation table above for fixes and new tests. | ID / priority / classification | Trigger, evidence and impact | Suggested correction / focused regression | |---|---|---| @@ -281,7 +376,7 @@ shutdown and post-install reconciliation. The default logger is no-op; productio No dependency advisory audit or transitive inventory was performed for this assessment, so version age alone is not a vulnerability finding. Validate the resolved dependency graph, Android workload/toolchain compatibility and packaged artifact on supported devices before release. -### Validation and production release gates +### Original validation evidence and remaining production release gates - **Observed:** the existing Release run passed all **3** `UpdateFlowEndToEndTests`, then all **48** core tests, without skips. [Main CI run 35453852521](https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/actions/runs/35453852521) also passed 48 tests and Android build/pack. @@ -289,8 +384,8 @@ Validate the resolved dependency graph, Android workload/toolchain compatibility - **Limits:** [tests link platform-independent sources][review-tests], excluding the default factory and real Android installer. [Flow tests use fake HTTP, random bytes, real storage/hash and a recording installer][review-flow-tests]—not a signed APK on a device. No local Android workload/device was available; installer permissions, actual install/restart and Avalonia UI dispatch were not exercised. - This documentation-only change adds no runtime behavior or new test tooling. -- **Before production:** address S1 for authenticated deployments and B1/B2/B4; fix B3/B5 and reconcile package license metadata. + Those original results predate the remediation and its new regression tests. +- **Before production:** validate the remediation in the host application, including explicit authentication-origin configuration. Exercise interruption/resume (200/206/416, changed ETag, corrupt sidecar), disk-full/permission/locked-file errors, timeout versus cancellation, competing coordinators, subscriber exceptions and UI-thread dispatch. - **On real Android devices:** test denied/granted install permission, bad FileProvider paths, user cancellation, wrong package/signature, diff --git a/README.md b/README.md index ed8d0e3..a576d95 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,10 @@ ZIP、差分包、驱动包不会交给 Android 安装器;`body` 为空数组 针对 #18 的评审基于 **2026-09-19 / `c3d8751`**,完整证据、源码行号、问题优先级及验收建议见 [英文评审正文](https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/blob/main/README-EN.md#source-review-and-production-readiness)。 -本次仅记录评审结论并修正英文环境说明,**不代表下述运行时问题已修复**。 +下表保留原始评审背景;当前已按项修复 B1–B5、S1、回调异常隔离及许可证元数据,详见正文的修复状态表。 +新增回归测试覆盖重试/续传、超时与取消、状态及清理失败、资源释放竞争、认证源限制和重定向拒绝。 +修复后的核心测试 **160/160 通过**;本地 Android 构建因缺少工作负载(`NETSDK1147`)受阻,当前 PR CI 尚需批准。 +这些测试不等价于真机安装或自动回滚验证。 | 维度 | 结论与风险 | 建议 | |---|---|---| @@ -183,14 +186,15 @@ ZIP、差分包、驱动包不会交给 Android 安装器;`body` 为空数组 **分类说明:** B1–B5 是有明确源码触发路径的行为缺陷,S1 是有前提的凭据泄露风险; 无安装完成确认/回滚、无 UI 调度保证属于架构或接入责任,示例与 API 易用性属于体验优化。 -包元数据声明 MIT 而仓库许可证为 Apache-2.0,发布前还需维护者确认并统一,不能自行推定许可。 +原包元数据 MIT 与仓库 Apache-2.0 的差异已按现有 LICENSE 修正为 Apache-2.0。 -**验证边界:** 3 个现有流程测试和全部 48 个核心测试通过;主分支 CI 的 Android 构建/打包也通过。 +**原始验证边界:** 修复前的 3 个流程测试和全部 48 个核心测试通过;当时主分支 CI 的 Android 构建/打包也通过。 测试使用模拟 HTTP、真实文件/哈希及记录型安装器,不覆盖真机安装、签名拒绝、Avalonia UI 线程或重启恢复。 没有证据支持把旧的“写流未关闭即重命名”问题、Zip Slip 或 Android 签名绕过列为当前缺陷。 **生产结论:不能直接作为开箱即用的跨平台、全闭环生产更新器。** -可在修复相关缺陷、限定可信更新源、补齐宿主协调及恢复逻辑,并通过 Android 真机故障场景验收后, +上述修复并未新增桌面支持、安装完成确认、自动重启/回滚、独立清单签名、APK 身份预检或可运行的 Avalonia 示例。 +仍需限定可信更新源、补齐宿主协调及恢复逻辑,并通过 Android 真机故障场景验收后, 作为 Android 更新基础组件使用;详细上线门槛见完整评审。 ## 目录结构 diff --git a/src/GeneralUpdate.Avalonia.Android/Abstractions/IAndroidBootstrap.cs b/src/GeneralUpdate.Avalonia.Android/Abstractions/IAndroidBootstrap.cs index 41dbb58..37050f0 100644 --- a/src/GeneralUpdate.Avalonia.Android/Abstractions/IAndroidBootstrap.cs +++ b/src/GeneralUpdate.Avalonia.Android/Abstractions/IAndroidBootstrap.cs @@ -3,6 +3,12 @@ namespace GeneralUpdate.Avalonia.Android.Abstractions; +/// +/// Coordinates serialized update operations. Notification exceptions are isolated by the default +/// bootstrap, but callbacks must not synchronously wait for another operation on the same instance. +/// The default bootstrap's Dispose requests cancellation without blocking; cast to IAsyncDisposable +/// and await DisposeAsync outside callbacks when deterministic resource release is required. +/// public interface IAndroidBootstrap : IDisposable { event EventHandler? AddListenerValidate; @@ -25,6 +31,8 @@ public interface IAndroidBootstrap : IDisposable /// /// Like GeneralUpdate.Core, the callback is ignored for forced updates /// (). + /// Callback exceptions fail validation and raise ; + /// the update does not proceed when this policy decision fails. /// /// /// The pre-check callback. Must not be null. diff --git a/src/GeneralUpdate.Avalonia.Android/GeneralUpdateBootstrap.cs b/src/GeneralUpdate.Avalonia.Android/GeneralUpdateBootstrap.cs index 09a813f..0f243d3 100644 --- a/src/GeneralUpdate.Avalonia.Android/GeneralUpdateBootstrap.cs +++ b/src/GeneralUpdate.Avalonia.Android/GeneralUpdateBootstrap.cs @@ -44,10 +44,9 @@ public static IAndroidBootstrap CreateDefault( } else { - // Legacy path: use injected httpClient or a bare new one - var usedClient = httpClient ?? new HttpClient(); + var usedClient = httpClient ?? new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }); downloader = new HttpResumableApkDownloader( - usedClient, usedStorage, effectiveOptions, usedLogger); + usedClient, usedStorage, effectiveOptions, null, ownsClient: httpClient is null, logger: usedLogger); } var validator = new Sha256HashValidator(); var installer = new AndroidApkInstaller( diff --git a/src/GeneralUpdate.Avalonia.Android/Models/HttpDownloadOptions.cs b/src/GeneralUpdate.Avalonia.Android/Models/HttpDownloadOptions.cs index f643323..3456b75 100644 --- a/src/GeneralUpdate.Avalonia.Android/Models/HttpDownloadOptions.cs +++ b/src/GeneralUpdate.Avalonia.Android/Models/HttpDownloadOptions.cs @@ -9,7 +9,8 @@ namespace GeneralUpdate.Avalonia.Android.Models; /// /// When provided to , /// the library constructs an internal from these settings. -/// When null, the existing behavior is preserved (bare HttpClient, no auth, system SSL). +/// Internally created clients do not follow redirects; configure direct verification and package URLs. +/// Host-supplied clients remain host-owned, including responsibility for redirect and default-header safety. /// /// public sealed record HttpDownloadOptions @@ -61,18 +62,76 @@ public sealed record HttpDownloadOptions public TimeSpan RetryBaseDelay { get; init; } = TimeSpan.FromSeconds(1); /// - /// Global authentication provider applied to update server verification and download requests. + /// Global authentication provider applied to update server verification requests. + /// Downloads receive it only at the verification origin or an explicitly allowed download origin. /// Per-package authentication on takes precedence for downloads. /// public IHttpAuthProvider? AuthProvider { get; init; } + /// + /// Allows global and per-package credentials over plaintext HTTP. Unsafe: use only for + /// explicitly trusted development endpoints. Defaults to false; authenticated requests + /// require HTTPS and are rejected before invoking the authentication provider otherwise. + /// This does not relax origin restrictions or enable redirects. + /// + public bool AllowInsecureAuthentication { get; init; } + + /// + /// Optional trusted origin for global authentication, overriding the configured verification URL's origin. + /// Origin matching uses scheme, host and effective port, not URL paths. + /// Without either this value or a valid verification URL, downloads do not receive global credentials + /// unless their origin is explicitly included in . + /// + public Uri? TrustedAuthenticationOrigin { get; init; } + + /// + /// Additional HTTP(S) origins explicitly trusted to receive global download credentials, such as a CDN. + /// Empty by default. Entries grant trust to the entire origin; paths are ignored. + /// Redirects are not followed even between trusted origins. + /// + public IReadOnlyCollection AllowedDownloadAuthenticationOrigins { get; init; } = Array.Empty(); + + internal bool IsDownloadAuthenticationAllowed(Uri? requestUri, string? verificationUrl) + { + var trustedOrigin = TrustedAuthenticationOrigin; + if (trustedOrigin is null) + { + Uri.TryCreate(verificationUrl, UriKind.Absolute, out trustedOrigin); + } + + return IsSameOrigin(requestUri, trustedOrigin) || + (AllowedDownloadAuthenticationOrigins?.Any(origin => IsSameOrigin(requestUri, origin)) ?? false); + } + + internal static bool IsSameOrigin(Uri? destination, Uri? origin) + => IsHttpOrigin(destination) && IsHttpOrigin(origin) && + string.Equals(destination!.Scheme, origin!.Scheme, StringComparison.OrdinalIgnoreCase) && + string.Equals(destination.IdnHost, origin.IdnHost, StringComparison.OrdinalIgnoreCase) && + destination.Port == origin.Port; + + internal static void EnsureAuthenticationTransport(Uri? requestUri, bool allowInsecureAuthentication = false) + { + if (!IsHttpOrigin(requestUri) || + (requestUri!.Scheme != Uri.UriSchemeHttps && !allowInsecureAuthentication)) + { + throw new InvalidDataException( + "Authentication requires an HTTP(S) URI without userinfo, and HTTPS unless AllowInsecureAuthentication is explicitly enabled."); + } + } + + private static bool IsHttpOrigin(Uri? uri) + => uri is { IsAbsoluteUri: true } && + (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp) && + string.IsNullOrEmpty(uri.UserInfo); + /// /// Builds an from the configured options. - /// Applies SSL validation policy and proxy settings. + /// Applies SSL validation policy and proxy settings. Automatic redirects are disabled + /// so custom authentication headers cannot be forwarded to a different endpoint. /// internal HttpClientHandler BuildHandler() { - var handler = new HttpClientHandler(); + var handler = new HttpClientHandler { AllowAutoRedirect = false }; if (SslValidationPolicy != null) { diff --git a/src/GeneralUpdate.Avalonia.Android/README.en.md b/src/GeneralUpdate.Avalonia.Android/README.en.md index 4fd74ed..a88a367 100644 --- a/src/GeneralUpdate.Avalonia.Android/README.en.md +++ b/src/GeneralUpdate.Avalonia.Android/README.en.md @@ -53,6 +53,31 @@ if (check.Success && check.UpdateFound && check.PackageInfo is { } packageInfo) } ``` +## Host UI and Recovery + +Global download authentication is limited to the configured verification origin. Set +`HttpDownloadOptions.AllowedDownloadAuthenticationOrigins` only for CDN origins trusted to receive the same credentials; +`TrustedAuthenticationOrigin` can override the verification origin. Origin checks include scheme, host and port, not paths. +Authenticated requests require HTTPS unless `AllowInsecureAuthentication` is explicitly enabled for development. +Internal clients reject redirects; configure final URLs. With an injected `HttpClient`, the host must disable redirects +and avoid unrestricted credential default headers. Per-package credentials retain precedence at the initial package URL. + +The default dispatcher invokes events inline; it does not marshal to the Avalonia UI thread. In the host application, +implement `IUpdateEventDispatcher.Dispatch` using `Avalonia.Threading.Dispatcher.UIThread.Post(callback)` and pass it +as `eventDispatcher` to `CreateDefault`. Pre-check is synchronous and is not dispatched; it should read captured policy, +not controls. Unsubscribe ViewModel event handlers when released, throttle progress rendering, and never synchronously +wait for another update operation inside a callback. Catch exceptions inside `async void` handlers after awaits. + +Use one coordinator per private staging directory, preserve the verified file while the installer may still read it, and +persist the intended version for reconciliation on next launch. Installer launch is not installation confirmation. +The host owns permission prompting, stale-cache retention, relaunch and failed-release/data-migration recovery. + +`Dispose()` cancels without blocking and defers resource release until operations and waiters drain. The concrete +`AndroidBootstrap` also implements `IAsyncDisposable`; use it outside callbacks when cleanup must be awaited. +Cancellation while waiting for the operation gate still throws; cancellation during verification returns a canceled result. +Notification exceptions are isolated, but a pre-check exception fails validation rather than bypassing host policy. +Configured download retries cover HEAD, GET and interrupted bodies, not metadata discovery or local file errors. + ## API ### Factory diff --git a/src/GeneralUpdate.Avalonia.Android/README.md b/src/GeneralUpdate.Avalonia.Android/README.md index e22b361..16026ad 100644 --- a/src/GeneralUpdate.Avalonia.Android/README.md +++ b/src/GeneralUpdate.Avalonia.Android/README.md @@ -58,6 +58,31 @@ if (check.Success && check.UpdateFound && check.PackageInfo is { } packageInfo) } ``` +## Host UI and Recovery + +Global download authentication is limited to the configured verification origin. Set +`HttpDownloadOptions.AllowedDownloadAuthenticationOrigins` only for CDN origins trusted to receive the same credentials; +`TrustedAuthenticationOrigin` can override the verification origin. Origin checks include scheme, host and port, not paths. +Authenticated requests require HTTPS unless `AllowInsecureAuthentication` is explicitly enabled for development. +Internal clients reject redirects; configure final URLs. With an injected `HttpClient`, the host must disable redirects +and avoid unrestricted credential default headers. Per-package credentials retain precedence at the initial package URL. + +The default dispatcher invokes events inline; it does not marshal to the Avalonia UI thread. In the host application, +implement `IUpdateEventDispatcher.Dispatch` using `Avalonia.Threading.Dispatcher.UIThread.Post(callback)` and pass it +as `eventDispatcher` to `CreateDefault`. Pre-check is synchronous and is not dispatched; it should read captured policy, +not controls. Unsubscribe ViewModel event handlers when released, throttle progress rendering, and never synchronously +wait for another update operation inside a callback. Catch exceptions inside `async void` handlers after awaits. + +Use one coordinator per private staging directory, preserve the verified file while the installer may still read it, and +persist the intended version for reconciliation on next launch. Installer launch is not installation confirmation. +The host owns permission prompting, stale-cache retention, relaunch and failed-release/data-migration recovery. + +`Dispose()` cancels without blocking and defers resource release until operations and waiters drain. The concrete +`AndroidBootstrap` also implements `IAsyncDisposable`; use it outside callbacks when cleanup must be awaited. +Cancellation while waiting for the operation gate still throws; cancellation during verification returns a canceled result. +Notification exceptions are isolated, but a pre-check exception fails validation rather than bypassing host policy. +Configured download retries cover HEAD, GET and interrupted bodies, not metadata discovery or local file errors. + ## API ### Static Factory diff --git a/src/GeneralUpdate.Avalonia.Android/README.zh-CN.md b/src/GeneralUpdate.Avalonia.Android/README.zh-CN.md index 31c6f2d..93e6f25 100644 --- a/src/GeneralUpdate.Avalonia.Android/README.zh-CN.md +++ b/src/GeneralUpdate.Avalonia.Android/README.zh-CN.md @@ -146,6 +146,29 @@ UpdateOperationResult (基类) `UpdateFailureReason`: `None`, `NetworkError`, `Canceled`, `InvalidMetadata`, `FileIoError`, `HashMismatch`, `ServerDoesNotSupportRange`, `InstallPermissionDenied`, `InstallLaunchFailed`, `VersionComparisonFailed`, `Unknown` +## UI 调度与恢复责任 + +全局下载认证默认仅发送到版本查询端点的源(协议、主机、有效端口,不按路径限制)。 +只有明确可信且允许接收相同凭据的 CDN 才应加入 `HttpDownloadOptions.AllowedDownloadAuthenticationOrigins`; +`TrustedAuthenticationOrigin` 可覆盖默认查询源。认证默认要求 HTTPS,`AllowInsecureAuthentication` +仅作为开发环境的显式不安全选项。内部 HTTP 客户端不跟随重定向,需配置最终地址; +注入自有 `HttpClient` 时,宿主必须自行禁用重定向并避免无范围限制的默认认证头。 + +默认事件分发器直接调用回调,不保证 Avalonia UI 线程。在宿主实现 `IUpdateEventDispatcher`, +通过 `Avalonia.Threading.Dispatcher.UIThread.Post(callback)` 分发事件,并传入 `CreateDefault`。 +pre-check 是同步策略判断,不经过 UI 分发器;应读取已捕获的策略数据,而不是操作控件。 +ViewModel 释放时使用 `-=` 解绑事件,对高频进度做合并;不要在回调中通过 `.Wait()` / `.Result` +同步等待其他更新操作或异步释放。`async void` 事件处理器须自行捕获 `await` 之后的异常。 + +每个应用私有下载目录仅使用一个流程协调器,安装器可能仍在读取 APK 时不要修改或删除它。 +拉起安装器前持久化目标版本,在下次启动时核对实际安装版本。安装权限引导、过期缓存保留策略、 +应用重启、失败版本恢复及数据迁移回退均由宿主负责,安装器拉起成功不代表更新已经完成。 + +`Dispose()` 非阻塞地请求取消,待操作和等待者退出后再释放资源。具体类 `AndroidBootstrap` 还实现 +`IAsyncDisposable`,需要等待清理时可在回调之外使用。等待操作锁时取消仍抛出异常,校验期间取消则返回取消结果。 +通知回调异常被隔离;pre-check 异常会让验证失败而不是绕过策略。配置的下载重试覆盖 HEAD、GET 和中断的正文读取, +不包含元数据查询或本地文件错误。 + ## Android 接入配置 申报安装权限(Android 8.0+):缺少时 `LaunchInstallerAsync` 返回 `InstallPermissionDenied`, diff --git a/src/GeneralUpdate.Avalonia.Android/Services/AndroidBootstrap.cs b/src/GeneralUpdate.Avalonia.Android/Services/AndroidBootstrap.cs index a5ca8b8..bbf6c2c 100644 --- a/src/GeneralUpdate.Avalonia.Android/Services/AndroidBootstrap.cs +++ b/src/GeneralUpdate.Avalonia.Android/Services/AndroidBootstrap.cs @@ -5,7 +5,7 @@ namespace GeneralUpdate.Avalonia.Android.Services; -public sealed class AndroidBootstrap : IAndroidBootstrap +public sealed class AndroidBootstrap : IAndroidBootstrap, IAsyncDisposable { private readonly IVersionComparer _versionComparer; private readonly IUpdateDownloader _downloader; @@ -17,7 +17,12 @@ public sealed class AndroidBootstrap : IAndroidBootstrap private readonly UpdateServerOptions? _updateServer; private readonly HttpUpdatePackageClient? _packageClient; private readonly SemaphoreSlim _operationGate = new(1, 1); + private readonly CancellationTokenSource _shutdown = new(); + private readonly TaskCompletionSource _drained = new(TaskCreationOptions.RunContinuationsAsynchronously); private bool _disposed; + private bool _shutdownCanceled; + private bool _resourcesReleased; + private int _operations; private readonly object _sync = new(); private UpdateStateSnapshot _snapshot = new(UpdateState.None, UpdateFailureReason.None, null); @@ -72,16 +77,16 @@ public IAndroidBootstrap AddListenerUpdatePrecheck(Func ValidateAsync(string currentVersion, CancellationToken cancellationToken = default) { - ThrowIfDisposed(); - await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + using var operation = await EnterOperationAsync(cancellationToken).ConfigureAwait(false); + cancellationToken = operation.Token; try { - cancellationToken.ThrowIfCancellationRequested(); SetState(UpdateState.Checking, UpdateFailureReason.None, "Checking for updates."); UpdatePackageInfo? packageInfo; try { + ThrowIfCancellationRequested(cancellationToken); if (string.IsNullOrWhiteSpace(currentVersion)) { throw new InvalidDataException("The current application version is required to query the update server."); @@ -105,11 +110,11 @@ public async Task ValidateAsync(string currentVersion, Cancel ProductId = _updateServer.ProductId }, cancellationToken).ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); + ThrowIfCancellationRequested(cancellationToken); } catch (Exception ex) when (IsQueryFailure(ex)) { - var canceled = ex is OperationCanceledException && cancellationToken.IsCancellationRequested; + var canceled = ex is OperationCanceledException && IsCancellationRequested(cancellationToken); var failure = new UpdateCheckResult { Success = false, @@ -176,7 +181,28 @@ public async Task ValidateAsync(string currentVersion, Cancel CurrentVersion = currentVersion }; - if (ShouldSkipUpdate(available, packageInfo, currentVersion)) + bool skip; + try + { + skip = ShouldSkipUpdate(available, packageInfo, currentVersion); + } + catch (Exception ex) + { + var canceled = ex is OperationCanceledException && IsCancellationRequested(cancellationToken); + var failed = available with + { + Success = false, + UpdateFound = false, + State = canceled ? UpdateState.Canceled : UpdateState.Failed, + FailureReason = canceled ? UpdateFailureReason.Canceled : UpdateFailureReason.Unknown, + Message = canceled ? "Update check canceled." : "Update pre-check callback failed.", + Exception = ex + }; + HandleFailure(failed); + return failed; + } + ThrowIfCancellationRequested(cancellationToken); + if (skip) { var skipped = available with { @@ -208,18 +234,30 @@ public async Task ValidateAsync(string currentVersion, Cancel CurrentVersion = currentVersion }; } - finally + catch (OperationCanceledException ex) { - _operationGate.Release(); + var failure = new UpdateCheckResult + { + Success = false, + State = UpdateState.Canceled, + FailureReason = UpdateFailureReason.Canceled, + Message = "Update check canceled.", + CurrentVersion = currentVersion, + Exception = ex + }; + HandleFailure(failure); + return failure; } } public async Task DownloadAndVerifyAsync(UpdatePackageInfo packageInfo, CancellationToken cancellationToken = default) { - ThrowIfDisposed(); - await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + using var operation = await EnterOperationAsync(cancellationToken).ConfigureAwait(false); + cancellationToken = operation.Token; + string? filePath = null; try { + ThrowIfCancellationRequested(cancellationToken); SetState(UpdateState.Downloading, UpdateFailureReason.None, "Downloading package."); var downloadResult = await _downloader.DownloadAsync( @@ -227,6 +265,8 @@ public async Task DownloadAndVerifyAsync(UpdatePackageInf progress => RaiseDownloadProgress(progress), cancellationToken).ConfigureAwait(false); + filePath = downloadResult.FilePath; + ThrowIfCancellationRequested(cancellationToken); if (!downloadResult.Success || string.IsNullOrWhiteSpace(downloadResult.FilePath)) { HandleFailure(downloadResult); @@ -238,7 +278,7 @@ public async Task DownloadAndVerifyAsync(UpdatePackageInf var actualLength = _fileStorage.GetFileLength(downloadResult.FilePath); if (actualLength != packageInfo.FileSize) { - _fileStorage.DeleteFile(downloadResult.FilePath); + TryDeleteFile(downloadResult.FilePath); var sizeFailed = new UpdateOperationResult { Success = false, @@ -255,15 +295,19 @@ public async Task DownloadAndVerifyAsync(UpdatePackageInf SetState(UpdateState.Verifying, UpdateFailureReason.None, "Validating package hash."); var hashResult = await _hashValidator.ValidateSha256Async(downloadResult.FilePath, packageInfo.Sha256, cancellationToken).ConfigureAwait(false); + ThrowIfCancellationRequested(cancellationToken); if (!hashResult.Success) { - _fileStorage.DeleteFile(downloadResult.FilePath); + TryDeleteFile(downloadResult.FilePath); + var canceled = hashResult.State == UpdateState.Canceled || hashResult.FailureReason == UpdateFailureReason.Canceled; var failed = hashResult with { PackageInfo = packageInfo, - State = UpdateState.Failed, - FailureReason = hashResult.FailureReason == UpdateFailureReason.None ? UpdateFailureReason.HashMismatch : hashResult.FailureReason, + FilePath = downloadResult.FilePath, + State = canceled ? UpdateState.Canceled : UpdateState.Failed, + FailureReason = canceled ? UpdateFailureReason.Canceled + : hashResult.FailureReason == UpdateFailureReason.None ? UpdateFailureReason.HashMismatch : hashResult.FailureReason, Message = hashResult.Message ?? "SHA256 validation failed." }; HandleFailure(failed); @@ -284,19 +328,35 @@ public async Task DownloadAndVerifyAsync(UpdatePackageInf RaiseCompleted(completed); return completed; } - finally + catch (Exception ex) { - _operationGate.Release(); + TryDeleteFile(filePath); + var canceled = ex is OperationCanceledException && IsCancellationRequested(cancellationToken); + var failure = new UpdateOperationResult + { + Success = false, + State = canceled ? UpdateState.Canceled : UpdateState.Failed, + FailureReason = canceled ? UpdateFailureReason.Canceled + : ex is IOException or UnauthorizedAccessException ? UpdateFailureReason.FileIoError + : ex is HttpRequestException or OperationCanceledException ? UpdateFailureReason.NetworkError + : UpdateFailureReason.Unknown, + Message = canceled ? "Download or verification canceled." : "Download or verification failed.", + PackageInfo = packageInfo, + FilePath = filePath, + Exception = ex + }; + HandleFailure(failure); + return failure; } } public async Task LaunchInstallerAsync(UpdatePackageInfo packageInfo, string apkFilePath, CancellationToken cancellationToken = default) { - ThrowIfDisposed(); - await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + using var operation = await EnterOperationAsync(cancellationToken).ConfigureAwait(false); + cancellationToken = operation.Token; try { - cancellationToken.ThrowIfCancellationRequested(); + ThrowIfCancellationRequested(cancellationToken); SetState(UpdateState.Installing, UpdateFailureReason.None, "Launching installer."); var result = await _apkInstaller.LaunchInstallAsync(packageInfo, apkFilePath, cancellationToken).ConfigureAwait(false); @@ -313,9 +373,23 @@ public async Task LaunchInstallerAsync(UpdatePackageInfo packageI return result; } - finally + catch (Exception ex) { - _operationGate.Release(); + var canceled = ex is OperationCanceledException && IsCancellationRequested(cancellationToken); + var failure = new InstallResult + { + Success = false, + State = canceled ? UpdateState.Canceled : UpdateState.Failed, + FailureReason = canceled ? UpdateFailureReason.Canceled + : ex is UnauthorizedAccessException ? UpdateFailureReason.InstallPermissionDenied + : ex is IOException ? UpdateFailureReason.FileIoError : UpdateFailureReason.InstallLaunchFailed, + Message = canceled ? "Installer launch canceled." : "Failed to launch installer.", + PackageInfo = packageInfo, + FilePath = apkFilePath, + Exception = ex + }; + HandleFailure(failure); + return failure; } } @@ -351,54 +425,230 @@ private void SetState(UpdateState state, UpdateFailureReason failureReason, stri private void HandleFailure(UpdateOperationResult result) { SetState(result.State == UpdateState.Canceled ? UpdateState.Canceled : UpdateState.Failed, result.FailureReason, result.Message); - _logger.LogError(result.Message ?? "Update failed.", result.Exception); + LogError(result.Message ?? "Update failed.", result.Exception); RaiseFailed(result); } private void RaiseValidate(UpdatePackageInfo packageInfo, string currentVersion) { var args = new ValidateEventArgs(packageInfo, currentVersion); - _eventDispatcher.Dispatch(() => AddListenerValidate?.Invoke(this, args)); + Dispatch(AddListenerValidate, args); } private void RaiseDownloadProgress(DownloadProgressInfo progress) { var args = new DownloadProgressChangedEventArgs(progress); - _eventDispatcher.Dispatch(() => AddListenerDownloadProgressChanged?.Invoke(this, args)); + Dispatch(AddListenerDownloadProgressChanged, args); } private void RaiseCompleted(UpdateOperationResult result) { var args = new UpdateCompletedEventArgs(result); - _eventDispatcher.Dispatch(() => AddListenerUpdateCompleted?.Invoke(this, args)); + Dispatch(AddListenerUpdateCompleted, args); } private void RaiseFailed(UpdateOperationResult result) { var args = new UpdateFailedEventArgs(result); - _eventDispatcher.Dispatch(() => AddListenerUpdateFailed?.Invoke(this, args)); + Dispatch(AddListenerUpdateFailed, args); } - public void Dispose() + private void Dispatch(EventHandler? handlers, T args) where T : EventArgs { - if (_disposed) + if (handlers is null) { return; } - _operationGate.Dispose(); - _packageClient?.Dispose(); + try + { + _eventDispatcher.Dispatch(() => + { + foreach (EventHandler handler in handlers.GetInvocationList()) + { + try + { + handler(this, args); + } + catch (Exception ex) + { + LogError("Update event subscriber failed.", ex); + } + } + }); + } + catch (Exception ex) + { + LogError("Update event dispatch failed.", ex); + } + } + + private void LogError(string message, Exception? exception) + { + try + { + _logger.LogError(message, exception); + } + catch + { + // Diagnostics must not replace the operation's outcome. + } + } - if (_downloader is IDisposable disposableDownloader) + private void TryDeleteFile(string? filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) { - disposableDownloader.Dispose(); + return; } - _disposed = true; + try + { + _fileStorage.DeleteFile(filePath); + } + catch (Exception ex) + { + LogError("Failed to remove an unverified package.", ex); + } + } + + private bool IsCancellationRequested(CancellationToken cancellationToken) => + cancellationToken.IsCancellationRequested || _shutdown.IsCancellationRequested; + + private void ThrowIfCancellationRequested(CancellationToken cancellationToken) + { + // CancelAsync marks shutdown immediately, but linked-token callbacks may still be queued. + _shutdown.Token.ThrowIfCancellationRequested(); + cancellationToken.ThrowIfCancellationRequested(); + } + + private async Task EnterOperationAsync(CancellationToken cancellationToken) + { + OperationLease operation; + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + operation = new OperationLease(this, CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _shutdown.Token)); + _operations++; + } + + try + { + await _operationGate.WaitAsync(operation.Token).ConfigureAwait(false); + operation.Acquired = true; + ThrowIfCancellationRequested(operation.Token); + return operation; + } + catch + { + operation.Dispose(); + throw; + } + } + + /// + /// Requests cancellation without blocking callbacks. Resources are released after all operations + /// and gate waiters drain; use to await that release outside callbacks. + /// + public void Dispose() + { + lock (_sync) + { + if (_disposed) + { + return; + } + + _disposed = true; + } + + _ = CancelAndDrainAsync(); + } + + public ValueTask DisposeAsync() + { + Dispose(); + return new ValueTask(_drained.Task); + } + + private async Task CancelAndDrainAsync() + { + try + { + await _shutdown.CancelAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + LogError("An update shutdown cancellation callback failed.", ex); + } + + lock (_sync) + { + _shutdownCanceled = true; + } + TryReleaseResources(); + } + + private void TryReleaseResources() + { + lock (_sync) + { + if (!_shutdownCanceled || _operations != 0 || _resourcesReleased) + { + return; + } + + _resourcesReleased = true; + } + + try + { + try + { + _packageClient?.Dispose(); + } + finally + { + (_downloader as IDisposable)?.Dispose(); + } + } + catch (Exception ex) + { + LogError("Failed to dispose update resources.", ex); + } + finally + { + _operationGate.Dispose(); + _shutdown.Dispose(); + _drained.TrySetResult(); + } + } + + private sealed class OperationLease(AndroidBootstrap owner, CancellationTokenSource cancellation) : IDisposable + { + public CancellationToken Token => cancellation.Token; + public bool Acquired { get; set; } + + public void Dispose() + { + if (Acquired) + { + owner._operationGate.Release(); + } + cancellation.Dispose(); + lock (owner._sync) + { + owner._operations--; + } + owner.TryReleaseResources(); + } } private void ThrowIfDisposed() { - ObjectDisposedException.ThrowIf(_disposed, this); + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + } } } diff --git a/src/GeneralUpdate.Avalonia.Android/Services/HttpResumableApkDownloader.cs b/src/GeneralUpdate.Avalonia.Android/Services/HttpResumableApkDownloader.cs index 138fa1a..8dce309 100644 --- a/src/GeneralUpdate.Avalonia.Android/Services/HttpResumableApkDownloader.cs +++ b/src/GeneralUpdate.Avalonia.Android/Services/HttpResumableApkDownloader.cs @@ -27,14 +27,20 @@ public sealed class HttpResumableApkDownloader : IUpdateDownloader, IDisposable /// No authentication or custom HTTP options are applied. /// public HttpResumableApkDownloader(HttpClient httpClient, IFileStorage fileStorage, AndroidUpdateOptions options, IUpdateLogger? logger = null) + : this(httpClient, fileStorage, options, null, false, logger) + { + } + + internal HttpResumableApkDownloader(HttpClient httpClient, IFileStorage fileStorage, AndroidUpdateOptions options, + HttpDownloadOptions? httpOptions, bool ownsClient, IUpdateLogger? logger = null) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _fileStorage = fileStorage ?? throw new ArgumentNullException(nameof(fileStorage)); _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? new NoOpUpdateLogger(); - _httpOptions = null; - _globalAuthProvider = null; - _ownsClient = false; + _httpOptions = httpOptions; + _globalAuthProvider = httpOptions?.AuthProvider; + _ownsClient = ownsClient; } /// @@ -74,13 +80,6 @@ public async Task DownloadAsync(UpdatePackageInfo packageInfo, A try { - _fileStorage.EnsureDirectory(_options.DownloadDirectoryPath); - var finalName = ResolveFileName(packageInfo); - var finalFilePath = Path.Combine(_options.DownloadDirectoryPath, finalName); - var tempFilePath = finalFilePath + _options.TemporaryFileExtension; - var sidecarPath = tempFilePath + _options.SidecarExtension; - - // Resolve download timeout: use configured value or infinite using var timeoutCts = _httpOptions != null ? new CancellationTokenSource(_httpOptions.DownloadTimeout) : null; @@ -89,113 +88,147 @@ public async Task DownloadAsync(UpdatePackageInfo packageInfo, A : null; var effectiveCt = linkedCts?.Token ?? cancellationToken; - // Use RequestTimeout for the HEAD probe (quick server info check) - using var probeCts = _httpOptions != null - ? new CancellationTokenSource(_httpOptions.RequestTimeout) - : null; - using var probeLinkedCts = probeCts != null - ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, probeCts.Token) - : null; - var probeCt = probeLinkedCts?.Token ?? cancellationToken; - - var remoteInfo = await WithRetryAsync( - ct => GetRemoteInfoAsync(packageInfo, ct), - probeCt).ConfigureAwait(false); - var expectedMetadata = CreateMetadata(packageInfo, finalName, remoteInfo); + effectiveCt.ThrowIfCancellationRequested(); + _fileStorage.EnsureDirectory(_options.DownloadDirectoryPath); + var finalName = ResolveFileName(packageInfo); + var finalFilePath = Path.Combine(_options.DownloadDirectoryPath, finalName); + var tempFilePath = finalFilePath + _options.TemporaryFileExtension; + var sidecarPath = tempFilePath + _options.SidecarExtension; - var canResume = await EnsureResumeConsistencyAsync(tempFilePath, sidecarPath, expectedMetadata, cancellationToken).ConfigureAwait(false); - var existingLength = canResume ? _fileStorage.GetFileLength(tempFilePath) : 0; - if (existingLength > 0 && !remoteInfo.AcceptRanges) + return await WithRetryAsync(async ct => { - _logger.LogWarning("Server does not support range requests. Restarting download from zero."); - _fileStorage.DeleteFile(tempFilePath); - existingLength = 0; - } + var remoteInfo = await GetRemoteInfoAsync(packageInfo, ct).ConfigureAwait(false); + var expectedMetadata = CreateMetadata(packageInfo, finalName, remoteInfo); - using var request = new HttpRequestMessage(HttpMethod.Get, packageInfo.DownloadUrl); - if (existingLength > 0) - { - request.Headers.Range = new RangeHeaderValue(existingLength, null); - } + var canResume = await EnsureResumeConsistencyAsync(tempFilePath, sidecarPath, expectedMetadata, ct).ConfigureAwait(false); + var existingLength = canResume ? _fileStorage.GetFileLength(tempFilePath) : 0; + var ifRange = CreateIfRange(expectedMetadata); + if (existingLength > 0 && (!remoteInfo.AcceptRanges || ifRange is null)) + { + _logger.LogWarning("Safe range resumption is unavailable. Restarting download from zero."); + ct.ThrowIfCancellationRequested(); + _fileStorage.DeleteFile(tempFilePath); + existingLength = 0; + } - await ApplyAuthAsync(request, packageInfo, effectiveCt).ConfigureAwait(false); + using var request = new HttpRequestMessage(HttpMethod.Get, packageInfo.DownloadUrl); + if (existingLength > 0) + { + request.Headers.Range = new RangeHeaderValue(existingLength, null); + request.Headers.IfRange = ifRange; + } - using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, effectiveCt).ConfigureAwait(false); - if (existingLength > 0 && response.StatusCode == HttpStatusCode.OK) - { - _logger.LogWarning("Server did not honor range request. Restarting download from zero."); - _fileStorage.DeleteFile(tempFilePath); - existingLength = 0; - } + await ApplyAuthAsync(request, packageInfo, ct).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); + using var response = await SendAsync(request, ct).ConfigureAwait(false); + if (existingLength > 0 && response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable) + { + ct.ThrowIfCancellationRequested(); + _fileStorage.DeleteFile(tempFilePath); + _fileStorage.DeleteFile(sidecarPath); + throw new HttpRequestException("The partial download is stale; restart from zero."); + } + if (existingLength > 0 && response.StatusCode == HttpStatusCode.OK) + { + _logger.LogWarning("Server did not honor range request. Restarting download from zero."); + ct.ThrowIfCancellationRequested(); + _fileStorage.DeleteFile(tempFilePath); + existingLength = 0; + } - var totalBytes = ResolveTotalBytes(packageInfo.FileSize, response.Content.Headers.ContentLength, existingLength); - var metadataWithResponse = expectedMetadata with - { - ETag = response.Headers.ETag?.Tag ?? expectedMetadata.ETag, - LastModified = response.Content.Headers.LastModified?.ToString() ?? expectedMetadata.LastModified - }; + response.EnsureSuccessStatusCode(); + if (response.StatusCode is not (HttpStatusCode.OK or HttpStatusCode.PartialContent)) + { + throw new HttpRequestException("Unexpected status for a package download.", null, response.StatusCode); + } + if (response.StatusCode == HttpStatusCode.PartialContent && + !IsValidRange(response, existingLength, expectedMetadata)) + { + ct.ThrowIfCancellationRequested(); + _fileStorage.DeleteFile(tempFilePath); + _fileStorage.DeleteFile(sidecarPath); + throw new HttpRequestException("The server returned an inconsistent Content-Range."); + } - await _fileStorage.WriteAllTextAsync(sidecarPath, JsonSerializer.Serialize(metadataWithResponse), cancellationToken).ConfigureAwait(false); + var totalBytes = ResolveTotalBytes(packageInfo.FileSize, response.Content.Headers.ContentLength, existingLength); + var metadataWithResponse = expectedMetadata with + { + ETag = response.Headers.ETag?.ToString() ?? expectedMetadata.ETag, + LastModified = response.Content.Headers.LastModified?.ToString() ?? expectedMetadata.LastModified + }; - await using var contentStream = await response.Content.ReadAsStreamAsync(effectiveCt).ConfigureAwait(false); - var buffer = new byte[_options.DownloadBufferSize]; - var downloaded = existingLength; - var speedMeter = new SmoothedSpeedMeter(Math.Max(3, _options.SpeedSmoothingWindowSeconds)); + await _fileStorage.WriteAllTextAsync(sidecarPath, JsonSerializer.Serialize(metadataWithResponse), ct).ConfigureAwait(false); - progressCallback?.Invoke(CreateProgress(packageInfo, downloaded, totalBytes, speedMeter.GetSpeed(downloaded), existingLength > 0 ? "Resuming" : "Downloading")); + await using var contentStream = await ReadNetworkAsync( + () => response.Content.ReadAsStreamAsync(ct), ct).ConfigureAwait(false); + var buffer = new byte[_options.DownloadBufferSize]; + var downloaded = existingLength; + var speedMeter = new SmoothedSpeedMeter(Math.Max(3, _options.SpeedSmoothingWindowSeconds)); - // The write stream is flushed and closed before the temporary file is renamed: - // PhysicalFileStorage opens files with FileShare.None, so renaming an open file fails on - // Windows, and skipping the flush could leave trailing bytes behind on other platforms. - await using (var fileStream = _fileStorage.OpenWrite(tempFilePath, append: existingLength > 0)) - { - while (true) + progressCallback?.Invoke(CreateProgress(packageInfo, downloaded, totalBytes, speedMeter.GetSpeed(downloaded), existingLength > 0 ? "Resuming" : "Downloading")); + + // Close the flushed write stream before moving the file (FileShare.None on Windows). + ct.ThrowIfCancellationRequested(); + await using (var fileStream = _fileStorage.OpenWrite(tempFilePath, append: existingLength > 0)) { - var read = await contentStream.ReadAsync(buffer.AsMemory(0, buffer.Length), effectiveCt).ConfigureAwait(false); - if (read <= 0) + while (true) { - break; + var read = await ReadNetworkAsync( + () => contentStream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct).AsTask(), ct).ConfigureAwait(false); + if (read <= 0) + { + break; + } + + await fileStream.WriteAsync(buffer.AsMemory(0, read), ct).ConfigureAwait(false); + downloaded += read; + var speed = speedMeter.GetSpeed(downloaded); + + progressCallback?.Invoke(CreateProgress(packageInfo, downloaded, totalBytes, speed, "Downloading")); } + await fileStream.FlushAsync(ct).ConfigureAwait(false); + } - await fileStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); - downloaded += read; - var speed = speedMeter.GetSpeed(downloaded); - - progressCallback?.Invoke(CreateProgress(packageInfo, downloaded, totalBytes, speed, "Downloading")); + var expectedBodyLength = response.Content.Headers.ContentLength; + if ((expectedBodyLength.HasValue && downloaded - existingLength != expectedBodyLength.Value) || + (response.Content.Headers.ContentRange?.Length is long rangeLength && downloaded != rangeLength) || + (expectedMetadata.ExpectedFileSize > 0 && downloaded != expectedMetadata.ExpectedFileSize)) + { + throw new HttpRequestException("The response body length does not match the expected package length."); } - } - if (_fileStorage.FileExists(finalFilePath)) - { - _fileStorage.DeleteFile(finalFilePath); - } + ct.ThrowIfCancellationRequested(); + if (_fileStorage.FileExists(finalFilePath)) + { + _fileStorage.DeleteFile(finalFilePath); + } - _fileStorage.MoveFile(tempFilePath, finalFilePath, overwrite: true); - _fileStorage.DeleteFile(sidecarPath); + _fileStorage.MoveFile(tempFilePath, finalFilePath, overwrite: true); + _fileStorage.DeleteFile(sidecarPath); - progressCallback?.Invoke(CreateProgress(packageInfo, downloaded, totalBytes, speedMeter.GetSpeed(downloaded), "Download completed")); + progressCallback?.Invoke(CreateProgress(packageInfo, downloaded, totalBytes, speedMeter.GetSpeed(downloaded), "Download completed")); - return new DownloadResult - { - Success = true, - State = UpdateState.Completed, - FailureReason = UpdateFailureReason.None, - Message = "Download finished.", - PackageInfo = packageInfo, - FilePath = finalFilePath - }; + return new DownloadResult + { + Success = true, + State = UpdateState.Completed, + FailureReason = UpdateFailureReason.None, + Message = "Download finished.", + PackageInfo = packageInfo, + FilePath = finalFilePath + }; + }, effectiveCt).ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException ex) { return new DownloadResult { Success = false, - State = UpdateState.Canceled, - FailureReason = UpdateFailureReason.Canceled, - Message = "Download canceled.", - PackageInfo = packageInfo + State = cancellationToken.IsCancellationRequested ? UpdateState.Canceled : UpdateState.Failed, + FailureReason = cancellationToken.IsCancellationRequested ? UpdateFailureReason.Canceled : UpdateFailureReason.NetworkError, + Message = cancellationToken.IsCancellationRequested ? "Download canceled." : "Download timed out.", + PackageInfo = packageInfo, + Exception = ex }; } catch (HttpRequestException ex) @@ -210,7 +243,7 @@ public async Task DownloadAsync(UpdatePackageInfo packageInfo, A Exception = ex }; } - catch (IOException ex) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { return new DownloadResult { @@ -238,20 +271,82 @@ public async Task DownloadAsync(UpdatePackageInfo packageInfo, A private async Task<(string? ETag, string? LastModified, long? ContentLength, bool AcceptRanges)> GetRemoteInfoAsync(UpdatePackageInfo packageInfo, CancellationToken cancellationToken) { + using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + if (_httpOptions != null) + { + probeCts.CancelAfter(_httpOptions.RequestTimeout); + } using var headRequest = new HttpRequestMessage(HttpMethod.Head, packageInfo.DownloadUrl); - await ApplyAuthAsync(headRequest, packageInfo, cancellationToken).ConfigureAwait(false); - using var headResponse = await _httpClient.SendAsync(headRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - if (!headResponse.IsSuccessStatusCode) + try { - return (null, null, null, false); + await ApplyAuthAsync(headRequest, packageInfo, probeCts.Token).ConfigureAwait(false); + using var headResponse = await SendAsync(headRequest, probeCts.Token).ConfigureAwait(false); + // Some GET-signed URLs reject HEAD. Probe rejection must not prevent a fresh GET; + // redirects remain rejected and transient failures still consume the retry budget. + if ((int)headResponse.StatusCode >= 400 && !IsTransientStatus(headResponse.StatusCode)) + { + return (null, null, null, false); + } + headResponse.EnsureSuccessStatusCode(); + var acceptRanges = headResponse.Headers.AcceptRanges.Any(r => string.Equals(r, "bytes", StringComparison.OrdinalIgnoreCase)); + return ( + headResponse.Headers.ETag?.ToString(), + headResponse.Content.Headers.LastModified?.ToString(), + headResponse.Content.Headers.ContentLength, + acceptRanges); } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + throw new HttpRequestException("The HEAD probe timed out.", ex); + } + } - var acceptRanges = headResponse.Headers.AcceptRanges.Any(r => string.Equals(r, "bytes", StringComparison.OrdinalIgnoreCase)); - return ( - headResponse.Headers.ETag?.Tag, - headResponse.Content.Headers.LastModified?.ToString(), - headResponse.Content.Headers.ContentLength, - acceptRanges); + private Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + ReadNetworkAsync(() => _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken), cancellationToken); + + // Only transport reads are translated; local file I/O must not trigger network retries. + private static async Task ReadNetworkAsync(Func> read, CancellationToken cancellationToken) + { + try + { + return await read().ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or TimeoutException || + ex is OperationCanceledException && !cancellationToken.IsCancellationRequested) + { + throw new HttpRequestException("The network transfer was interrupted.", ex); + } + } + + private static RangeConditionHeaderValue? CreateIfRange(DownloadResumeMetadata metadata) + { + if (EntityTagHeaderValue.TryParse(metadata.ETag, out var etag) && !etag.IsWeak) + { + return new RangeConditionHeaderValue(etag); + } + return DateTimeOffset.TryParse(metadata.LastModified, out var modified) + ? new RangeConditionHeaderValue(modified) + : null; + } + + private static bool IsValidRange(HttpResponseMessage response, long offset, DownloadResumeMetadata metadata) + { + var range = response.Content.Headers.ContentRange; + if (range is null || !string.Equals(range.Unit, "bytes", StringComparison.OrdinalIgnoreCase) || + range.From != offset || !range.To.HasValue || !range.Length.HasValue || + range.To != range.Length - 1 || + (metadata.ExpectedFileSize > 0 && range.Length != metadata.ExpectedFileSize) || + (response.Content.Headers.ContentLength.HasValue && + response.Content.Headers.ContentLength != range.To - range.From + 1)) + { + return false; + } + if (offset == 0) return true; + if (response.Headers.ETag != null && metadata.ETag != null && + response.Headers.ETag.ToString() != metadata.ETag) return false; + if (response.Content.Headers.LastModified != null && metadata.LastModified != null && + response.Content.Headers.LastModified.Value.ToString() != metadata.LastModified) return false; + return true; } private async Task ApplyAuthAsync(HttpRequestMessage request, UpdatePackageInfo packageInfo, CancellationToken cancellationToken) @@ -270,7 +365,8 @@ private async Task ApplyAuthAsync(HttpRequestMessage request, UpdatePackageInfo } // Fall back to global auth when per-package is not set or not configured - if ((provider is null || provider is NoOpAuthProvider) && _globalAuthProvider != null) + if ((provider is null || provider is NoOpAuthProvider) && _globalAuthProvider != null && + _httpOptions?.IsDownloadAuthenticationAllowed(request.RequestUri, _options.UpdateServer?.RequestUrl) == true) { if (packageInfo.AuthScheme.HasValue) { @@ -279,20 +375,17 @@ private async Task ApplyAuthAsync(HttpRequestMessage request, UpdatePackageInfo provider = _globalAuthProvider; } - if (provider != null) + if (provider is not null and not NoOpAuthProvider) { + HttpDownloadOptions.EnsureAuthenticationTransport( + request.RequestUri, _httpOptions?.AllowInsecureAuthentication == true); await provider.ApplyAuthAsync(request, cancellationToken).ConfigureAwait(false); } } private async Task WithRetryAsync(Func> action, CancellationToken cancellationToken) { - if (_httpOptions == null || _httpOptions.MaxRetryAttempts <= 1) - { - return await action(cancellationToken).ConfigureAwait(false); - } - - var maxAttempts = _httpOptions.MaxRetryAttempts; + var maxAttempts = Math.Max(1, _httpOptions?.MaxRetryAttempts ?? 1); for (var attempt = 0; ; attempt++) { @@ -302,29 +395,26 @@ private async Task WithRetryAsync(Func> action, { return await action(cancellationToken).ConfigureAwait(false); } - catch (Exception ex) when (attempt < maxAttempts - 1 && IsTransient(ex)) + catch (Exception ex) when (!cancellationToken.IsCancellationRequested && attempt < maxAttempts - 1 && IsTransient(ex)) { var delay = TimeSpan.FromMilliseconds( - _httpOptions.RetryBaseDelay.TotalMilliseconds * Math.Pow(2, attempt)); + Math.Min(int.MaxValue, Math.Max(0, _httpOptions!.RetryBaseDelay.TotalMilliseconds) * Math.Pow(2, attempt))); _logger.LogWarning($"Download attempt {attempt + 1} failed with transient error. Retrying in {delay.TotalMilliseconds}ms. {ex.GetType().Name}: {ex.Message}"); await Task.Delay(delay, cancellationToken).ConfigureAwait(false); } } } - private static bool IsTransient(Exception ex) => ex switch - { - TimeoutException => true, - OperationCanceledException => false, - IOException ioe when ioe.InnerException is TimeoutException => true, - HttpRequestException hre => hre.StatusCode is - HttpStatusCode.RequestTimeout or - HttpStatusCode.InternalServerError or - HttpStatusCode.BadGateway or - HttpStatusCode.ServiceUnavailable or - HttpStatusCode.GatewayTimeout, - _ => false - }; + private static bool IsTransient(Exception ex) => + ex is HttpRequestException hre && IsTransientStatus(hre.StatusCode); + + private static bool IsTransientStatus(HttpStatusCode? status) => status is null or + HttpStatusCode.RequestTimeout or + HttpStatusCode.TooManyRequests or + HttpStatusCode.InternalServerError or + HttpStatusCode.BadGateway or + HttpStatusCode.ServiceUnavailable or + HttpStatusCode.GatewayTimeout; private async Task EnsureResumeConsistencyAsync( string tempFilePath, @@ -332,6 +422,7 @@ private async Task EnsureResumeConsistencyAsync( DownloadResumeMetadata expected, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); if (!_fileStorage.FileExists(tempFilePath)) { return false; @@ -370,12 +461,12 @@ private async Task EnsureResumeConsistencyAsync( private static bool CanResume(DownloadResumeMetadata expected, DownloadResumeMetadata actual) { - if (!string.Equals(expected.DownloadUrl, actual.DownloadUrl, StringComparison.OrdinalIgnoreCase)) return false; + if (!string.Equals(expected.DownloadUrl, actual.DownloadUrl, StringComparison.Ordinal)) return false; if (!string.Equals(expected.ExpectedSha256, actual.ExpectedSha256, StringComparison.OrdinalIgnoreCase)) return false; if (expected.ExpectedFileSize > 0 && actual.ExpectedFileSize > 0 && expected.ExpectedFileSize != actual.ExpectedFileSize) return false; if (!string.Equals(expected.FileName, actual.FileName, StringComparison.OrdinalIgnoreCase)) return false; - if (!string.IsNullOrWhiteSpace(expected.ETag) && !string.IsNullOrWhiteSpace(actual.ETag) && !string.Equals(expected.ETag, actual.ETag, StringComparison.Ordinal)) return false; - if (!string.IsNullOrWhiteSpace(expected.LastModified) && !string.IsNullOrWhiteSpace(actual.LastModified) && !string.Equals(expected.LastModified, actual.LastModified, StringComparison.Ordinal)) return false; + if (!string.Equals(expected.ETag, actual.ETag, StringComparison.Ordinal)) return false; + if (!string.Equals(expected.LastModified, actual.LastModified, StringComparison.Ordinal)) return false; return true; } diff --git a/src/GeneralUpdate.Avalonia.Android/Services/HttpUpdatePackageClient.cs b/src/GeneralUpdate.Avalonia.Android/Services/HttpUpdatePackageClient.cs index 7c94db4..d8676f0 100644 --- a/src/GeneralUpdate.Avalonia.Android/Services/HttpUpdatePackageClient.cs +++ b/src/GeneralUpdate.Avalonia.Android/Services/HttpUpdatePackageClient.cs @@ -11,7 +11,8 @@ namespace GeneralUpdate.Avalonia.Android.Services; /// /// Retrieves the update package metadata published by a server without comparing the installed /// version or starting a download. The caller owns the supplied , -/// including its timeout and transport configuration. +/// including its timeout, redirect policy and default-header configuration. +/// Internally created clients require direct endpoints and never follow redirects. /// internal sealed class HttpUpdatePackageClient : IDisposable { @@ -24,6 +25,8 @@ internal sealed class HttpUpdatePackageClient : IDisposable private readonly IHttpAuthProvider? _authProvider; private readonly IVersionComparer _versionComparer; private readonly bool _ownsClient; + private readonly Uri? _trustedAuthenticationOrigin; + private readonly bool _allowInsecureAuthentication; /// /// Creates a client from the HTTP transport settings when available, otherwise reuses @@ -38,11 +41,13 @@ internal static HttpUpdatePackageClient Create( { Timeout = httpOptions.RequestTimeout }; - return new HttpUpdatePackageClient(client, httpOptions.AuthProvider, versionComparer, ownsClient: true); + return new HttpUpdatePackageClient(client, httpOptions.AuthProvider, versionComparer, ownsClient: true, + trustedAuthenticationOrigin: httpOptions.TrustedAuthenticationOrigin, + allowInsecureAuthentication: httpOptions.AllowInsecureAuthentication); } return new HttpUpdatePackageClient( - httpClient ?? new HttpClient(), + httpClient ?? new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }), versionComparer: versionComparer, ownsClient: httpClient is null); } @@ -51,12 +56,16 @@ public HttpUpdatePackageClient( HttpClient httpClient, IHttpAuthProvider? authProvider = null, IVersionComparer? versionComparer = null, - bool ownsClient = false) + bool ownsClient = false, + Uri? trustedAuthenticationOrigin = null, + bool allowInsecureAuthentication = false) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _authProvider = authProvider; _versionComparer = versionComparer ?? new SystemVersionComparer(); _ownsClient = ownsClient; + _trustedAuthenticationOrigin = trustedAuthenticationOrigin; + _allowInsecureAuthentication = allowInsecureAuthentication; } public void Dispose() @@ -162,8 +171,14 @@ public void Dispose() private async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { request.Headers.Accept.ParseAdd("application/json"); - if (_authProvider is not null) + if (_authProvider is not null && + (_trustedAuthenticationOrigin is null || + HttpDownloadOptions.IsSameOrigin(request.RequestUri, _trustedAuthenticationOrigin))) { + if (_authProvider is not NoOpAuthProvider) + { + HttpDownloadOptions.EnsureAuthenticationTransport(request.RequestUri, _allowInsecureAuthentication); + } await _authProvider.ApplyAuthAsync(request, cancellationToken).ConfigureAwait(false); } diff --git a/tests/GeneralUpdate.Avalonia.Android.Tests/AuthenticationOriginTests.cs b/tests/GeneralUpdate.Avalonia.Android.Tests/AuthenticationOriginTests.cs new file mode 100644 index 0000000..cf8efa9 --- /dev/null +++ b/tests/GeneralUpdate.Avalonia.Android.Tests/AuthenticationOriginTests.cs @@ -0,0 +1,472 @@ +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using GeneralUpdate.Avalonia.Android.Abstractions; +using GeneralUpdate.Avalonia.Android.Enums; +using GeneralUpdate.Avalonia.Android.Models; +using GeneralUpdate.Avalonia.Android.Services; +using Xunit; + +namespace GeneralUpdate.Avalonia.Android.Tests; + +public sealed class AuthenticationOriginTests +{ + private const string VerificationUrl = "https://updates.example/verify"; + + [Theory] + [InlineData("https://updates.example/app.apk", true)] + [InlineData("https://UPDATES.example:443/packages/app.apk", true)] + [InlineData("http://updates.example/app.apk", false)] + [InlineData("https://updates.example:444/app.apk", false)] + [InlineData("https://updates.example.attacker.invalid/app.apk", false)] + [InlineData("https://cdn.example/app.apk", false)] + [InlineData("https://user@updates.example/app.apk", false)] + public async Task Downloads_GlobalCredentialsAreLimitedToVerificationOrigin(string url, bool authenticated) + { + await AssertDownloadAuthenticationAsync(url, VerificationUrl, authenticated); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("relative/verify")] + [InlineData("file:///verify")] + public async Task Downloads_MissingOrInvalidTrustedOriginFailsClosed(string? verificationUrl) + { + await AssertDownloadAuthenticationAsync("https://cdn.example/app.apk", verificationUrl, false); + } + + [Theory] + [InlineData(VerificationUrl)] + [InlineData(null)] + public async Task Downloads_ExplicitCdnOptInAllowsGlobalCredentials(string? verificationUrl) + { + await AssertDownloadAuthenticationAsync("https://cdn.example/app.apk", verificationUrl, true, + configure: options => options with + { + AllowedDownloadAuthenticationOrigins = new[] { new Uri("https://cdn.example/") } + }); + } + + [Theory] + [InlineData("http://cdn.example/app.apk")] + [InlineData("https://cdn.example:444/app.apk")] + [InlineData("https://cdn.example.attacker.invalid/app.apk")] + public async Task Downloads_CdnOptInIsExactOriginOnly(string url) + { + await AssertDownloadAuthenticationAsync(url, VerificationUrl, false, + configure: options => options with + { + AllowedDownloadAuthenticationOrigins = new[] { new Uri("https://cdn.example/") } + }); + } + + [Fact] + public async Task Downloads_ExplicitTrustedOriginWorksWithoutVerificationUrl() + { + await AssertDownloadAuthenticationAsync("https://cdn.example/app.apk", null, true, + configure: options => options with + { + TrustedAuthenticationOrigin = new Uri("https://cdn.example/") + }); + } + + [Fact] + public async Task Downloads_InvalidExplicitOriginDoesNotFallBackToVerificationOrigin() + { + await AssertDownloadAuthenticationAsync("https://updates.example/app.apk", VerificationUrl, false, + configure: options => options with + { + TrustedAuthenticationOrigin = new Uri("relative", UriKind.Relative) + }); + } + + [Theory] + [InlineData("https://updates.example/app.apk", true)] + [InlineData("https://cdn.example/app.apk", false)] + public async Task Downloads_MissingPackageCredentialsOnlyFallBackAtTrustedOrigin(string url, bool authenticated) + { + await AssertDownloadAuthenticationAsync(url, VerificationUrl, authenticated, packageScheme: AuthScheme.ApiKey); + } + + [Theory] + [InlineData("https://cdn.example/app.apk")] + [InlineData("https://updates.example/app.apk")] + public async Task Downloads_PerPackageCredentialsTakePrecedenceAtInitialPackageOrigin(string url) + { + await AssertDownloadAuthenticationAsync(url, VerificationUrl, false, + packageScheme: AuthScheme.ApiKey, packageToken: Guid.NewGuid().ToString("N")); + } + + [Theory] + [InlineData("https://updates.example/verify", true)] + [InlineData("https://cdn.example/verify", false)] + public async Task Verification_ExplicitOriginLimitsGlobalAuthentication(string url, bool authenticated) + { + var credential = Guid.NewGuid().ToString("N"); + using var http = new HttpClient(new DelegateHandler(request => + { + Assert.Equal(authenticated, request.Headers.Contains("X-Custom-Auth")); + return new HttpResponseMessage(HttpStatusCode.NoContent); + })); + using var client = new HttpUpdatePackageClient(http, + new ApiKeyAuthProvider(credential, "X-Custom-Auth"), + trustedAuthenticationOrigin: new Uri(VerificationUrl)); + + Assert.Null(await client.GetPackageInfoAsync(url)); + } + + [Fact] + public void InternalHandler_DisablesAutomaticRedirects() + { + using var handler = new HttpDownloadOptions().BuildHandler(); + Assert.False(handler.AllowAutoRedirect); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Verification_PlaintextAuthRequiresOptInBeforeProviderOrNetwork(bool allowInsecureAuthentication) + { + var provider = new RecordingAuthProvider(); + var networkCalls = 0; + using var http = new HttpClient(new DelegateHandler(request => + { + networkCalls++; + Assert.True(request.Headers.Contains("X-Custom-Auth")); + return new HttpResponseMessage(HttpStatusCode.NoContent); + })); + using var client = new HttpUpdatePackageClient(http, provider, + allowInsecureAuthentication: allowInsecureAuthentication); + + var request = client.GetPackageInfoAsync("http://updates.example/verify"); + if (allowInsecureAuthentication) + { + Assert.Null(await request); + } + else + { + await Assert.ThrowsAsync(() => request); + } + Assert.Equal(allowInsecureAuthentication ? 1 : 0, provider.Calls); + Assert.Equal(allowInsecureAuthentication ? 1 : 0, networkCalls); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Downloads_PlaintextAuthRequiresOptInForGlobalAndPackageCredentials( + bool packageCredentials, bool allowInsecureAuthentication) + { + var directory = Path.Combine(Directory.GetCurrentDirectory(), ".auth-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + var provider = new RecordingAuthProvider(); + var networkCalls = 0; + using var http = new HttpClient(new DelegateHandler(request => + { + networkCalls++; + Assert.True(request.Headers.Contains(packageCredentials ? "X-Api-Key" : "X-Custom-Auth")); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(new byte[] { 42 }) + }; + })); + using var downloader = new HttpResumableApkDownloader(http, new PhysicalFileStorage(), + new AndroidUpdateOptions + { + DownloadDirectoryPath = directory, + UpdateServer = new UpdateServerOptions { RequestUrl = "http://updates.example/verify" } + }, + new HttpDownloadOptions + { + AuthProvider = provider, + AllowInsecureAuthentication = allowInsecureAuthentication, + MaxRetryAttempts = 1 + }, ownsClient: false); + var result = await downloader.DownloadAsync(Package("http://updates.example/app.apk") with + { + AuthScheme = packageCredentials ? AuthScheme.ApiKey : null, + AuthToken = packageCredentials ? Guid.NewGuid().ToString("N") : null + }, null); + + Assert.Equal(allowInsecureAuthentication, result.Success); + Assert.Equal(allowInsecureAuthentication ? 2 : 0, networkCalls); + Assert.Equal(allowInsecureAuthentication && !packageCredentials ? 2 : 0, provider.Calls); + } + finally + { + Directory.Delete(directory, true); + } + } + + [Theory] + [InlineData(false, false, false)] + [InlineData(false, true, false)] + [InlineData(true, false, false)] + [InlineData(true, true, false)] + [InlineData(false, false, true)] + [InlineData(false, true, true)] + [InlineData(true, false, true)] + [InlineData(true, true, true)] + public async Task InternalVerificationClient_DoesNotForwardCustomAuthOnRedirect( + bool useOptions, bool sameOrigin, bool usePost) + { + using var source = StartListener(); + using var target = StartListener(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var sourceUrl = ListenerUrl(source); + var targetUrl = sameOrigin ? "/target" : ListenerUrl(target); + var credential = Guid.NewGuid().ToString("N"); + using var client = HttpUpdatePackageClient.Create(null, + useOptions ? new HttpDownloadOptions + { + AuthProvider = new ApiKeyAuthProvider(credential, "X-Custom-Auth"), + AllowInsecureAuthentication = true + } : null, + new SystemVersionComparer()); + + var responseTask = RespondWithRedirectAsync(source, targetUrl, timeout.Token); + var requestTask = usePost + ? client.GetPackageInfoAsync(sourceUrl, new UpdatePackageRequest + { + Version = "1.0.0", AppKey = "test-app", Platform = 42, ProductId = "test-product" + }, timeout.Token) + : client.GetPackageInfoAsync(sourceUrl, timeout.Token); + var incomingHeaders = await responseTask; + await Assert.ThrowsAsync(() => requestTask); + + Assert.Equal(useOptions, incomingHeaders.Contains("X-Custom-Auth: " + credential)); + Assert.StartsWith(usePost ? "POST " : "GET ", incomingHeaders); + Assert.False(source.Pending()); + Assert.False(target.Pending()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task InternalDownloader_DoesNotForwardCustomAuthOnRedirect(bool redirectHead) + { + using var source = StartListener(); + using var target = StartListener(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var sourceUrl = ListenerUrl(source); + var directory = Path.Combine(Directory.GetCurrentDirectory(), ".auth-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + using var downloader = new HttpResumableApkDownloader(new PhysicalFileStorage(), + new AndroidUpdateOptions + { + DownloadDirectoryPath = directory, + UpdateServer = new UpdateServerOptions { RequestUrl = sourceUrl } + }, + new HttpDownloadOptions + { + AuthProvider = new ApiKeyAuthProvider(Guid.NewGuid().ToString("N"), "X-Custom-Auth"), + AllowInsecureAuthentication = true, + MaxRetryAttempts = 1 + }); + var requests = new List(); + using var serverCancellation = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token); + async Task ServeRedirectsAsync() + { + try + { + while (true) + { + requests.Add(await RespondWithRedirectAsync(source, ListenerUrl(target), + serverCancellation.Token, redirect: redirectHead || requests.Count > 0)); + } + } + catch (OperationCanceledException) when (serverCancellation.IsCancellationRequested) + { + } + } + var serverTask = ServeRedirectsAsync(); + var result = await downloader.DownloadAsync(Package(sourceUrl + "app.apk"), null, timeout.Token); + await serverCancellation.CancelAsync(); + await serverTask; + + Assert.False(result.Success); + Assert.NotEmpty(requests); + Assert.Equal(redirectHead ? 1 : 2, requests.Count); + Assert.StartsWith(redirectHead ? "HEAD " : "GET ", requests[^1]); + Assert.All(requests, headers => Assert.Contains("X-Custom-Auth:", headers)); + Assert.False(target.Pending()); + } + finally + { + Directory.Delete(directory, true); + } + } + + [Fact] + public async Task InternalVerificationClient_DoesNotFollowHttpsToHttpDowngrade() + { + using var source = StartListener(); + using var target = StartListener(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var key = RSA.Create(2048); + var certificateRequest = new CertificateRequest("CN=localhost", key, + HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var certificate = certificateRequest.CreateSelfSigned( + DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddHours(1)); + var credential = Guid.NewGuid().ToString("N"); + using var client = HttpUpdatePackageClient.Create(null, new HttpDownloadOptions + { + AuthProvider = new ApiKeyAuthProvider(credential, "X-Custom-Auth"), + SslValidationPolicy = new TestCertificatePolicy(certificate.Thumbprint) + }, new SystemVersionComparer()); + async Task ServeHttpsAsync() + { + using var connection = await source.AcceptTcpClientAsync(timeout.Token); + await using var stream = new SslStream(connection.GetStream()); + await stream.AuthenticateAsServerAsync(new SslServerAuthenticationOptions + { + ServerCertificate = certificate + }, timeout.Token); + return await WriteResponseAsync(stream, ListenerUrl(target), timeout.Token); + } + + var responseTask = ServeHttpsAsync(); + var requestTask = client.GetPackageInfoAsync( + ListenerUrl(source).Replace("http://", "https://"), timeout.Token); + var headers = await responseTask; + await Assert.ThrowsAsync(() => requestTask); + + Assert.Contains("X-Custom-Auth: " + credential, headers); + Assert.False(target.Pending()); + } + + private static async Task AssertDownloadAuthenticationAsync( + string url, string? verificationUrl, bool authenticated, + Func? configure = null, + AuthScheme? packageScheme = null, string? packageToken = null) + { + var directory = Path.Combine(Directory.GetCurrentDirectory(), ".auth-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + var globalCredential = Guid.NewGuid().ToString("N"); + var methods = new List(); + using var http = new HttpClient(new DelegateHandler(request => + { + methods.Add(request.Method); + Assert.Equal(authenticated, request.Headers.Contains("X-Custom-Auth")); + if (authenticated) + { + Assert.Equal(globalCredential, Assert.Single(request.Headers.GetValues("X-Custom-Auth"))); + } + Assert.Equal(packageToken is not null, request.Headers.Contains("X-Api-Key")); + if (packageToken is not null) + { + Assert.Equal(packageToken, Assert.Single(request.Headers.GetValues("X-Api-Key"))); + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(new byte[] { 42 }) + }; + })); + var httpOptions = new HttpDownloadOptions + { + AuthProvider = new ApiKeyAuthProvider(globalCredential, "X-Custom-Auth"), + MaxRetryAttempts = 1 + }; + using var downloader = new HttpResumableApkDownloader(http, new PhysicalFileStorage(), + new AndroidUpdateOptions + { + DownloadDirectoryPath = directory, + UpdateServer = verificationUrl is null ? null : new UpdateServerOptions { RequestUrl = verificationUrl } + }, configure?.Invoke(httpOptions) ?? httpOptions, ownsClient: false); + + var result = await downloader.DownloadAsync(Package(url) with + { + AuthScheme = packageScheme, + AuthToken = packageToken + }, null); + + Assert.True(result.Success, result.Message + " " + result.Exception); + Assert.Contains(HttpMethod.Head, methods); + Assert.Contains(HttpMethod.Get, methods); + } + finally + { + Directory.Delete(directory, true); + } + } + + private static UpdatePackageInfo Package(string url) => new() + { + Version = "2.0.0", + DownloadUrl = url, + FileName = "app.apk", + FileSize = 1, + Sha256 = new string('a', 64) + }; + + private static TcpListener StartListener() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return listener; + } + + private static string ListenerUrl(TcpListener listener) + => $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}/"; + + private static async Task RespondWithRedirectAsync( + TcpListener listener, string destination, CancellationToken cancellationToken, bool redirect = true) + { + using var connection = await listener.AcceptTcpClientAsync(cancellationToken); + await using var stream = connection.GetStream(); + return await WriteResponseAsync(stream, destination, cancellationToken, redirect); + } + + private static async Task WriteResponseAsync( + Stream stream, string destination, CancellationToken cancellationToken, bool redirect = true) + { + using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true); + var headers = new StringBuilder(); + while (await reader.ReadLineAsync(cancellationToken) is { Length: > 0 } line) + { + headers.AppendLine(line); + } + var response = Encoding.ASCII.GetBytes(redirect + ? $"HTTP/1.1 302 Found\r\nLocation: {destination}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + : "HTTP/1.1 200 OK\r\nContent-Length: 1\r\nConnection: close\r\n\r\n"); + await stream.WriteAsync(response, cancellationToken); + return headers.ToString(); + } + + private sealed class TestCertificatePolicy(string thumbprint) : ISslValidationPolicy + { + public bool ValidateCertificate(X509Certificate2? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors) + => certificate?.Thumbprint == thumbprint; + } + + private sealed class DelegateHandler(Func send) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(send(request)); + } + + private sealed class RecordingAuthProvider : IHttpAuthProvider + { + public int Calls { get; private set; } + + public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + { + Calls++; + request.Headers.Add("X-Custom-Auth", Guid.NewGuid().ToString("N")); + return Task.CompletedTask; + } + } +} diff --git a/tests/GeneralUpdate.Avalonia.Android.Tests/BootstrapLifecycleTests.cs b/tests/GeneralUpdate.Avalonia.Android.Tests/BootstrapLifecycleTests.cs new file mode 100644 index 0000000..41377cc --- /dev/null +++ b/tests/GeneralUpdate.Avalonia.Android.Tests/BootstrapLifecycleTests.cs @@ -0,0 +1,575 @@ +using System.Net; +using GeneralUpdate.Avalonia.Android.Abstractions; +using GeneralUpdate.Avalonia.Android.Models; +using GeneralUpdate.Avalonia.Android.Services; +using Xunit; + +namespace GeneralUpdate.Avalonia.Android.Tests; + +public sealed class BootstrapLifecycleTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + private static readonly UpdatePackageInfo Package = new() + { + Version = "2.0.0", + DownloadUrl = "https://example.com/app.apk", + Sha256 = new string('a', 64), + FileName = "app.apk", + FileSize = 10 + }; + + [Fact] + public async Task HashCancellation_ReportsOneTerminalFailure_AndReleasesGate() + { + using var cancel = new CancellationTokenSource(); + var original = new OperationCanceledException(cancel.Token); + var hash = new HashValidator + { + Run = _ => + { + cancel.Cancel(); + throw original; + } + }; + var storage = new Storage { DeleteError = new UnauthorizedAccessException("cleanup") }; + using var bootstrap = Create(hash: hash, storage: storage); + var failures = ObserveFailures(bootstrap); + + var result = await bootstrap.DownloadAndVerifyAsync(Package, cancel.Token); + + AssertTerminal(bootstrap, failures, result, UpdateState.Canceled, UpdateFailureReason.Canceled); + Assert.Same(original, result.Exception); + Assert.Equal(1, storage.Deletes); + hash.Run = _ => Task.FromResult(new HashValidationResult { Success = true }); + Assert.True((await bootstrap.DownloadAndVerifyAsync(Package).WaitAsync(Timeout)).Success); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SizeReadFailure_PreservesOriginalDespiteCleanupFailure(bool denied) + { + Exception original = denied ? new UnauthorizedAccessException("size") : new IOException("size"); + var storage = new Storage { LengthError = original, DeleteError = new IOException("cleanup") }; + using var bootstrap = Create(storage: storage); + var failures = ObserveFailures(bootstrap); + + var result = await bootstrap.DownloadAndVerifyAsync(Package); + + AssertTerminal(bootstrap, failures, result, UpdateState.Failed, UpdateFailureReason.FileIoError); + Assert.Same(original, result.Exception); + Assert.Equal("app.apk", result.FilePath); + storage.LengthError = null; + Assert.True((await bootstrap.DownloadAndVerifyAsync(Package).WaitAsync(Timeout)).Success); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RejectedFileCleanupFailure_DoesNotReplaceMismatch(bool sizeMismatch) + { + var original = new InvalidDataException("hash"); + var hash = new HashValidator + { + Run = _ => Task.FromResult(new HashValidationResult + { + Success = false, + FailureReason = UpdateFailureReason.HashMismatch, + Exception = original + }) + }; + var storage = new Storage { Length = sizeMismatch ? 3 : 10, DeleteError = new UnauthorizedAccessException("cleanup") }; + using var bootstrap = Create(hash: hash, storage: storage); + var failures = ObserveFailures(bootstrap); + + var result = await bootstrap.DownloadAndVerifyAsync(Package); + + AssertTerminal(bootstrap, failures, result, UpdateState.Failed, + sizeMismatch ? UpdateFailureReason.FileIoError : UpdateFailureReason.HashMismatch); + Assert.Equal(1, storage.Deletes); + if (sizeMismatch) + Assert.Contains("size mismatch", result.Message); + else + Assert.Same(original, result.Exception); + } + + [Fact] + public async Task HashReturnedCancellation_RemainsCanceled() + { + var hash = new HashValidator + { + Run = _ => Task.FromResult(new HashValidationResult { State = UpdateState.Canceled, FailureReason = UpdateFailureReason.Canceled }) + }; + using var bootstrap = Create(hash: hash); + var failures = ObserveFailures(bootstrap); + var result = await bootstrap.DownloadAndVerifyAsync(Package); + AssertTerminal(bootstrap, failures, result, UpdateState.Canceled, UpdateFailureReason.Canceled); + } + + [Fact] + public async Task InstallPermissionException_ReportsOneTerminalFailure() + { + var original = new UnauthorizedAccessException("permission"); + using var bootstrap = Create(installer: new Installer { Run = _ => throw original }); + var failures = ObserveFailures(bootstrap); + var result = await bootstrap.LaunchInstallerAsync(Package, "app.apk"); + AssertTerminal(bootstrap, failures, result, UpdateState.Failed, UpdateFailureReason.InstallPermissionDenied); + Assert.Same(original, result.Exception); + } + + [Fact] + public async Task Dispose_CancelsActiveOperationAndWaiters_ButDefersResourcesUntilDrain() + { + var started = Signal(); + var cancellationSeen = Signal(); + var finish = Signal(); + var downloader = new Downloader + { + Run = async token => + { + using var registration = token.Register(() => cancellationSeen.TrySetResult()); + started.TrySetResult(); + await finish.Task; + token.ThrowIfCancellationRequested(); + return Downloaded(); + } + }; + var bootstrap = Create(downloader: downloader); + var failures = ObserveFailures(bootstrap); + var active = bootstrap.DownloadAndVerifyAsync(Package); + await started.Task.WaitAsync(Timeout); + var waitingDownload = bootstrap.DownloadAndVerifyAsync(Package); + var waitingValidation = bootstrap.ValidateAsync("1.0.0"); + var waitingInstall = bootstrap.LaunchInstallerAsync(Package, "app.apk"); + + bootstrap.Dispose(); + var drained = bootstrap.DisposeAsync().AsTask(); + await cancellationSeen.Task.WaitAsync(Timeout); + await Assert.ThrowsAnyAsync(() => waitingDownload.WaitAsync(Timeout)); + await Assert.ThrowsAnyAsync(() => waitingValidation.WaitAsync(Timeout)); + await Assert.ThrowsAnyAsync(() => waitingInstall.WaitAsync(Timeout)); + Assert.False(drained.IsCompleted); + Assert.Equal(0, downloader.DisposeCount); + await Assert.ThrowsAsync(() => bootstrap.DownloadAndVerifyAsync(Package)); + + finish.TrySetResult(); + var result = await active.WaitAsync(Timeout); + await drained.WaitAsync(Timeout); + Assert.Equal(UpdateState.Canceled, result.State); + Assert.Single(failures); + Assert.Equal(1, downloader.DisposeCount); + await bootstrap.DisposeAsync(); + Assert.Equal(1, downloader.DisposeCount); + } + + [Fact] + public async Task CallerCancellationWhileWaiting_DoesNotChangeActiveStateOrRaiseFailure() + { + var finish = Signal(); + var downloader = new Downloader { Run = async _ => { await finish.Task; return Downloaded(); } }; + using var bootstrap = Create(downloader: downloader); + var failures = ObserveFailures(bootstrap); + var active = bootstrap.DownloadAndVerifyAsync(Package); + using var cancel = new CancellationTokenSource(); + var waiting = bootstrap.DownloadAndVerifyAsync(Package, cancel.Token); + cancel.Cancel(); + await Assert.ThrowsAnyAsync(() => waiting.WaitAsync(Timeout)); + Assert.Equal(UpdateState.Downloading, bootstrap.GetSnapshot().State); + Assert.Empty(failures); + finish.TrySetResult(); + Assert.True((await active.WaitAsync(Timeout)).Success); + } + + [Fact] + public async Task SynchronousDisposeFromProgress_DoesNotDeadlockOrDisposeActiveDownloader() + { + var downloader = new Downloader(); + var bootstrap = Create(downloader: downloader); + var failures = ObserveFailures(bootstrap); + var disposedDuringCallback = -1; + bootstrap.AddListenerDownloadProgressChanged += (_, _) => + { + bootstrap.Dispose(); + disposedDuringCallback = downloader.DisposeCount; + }; + + var result = await Task.Run(() => bootstrap.DownloadAndVerifyAsync(Package)).WaitAsync(Timeout); + + Assert.Equal(UpdateState.Canceled, result.State); + Assert.Single(failures); + Assert.Equal(0, disposedDuringCallback); + await bootstrap.DisposeAsync().AsTask().WaitAsync(Timeout); + Assert.Equal(1, downloader.DisposeCount); + } + + [Fact] + public async Task ThrowingProgressAndCompletionSubscribers_DoNotBlockOtherSubscribersOrSuccess() + { + using var bootstrap = Create(logger: new ThrowingLogger()); + var progress = 0; + var completed = 0; + bootstrap.AddListenerDownloadProgressChanged += (_, _) => throw new InvalidOperationException("progress"); + bootstrap.AddListenerDownloadProgressChanged += (_, _) => progress++; + bootstrap.AddListenerUpdateCompleted += (_, _) => throw new InvalidOperationException("completed"); + bootstrap.AddListenerUpdateCompleted += (_, _) => completed++; + + var result = await bootstrap.DownloadAndVerifyAsync(Package); + + Assert.True(result.Success); + Assert.Equal(1, progress); + Assert.Equal(1, completed); + Assert.Equal(UpdateState.ReadyToInstall, bootstrap.GetSnapshot().State); + } + + [Fact] + public async Task ThrowingFailureSubscriberAndLogger_DoNotMaskOriginalFailure() + { + var original = new IOException("size"); + using var bootstrap = Create(storage: new Storage { LengthError = original }, logger: new ThrowingLogger()); + bootstrap.AddListenerUpdateFailed += (_, _) => throw new InvalidOperationException("subscriber"); + var failures = ObserveFailures(bootstrap); + var result = await bootstrap.DownloadAndVerifyAsync(Package); + AssertTerminal(bootstrap, failures, result, UpdateState.Failed, UpdateFailureReason.FileIoError); + Assert.Same(original, result.Exception); + } + + [Fact] + public async Task QueuedDispatcher_IsPreserved_AndCatchesSubscriberExceptionsWhenInvokedLater() + { + var dispatcher = new QueuedDispatcher(); + using var bootstrap = Create(dispatcher: dispatcher); + var observed = 0; + bootstrap.AddListenerUpdateCompleted += (_, _) => throw new InvalidOperationException("subscriber"); + bootstrap.AddListenerUpdateCompleted += (_, _) => observed++; + + Assert.True((await bootstrap.DownloadAndVerifyAsync(Package)).Success); + Assert.Equal(0, observed); + Assert.Single(dispatcher.Callbacks)(); + Assert.Equal(1, observed); + } + + [Fact] + public async Task ThrowingDispatcher_DoesNotFailOperation() + { + using var bootstrap = Create(dispatcher: new ThrowingDispatcher()); + bootstrap.AddListenerDownloadProgressChanged += (_, _) => { }; + bootstrap.AddListenerUpdateCompleted += (_, _) => { }; + Assert.True((await bootstrap.DownloadAndVerifyAsync(Package)).Success); + } + + [Fact] + public async Task ThrowingValidationSubscriber_DoesNotBlockOtherSubscribers() + { + using var http = new HttpClient(new MetadataHandler()); + using var bootstrap = Create(http: http); + var observed = 0; + bootstrap.AddListenerValidate += (_, _) => throw new InvalidOperationException("validation"); + bootstrap.AddListenerValidate += (_, _) => observed++; + + var result = await bootstrap.ValidateAsync("1.0.0"); + + Assert.True(result.UpdateFound); + Assert.Equal(UpdateState.UpdateAvailable, bootstrap.GetSnapshot().State); + Assert.Equal(1, observed); + } + + [Fact] + public async Task ThrowingPrecheck_FailsClosedWithOriginalException_DespiteThrowingLoggerAndSubscriber() + { + using var http = new HttpClient(new MetadataHandler()); + using var bootstrap = Create(http: http, logger: new ThrowingLogger()); + var original = new InvalidOperationException("policy unavailable"); + var validateNotifications = 0; + bootstrap.AddListenerUpdatePrecheck(_ => throw original); + bootstrap.AddListenerValidate += (_, _) => validateNotifications++; + bootstrap.AddListenerUpdateFailed += (_, _) => throw new InvalidOperationException("subscriber"); + var failures = ObserveFailures(bootstrap); + + var result = await bootstrap.ValidateAsync("1.0.0"); + + AssertTerminal(bootstrap, failures, result, UpdateState.Failed, UpdateFailureReason.Unknown); + Assert.False(result.UpdateFound); + Assert.Same(original, result.Exception); + Assert.Equal(0, validateNotifications); + Assert.Equal("2.0.0", result.PackageInfo!.Version); + bootstrap.AddListenerUpdatePrecheck(_ => false); + Assert.True((await bootstrap.ValidateAsync("1.0.0").WaitAsync(Timeout)).UpdateFound); + Assert.Equal(1, validateNotifications); + Assert.Single(failures); + } + + [Theory] + [InlineData("http://updates.example/check", false)] + [InlineData("http://updates.example/check", true)] + [InlineData("https://user@updates.example/check", false)] + [InlineData("https://user@updates.example/check", true)] + public async Task RejectedAuthenticationTransport_IsTerminalInvalidMetadataBeforeProviderOrNetwork( + string requestUrl, bool useJsonEndpoint) + { + var provider = new RecordingAuthProvider(); + var proxy = new BlockingRecordingProxy(); + using var bootstrap = new AndroidBootstrap( + new SystemVersionComparer(), new Downloader(), new HashValidator(), new Installer(), new Storage(), + updateServer: new UpdateServerOptions { RequestUrl = requestUrl, UseJsonEndpoint = useJsonEndpoint }, + httpOptions: new HttpDownloadOptions { AuthProvider = provider, UseProxy = true, Proxy = proxy }); + var failures = ObserveFailures(bootstrap); + var validateNotifications = 0; + var prechecks = 0; + bootstrap.AddListenerValidate += (_, _) => validateNotifications++; + bootstrap.AddListenerUpdatePrecheck(_ => { prechecks++; return false; }); + + var result = await bootstrap.ValidateAsync("1.0.0").WaitAsync(Timeout); + + AssertTerminal(bootstrap, failures, result, UpdateState.Failed, UpdateFailureReason.InvalidMetadata); + Assert.NotNull(result.Exception); + Assert.Contains("Authentication requires", result.Exception.Message); + Assert.False(result.UpdateFound); + Assert.Equal(0, provider.Calls); + Assert.Equal(0, proxy.Calls); + Assert.Equal(0, validateNotifications); + Assert.Equal(0, prechecks); + } + + [Fact] + public async Task ShutdownCancellationCallbackException_DoesNotPreventDrain() + { + var started = Signal(); + var finish = Signal(); + var downloader = new Downloader + { + Run = async token => + { + using var registration = token.Register(() => throw new InvalidOperationException("cancellation callback")); + started.TrySetResult(); + await finish.Task; + token.ThrowIfCancellationRequested(); + return Downloaded(); + } + }; + var bootstrap = Create(downloader: downloader); + var active = bootstrap.DownloadAndVerifyAsync(Package); + await started.Task.WaitAsync(Timeout); + bootstrap.Dispose(); + finish.TrySetResult(); + Assert.Equal(UpdateState.Canceled, (await active.WaitAsync(Timeout)).State); + await bootstrap.DisposeAsync().AsTask().WaitAsync(Timeout); + Assert.Equal(1, downloader.DisposeCount); + } + + [Fact] + public async Task DisposeDuringHashing_CancelsAndDrainsBeforeDisposingDownloader() + { + var started = Signal(); + var downloader = new Downloader(); + var hash = new HashValidator + { + Run = async token => + { + started.TrySetResult(); + await Task.Delay(System.Threading.Timeout.Infinite, token); + return new HashValidationResult { Success = true }; + } + }; + var bootstrap = Create(downloader: downloader, hash: hash); + var failures = ObserveFailures(bootstrap); + var active = bootstrap.DownloadAndVerifyAsync(Package); + await started.Task.WaitAsync(Timeout); + + var drained = bootstrap.DisposeAsync().AsTask(); + + Assert.Equal(UpdateState.Canceled, (await active.WaitAsync(Timeout)).State); + await drained.WaitAsync(Timeout); + Assert.Single(failures); + Assert.Equal(1, downloader.DisposeCount); + } + + [Fact] + public async Task DisposeDuringInstaller_CancelsAndDrains() + { + var started = Signal(); + var downloader = new Downloader(); + var installer = new Installer + { + Run = async token => + { + started.TrySetResult(); + await Task.Delay(System.Threading.Timeout.Infinite, token); + return new InstallResult { Success = true }; + } + }; + var bootstrap = Create(downloader: downloader, installer: installer); + var failures = ObserveFailures(bootstrap); + var active = bootstrap.LaunchInstallerAsync(Package, "app.apk"); + await started.Task.WaitAsync(Timeout); + + bootstrap.Dispose(); + + Assert.Equal(UpdateState.Canceled, (await active.WaitAsync(Timeout)).State); + await bootstrap.DisposeAsync().AsTask().WaitAsync(Timeout); + Assert.Single(failures); + Assert.Equal(1, downloader.DisposeCount); + } + + [Fact] + public async Task ConcurrentDisposeAndOperationAdmission_DoesNotRaceSemaphoreDisposal() + { + for (var iteration = 0; iteration < 50; iteration++) + { + var downloader = new Downloader(); + var bootstrap = Create(downloader: downloader); + var operations = Enumerable.Range(0, 4).Select(_ => Task.Run(async () => + { + try + { + var result = await bootstrap.DownloadAndVerifyAsync(Package); + Assert.True(result.Success || result.State == UpdateState.Canceled); + } + catch (ObjectDisposedException ex) + { + Assert.Contains(nameof(AndroidBootstrap), ex.ObjectName); + } + catch (OperationCanceledException) + { + // An admitted gate waiter can be canceled before entering the operation. + } + })).ToArray(); + await Task.WhenAll(Task.Run(bootstrap.Dispose), Task.Run(bootstrap.Dispose)); + await Task.WhenAll(operations).WaitAsync(Timeout); + await bootstrap.DisposeAsync().AsTask().WaitAsync(Timeout); + Assert.Equal(1, downloader.DisposeCount); + } + } + + private static AndroidBootstrap Create(Downloader? downloader = null, HashValidator? hash = null, + Storage? storage = null, Installer? installer = null, IUpdateEventDispatcher? dispatcher = null, + IUpdateLogger? logger = null, HttpClient? http = null) => + new(new SystemVersionComparer(), downloader ?? new Downloader(), hash ?? new HashValidator(), + installer ?? new Installer(), storage ?? new Storage(), dispatcher, logger, + http is null ? null : new UpdateServerOptions { RequestUrl = "https://example.com/check", UseJsonEndpoint = true }, http); + + private static List ObserveFailures(AndroidBootstrap bootstrap) + { + var results = new List(); + bootstrap.AddListenerUpdateFailed += (_, args) => results.Add(args.Result); + return results; + } + + private static void AssertTerminal(AndroidBootstrap bootstrap, List failures, + UpdateOperationResult result, UpdateState state, UpdateFailureReason reason) + { + Assert.False(result.Success); + Assert.Equal(state, result.State); + Assert.Equal(reason, result.FailureReason); + Assert.Equal(state, bootstrap.GetSnapshot().State); + Assert.Equal(reason, bootstrap.GetSnapshot().FailureReason); + Assert.Same(result, Assert.Single(failures)); + } + + private static TaskCompletionSource Signal() => new(TaskCreationOptions.RunContinuationsAsynchronously); + private static DownloadResult Downloaded() => new() { Success = true, FilePath = "app.apk", PackageInfo = Package }; + + private sealed class Downloader : IUpdateDownloader, IDisposable + { + public Func> Run { get; init; } = _ => Task.FromResult(Downloaded()); + public int DisposeCount { get; private set; } + public Task DownloadAsync(UpdatePackageInfo packageInfo, Action? progressCallback, CancellationToken cancellationToken = default) + { + progressCallback?.Invoke(new DownloadProgressInfo + { + PackageInfo = packageInfo, DownloadSpeedBytesPerSecond = 0, DownloadedBytes = 0, + RemainingBytes = 10, TotalBytes = 10, ProgressPercentage = 0, StatusDescription = "starting" + }); + return Run(cancellationToken); + } + public void Dispose() => DisposeCount++; + } + + private sealed class HashValidator : IHashValidator + { + public Func> Run { get; set; } = + _ => Task.FromResult(new HashValidationResult { Success = true }); + public Task ValidateSha256Async(string filePath, string expectedSha256, CancellationToken cancellationToken = default) => Run(cancellationToken); + } + + private sealed class Installer : IApkInstaller + { + public Func> Run { get; init; } = + _ => Task.FromResult(new InstallResult { Success = true, State = UpdateState.Installing }); + public Task LaunchInstallAsync(UpdatePackageInfo packageInfo, string apkFilePath, CancellationToken cancellationToken = default) => Run(cancellationToken); + } + + private sealed class Storage : IFileStorage + { + public long Length { get; init; } = 10; + public Exception? LengthError { get; set; } + public Exception? DeleteError { get; init; } + public int Deletes { get; private set; } + public long GetFileLength(string path) => LengthError is null ? Length : throw LengthError; + public void DeleteFile(string path) + { + Deletes++; + if (DeleteError is not null) throw DeleteError; + } + public void EnsureDirectory(string path) { } + public bool FileExists(string path) => true; + public void MoveFile(string sourceFilePath, string destinationFilePath, bool overwrite) { } + public Stream OpenRead(string filePath) => new MemoryStream(); + public Stream OpenWrite(string filePath, bool append) => new MemoryStream(); + public Task ReadAllTextAsync(string path, CancellationToken cancellationToken = default) => Task.FromResult(null); + public Task WriteAllTextAsync(string path, string content, CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + private sealed class ThrowingLogger : IUpdateLogger + { + public void LogDebug(string message) { } + public void LogInformation(string message) { } + public void LogWarning(string message) { } + public void LogError(string message, Exception? exception = null) => throw new InvalidOperationException("logger"); + } + + private sealed class RecordingAuthProvider : IHttpAuthProvider + { + public int Calls { get; private set; } + public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + { + Calls++; + return Task.CompletedTask; + } + } + + private sealed class BlockingRecordingProxy : IWebProxy + { + public int Calls { get; private set; } + public ICredentials? Credentials { get; set; } + public Uri? GetProxy(Uri destination) + { + Calls++; + throw new InvalidOperationException("Unexpected network dispatch."); + } + public bool IsBypassed(Uri host) + { + Calls++; + throw new InvalidOperationException("Unexpected network dispatch."); + } + } + + private sealed class QueuedDispatcher : IUpdateEventDispatcher + { + public List Callbacks { get; } = []; + public void Dispatch(Action callback) => Callbacks.Add(callback); + } + + private sealed class ThrowingDispatcher : IUpdateEventDispatcher + { + public void Dispatch(Action callback) => throw new InvalidOperationException("dispatcher"); + } + + private sealed class MetadataHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent($$"""{"version":"2.0.0","downloadUrl":"https://example.com/app.apk","sha256":"{{Package.Sha256}}","fileSize":10,"fileName":"app.apk"}""") + }); + } +} diff --git a/tests/GeneralUpdate.Avalonia.Android.Tests/HttpResumableApkDownloaderTests.cs b/tests/GeneralUpdate.Avalonia.Android.Tests/HttpResumableApkDownloaderTests.cs new file mode 100644 index 0000000..fbace36 --- /dev/null +++ b/tests/GeneralUpdate.Avalonia.Android.Tests/HttpResumableApkDownloaderTests.cs @@ -0,0 +1,556 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using GeneralUpdate.Avalonia.Android.Abstractions; +using GeneralUpdate.Avalonia.Android.Enums; +using GeneralUpdate.Avalonia.Android.Models; +using GeneralUpdate.Avalonia.Android.Services; +using Xunit; + +namespace GeneralUpdate.Avalonia.Android.Tests; + +public sealed class HttpResumableApkDownloaderTests +{ + private static readonly byte[] Bytes = [1, 2, 3, 4, 5, 6]; + private static readonly UpdatePackageInfo Package = new() + { + DownloadUrl = "https://example.com/app.apk", Sha256 = new string('a', 64), + Version = "2.0.0", FileName = "app.apk", FileSize = 6 + }; + private static readonly AndroidUpdateOptions Options = new() { DownloadDirectoryPath = "downloads" }; + private static readonly HttpDownloadOptions HttpOptions = new() { MaxRetryAttempts = 3, RetryBaseDelay = TimeSpan.Zero }; + private static readonly string Partial = Path.Combine("downloads", "app.apk.part"); + private static readonly string Final = Path.Combine("downloads", "app.apk"); + + [Theory] + [InlineData(HttpStatusCode.ServiceUnavailable)] + [InlineData(HttpStatusCode.RequestTimeout)] + [InlineData(HttpStatusCode.TooManyRequests)] + public async Task TransientGetStatus_RetriesWithFreshRequests(HttpStatusCode status) + { + var gets = 0; + var requests = new HashSet(); + using var handler = new Handler((request, _) => + { + Assert.True(requests.Add(request)); + return Task.FromResult(request.Method == HttpMethod.Head ? Head() : + ++gets == 1 ? new HttpResponseMessage(status) : Body()); + }); + var storage = new Storage(); + using var client = new HttpClient(handler); + using var downloader = Create(client, storage); + + var result = await downloader.DownloadAsync(Package, null); + + Assert.True(result.Success, result.Exception?.ToString()); + Assert.Equal(2, gets); + Assert.Equal(Bytes, storage.Files[Final]); + } + + [Fact] + public async Task HeadAndGetFailures_ShareBoundedAttemptBudget() + { + var heads = 0; + var gets = 0; + using var handler = new Handler((request, _) => + { + if (request.Method == HttpMethod.Head) + return Task.FromResult(++heads == 1 ? new HttpResponseMessage(HttpStatusCode.BadGateway) : Head()); + gets++; + throw new HttpRequestException("Connection reset"); + }); + using var client = new HttpClient(handler); + using var downloader = Create(client, new Storage()); + + var result = await downloader.DownloadAsync(Package, null); + + Assert.Equal(UpdateFailureReason.NetworkError, result.FailureReason); + Assert.Equal(3, heads); + Assert.Equal(2, gets); + } + + [Theory] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.MethodNotAllowed)] + [InlineData(HttpStatusCode.NotImplemented)] + public async Task RejectedHead_FallsBackToFreshGetWithoutResuming(HttpStatusCode status) + { + var storage = new Storage(); + SeedPartial(storage); + var gets = 0; + using var handler = new Handler((request, _) => + { + if (request.Method == HttpMethod.Head) return Task.FromResult(new HttpResponseMessage(status)); + gets++; + Assert.Null(request.Headers.Range); + Assert.Null(request.Headers.IfRange); + return Task.FromResult(Body()); + }); + using var client = new HttpClient(handler); + using var downloader = Create(client, storage); + + Assert.True((await downloader.DownloadAsync(Package, null)).Success); + Assert.Equal(1, gets); + Assert.Equal(Bytes, storage.Files[Final]); + } + + [Fact] + public async Task RedirectedHead_IsRejectedWithoutGetOrRetry() + { + var requests = 0; + using var handler = new Handler((request, _) => + { + requests++; + Assert.Equal(HttpMethod.Head, request.Method); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Redirect)); + }); + using var client = new HttpClient(handler); + using var downloader = Create(client, new Storage()); + + Assert.Equal(UpdateFailureReason.NetworkError, (await downloader.DownloadAsync(Package, null)).FailureReason); + Assert.Equal(1, requests); + } + + [Fact] + public async Task PermanentGetFailure_IsNotRetried() + { + var gets = 0; + using var handler = new Handler((request, _) => Task.FromResult(request.Method == HttpMethod.Head + ? Head() : ++gets > 0 ? new HttpResponseMessage(HttpStatusCode.Forbidden) : Body())); + using var client = new HttpClient(handler); + using var downloader = Create(client, new Storage()); + Assert.Equal(UpdateFailureReason.NetworkError, (await downloader.DownloadAsync(Package, null)).FailureReason); + Assert.Equal(1, gets); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task InterruptedBody_ResumesUsingValidatedRange(bool prematureEnd) + { + var gets = 0; + using var handler = new Handler((request, _) => + { + if (request.Method == HttpMethod.Head) return Task.FromResult(Head()); + if (++gets == 1) + { + var response = Body(); + response.Content = new StreamContent(prematureEnd + ? new MemoryStream(Bytes[..3]) : new BrokenReadStream()); + response.Content.Headers.ContentLength = Bytes.Length; + return Task.FromResult(response); + } + Assert.Equal(3, request.Headers.Range!.Ranges.Single().From); + Assert.Equal("\"version-one\"", request.Headers.IfRange!.EntityTag!.Tag); + return Task.FromResult(Body(3)); + }); + var storage = new Storage(); + using var client = new HttpClient(handler); + using var downloader = Create(client, storage); + + Assert.True((await downloader.DownloadAsync(Package, null)).Success); + Assert.Equal(2, gets); + Assert.Equal(Bytes, storage.Files[Final]); + } + + [Theory] + [InlineData("416")] + [InlineData("wrong-offset")] + [InlineData("wrong-total")] + [InlineData("changed-etag")] + [InlineData("ignored-range")] + public async Task StaleOrInvalidRange_RestartsWithoutAppending(string failure) + { + var storage = new Storage(); + SeedPartial(storage); + var gets = 0; + using var handler = new Handler((request, _) => + { + if (request.Method == HttpMethod.Head) return Task.FromResult(Head()); + if (++gets == 1) + { + Assert.NotNull(request.Headers.Range); + var response = failure == "416" ? new HttpResponseMessage(HttpStatusCode.RequestedRangeNotSatisfiable) + : failure == "ignored-range" ? Body() : Body(3); + if (failure == "wrong-offset") response.Content.Headers.ContentRange = new ContentRangeHeaderValue(2, 5, 6); + if (failure == "wrong-total") response.Content.Headers.ContentRange = new ContentRangeHeaderValue(3, 6, 7); + if (failure == "changed-etag") response.Headers.ETag = new EntityTagHeaderValue("\"other\""); + return Task.FromResult(response); + } + Assert.Null(request.Headers.Range); + return Task.FromResult(Body()); + }); + using var client = new HttpClient(handler); + using var downloader = Create(client, storage); + + Assert.True((await downloader.DownloadAsync(Package, null)).Success); + Assert.Equal(failure == "ignored-range" ? 1 : 2, gets); + Assert.Equal(Bytes, storage.Files[Final]); + } + + [Theory] + [InlineData(null)] + [InlineData("{invalid-json")] + public async Task MissingOrCorruptSidecar_RestartsWithoutAppending(string? sidecar) + { + var storage = new Storage { Sidecar = sidecar }; + storage.Files[Partial] = Bytes[..3]; + using var handler = new Handler((request, _) => + { + if (request.Method == HttpMethod.Head) return Task.FromResult(Head()); + Assert.Null(request.Headers.Range); + Assert.Null(request.Headers.IfRange); + return Task.FromResult(Body()); + }); + using var client = new HttpClient(handler); + using var downloader = Create(client, storage); + + Assert.True((await downloader.DownloadAsync(Package, null)).Success); + Assert.Equal(Bytes, storage.Files[Final]); + Assert.False(storage.FileExists(Partial)); + } + + [Fact] + public async Task EtagChangesBetweenAttempts_RestartsWithoutAppending() + { + var storage = new Storage(); + var heads = 0; + var gets = 0; + using var handler = new Handler((request, _) => + { + if (request.Method == HttpMethod.Head) + { + var head = Head(); + if (++heads > 1) head.Headers.ETag = new EntityTagHeaderValue("\"version-two\""); + return Task.FromResult(head); + } + var response = Body(); + if (++gets == 1) + { + response.Content = new StreamContent(new BrokenReadStream()); + response.Content.Headers.ContentLength = Bytes.Length; + } + else + { + Assert.Null(request.Headers.Range); + Assert.Null(request.Headers.IfRange); + response.Headers.ETag = new EntityTagHeaderValue("\"version-two\""); + } + return Task.FromResult(response); + }); + using var client = new HttpClient(handler); + using var downloader = Create(client, storage); + + Assert.True((await downloader.DownloadAsync(Package, null)).Success); + Assert.Equal(2, gets); + Assert.Equal(Bytes, storage.Files[Final]); + } + + [Fact] + public async Task StreamFailures_ExhaustRetriesAndPreservePartial() + { + var gets = 0; + using var handler = new Handler((request, _) => + { + if (request.Method == HttpMethod.Head) return Task.FromResult(Head()); + gets++; + var response = Body(); + response.Content = new StreamContent(new BrokenReadStream()); + response.Content.Headers.ContentLength = 6; + return Task.FromResult(response); + }); + var storage = new Storage(); + using var client = new HttpClient(handler); + using var downloader = Create(client, storage); + + var result = await downloader.DownloadAsync(Package, null); + + Assert.Equal(UpdateFailureReason.NetworkError, result.FailureReason); + Assert.Equal(3, gets); + Assert.False(storage.FileExists(Final)); + Assert.Equal(Bytes[..3], storage.Files[Partial]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task LocalStorageFailures_AreNotRetried(bool denied) + { + var gets = 0; + var storage = new Storage { OpenWriteError = denied ? new UnauthorizedAccessException() : new IOException("Disk full") }; + using var handler = new Handler((request, _) => + { + if (request.Method == HttpMethod.Head) return Task.FromResult(Head()); + gets++; + return Task.FromResult(Body()); + }); + using var client = new HttpClient(handler); + using var downloader = Create(client, storage); + + Assert.Equal(UpdateFailureReason.FileIoError, (await downloader.DownloadAsync(Package, null)).FailureReason); + Assert.Equal(1, gets); + } + + [Theory] + [InlineData("sidecar")] + [InlineData("write")] + [InlineData("flush")] + public async Task StorageWriteFailures_AreNotTreatedAsTransportFailures(string phase) + { + static Task Fail(CancellationToken _) => throw new IOException("Disk full"); + var storage = new Storage + { + SidecarWait = phase == "sidecar" ? Fail : null, + StreamWait = phase is "write" or "flush" ? Fail : null, + WaitOnFlush = phase == "flush" + }; + var gets = 0; + using var handler = new Handler((request, _) => + { + if (request.Method == HttpMethod.Head) return Task.FromResult(Head()); + gets++; + return Task.FromResult(Body()); + }); + using var client = new HttpClient(handler); + using var downloader = Create(client, storage); + Assert.Equal(UpdateFailureReason.FileIoError, (await downloader.DownloadAsync(Package, null)).FailureReason); + Assert.Equal(1, gets); + } + + [Theory] + [InlineData("head", false)] + [InlineData("get", false)] + [InlineData("body", false)] + [InlineData("read-sidecar", false)] + [InlineData("sidecar", false)] + [InlineData("write", false)] + [InlineData("flush", false)] + [InlineData("head", true)] + [InlineData("get", true)] + [InlineData("body", true)] + [InlineData("read-sidecar", true)] + [InlineData("sidecar", true)] + [InlineData("write", true)] + [InlineData("flush", true)] + public async Task OverallTimeoutAndCallerCancellation_AreDistinctAndBoundDiskOperations(string phase, bool cancelCaller) + { + using var cts = new CancellationTokenSource(); + async Task Wait(CancellationToken token) + { + if (cancelCaller) cts.Cancel(); + await Task.Delay(Timeout.Infinite, token); + } + var storage = new Storage + { + SidecarWait = phase == "sidecar" ? Wait : null, + SidecarReadWait = phase == "read-sidecar" ? Wait : null, + StreamWait = phase is "write" or "flush" ? Wait : null, + WaitOnFlush = phase == "flush" + }; + if (phase == "read-sidecar") SeedPartial(storage); + using var handler = new Handler(async (request, token) => + { + if (phase == "head" && request.Method == HttpMethod.Head || + phase == "get" && request.Method == HttpMethod.Get) await Wait(token); + if (request.Method == HttpMethod.Head) return Head(); + var response = Body(); + if (phase == "body") response.Content = new StreamContent(new WaitingReadStream(Wait)); + return response; + }); + using var client = new HttpClient(handler); + using var downloader = Create(client, storage, HttpOptions with + { + DownloadTimeout = cancelCaller ? TimeSpan.FromSeconds(10) : TimeSpan.FromMilliseconds(100) + }); + + var result = await downloader.DownloadAsync(Package, null, cts.Token); + + Assert.Equal(cancelCaller ? UpdateState.Canceled : UpdateState.Failed, result.State); + Assert.Equal(cancelCaller ? UpdateFailureReason.Canceled : UpdateFailureReason.NetworkError, result.FailureReason); + Assert.False(storage.FileExists(Final)); + } + + [Fact] + public async Task ProbeTimeout_IsRetriedAndReportedAsNetworkFailure() + { + var heads = 0; + using var handler = new Handler(async (_, token) => + { + heads++; + await Task.Delay(Timeout.Infinite, token); + return Head(); + }); + using var client = new HttpClient(handler); + using var downloader = Create(client, new Storage(), HttpOptions with { RequestTimeout = TimeSpan.FromMilliseconds(30) }); + + var result = await downloader.DownloadAsync(Package, null); + + Assert.Equal(UpdateFailureReason.NetworkError, result.FailureReason); + Assert.Equal(3, heads); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Resume_RequiresValidatorAndUsesLastModifiedWhenEtagIsAbsent(bool hasLastModified) + { + var modified = DateTimeOffset.Parse("2026-01-01T00:00:00Z"); + var storage = new Storage(); + storage.Files[Partial] = Bytes[..3]; + storage.Sidecar = JsonSerializer.Serialize(new DownloadResumeMetadata + { + DownloadUrl = Package.DownloadUrl, ExpectedSha256 = Package.Sha256, + ExpectedFileSize = Package.FileSize, FileName = Package.FileName!, + LastModified = hasLastModified ? modified.ToString() : null + }); + using var handler = new Handler((request, _) => + { + var response = request.Method == HttpMethod.Head ? Head() : Body(hasLastModified ? 3 : 0); + response.Headers.ETag = null; + if (hasLastModified) response.Content.Headers.LastModified = modified; + if (request.Method == HttpMethod.Get) + { + if (hasLastModified) Assert.Equal(modified, request.Headers.IfRange!.Date); + else Assert.Null(request.Headers.Range); + } + return Task.FromResult(response); + }); + using var client = new HttpClient(handler); + using var downloader = Create(client, storage); + Assert.True((await downloader.DownloadAsync(Package, null)).Success); + Assert.Equal(Bytes, storage.Files[Final]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Disposal_RespectsClientOwnership(bool ownsClient) + { + using var handler = new Handler((_, _) => Task.FromResult(Head())); + using var client = new HttpClient(handler); + using (var downloader = ownsClient + ? new HttpResumableApkDownloader(client, new Storage(), Options, null, true) + : new HttpResumableApkDownloader(client, new Storage(), Options)) + { + } + Assert.Equal(ownsClient, handler.Disposed); + } + + private static HttpResumableApkDownloader Create(HttpClient client, Storage storage, HttpDownloadOptions? options = null) => + new(client, storage, Options, options ?? HttpOptions, false); + + private static HttpResponseMessage Head() + { + var response = Body(); + response.Headers.AcceptRanges.Add("bytes"); + return response; + } + + private static HttpResponseMessage Body(int offset = 0) + { + var response = new HttpResponseMessage(offset == 0 ? HttpStatusCode.OK : HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(Bytes[offset..]) + }; + response.Headers.ETag = new EntityTagHeaderValue("\"version-one\""); + if (offset > 0) response.Content.Headers.ContentRange = new ContentRangeHeaderValue(offset, 5, 6); + return response; + } + + private static void SeedPartial(Storage storage) + { + storage.Files[Partial] = Bytes[..3]; + storage.Sidecar = JsonSerializer.Serialize(new DownloadResumeMetadata + { + DownloadUrl = Package.DownloadUrl, ExpectedSha256 = Package.Sha256, + ExpectedFileSize = Package.FileSize, FileName = Package.FileName!, ETag = "\"version-one\"" + }); + } + + private sealed class Handler(Func> send) : HttpMessageHandler + { + public bool Disposed { get; private set; } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => send(request, cancellationToken); + protected override void Dispose(bool disposing) { Disposed = true; base.Dispose(disposing); } + } + + private sealed class BrokenReadStream : MemoryStream + { + private bool _read; + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (_read) throw new IOException("Connection dropped"); + _read = true; + Bytes.AsMemory(0, 3).CopyTo(buffer); + return ValueTask.FromResult(3); + } + } + + private sealed class WaitingReadStream(Func wait) : MemoryStream + { + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + await wait(cancellationToken); + return 0; + } + } + + private sealed class Storage : IFileStorage + { + public Dictionary Files { get; } = new(); + public string? Sidecar { get; set; } + public Exception? OpenWriteError { get; init; } + public Func? SidecarWait { get; init; } + public Func? SidecarReadWait { get; init; } + public Func? StreamWait { get; init; } + public bool WaitOnFlush { get; init; } + public void EnsureDirectory(string path) { } + public bool FileExists(string path) => Files.ContainsKey(path); + public long GetFileLength(string path) => Files[path].Length; + public void DeleteFile(string path) { Files.Remove(path); if (path.EndsWith(".json")) Sidecar = null; } + public void MoveFile(string sourceFilePath, string destinationFilePath, bool overwrite) + { + Files[destinationFilePath] = Files[sourceFilePath]; + Files.Remove(sourceFilePath); + } + public Stream OpenRead(string filePath) => new MemoryStream(Files[filePath]); + public Stream OpenWrite(string filePath, bool append) + { + if (OpenWriteError != null) throw OpenWriteError; + var stream = new StoredStream(bytes => Files[filePath] = bytes, StreamWait, WaitOnFlush); + if (append) stream.Write(Files[filePath]); + return stream; + } + public async Task ReadAllTextAsync(string filePath, CancellationToken cancellationToken = default) + { + if (SidecarReadWait != null) await SidecarReadWait(cancellationToken); + return Sidecar; + } + public async Task WriteAllTextAsync(string filePath, string content, CancellationToken cancellationToken = default) + { + if (SidecarWait != null) await SidecarWait(cancellationToken); + Sidecar = content; + } + } + + private sealed class StoredStream(Action save, Func? wait, bool waitOnFlush) : MemoryStream + { + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (wait != null && !waitOnFlush) await wait(cancellationToken); + await base.WriteAsync(buffer, cancellationToken); + } + public override async Task FlushAsync(CancellationToken cancellationToken) + { + if (wait != null && waitOnFlush) await wait(cancellationToken); + await base.FlushAsync(cancellationToken); + } + protected override void Dispose(bool disposing) + { + if (disposing) save(ToArray()); + base.Dispose(disposing); + } + } +} diff --git a/tests/GeneralUpdate.Avalonia.Android.Tests/HttpUpdatePackageClientTests.cs b/tests/GeneralUpdate.Avalonia.Android.Tests/HttpUpdatePackageClientTests.cs index cfa3ca2..4dc789f 100644 --- a/tests/GeneralUpdate.Avalonia.Android.Tests/HttpUpdatePackageClientTests.cs +++ b/tests/GeneralUpdate.Avalonia.Android.Tests/HttpUpdatePackageClientTests.cs @@ -172,6 +172,7 @@ public async Task ValidateAsync_WithHttpOptions_AuthenticatesInternalRequestAndS httpOptions: new HttpDownloadOptions { AuthProvider = new BearerTokenAuthProvider(token), + AllowInsecureAuthentication = true, RequestTimeout = TimeSpan.FromSeconds(5) }); From cce6ac1c5eb33331c6ab7afa8b7d30bf3a2e0e69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:42:27 +0000 Subject: [PATCH 5/6] Complete updater remediation verification and review response Co-authored-by: JusterZhu <11714536+JusterZhu@users.noreply.github.com> --- .../Models/HttpDownloadOptions.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/GeneralUpdate.Avalonia.Android/Models/HttpDownloadOptions.cs b/src/GeneralUpdate.Avalonia.Android/Models/HttpDownloadOptions.cs index 3456b75..f0dff37 100644 --- a/src/GeneralUpdate.Avalonia.Android/Models/HttpDownloadOptions.cs +++ b/src/GeneralUpdate.Avalonia.Android/Models/HttpDownloadOptions.cs @@ -48,7 +48,8 @@ public sealed record HttpDownloadOptions public bool UseProxy { get; init; } /// - /// Maximum number of retry attempts for transient failures. + /// Maximum total attempts for transient download failures, shared across the HEAD probe, + /// GET request and body transfer; retries restart the download attempt. /// Default is 3 (meaning 1 initial attempt + 2 retries). /// Set to 1 to disable retry. /// From 1e2685c0ace17d605a4125879835b80bb6e2158e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:17:53 +0000 Subject: [PATCH 6/6] Add durable update coordination and next-launch reconciliation Co-authored-by: JusterZhu <11714536+JusterZhu@users.noreply.github.com> --- README-EN.md | 90 +- README.md | 58 +- .../Abstractions/IAndroidUpdateCoordinator.cs | 36 + .../Abstractions/IPendingUpdateStore.cs | 17 + .../IPendingUpdateStoreLeaseProvider.cs | 10 + .../Enums/PendingUpdatePhase.cs | 8 + .../Enums/UpdateCoordinatorOutcome.cs | 17 + .../Enums/UpdateCoordinatorStage.cs | 13 + .../Events/UpdateCoordinatorEventArgs.cs | 8 + .../GeneralUpdateBootstrap.cs | 36 + .../Models/PendingUpdateAttempt.cs | 32 + .../Models/UpdateCoordinatorResult.cs | 14 + .../README.en.md | 35 +- src/GeneralUpdate.Avalonia.Android/README.md | 52 +- .../README.zh-CN.md | 29 +- .../Services/AndroidUpdateCoordinator.cs | 439 ++++++++ .../Services/JsonPendingUpdateStore.cs | 111 ++ .../AndroidUpdateCoordinatorTests.cs | 1001 +++++++++++++++++ ...eneralUpdate.Avalonia.Android.Tests.csproj | 11 + 19 files changed, 1998 insertions(+), 19 deletions(-) create mode 100644 src/GeneralUpdate.Avalonia.Android/Abstractions/IAndroidUpdateCoordinator.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Abstractions/IPendingUpdateStore.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Abstractions/IPendingUpdateStoreLeaseProvider.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Enums/PendingUpdatePhase.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Enums/UpdateCoordinatorOutcome.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Enums/UpdateCoordinatorStage.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Events/UpdateCoordinatorEventArgs.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Models/PendingUpdateAttempt.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Models/UpdateCoordinatorResult.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Services/AndroidUpdateCoordinator.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Services/JsonPendingUpdateStore.cs create mode 100644 tests/GeneralUpdate.Avalonia.Android.Tests/AndroidUpdateCoordinatorTests.cs diff --git a/README-EN.md b/README-EN.md index e512851..343f262 100644 --- a/README-EN.md +++ b/README-EN.md @@ -22,6 +22,7 @@ The project uses composable abstractions so you can replace version comparison, - **Extensible architecture**: `IVersionComparer`, `IUpdateDownloader`, `IHashValidator`, `IApkInstaller`, and more are replaceable. - **Resumable downloading**: sidecar metadata + streaming writes for better reliability on unstable networks. - **Unified event model**: built-in validation, progress, completion, and failure events for UI/log integration. +- **Durable coordinator**: complete-attempt serialization, pending-install tracking, next-launch version confirmation and explicit recovery. ## Quick Start @@ -53,7 +54,7 @@ dotnet add package GeneralUpdate.Avalonia.Android dotnet test tests/GeneralUpdate.Avalonia.Android.Tests/GeneralUpdate.Avalonia.Android.Tests.csproj ``` -### Basic Usage +### Low-level Usage ```csharp using GeneralUpdate.Avalonia.Android; @@ -91,6 +92,63 @@ if (check.Success && check.UpdateFound && check.PackageInfo is { } packageInfo) } ``` +### Coordinated Updates and Next-launch Confirmation + +For new integrations, use `GeneralUpdateBootstrap.CreateCoordinator(options)` instead of manually chaining the three +low-level operations. It owns the bootstrap it creates, serializes the complete workflow, and records a durable intent +**before** launching Android's installer. Keep the coordinator for the host's update-service lifetime. + +```csharp +using GeneralUpdate.Avalonia.Android.Enums; + +await using var coordinator = GeneralUpdateBootstrap.CreateCoordinator(options); +coordinator.StateChanged += (_, args) => + Console.WriteLine($"{args.Result.Stage}: {args.Result.Outcome}"); + +// Read the actual installed app version from the host, not the server's target version. +var startup = await coordinator.ReconcileAsync(installedVersion, cancellationToken); +if (startup.Outcome == UpdateCoordinatorOutcome.NoPendingUpdate) +{ + // Invoke from the host's update command/policy, not from a notification callback. + var result = await coordinator.RunAsync(installedVersion, cancellationToken); + // InstallerLaunched is a handoff; Updated is reported only by reconciliation. +} +``` + +| Operation | Contract | +|---|---| +| `RunAsync(currentVersion, ct)` | Check → download/verify → persist intent → launch installer. Existing pending state returns `PendingUpdateExists` without another install. | +| `ReconcileAsync(currentVersion, ct)` | Offline startup check. Installed version equal to or newer than the pending target returns `Updated`; the old version remains `AwaitingInstallation` or `RecoveryRequired`. No intent returns `NoPendingUpdate`. | +| `RetryAsync(currentVersion, ct)` | Explicit recovery of a pending attempt: reconcile first, then rediscover and verify the same target. A changed server target returns `RecoveryRequired` without overwriting the earlier handoff. Never installs a persisted path or reuses stored credentials. Use `RunAsync` again after failures that left no pending intent. | +| `AbandonAsync(ct)` | Explicitly forget pending tracking, including corrupt state. Does **not** cancel Android installation, delete APKs, or roll back the app/data. | + +`StateChanged` reports named stages and a terminal outcome, rather than treating a generic “completed” notification as +installation success. Pass an `IUpdateEventDispatcher` to `CreateCoordinator` for Avalonia UI dispatch, as shown below. +Check `Outcome` and `FailureReason`: `InstallerLaunched`, `NoUpdate` and `Updated` have different meanings. +Subscribe to `AddListenerDownloadProgressChanged` for byte/speed progress and register `AddListenerUpdatePrecheck` before +starting operations for optional-update policy (`true` still means skip; forced updates bypass it). These are forwarded +through the coordinator, so factory users do not need access to its underlying bootstrap. +The legacy `CreateDefault` / `IAndroidBootstrap` API remains available and unchanged. + +The factory stores only a versioned attempt ID, original/target versions, timestamp and handoff phase in +`/generalupdate/pending-update.json`. No APK path, URL, package credentials or exception is serialized. +The file is atomically replaced from a flushed temporary file in the same directory. Corrupt, oversized or unknown-schema state +fails closed instead of silently starting another update. A write failure before handoff prevents installer launch; uncertainty +after handoff remains pending for reconciliation. This protects process-restart recovery, not arbitrary storage hardware failure. +For a custom location/store, pass `pendingStore: new JsonPendingUpdateStore(privatePersistentPath)` (services namespace); +do not place the record in a cache, shared downloads folder or backup-restored location. +The default JSON store holds an exclusive `.lock` file lease for the entire workflow, preventing cooperating coordinator +instances/processes using the same state path from overwriting each other's intent. Do not delete that lock file while in use. +Custom stores can implement `IPendingUpdateStoreLeaseProvider`; otherwise the host must enforce a single coordinator. +Separate state paths do not protect a shared APK staging directory, so use one coordinator per staging directory. + +**Recovery policy:** reconcile on each app launch and when returning from the installer, using the actual installed version. +An unchanged version is not proof the user rejected installation—it may still be in progress. Offer explicit retry or abandonment; +do not automatically loop on either. If the server now offers a different target, reconcile the earlier handoff or explicitly abandon +its tracking before starting a new attempt. A successful reconciliation confirms the observed version, not application health or successful +data migration. Silent installation, automatic relaunch, OS downgrade/rollback, signed manifests and APK identity preflight are not +provided. The host still supplies installation permissions/FileProvider configuration and must validate device behavior. + ### Server-Driven Version Validation `ValidateAsync(currentVersion, cancellationToken)` only needs the version installed on the device: the component queries @@ -234,12 +292,13 @@ Cancellation while waiting for the gate still throws `OperationCanceledException cancellation during verification returns a canceled result. Notification exceptions are logged and isolated, whereas a pre-check exception produces a failed validation result. Installed-version confirmation is still not part of disposal or a completed event. -Use a single host coordinator and a private staging directory for the full check → download/verify → install sequence. +Use a single coordinator and a private staging directory for the full check → download/verify → install sequence. Only hand the returned verified path to the installer; do not modify or remove the APK while installation may be reading it. The public installer method also supports independent calls, so it does not establish verification provenance for arbitrary paths. -Persist the target version before handoff, reconcile the actual installed version on next launch, and clear obsolete staging files -only when no update/installer is using them. Keep resumable partial files for a bounded retention period. -Permission prompting, actual installation outcome, app relaunch and recovery from a bad release or data migration remain host/platform +`CreateCoordinator` handles target-version persistence and next-launch reconciliation; call `ReconcileAsync` at startup with the +actual installed version. With the low-level API, implement that tracking in the host. Clear obsolete staging files only when no +update/installer is using them. Keep resumable partial files for a bounded retention period. +Permission prompting, app relaunch and recovery from a bad release or data migration remain host/platform responsibilities; they are not made reliable merely by a successful installer intent. ## Source Review and Production Readiness @@ -260,13 +319,20 @@ retained with revision-pinned evidence; **its defect descriptions refer to the p | Callback errors | Synchronous notification subscriber, dispatcher and logger exceptions cannot replace operation outcomes. A throwing pre-check fails validation instead of bypassing host policy. | `BootstrapLifecycleTests`: throwing subscribers/loggers/dispatchers and fail-closed pre-check. | | Package license | NuGet metadata now declares Apache-2.0, matching the existing LICENSE. | MSBuild property evaluation against LICENSE. | -Validation after remediation: **160/160 core tests passed** (no failures or skips), including HEAD-rejection fallback and +Validation of the earlier remediation: **160/160 core tests passed** (no failures or skips), including HEAD-rejection fallback and authentication-policy failures returning terminal validation results before provider/network invocation. -The local Android build could not run because the `android` workload is missing (`NETSDK1147`); current PR CI requires approval. +At that stage the local Android build was blocked by the missing `android` workload (`NETSDK1147`). Tests for these fixes use the existing .NET core test project; they are not Android device installation tests. -The fixes do **not** add desktop support, installer completion callbacks, automatic restart/rollback, independent manifest signing, -APK identity preflight, persisted workflow state, directory-wide coordination, or a runnable Avalonia sample. -UI dispatch, verified-path handoff, cache retention and next-launch reconciliation remain explicit host responsibilities described above. +The coordinator addition now provides persistent intent tracking, complete-attempt orchestration, offline installed-version +reconciliation, explicit retry/abandon recovery and stage-specific outcomes (see the new integration section). +Coordinator validation: **57 focused tests and all 217 core tests passed**. With the Android workload installed, +the Android library **Release build succeeded**, including the default factory. Coverage includes real HTTP downloader/storage/hash +integration with fake transport/installer, persistent state across recreated coordinators, uncertain handoff, concurrency and recovery. +The build retains the existing dependency advisory noted below and three XML-documentation warnings. PR CI still requires approval; +no device/emulator installation, restart or application-health validation was performed. +The fixes do **not** add desktop support, native installer completion callbacks, automatic restart/rollback, independent manifest signing, +APK identity preflight, or a runnable Avalonia sample. +UI dispatch, providing the actual installed version, invoking reconciliation at startup and cache retention remain host responsibilities. Metadata-provider/storage extensibility and production dependency/device validation remain follow-up work, not silently resolved findings. ### Historical scope and evidence (before remediation) @@ -373,7 +439,9 @@ Snapshots are in-memory and do not establish verified-package provenance or cras shutdown and post-install reconciliation. The default logger is no-op; production hosts need stage/failure telemetry without credentials. **Dependency risks:** [AndroidX Core is the runtime package dependency; SourceLink is private build tooling][review-project]. -No dependency advisory audit or transitive inventory was performed for this assessment, so version age alone is not a vulnerability finding. +The original assessment did not include a dependency advisory audit or transitive inventory. +The subsequent coordinator build reports a pre-existing `Microsoft.Build.Tasks.Git` 8.0.0 advisory +([GHSA-23fw-v26w-5fgq](https://github.com/advisories/GHSA-23fw-v26w-5fgq)); this change does not update dependencies. Validate the resolved dependency graph, Android workload/toolchain compatibility and packaged artifact on supported devices before release. ### Original validation evidence and remaining production release gates diff --git a/README.md b/README.md index a576d95..7074d89 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ - **可扩展架构**:`IVersionComparer`、`IUpdateDownloader`、`IHashValidator`、`IApkInstaller` 等均可替换。 - **断点续传下载**:支持 sidecar 元数据与流式写入,提升弱网场景稳定性。 - **统一事件通知**:提供验证、进度、完成、失败等事件用于 UI/日志集成。 +- **持久化协调器**:完整流程串行化、待安装状态跟踪、下次启动版本确认以及显式恢复。 ## 快速开始 @@ -91,6 +92,51 @@ if (check.Success && check.UpdateFound && check.PackageInfo is { } packageInfo) } ``` +### 完整流程协调与下次启动确认 + +新接入可使用 `GeneralUpdateBootstrap.CreateCoordinator(options)`,无需自行串联三个低层调用。 +协调器按“查询 → 下载并验证 → 持久化意图 → 拉起安装器”执行;`CreateDefault` 低层 API 保持兼容。 + +```csharp +using GeneralUpdate.Avalonia.Android.Enums; + +await using var coordinator = GeneralUpdateBootstrap.CreateCoordinator(options); +coordinator.StateChanged += (_, args) => + Console.WriteLine($"{args.Result.Stage}: {args.Result.Outcome}"); + +// installedVersion 必须来自当前实际安装的应用,不能传服务端目标版本。 +var startup = await coordinator.ReconcileAsync(installedVersion, cancellationToken); +if (startup.Outcome == UpdateCoordinatorOutcome.NoPendingUpdate) +{ + // 由宿主更新命令/策略触发,不要在通知回调里同步等待此调用。 + var result = await coordinator.RunAsync(installedVersion, cancellationToken); +} +``` + +- `RunAsync`:串行执行整个流程;存在待确认记录时返回 `PendingUpdateExists`,不会覆盖并再次安装。 +- `ReconcileAsync`:离线核对待确认目标版本。实际版本达到或超过目标才返回 `Updated`; + 仍为旧版本则返回 `AwaitingInstallation` 或 `RecoveryRequired`,没有记录为 `NoPendingUpdate`。 +- `RetryAsync`:显式恢复,先核对安装版本,再重新查询服务器并下载验证同一目标;若服务端目标改变, + 返回 `RecoveryRequired` 并保留原交接记录,需先核对或明确放弃后再开始新尝试。 + 不会安装从磁盘恢复的任意路径,也不会重复安装已经达到的目标版本。 +- `AbandonAsync`:显式放弃跟踪,也可清除损坏状态;不取消系统安装、不删除 APK、不回滚应用或数据。 + +默认状态保存在 `/generalupdate/pending-update.json`,不是可被清理的 APK 缓存。 +只记录 schema、尝试 ID、原始/目标版本、时间和交接阶段,不保存 URL、APK 路径、凭据或异常。 +状态通过同目录临时文件刷新后原子替换;安装前保存失败就停止,安装交接后的不确定状态留待下次启动核对。 +损坏、过大或未知 schema 不会被当成“无更新”,而是明确失败。可注入 `IPendingUpdateStore`; +自定义文件位置必须是应用私有持久化目录,不应参与备份恢复。 +默认 JSON 存储对整个流程持有 `.lock` 文件独占租约,协调使用同一路径的实例/进程;使用中不要删除锁文件。 +自定义存储可实现 `IPendingUpdateStoreLeaseProvider`,否则宿主必须保证单协调器。不同状态路径不能保护共用的 APK 目录。 + +保持协调器与宿主更新服务相同生命周期,释放时可 `await DisposeAsync()`。 +`StateChanged` 区分阶段与最终 `Outcome`,`InstallerLaunched` 仅表示交接,不能展示为安装成功。 +UI 线程仍需传入 `IUpdateEventDispatcher`;应用每次启动及从安装器返回时调用 `ReconcileAsync`。 +协调器还转发 `AddListenerDownloadProgressChanged` 和 `AddListenerUpdatePrecheck`,无需获取内部 bootstrap; +pre-check 应在开始操作前注册,仍保持 `true` 表示跳过、强制更新不调用的兼容语义。 +旧版本仍在运行不等于用户拒绝安装,可能尚未完成;应由用户明确选择重试或放弃,不要自动循环。 +此核心闭环确认的是实际安装版本,不是应用健康或数据迁移成功;静默安装、自动重启、系统回滚仍不提供。 + ### 服务端版本校验 `ValidateAsync(currentVersion, cancellationToken)` 只需要当前应用的版本号:组件按 @@ -174,7 +220,12 @@ ZIP、差分包、驱动包不会交给 Android 安装器;`body` 为空数组 [英文评审正文](https://github.com/GeneralLibrary/GeneralUpdate.Avalonia/blob/main/README-EN.md#source-review-and-production-readiness)。 下表保留原始评审背景;当前已按项修复 B1–B5、S1、回调异常隔离及许可证元数据,详见正文的修复状态表。 新增回归测试覆盖重试/续传、超时与取消、状态及清理失败、资源释放竞争、认证源限制和重定向拒绝。 -修复后的核心测试 **160/160 通过**;本地 Android 构建因缺少工作负载(`NETSDK1147`)受阻,当前 PR CI 尚需批准。 +此前缺陷修复后的核心测试 **160/160 通过**;当时本地 Android 构建因缺少工作负载(`NETSDK1147`)受阻。 +本次协调器新增验证:**57 个专项测试、全部 217 个核心测试通过**;安装 Android 工作负载后, +包含默认工厂的 Android 库 **Release 构建成功**。覆盖真实下载器、文件及哈希处理与模拟网络/安装器、 +协调器重建后的持久化确认、不确定交接、并发及恢复。构建仍报告已有的 +`Microsoft.Build.Tasks.Git` 8.0.0 安全公告([GHSA-23fw-v26w-5fgq](https://github.com/advisories/GHSA-23fw-v26w-5fgq)) +和 3 个 XML 文档警告;未修改依赖版本,PR CI 仍需批准,尚未进行真机/模拟器安装、重启或应用健康验证。 这些测试不等价于真机安装或自动回滚验证。 | 维度 | 结论与风险 | 建议 | @@ -193,8 +244,9 @@ ZIP、差分包、驱动包不会交给 Android 安装器;`body` 为空数组 没有证据支持把旧的“写流未关闭即重命名”问题、Zip Slip 或 Android 签名绕过列为当前缺陷。 **生产结论:不能直接作为开箱即用的跨平台、全闭环生产更新器。** -上述修复并未新增桌面支持、安装完成确认、自动重启/回滚、独立清单签名、APK 身份预检或可运行的 Avalonia 示例。 -仍需限定可信更新源、补齐宿主协调及恢复逻辑,并通过 Android 真机故障场景验收后, +新增协调器已提供持久化意图、下次启动的安装版本确认、完整流程串行化及重试/放弃恢复。 +仍未新增桌面支持、原生安装器完成回调、自动重启/回滚、独立清单签名、APK 身份预检或可运行的 Avalonia 示例。 +仍需限定可信更新源、在宿主启动时调用核对并处理平台权限和健康恢复,并通过 Android 真机故障场景验收后, 作为 Android 更新基础组件使用;详细上线门槛见完整评审。 ## 目录结构 diff --git a/src/GeneralUpdate.Avalonia.Android/Abstractions/IAndroidUpdateCoordinator.cs b/src/GeneralUpdate.Avalonia.Android/Abstractions/IAndroidUpdateCoordinator.cs new file mode 100644 index 0000000..719f9b3 --- /dev/null +++ b/src/GeneralUpdate.Avalonia.Android/Abstractions/IAndroidUpdateCoordinator.cs @@ -0,0 +1,36 @@ +using GeneralUpdate.Avalonia.Android.Events; +using GeneralUpdate.Avalonia.Android.Models; + +namespace GeneralUpdate.Avalonia.Android.Abstractions; + +/// +/// Serializes complete update attempts. Stores without IPendingUpdateStoreLeaseProvider require +/// one coordinator per store. Do not interleave operations with direct bootstrap calls or store +/// writes. Construction never launches an installer. +/// Callbacks must not synchronously wait for another operation or asynchronous disposal. +/// +public interface IAndroidUpdateCoordinator : IDisposable, IAsyncDisposable +{ + event EventHandler? StateChanged; + event EventHandler? AddListenerDownloadProgressChanged; + + /// + /// Configures the bootstrap pre-check before starting an operation. Returning true skips an + /// optional update; forced updates bypass this callback. Policy exceptions fail the check. + /// + IAndroidUpdateCoordinator AddListenerUpdatePrecheck(Func func); + + Task RunAsync(string currentVersion, CancellationToken cancellationToken = default); + Task ReconcileAsync(string currentVersion, CancellationToken cancellationToken = default); + + /// + /// Reconciles first, then discovers and verifies the same pending target again. If fresh + /// discovery selects a different version, returns RecoveryRequired without replacing the + /// pending intent: an earlier installer may still finish. Explicitly reconcile or abandon + /// tracking before starting that different target with RunAsync. + /// + Task RetryAsync(string currentVersion, CancellationToken cancellationToken = default); + + /// Forgets tracking only; does not cancel Android installation, roll back, or delete APKs. + Task AbandonAsync(CancellationToken cancellationToken = default); +} diff --git a/src/GeneralUpdate.Avalonia.Android/Abstractions/IPendingUpdateStore.cs b/src/GeneralUpdate.Avalonia.Android/Abstractions/IPendingUpdateStore.cs new file mode 100644 index 0000000..f817a75 --- /dev/null +++ b/src/GeneralUpdate.Avalonia.Android/Abstractions/IPendingUpdateStore.cs @@ -0,0 +1,17 @@ +using GeneralUpdate.Avalonia.Android.Models; + +namespace GeneralUpdate.Avalonia.Android.Abstractions; + +/// +/// App-private durable tracking for one coordinator. Read returns null only when no record exists; +/// corrupt or inaccessible state must throw. Write must atomically replace a complete record, +/// and a failed write/clear must preserve the previous record. +/// Implement IPendingUpdateStoreLeaseProvider to support multiple coordinators/processes; +/// otherwise the host must use exactly one coordinator for this store. +/// +public interface IPendingUpdateStore +{ + Task ReadAsync(CancellationToken cancellationToken = default); + Task WriteAsync(PendingUpdateAttempt attempt, CancellationToken cancellationToken = default); + Task ClearAsync(CancellationToken cancellationToken = default); +} diff --git a/src/GeneralUpdate.Avalonia.Android/Abstractions/IPendingUpdateStoreLeaseProvider.cs b/src/GeneralUpdate.Avalonia.Android/Abstractions/IPendingUpdateStoreLeaseProvider.cs new file mode 100644 index 0000000..3b3f4ee --- /dev/null +++ b/src/GeneralUpdate.Avalonia.Android/Abstractions/IPendingUpdateStoreLeaseProvider.cs @@ -0,0 +1,10 @@ +namespace GeneralUpdate.Avalonia.Android.Abstractions; + +/// +/// Optional workflow-wide exclusion for coordinators sharing a store. The lease must remain +/// held across read, discovery, download, persistence, and installer handoff, not only store IO. +/// +public interface IPendingUpdateStoreLeaseProvider +{ + ValueTask AcquireLeaseAsync(CancellationToken cancellationToken = default); +} diff --git a/src/GeneralUpdate.Avalonia.Android/Enums/PendingUpdatePhase.cs b/src/GeneralUpdate.Avalonia.Android/Enums/PendingUpdatePhase.cs new file mode 100644 index 0000000..69548f2 --- /dev/null +++ b/src/GeneralUpdate.Avalonia.Android/Enums/PendingUpdatePhase.cs @@ -0,0 +1,8 @@ +namespace GeneralUpdate.Avalonia.Android.Enums; + +public enum PendingUpdatePhase +{ + IntentPersisted = 1, + InstallerLaunched = 2, + Retryable = 3 +} diff --git a/src/GeneralUpdate.Avalonia.Android/Enums/UpdateCoordinatorOutcome.cs b/src/GeneralUpdate.Avalonia.Android/Enums/UpdateCoordinatorOutcome.cs new file mode 100644 index 0000000..8b09afe --- /dev/null +++ b/src/GeneralUpdate.Avalonia.Android/Enums/UpdateCoordinatorOutcome.cs @@ -0,0 +1,17 @@ +namespace GeneralUpdate.Avalonia.Android.Enums; + +public enum UpdateCoordinatorOutcome +{ + None, + NoUpdate, + Skipped, + InstallerLaunched, + Updated, + NoPendingUpdate, + PendingUpdateExists, + AwaitingInstallation, + RecoveryRequired, + Abandoned, + Canceled, + Failed +} diff --git a/src/GeneralUpdate.Avalonia.Android/Enums/UpdateCoordinatorStage.cs b/src/GeneralUpdate.Avalonia.Android/Enums/UpdateCoordinatorStage.cs new file mode 100644 index 0000000..eacac6f --- /dev/null +++ b/src/GeneralUpdate.Avalonia.Android/Enums/UpdateCoordinatorStage.cs @@ -0,0 +1,13 @@ +namespace GeneralUpdate.Avalonia.Android.Enums; + +public enum UpdateCoordinatorStage +{ + ReadingPending, + Checking, + DownloadingAndVerifying, + PersistingIntent, + LaunchingInstaller, + Reconciling, + Abandoning, + Finished +} diff --git a/src/GeneralUpdate.Avalonia.Android/Events/UpdateCoordinatorEventArgs.cs b/src/GeneralUpdate.Avalonia.Android/Events/UpdateCoordinatorEventArgs.cs new file mode 100644 index 0000000..27d7d0e --- /dev/null +++ b/src/GeneralUpdate.Avalonia.Android/Events/UpdateCoordinatorEventArgs.cs @@ -0,0 +1,8 @@ +using GeneralUpdate.Avalonia.Android.Models; + +namespace GeneralUpdate.Avalonia.Android.Events; + +public sealed class UpdateCoordinatorEventArgs(UpdateCoordinatorResult result) : EventArgs +{ + public UpdateCoordinatorResult Result { get; } = result; +} diff --git a/src/GeneralUpdate.Avalonia.Android/GeneralUpdateBootstrap.cs b/src/GeneralUpdate.Avalonia.Android/GeneralUpdateBootstrap.cs index 0f243d3..91eed19 100644 --- a/src/GeneralUpdate.Avalonia.Android/GeneralUpdateBootstrap.cs +++ b/src/GeneralUpdate.Avalonia.Android/GeneralUpdateBootstrap.cs @@ -7,6 +7,42 @@ namespace GeneralUpdate.Avalonia.Android; public static class GeneralUpdateBootstrap { + /// + /// Creates an owning coordinator for complete update attempts and next-launch reconciliation. + /// Pending state defaults to the app-private no-backup files directory, never the APK cache. + /// Supply a store explicitly when an Android context is unavailable. + /// + public static IAndroidUpdateCoordinator CreateCoordinator( + AndroidUpdateOptions options, + IPendingUpdateStore? pendingStore = null, + IAndroidContextProvider? contextProvider = null, + IAndroidActivityProvider? activityProvider = null, + HttpClient? httpClient = null, + IVersionComparer? versionComparer = null, + IUpdateEventDispatcher? eventDispatcher = null, + IUpdateLogger? logger = null, + HttpDownloadOptions? httpOptions = null) + { + ArgumentNullException.ThrowIfNull(options); + var usedContextProvider = contextProvider ?? new DefaultAndroidContextProvider(); + if (pendingStore is null) + { + var filesDirectory = usedContextProvider.GetContext()?.NoBackupFilesDir?.AbsolutePath; + if (string.IsNullOrWhiteSpace(filesDirectory)) + { + throw new InvalidOperationException( + "A persistent app-private directory is unavailable. Supply an IPendingUpdateStore."); + } + + pendingStore = new JsonPendingUpdateStore(Path.Combine(filesDirectory, "generalupdate", "pending-update.json")); + } + + var usedVersionComparer = versionComparer ?? new SystemVersionComparer(); + var bootstrap = CreateDefault(options, usedContextProvider, activityProvider, httpClient, + usedVersionComparer, eventDispatcher, logger, httpOptions); + return new AndroidUpdateCoordinator(bootstrap, pendingStore, usedVersionComparer, eventDispatcher, ownsBootstrap: true); + } + public static IAndroidBootstrap CreateDefault( AndroidUpdateOptions options, IAndroidContextProvider? contextProvider = null, diff --git a/src/GeneralUpdate.Avalonia.Android/Models/PendingUpdateAttempt.cs b/src/GeneralUpdate.Avalonia.Android/Models/PendingUpdateAttempt.cs new file mode 100644 index 0000000..291e448 --- /dev/null +++ b/src/GeneralUpdate.Avalonia.Android/Models/PendingUpdateAttempt.cs @@ -0,0 +1,32 @@ +using GeneralUpdate.Avalonia.Android.Enums; +using System.Text.Json.Serialization; + +namespace GeneralUpdate.Avalonia.Android.Models; + +/// +/// Minimal restart-safe intent. Contains no package, path, URL, credentials, or exception. +/// IntentPersisted is deliberately uncertain: a process can die during installer handoff. +/// +public sealed record PendingUpdateAttempt +{ + [JsonRequired] + public int SchemaVersion { get; init; } = 1; + public required Guid AttemptId { get; init; } + public required string OriginalVersion { get; init; } + public required string TargetVersion { get; init; } + public required DateTimeOffset CreatedAtUtc { get; init; } + public required PendingUpdatePhase Phase { get; init; } + + public void Validate() + { + if (SchemaVersion != 1 || AttemptId == Guid.Empty || CreatedAtUtc == default || + !Enum.IsDefined(Phase) || !IsVersionText(OriginalVersion) || !IsVersionText(TargetVersion)) + { + throw new InvalidDataException("The pending update record is invalid or uses an unsupported schema."); + } + } + + private static bool IsVersionText(string? value) => + !string.IsNullOrWhiteSpace(value) && value.Length <= 256 && + value.All(character => char.IsAsciiLetterOrDigit(character) || character is '.' or '-' or '+' or '_'); +} diff --git a/src/GeneralUpdate.Avalonia.Android/Models/UpdateCoordinatorResult.cs b/src/GeneralUpdate.Avalonia.Android/Models/UpdateCoordinatorResult.cs new file mode 100644 index 0000000..d0d4835 --- /dev/null +++ b/src/GeneralUpdate.Avalonia.Android/Models/UpdateCoordinatorResult.cs @@ -0,0 +1,14 @@ +using GeneralUpdate.Avalonia.Android.Enums; + +namespace GeneralUpdate.Avalonia.Android.Models; + +/// InstallerLaunched acknowledges only handoff; only reconciliation can report Updated. +public sealed record UpdateCoordinatorResult +{ + public required Guid OperationId { get; init; } + public required UpdateCoordinatorStage Stage { get; init; } + public required UpdateCoordinatorOutcome Outcome { get; init; } + public UpdateFailureReason FailureReason { get; init; } + public string? Message { get; init; } + public PendingUpdateAttempt? PendingUpdate { get; init; } +} diff --git a/src/GeneralUpdate.Avalonia.Android/README.en.md b/src/GeneralUpdate.Avalonia.Android/README.en.md index a88a367..ab5b853 100644 --- a/src/GeneralUpdate.Avalonia.Android/README.en.md +++ b/src/GeneralUpdate.Avalonia.Android/README.en.md @@ -13,6 +13,7 @@ UI-free Android auto-update core for Avalonia 12+ apps (`net10.0-android`). - Resumable HTTP download with sidecar metadata and smoothed speed reporting - Replaceable abstractions for every pipeline stage - Operation serialization — concurrent calls are gated, safe from any thread +- Durable coordination — pending-install tracking, next-launch version reconciliation, and explicit retry/abandon ## Quick Start @@ -53,6 +54,35 @@ if (check.Success && check.UpdateFound && check.PackageInfo is { } packageInfo) } ``` +## Durable Coordinator (recommended) + +Use `GeneralUpdateBootstrap.CreateCoordinator(options)` for whole-flow orchestration with the same server/FileProvider +configuration. It returns `IAndroidUpdateCoordinator`, supporting `await using`, `StateChanged`, and: + +- `RunAsync(installedVersion, ct)`: check → download/verify → durably record intent → installer handoff. +- `ReconcileAsync(installedVersion, ct)`: offline next-launch confirmation; only a version at or above the pending target + returns `Updated`. `InstallerLaunched` never implies installation success. +- `RetryAsync(installedVersion, ct)`: explicitly recover a pending attempt by reconciling, rediscovering and re-verifying; + never install from a persisted path. A changed server target returns `RecoveryRequired`, preserving the earlier handoff + until explicitly reconciled/abandoned. Without a pending intent, use `RunAsync` for a new attempt. +- `AbandonAsync(ct)`: forget tracking (including corrupt state), not OS installation cancellation, APK deletion or rollback. + +Always pass the actual installed version. The factory stores versioned minimal intent in +`/generalupdate/pending-update.json`, atomically replaced after flushing. No URL/path/credentials are persisted. +Inject `IPendingUpdateStore` through `pendingStore` for other private, persistent storage. Corrupt state fails closed; +a persistence failure before handoff prevents installation. Pending state blocks another `RunAsync` until explicitly reconciled, +retried or abandoned. Pass a UI `eventDispatcher`, keep one coordinator per staging directory, and do not mix direct bootstrap calls. +Coordinator cancellation returns `Canceled`, including gate waits. Factory-created bootstraps are coordinator-owned; +direct construction is non-owning by default. Await disposal outside callbacks. +The coordinator also forwards `AddListenerDownloadProgressChanged` and `AddListenerUpdatePrecheck` (configure policy before +operations; `true` skips, forced updates bypass). The default store holds an exclusive `.lock` lease across cooperating +instances/processes for each complete workflow. Do not remove the lock file in use. Custom stores without +`IPendingUpdateStoreLeaseProvider` require a singleton coordinator; separate state files do not protect shared staging paths. + +The host invokes reconciliation at startup/installer return and decides when to retry or abandon. This closes the observed +installed-version loop, not application-health/data-migration recovery, silent installation, automatic relaunch or OS rollback. +See the repository README for the complete integration example and device-validation requirements. + ## Host UI and Recovery Global download authentication is limited to the configured verification origin. Set @@ -68,8 +98,9 @@ as `eventDispatcher` to `CreateDefault`. Pre-check is synchronous and is not dis not controls. Unsubscribe ViewModel event handlers when released, throttle progress rendering, and never synchronously wait for another update operation inside a callback. Catch exceptions inside `async void` handlers after awaits. -Use one coordinator per private staging directory, preserve the verified file while the installer may still read it, and -persist the intended version for reconciliation on next launch. Installer launch is not installation confirmation. +Use one coordinator per private staging directory and preserve the verified file while the installer may still read it. +The durable coordinator tracks the intended version; call its reconciliation method on next launch. With the low-level API, +the host must provide that tracking. Installer launch is not installation confirmation. The host owns permission prompting, stale-cache retention, relaunch and failed-release/data-migration recovery. `Dispose()` cancels without blocking and defers resource release until operations and waiters drain. The concrete diff --git a/src/GeneralUpdate.Avalonia.Android/README.md b/src/GeneralUpdate.Avalonia.Android/README.md index 16026ad..22bb62c 100644 --- a/src/GeneralUpdate.Avalonia.Android/README.md +++ b/src/GeneralUpdate.Avalonia.Android/README.md @@ -18,6 +18,7 @@ UI-free Android auto-update core library for Avalonia 12+ apps (`net10.0-android - **Resumable HTTP download** with sidecar metadata and smoothed speed reporting. - **Replaceable abstractions** — every stage is an interface you can swap. - **Operation serialization** — concurrent calls are gated, safe to call from any thread. +- **Durable coordination** — pending-install tracking, next-launch version reconciliation, and explicit retry/abandon. ## Quick Start @@ -58,6 +59,52 @@ if (check.Success && check.UpdateFound && check.PackageInfo is { } packageInfo) } ``` +## Durable Coordinator (recommended) + +`GeneralUpdateBootstrap.CreateCoordinator(options)` provides a higher-level `IAndroidUpdateCoordinator` without +changing `IAndroidBootstrap`. Use the same server, FileProvider and permission configuration shown above. + +```csharp +using GeneralUpdate.Avalonia.Android.Enums; + +await using var coordinator = GeneralUpdateBootstrap.CreateCoordinator(options); +coordinator.StateChanged += (_, e) => Console.WriteLine($"{e.Result.Stage}: {e.Result.Outcome}"); +var startup = await coordinator.ReconcileAsync(installedVersion, ct); +if (startup.Outcome == UpdateCoordinatorOutcome.NoPendingUpdate) +{ + // On an explicit host update command: + var result = await coordinator.RunAsync(installedVersion, ct); +} +``` + +Supply the actual installed version, not the server target. The coordinator serializes complete attempts and persists +intent before installer launch in `/generalupdate/pending-update.json`. The versioned, atomically replaced +record contains only attempt ID, original/target versions, timestamp and phase—never URLs, file paths or credentials. +Pass `pendingStore` to inject an `IPendingUpdateStore`; custom storage must be private, persistent and not restored from backups. + +| Method | Outcome | +|---|---| +| `RunAsync` | Check → download/verify → persist → handoff. `InstallerLaunched` is **not** installed; existing intent returns `PendingUpdateExists`. | +| `ReconcileAsync` | Offline next-launch confirmation: `Updated` only when the observed installed version meets/exceeds the target; otherwise `AwaitingInstallation`/`RecoveryRequired`. | +| `RetryAsync` | Explicit recovery of a pending attempt; reconcile first, then rediscover/download/verify the same target. Changed targets return `RecoveryRequired` and preserve the old intent. No stored APK path is installed. Use `RunAsync` again if no intent was persisted. | +| `AbandonAsync` | Forget tracking, including corrupt state; no APK deletion, OS cancellation or rollback. | + +Progress and pre-check remain available through `AddListenerDownloadProgressChanged` and `AddListenerUpdatePrecheck`. +Register policy before operations; `true` means skip an optional update, and forced updates bypass the callback. +The JSON store holds a workflow-wide exclusive `.lock` lease across cooperating instances/processes; do not remove it in use. +Custom stores without `IPendingUpdateStoreLeaseProvider` require a singleton coordinator. Different state files do not +protect the same staging directory. + +Corrupt/unknown-schema state fails closed. Write failures prevent launch; an uncertain handoff remains recoverable. +Pass `eventDispatcher` for UI delivery of named stages and terminal outcomes. Cancellation returns `Canceled`, including +while waiting for the coordinator gate. The factory coordinator owns its bootstrap; direct `new AndroidUpdateCoordinator(...)` +leaves a supplied bootstrap host-owned unless `ownsBootstrap: true`. Dispose asynchronously outside callbacks to await shutdown. +Do not mix direct bootstrap calls with coordinator operations. + +Call reconciliation at startup and after returning from the installer. An unchanged version may mean installation is still pending; +retry/abandon must be explicit. This confirms installed-version convergence, not app health, data migration, silent installation, +automatic relaunch or rollback. Those require host/platform policy and real-device validation. + ## Host UI and Recovery Global download authentication is limited to the configured verification origin. Set @@ -73,8 +120,9 @@ as `eventDispatcher` to `CreateDefault`. Pre-check is synchronous and is not dis not controls. Unsubscribe ViewModel event handlers when released, throttle progress rendering, and never synchronously wait for another update operation inside a callback. Catch exceptions inside `async void` handlers after awaits. -Use one coordinator per private staging directory, preserve the verified file while the installer may still read it, and -persist the intended version for reconciliation on next launch. Installer launch is not installation confirmation. +Use one coordinator per private staging directory and preserve the verified file while the installer may still read it. +The durable coordinator tracks the intended version; call its reconciliation method on next launch. With the low-level API, +the host must provide that tracking. Installer launch is not installation confirmation. The host owns permission prompting, stale-cache retention, relaunch and failed-release/data-migration recovery. `Dispose()` cancels without blocking and defers resource release until operations and waiters drain. The concrete diff --git a/src/GeneralUpdate.Avalonia.Android/README.zh-CN.md b/src/GeneralUpdate.Avalonia.Android/README.zh-CN.md index 93e6f25..39dec08 100644 --- a/src/GeneralUpdate.Avalonia.Android/README.zh-CN.md +++ b/src/GeneralUpdate.Avalonia.Android/README.zh-CN.md @@ -146,6 +146,32 @@ UpdateOperationResult (基类) `UpdateFailureReason`: `None`, `NetworkError`, `Canceled`, `InvalidMetadata`, `FileIoError`, `HashMismatch`, `ServerDoesNotSupportRange`, `InstallPermissionDenied`, `InstallLaunchFailed`, `VersionComparisonFailed`, `Unknown` +## 持久化流程协调器(推荐) + +`GeneralUpdateBootstrap.CreateCoordinator(options)` 返回 `IAndroidUpdateCoordinator`,原有低层 API 保持不变。 +使用相同的服务器、FileProvider 和安装权限配置,并传入当前**实际安装版本**: + +- `RunAsync`:查询 → 下载并验证 → 持久化意图 → 安装交接,整个尝试串行化;已有记录返回 `PendingUpdateExists`。 +- `ReconcileAsync`:下次启动/从安装器返回时离线核对;实际版本达到或超过目标才返回 `Updated`。 +- `RetryAsync`:显式恢复待确认尝试,先核对再重新查询、下载和验证同一目标,不安装持久化路径。 + 服务端目标改变则返回 `RecoveryRequired` 并保留原记录,需核对/明确放弃;没有记录时用 `RunAsync` 开始新尝试。 +- `AbandonAsync`:放弃跟踪(也可恢复损坏的状态文件),不删除 APK、不取消系统安装、不回滚。 + +`StateChanged` 区分阶段与最终 `Outcome`,`InstallerLaunched` 不等于安装完成。默认将最小化版本化记录 +保存在 `/generalupdate/pending-update.json`,同目录写入并刷新临时文件后原子替换。 +记录仅含尝试 ID、原始/目标版本、时间和阶段,不保存 URL、文件路径、凭据或异常。 +可通过 `pendingStore` 注入私有持久化 `IPendingUpdateStore`,不应使用缓存或备份恢复目录。 +默认存储持有跨协作实例/进程的完整流程 `.lock` 独占租约;使用中不要删除锁文件。 +自定义存储可实现 `IPendingUpdateStoreLeaseProvider`,否则需单协调器;不同状态文件不能保护共享的 APK 目录。 +状态损坏会明确失败,安装前持久化失败会阻止交接;不确定的交接保留待确认状态。 + +通过 `eventDispatcher` 接入 UI 线程,不要混用直接 bootstrap 调用。协调器包括等待锁在内的取消均返回 `Canceled`; +下载进度与 pre-check 通过 `AddListenerDownloadProgressChanged` / `AddListenerUpdatePrecheck` 转发。 +操作前注册策略,`true` 仍表示跳过可选更新,强制更新绕过该回调。 +工厂协调器拥有其 bootstrap,直接构造默认不拥有。支持 `await using`,异步释放应在回调之外等待。 +旧版本可能仍处于安装等待中,不应自动重试;宿主明确决定重试/放弃。 +此闭环确认已观察到的安装版本,不保证应用健康、数据迁移成功、静默安装、自动重启或系统回滚。 + ## UI 调度与恢复责任 全局下载认证默认仅发送到版本查询端点的源(协议、主机、有效端口,不按路径限制)。 @@ -161,7 +187,8 @@ ViewModel 释放时使用 `-=` 解绑事件,对高频进度做合并;不要 同步等待其他更新操作或异步释放。`async void` 事件处理器须自行捕获 `await` 之后的异常。 每个应用私有下载目录仅使用一个流程协调器,安装器可能仍在读取 APK 时不要修改或删除它。 -拉起安装器前持久化目标版本,在下次启动时核对实际安装版本。安装权限引导、过期缓存保留策略、 +使用协调器时由其持久化目标版本,宿主在下次启动时调用 `ReconcileAsync` 核对实际安装版本; +直接使用低层 API 时则自行实现此跟踪。安装权限引导、过期缓存保留策略、 应用重启、失败版本恢复及数据迁移回退均由宿主负责,安装器拉起成功不代表更新已经完成。 `Dispose()` 非阻塞地请求取消,待操作和等待者退出后再释放资源。具体类 `AndroidBootstrap` 还实现 diff --git a/src/GeneralUpdate.Avalonia.Android/Services/AndroidUpdateCoordinator.cs b/src/GeneralUpdate.Avalonia.Android/Services/AndroidUpdateCoordinator.cs new file mode 100644 index 0000000..64242c0 --- /dev/null +++ b/src/GeneralUpdate.Avalonia.Android/Services/AndroidUpdateCoordinator.cs @@ -0,0 +1,439 @@ +using GeneralUpdate.Avalonia.Android.Abstractions; +using GeneralUpdate.Avalonia.Android.Enums; +using GeneralUpdate.Avalonia.Android.Events; +using GeneralUpdate.Avalonia.Android.Models; + +namespace GeneralUpdate.Avalonia.Android.Services; + +/// +/// Durable, explicitly initiated update orchestration. Does not own a supplied bootstrap unless +/// requested. Dispose requests cancellation without blocking; await DisposeAsync outside callbacks +/// to wait for operations to drain and for owned resources to be released. +/// +public sealed class AndroidUpdateCoordinator : IAndroidUpdateCoordinator +{ + private readonly IAndroidBootstrap _bootstrap; + private readonly IPendingUpdateStore _store; + private readonly IVersionComparer _versions; + private readonly IUpdateEventDispatcher _dispatcher; + private readonly bool _ownsBootstrap; + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly CancellationTokenSource _shutdown = new(); + private readonly TaskCompletionSource _drained = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly object _lifetime = new(); + private bool _disposed; + private bool _shutdownCanceled; + private bool _releasing; + private int _operations; + + public AndroidUpdateCoordinator( + IAndroidBootstrap bootstrap, + IPendingUpdateStore store, + IVersionComparer? versionComparer = null, + IUpdateEventDispatcher? eventDispatcher = null, + bool ownsBootstrap = false) + { + _bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap)); + _store = store ?? throw new ArgumentNullException(nameof(store)); + _versions = versionComparer ?? new SystemVersionComparer(); + _dispatcher = eventDispatcher ?? new ImmediateEventDispatcher(); + _ownsBootstrap = ownsBootstrap; + _bootstrap.AddListenerDownloadProgressChanged += ForwardDownloadProgress; + } + + public event EventHandler? StateChanged; + public event EventHandler? AddListenerDownloadProgressChanged; + + public IAndroidUpdateCoordinator AddListenerUpdatePrecheck(Func func) + { + ArgumentNullException.ThrowIfNull(func); + lock (_lifetime) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _bootstrap.AddListenerUpdatePrecheck(func); + } + return this; + } + + private void ForwardDownloadProgress(object? sender, DownloadProgressChangedEventArgs args) => + Dispatch(AddListenerDownloadProgressChanged, args); + + public Task RunAsync(string currentVersion, CancellationToken cancellationToken = default) => + ExecuteAsync(async (operation, token) => + { + await ReadPendingAsync(operation, token).ConfigureAwait(false); + if (operation.Pending is not null) + return Finish(operation, UpdateCoordinatorOutcome.PendingUpdateExists, + "Reconcile, explicitly retry, or abandon the pending update before starting another."); + return await RunCoreAsync(operation, currentVersion, token).ConfigureAwait(false); + }, cancellationToken); + + public Task ReconcileAsync(string currentVersion, CancellationToken cancellationToken = default) => + ExecuteAsync(async (operation, token) => + { + await ReadPendingAsync(operation, token).ConfigureAwait(false); + return await ReconcileCoreAsync(operation, currentVersion, token).ConfigureAwait(false); + }, cancellationToken); + + /// + /// Reconciles first, then rediscovers and verifies the same target; never trusts a persisted + /// APK path or replaces an earlier intent with a different target during retry. + /// + public Task RetryAsync(string currentVersion, CancellationToken cancellationToken = default) => + ExecuteAsync(async (operation, token) => + { + await ReadPendingAsync(operation, token).ConfigureAwait(false); + var result = await ReconcileCoreAsync(operation, currentVersion, token, notifyTerminal: false).ConfigureAwait(false); + if (result.Outcome is not (UpdateCoordinatorOutcome.AwaitingInstallation or UpdateCoordinatorOutcome.RecoveryRequired)) + return Finish(operation, result.Outcome, result.Message!, result.FailureReason); + return await RunCoreAsync(operation, currentVersion, token).ConfigureAwait(false); + }, cancellationToken); + + public Task AbandonAsync(CancellationToken cancellationToken = default) => + ExecuteAsync(async (operation, token) => + { + // Explicit abandonment can also recover corrupt state, so it does not deserialize first. + Notify(operation, UpdateCoordinatorStage.Abandoning); + ThrowIfCancellationRequested(token); + await _store.ClearAsync(token).ConfigureAwait(false); + operation.Pending = null; + return Finish(operation, UpdateCoordinatorOutcome.Abandoned, + "Update tracking forgotten. Android installation was not canceled; no APK was deleted."); + }, cancellationToken); + + private async Task ReadPendingAsync(Operation operation, CancellationToken token) + { + Notify(operation, UpdateCoordinatorStage.ReadingPending); + ThrowIfCancellationRequested(token); + operation.Pending = await _store.ReadAsync(token).ConfigureAwait(false); + operation.Pending?.Validate(); + } + + private async Task RunCoreAsync(Operation operation, string currentVersion, CancellationToken token) + { + if (!TryCompare(currentVersion, currentVersion, out _)) + return InvalidVersion(operation); + Notify(operation, UpdateCoordinatorStage.Checking); + ThrowIfCancellationRequested(token); + var check = await _bootstrap.ValidateAsync(currentVersion, token).ConfigureAwait(false); + ThrowIfCancellationRequested(token); + if (!check.Success) + return BootstrapFailure(operation, check); + if (!check.UpdateFound) + { + var skipped = check.PackageInfo is not null && + TryCompare(currentVersion, check.PackageInfo.Version, out var available) && available > 0; + return Finish(operation, skipped ? UpdateCoordinatorOutcome.Skipped : UpdateCoordinatorOutcome.NoUpdate, + skipped ? "Update skipped by pre-check." : "No update available. Any earlier pending attempt remains tracked."); + } + var package = check.PackageInfo; + if (package is null || !TryCompare(currentVersion, package.Version, out var comparison)) + return InvalidVersion(operation); + if (comparison <= 0) + return Finish(operation, UpdateCoordinatorOutcome.NoUpdate, "The discovered package is not newer than the installed version."); + if (operation.Pending is not null) + { + if (!TryCompare(operation.Pending.TargetVersion, package.Version, out var targetComparison)) + return InvalidVersion(operation); + if (targetComparison != 0) + return Finish(operation, UpdateCoordinatorOutcome.RecoveryRequired, + "Fresh discovery selected a different target. Reconcile the earlier attempt or explicitly abandon tracking before starting another update; its installer may still finish."); + } + + var intent = new PendingUpdateAttempt + { + AttemptId = Guid.NewGuid(), + OriginalVersion = currentVersion, + TargetVersion = package.Version, + CreatedAtUtc = DateTimeOffset.UtcNow, + Phase = PendingUpdatePhase.IntentPersisted + }; + intent.Validate(); + Notify(operation, UpdateCoordinatorStage.DownloadingAndVerifying); + ThrowIfCancellationRequested(token); + var prepared = await _bootstrap.DownloadAndVerifyAsync(package, token).ConfigureAwait(false); + ThrowIfCancellationRequested(token); + if (!prepared.Success) + return BootstrapFailure(operation, prepared); + if (string.IsNullOrWhiteSpace(prepared.FilePath)) + return Finish(operation, UpdateCoordinatorOutcome.Failed, "Verification returned no APK path.", UpdateFailureReason.InvalidMetadata); + + Notify(operation, UpdateCoordinatorStage.PersistingIntent); + ThrowIfCancellationRequested(token); + await _store.WriteAsync(intent, token).ConfigureAwait(false); + operation.Pending = intent; + var handoffStarted = false; + try + { + Notify(operation, UpdateCoordinatorStage.LaunchingInstaller); + ThrowIfCancellationRequested(token); + handoffStarted = true; + var launched = await _bootstrap.LaunchInstallerAsync(package, prepared.FilePath, token).ConfigureAwait(false); + // Once handed off, cancellation cannot cancel Android. Preserve the actual handoff outcome. + if (launched.Success) + { + var handedOff = intent with { Phase = PendingUpdatePhase.InstallerLaunched }; + if (!await TrySaveStatusAsync(operation, handedOff).ConfigureAwait(false)) + return Finish(operation, UpdateCoordinatorOutcome.RecoveryRequired, + "Installer launched, but its status could not be saved. Reconcile the retained intent.", + UpdateFailureReason.FileIoError); + return Finish(operation, UpdateCoordinatorOutcome.InstallerLaunched, + "Installer launched. Installation is not confirmed; reconcile the actual installed version later."); + } + if (launched.State != UpdateState.Canceled && launched.FailureReason != UpdateFailureReason.Canceled) + await TrySaveStatusAsync(operation, intent with { Phase = PendingUpdatePhase.Retryable }).ConfigureAwait(false); + return BootstrapFailure(operation, launched); + } + catch (OperationCanceledException) when (token.IsCancellationRequested || _shutdown.IsCancellationRequested) + { + // Cancellation after entering an external installer cannot prove that no handoff occurred. + if (!handoffStarted) + await TrySaveStatusAsync(operation, intent with { Phase = PendingUpdatePhase.Retryable }).ConfigureAwait(false); + throw; + } + catch + { + // An exception does not prove that an external installer was not launched. + return Finish(operation, UpdateCoordinatorOutcome.RecoveryRequired, + "Installer handoff is uncertain. Reconcile before explicitly retrying.", UpdateFailureReason.InstallLaunchFailed); + } + } + + private async Task ReconcileCoreAsync( + Operation operation, string currentVersion, CancellationToken token, bool notifyTerminal = true) + { + Notify(operation, UpdateCoordinatorStage.Reconciling); + ThrowIfCancellationRequested(token); + UpdateCoordinatorOutcome outcome; + string message; + if (operation.Pending is null) + { + outcome = UpdateCoordinatorOutcome.NoPendingUpdate; + message = "No pending update."; + } + else + { + if (!TryCompare(currentVersion, operation.Pending.TargetVersion, out var comparison) || + !TryCompare(operation.Pending.OriginalVersion, operation.Pending.TargetVersion, out var originalComparison) || + originalComparison <= 0) + return InvalidVersion(operation, notifyTerminal); + if (comparison <= 0) + { + await _store.ClearAsync(token).ConfigureAwait(false); + operation.Pending = null; + outcome = UpdateCoordinatorOutcome.Updated; + message = "The host-reported installed version matches or exceeds the pending target."; + } + else + { + outcome = operation.Pending.Phase == PendingUpdatePhase.Retryable + ? UpdateCoordinatorOutcome.RecoveryRequired : UpdateCoordinatorOutcome.AwaitingInstallation; + message = "The target is not installed. Wait, explicitly retry after checking Android, or abandon tracking."; + } + } + return notifyTerminal ? Finish(operation, outcome, message) : Result(operation, outcome, message); + } + + private bool TryCompare(string current, string target, out int comparison) + { + comparison = 0; + return !string.IsNullOrWhiteSpace(current) && !string.IsNullOrWhiteSpace(target) && + _versions.TryCompare(current, target, out comparison, out _); + } + + private void ThrowIfCancellationRequested(CancellationToken token) + { + // Async shutdown marks this token immediately, before linked-token callbacks finish. + _shutdown.Token.ThrowIfCancellationRequested(); + token.ThrowIfCancellationRequested(); + } + + private async Task TrySaveStatusAsync(Operation operation, PendingUpdateAttempt attempt) + { + try + { + await _store.WriteAsync(attempt, CancellationToken.None).ConfigureAwait(false); + operation.Pending = attempt; + return true; + } + catch { return false; } + } + + private UpdateCoordinatorResult InvalidVersion(Operation operation, bool notifyTerminal = true) => + notifyTerminal + ? Finish(operation, UpdateCoordinatorOutcome.Failed, "Invalid installed or pending target version.", UpdateFailureReason.VersionComparisonFailed) + : Result(operation, UpdateCoordinatorOutcome.Failed, "Invalid installed or pending target version.", UpdateFailureReason.VersionComparisonFailed); + + private UpdateCoordinatorResult BootstrapFailure(Operation operation, UpdateOperationResult result) + { + var canceled = result.State == UpdateState.Canceled || result.FailureReason == UpdateFailureReason.Canceled; + return Finish(operation, + canceled ? UpdateCoordinatorOutcome.Canceled : UpdateCoordinatorOutcome.Failed, + "Update operation did not complete. Consult the failure reason; reconcile any retained pending attempt before retrying.", + canceled ? UpdateFailureReason.Canceled + : result.FailureReason == UpdateFailureReason.None ? UpdateFailureReason.Unknown : result.FailureReason); + } + + private async Task ExecuteAsync( + Func> action, CancellationToken cancellationToken) + { + lock (_lifetime) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _operations++; + } + var operation = new Operation(); + var entered = false; + try + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _shutdown.Token); + try + { + await _gate.WaitAsync(linked.Token).ConfigureAwait(false); + entered = true; + ThrowIfCancellationRequested(linked.Token); + await using var storeLease = _store is IPendingUpdateStoreLeaseProvider leaseProvider + ? await leaseProvider.AcquireLeaseAsync(linked.Token).ConfigureAwait(false) + : null; + ThrowIfCancellationRequested(linked.Token); + return await action(operation, linked.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (linked.IsCancellationRequested || _shutdown.IsCancellationRequested) + { + return Finish(operation, UpdateCoordinatorOutcome.Canceled, + "Operation canceled. Any pending intent remains tracked; reconcile before explicitly retrying.", + UpdateFailureReason.Canceled); + } + catch (Exception ex) + { + return Finish(operation, UpdateCoordinatorOutcome.Failed, + "Update operation failed. No pending tracking was intentionally discarded.", + ex is InvalidDataException ? UpdateFailureReason.InvalidMetadata + : ex is IOException or UnauthorizedAccessException ? UpdateFailureReason.FileIoError + : UpdateFailureReason.Unknown); + } + } + finally + { + if (entered) _gate.Release(); + lock (_lifetime) _operations--; + TryRelease(); + } + } + + private static UpdateCoordinatorResult Result(Operation operation, UpdateCoordinatorOutcome outcome, + string? message = null, UpdateFailureReason failure = UpdateFailureReason.None) => new() + { + OperationId = operation.Id, + Stage = operation.Stage, + Outcome = outcome, + FailureReason = failure, + Message = message, + PendingUpdate = operation.Pending + }; + + private UpdateCoordinatorResult Finish(Operation operation, UpdateCoordinatorOutcome outcome, string message, + UpdateFailureReason failure = UpdateFailureReason.None) + { + operation.Stage = UpdateCoordinatorStage.Finished; + var result = Result(operation, outcome, message, failure); + Dispatch(result); + return result; + } + + private void Notify(Operation operation, UpdateCoordinatorStage stage) + { + operation.Stage = stage; + Dispatch(Result(operation, UpdateCoordinatorOutcome.None)); + } + + private void Dispatch(UpdateCoordinatorResult result) + { + Dispatch(StateChanged, new UpdateCoordinatorEventArgs(result)); + } + + private void Dispatch(EventHandler? listeners, TEventArgs args) where TEventArgs : EventArgs + { + if (listeners is null) return; + try + { + _dispatcher.Dispatch(() => + { + foreach (EventHandler listener in listeners.GetInvocationList()) + { + try { listener(this, args); } + catch { } + } + }); + } + catch { } + } + + public void Dispose() + { + lock (_lifetime) + { + if (_disposed) return; + _disposed = true; + } + _ = CancelAndDrainAsync(); + } + + private async Task CancelAndDrainAsync() + { + try { await _shutdown.CancelAsync().ConfigureAwait(false); } + catch (Exception) { } + finally + { + lock (_lifetime) _shutdownCanceled = true; + TryRelease(); + } + } + + public async ValueTask DisposeAsync() + { + Dispose(); + await _drained.Task.ConfigureAwait(false); + } + + private void TryRelease() + { + lock (_lifetime) + { + if (!_disposed || !_shutdownCanceled || _operations != 0 || _releasing) return; + _releasing = true; + } + _ = ReleaseAsync(); + } + + private async Task ReleaseAsync() + { + Exception? failure = null; + try + { + _bootstrap.AddListenerDownloadProgressChanged -= ForwardDownloadProgress; + if (_ownsBootstrap) + { + if (_bootstrap is IAsyncDisposable asyncDisposable) + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + else + _bootstrap.Dispose(); + } + } + catch (Exception ex) { failure = ex; } + finally + { + _gate.Dispose(); + _shutdown.Dispose(); + } + if (failure is null) _drained.TrySetResult(); + else _drained.TrySetException(failure); + } + + private sealed class Operation + { + public Guid Id { get; } = Guid.NewGuid(); + public UpdateCoordinatorStage Stage { get; set; } + public PendingUpdateAttempt? Pending { get; set; } + } +} diff --git a/src/GeneralUpdate.Avalonia.Android/Services/JsonPendingUpdateStore.cs b/src/GeneralUpdate.Avalonia.Android/Services/JsonPendingUpdateStore.cs new file mode 100644 index 0000000..ed4aad2 --- /dev/null +++ b/src/GeneralUpdate.Avalonia.Android/Services/JsonPendingUpdateStore.cs @@ -0,0 +1,111 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using GeneralUpdate.Avalonia.Android.Abstractions; +using GeneralUpdate.Avalonia.Android.Models; + +namespace GeneralUpdate.Avalonia.Android.Services; + +/// +/// Stores an intent in a host-selected app-private file (not a cache or shared downloads directory). +/// Atomic same-directory replacement protects the previous record if writing fails. +/// Cooperating coordinators hold an exclusive file lease for the entire workflow. +/// Flush and rename protect against process interruption; directory fsync/power-loss durability +/// is not guaranteed. Do not remove the persistent .lock file while this store is in use. +/// +public sealed class JsonPendingUpdateStore : IPendingUpdateStore, IPendingUpdateStoreLeaseProvider +{ + private const int MaximumRecordBytes = 16 * 1024; + private static readonly JsonSerializerOptions JsonOptions = new() + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + AllowDuplicateProperties = false + }; + private readonly string _filePath; + + public JsonPendingUpdateStore(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + _filePath = Path.GetFullPath(filePath); + } + + public async ValueTask AcquireLeaseAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!); + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + // Keep the lock inode stable: deleting it could let another process lock a new file. + return new FileStream(_filePath + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, + FileShare.None, 1, FileOptions.None); + } + catch (IOException ex) when ((ex.HResult & 0xffff) is 11 or 32 or 33) + { + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + } + } + + public async Task ReadAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + await using var file = new FileStream(_filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + if (file.Length > MaximumRecordBytes) + throw new InvalidDataException("The pending update record is too large."); + var attempt = await JsonSerializer.DeserializeAsync(file, JsonOptions, cancellationToken) + .ConfigureAwait(false) ?? throw new InvalidDataException("The pending update record is empty."); + attempt.Validate(); + return attempt; + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + catch (JsonException ex) + { + throw new InvalidDataException("The pending update record is corrupt.", ex); + } + } + + public async Task WriteAsync(PendingUpdateAttempt attempt, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(attempt); + attempt.Validate(); + cancellationToken.ThrowIfCancellationRequested(); + Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!); + var temporaryPath = _filePath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + await using (var file = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, + FileShare.None, 4096, FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await JsonSerializer.SerializeAsync(file, attempt, JsonOptions, cancellationToken).ConfigureAwait(false); + await file.FlushAsync(cancellationToken).ConfigureAwait(false); + file.Flush(flushToDisk: true); + } + cancellationToken.ThrowIfCancellationRequested(); + File.Move(temporaryPath, _filePath, overwrite: true); + } + finally + { + try { File.Delete(temporaryPath); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + } + + public Task ClearAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + File.Delete(_filePath); + return Task.CompletedTask; + } +} diff --git a/tests/GeneralUpdate.Avalonia.Android.Tests/AndroidUpdateCoordinatorTests.cs b/tests/GeneralUpdate.Avalonia.Android.Tests/AndroidUpdateCoordinatorTests.cs new file mode 100644 index 0000000..91550b4 --- /dev/null +++ b/tests/GeneralUpdate.Avalonia.Android.Tests/AndroidUpdateCoordinatorTests.cs @@ -0,0 +1,1001 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using GeneralUpdate.Avalonia.Android.Abstractions; +using GeneralUpdate.Avalonia.Android.Enums; +using GeneralUpdate.Avalonia.Android.Events; +using GeneralUpdate.Avalonia.Android.Models; +using GeneralUpdate.Avalonia.Android.Services; +using Xunit; +using DownloadProgressChangedEventArgs = GeneralUpdate.Avalonia.Android.Events.DownloadProgressChangedEventArgs; + +namespace GeneralUpdate.Avalonia.Android.Tests; + +public sealed class AndroidUpdateCoordinatorTests +{ + private static UpdatePackageInfo Package(string version = "2.0") => new() + { + Version = version, + DownloadUrl = "https://updates.example/app.apk?signature=private-query", + Sha256 = new string('a', 64), + AuthToken = "private-token", + AuthSecretKey = "private-secret", + BasicUsername = "private-user", + BasicPassword = "private-password" + }; + + private static PendingUpdateAttempt Intent(PendingUpdatePhase phase = PendingUpdatePhase.IntentPersisted) => new() + { + AttemptId = Guid.NewGuid(), + OriginalVersion = "1.0", + TargetVersion = "2.0", + CreatedAtUtc = DateTimeOffset.UtcNow, + Phase = phase + }; + + [Fact] + public async Task RunUsesRealBootstrapStagesAndPersistsBeforeInstaller() + { + var calls = new List(); + var store = new MemoryStore { OnWrite = _ => calls.Add("persist") }; + using var http = new HttpClient(new MetadataHandler(() => + { + calls.Add("check"); + return Package(); + })); + using var bootstrap = new AndroidBootstrap(new SystemVersionComparer(), + new RecordingDownloader(calls), new RecordingValidator(calls), new RecordingInstaller(calls, store), + new PhysicalFileStorage(), updateServer: Server(), httpClient: http); + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + var stages = new List(); + coordinator.StateChanged += (_, args) => stages.Add(args.Result.Stage); + + var result = await coordinator.RunAsync("1.0"); + + Assert.Equal(UpdateCoordinatorOutcome.InstallerLaunched, result.Outcome); + Assert.Equal(new[] { "check", "download", "verify", "persist", "install", "persist" }, calls); + Assert.Equal(new[] + { + UpdateCoordinatorStage.ReadingPending, UpdateCoordinatorStage.Checking, + UpdateCoordinatorStage.DownloadingAndVerifying, UpdateCoordinatorStage.PersistingIntent, + UpdateCoordinatorStage.LaunchingInstaller, UpdateCoordinatorStage.Finished + }, stages); + Assert.Equal(PendingUpdatePhase.InstallerLaunched, store.Pending!.Phase); + Assert.NotEqual(UpdateCoordinatorOutcome.Updated, result.Outcome); + } + + [Fact] + public async Task RealTransportHashAndDurableStoreCompleteHandoffThenRecreatedCoordinatorConfirmsInstalledVersion() + { + using var directory = new TestDirectory(); + var calls = new List(); + byte[] apk = [1, 2, 3, 4, 5]; + var package = Package() with { Sha256 = Convert.ToHexString(SHA256.HashData(apk)), FileSize = apk.Length }; + using var http = new HttpClient(new PackageHandler(package, apk, calls)); + var pendingPath = Path.Combine(directory.Path, "pending.json"); + var store = new JsonPendingUpdateStore(pendingPath); + var storage = new PhysicalFileStorage(); + using var downloader = new HttpResumableApkDownloader(http, storage, + new AndroidUpdateOptions { DownloadDirectoryPath = directory.Path }); + using var bootstrap = new AndroidBootstrap(new SystemVersionComparer(), + new TrackingDownloader(downloader, calls), new TrackingValidator(new Sha256HashValidator(), calls), + new RecordingInstaller(calls, store), storage, updateServer: Server(), httpClient: http); + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + var receivedProgress = 0; + coordinator.AddListenerDownloadProgressChanged += (_, _) => throw new InvalidOperationException("progress listener"); + coordinator.AddListenerDownloadProgressChanged += (_, args) => + { + if (args.DownloadedBytes == apk.Length) receivedProgress++; + }; + var result = await coordinator.RunAsync("1.0"); + Assert.Equal(UpdateCoordinatorOutcome.InstallerLaunched, result.Outcome); + Assert.True(receivedProgress > 0); + Assert.Equal(new[] { "check", "download", "verify", "install" }, calls); + Assert.Equal(PendingUpdatePhase.InstallerLaunched, (await store.ReadAsync())!.Phase); + Assert.Equal(UpdateCoordinatorOutcome.AwaitingInstallation, (await coordinator.ReconcileAsync("1.0")).Outcome); + var json = await File.ReadAllTextAsync(pendingPath); + foreach (var forbidden in new[] { "private-", "https:", "DownloadUrl", "Auth", "Password", "FilePath", "apk", "Exception" }) + Assert.DoesNotContain(forbidden, json); + await coordinator.DisposeAsync(); + await bootstrap.DisposeAsync(); + + var recreatedStore = new JsonPendingUpdateStore(pendingPath); + using var recreatedDownloader = new HttpResumableApkDownloader(http, storage, + new AndroidUpdateOptions { DownloadDirectoryPath = directory.Path }); + using var recreatedBootstrap = new AndroidBootstrap(new SystemVersionComparer(), + new TrackingDownloader(recreatedDownloader, calls), new TrackingValidator(new Sha256HashValidator(), calls), + new RecordingInstaller(calls, recreatedStore), storage, updateServer: Server(), httpClient: http); + await using var recreatedCoordinator = new AndroidUpdateCoordinator(recreatedBootstrap, recreatedStore); + Assert.Equal(UpdateCoordinatorOutcome.Updated, (await recreatedCoordinator.ReconcileAsync("2.0")).Outcome); + Assert.Equal(UpdateCoordinatorOutcome.NoPendingUpdate, (await recreatedCoordinator.ReconcileAsync("2.0")).Outcome); + Assert.False(File.Exists(pendingPath)); + Assert.Equal(new[] { "check", "download", "verify", "install" }, calls); + } + + [Fact] + public async Task PersistedPrelaunchIntentSurvivesRestartAsUncertainWithoutAutomaticallyLaunching() + { + using var directory = new TestDirectory(); + var pendingPath = Path.Combine(directory.Path, "pending.json"); + var beforeInterruption = new JsonPendingUpdateStore(pendingPath); + var intent = Intent(); + await beforeInterruption.WriteAsync(intent); + + var bootstrap = new StubBootstrap(); + var afterInterruption = new JsonPendingUpdateStore(pendingPath); + await using var recreated = new AndroidUpdateCoordinator(bootstrap, afterInterruption); + var result = await recreated.ReconcileAsync("1.0"); + Assert.Equal(UpdateCoordinatorOutcome.AwaitingInstallation, result.Outcome); + Assert.Equal(PendingUpdatePhase.IntentPersisted, result.PendingUpdate!.Phase); + Assert.Equal(intent, await afterInterruption.ReadAsync()); + Assert.Equal(UpdateCoordinatorOutcome.PendingUpdateExists, (await recreated.RunAsync("1.0")).Outcome); + Assert.Empty(bootstrap.Calls); + } + + [Theory] + [InlineData("2.0", UpdateCoordinatorOutcome.Updated)] + [InlineData("3.0", UpdateCoordinatorOutcome.Updated)] + [InlineData("1.0", UpdateCoordinatorOutcome.AwaitingInstallation)] + [InlineData("1.5", UpdateCoordinatorOutcome.AwaitingInstallation)] + public async Task RecreatedCoordinatorReconcilesActualVersion(string actualVersion, UpdateCoordinatorOutcome expected) + { + using var directory = new TestDirectory(); + var file = Path.Combine(directory.Path, "pending.json"); + using (var first = new AndroidUpdateCoordinator(new StubBootstrap(), new JsonPendingUpdateStore(file))) + Assert.Equal(UpdateCoordinatorOutcome.InstallerLaunched, (await first.RunAsync("1.0")).Outcome); + + var bootstrap = new StubBootstrap(); + await using var recreated = new AndroidUpdateCoordinator(bootstrap, new JsonPendingUpdateStore(file)); + var result = await recreated.ReconcileAsync(actualVersion); + Assert.Equal(expected, result.Outcome); + Assert.Empty(bootstrap.Calls); + Assert.Equal(expected != UpdateCoordinatorOutcome.Updated, File.Exists(file)); + if (expected == UpdateCoordinatorOutcome.Updated) + Assert.Equal(UpdateCoordinatorOutcome.NoPendingUpdate, (await recreated.ReconcileAsync(actualVersion)).Outcome); + } + + [Theory] + [InlineData(false, UpdateCoordinatorOutcome.NoUpdate)] + [InlineData(true, UpdateCoordinatorOutcome.Skipped)] + public async Task NoUpdateAndRealPrecheckDoNotDownload(bool skip, UpdateCoordinatorOutcome expected) + { + var calls = new List(); + var store = new MemoryStore(); + using var http = new HttpClient(new MetadataHandler(() => skip ? Package() : null)); + using var bootstrap = new AndroidBootstrap(new SystemVersionComparer(), + new RecordingDownloader(calls), new RecordingValidator(calls), new RecordingInstaller(calls, store), + new PhysicalFileStorage(), updateServer: Server(), httpClient: http); + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + Assert.Same(coordinator, coordinator.AddListenerUpdatePrecheck(_ => skip)); + + Assert.Equal(expected, (await coordinator.RunAsync("1.0")).Outcome); + Assert.Empty(calls); + Assert.Null(store.Pending); + } + + [Theory] + [InlineData(false, UpdateCoordinatorOutcome.Failed)] + [InlineData(true, UpdateCoordinatorOutcome.InstallerLaunched)] + public async Task CoordinatorPrecheckPreservesPolicyFailureAndForcedUpdateSemantics(bool forced, UpdateCoordinatorOutcome expected) + { + var calls = new List(); + var store = new MemoryStore(); + using var http = new HttpClient(new MetadataHandler(() => Package() with { IsForced = forced })); + using var bootstrap = new AndroidBootstrap(new SystemVersionComparer(), + new RecordingDownloader(calls), new RecordingValidator(calls), new RecordingInstaller(calls, store), + new PhysicalFileStorage(), updateServer: Server(), httpClient: http); + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + var prechecks = 0; + coordinator.AddListenerUpdatePrecheck(_ => + { + prechecks++; + throw new InvalidOperationException("policy failed"); + }); + Assert.Equal(expected, (await coordinator.RunAsync("1.0")).Outcome); + Assert.Equal(forced ? 0 : 1, prechecks); + } + + [Theory] + [InlineData(UpdateFailureReason.InstallPermissionDenied)] + [InlineData(UpdateFailureReason.InstallLaunchFailed)] + public async Task FailedHandoffIsRetryable(UpdateFailureReason reason) + { + var bootstrap = new StubBootstrap + { + Install = _ => Task.FromResult(new InstallResult { FailureReason = reason, State = UpdateState.Failed }) + }; + var store = new MemoryStore(); + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + var result = await coordinator.RunAsync("1.0"); + Assert.Equal(UpdateCoordinatorOutcome.Failed, result.Outcome); + Assert.Equal(reason, result.FailureReason); + Assert.Equal(PendingUpdatePhase.Retryable, store.Pending!.Phase); + Assert.Equal(UpdateCoordinatorOutcome.RecoveryRequired, (await coordinator.ReconcileAsync("1.0")).Outcome); + } + + [Fact] + public async Task FailedIntentWritePreventsLaunch() + { + var store = new MemoryStore { FailWriteNumber = 1 }; + var bootstrap = new StubBootstrap(); + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + var result = await coordinator.RunAsync("1.0"); + Assert.Equal(UpdateCoordinatorOutcome.Failed, result.Outcome); + Assert.Equal(UpdateFailureReason.FileIoError, result.FailureReason); + Assert.DoesNotContain("install", bootstrap.Calls); + Assert.Null(store.Pending); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task FailedPostHandoffWriteRetainsUncertainIntent(bool launchSucceeds) + { + var store = new MemoryStore { FailWriteNumber = 2 }; + var bootstrap = new StubBootstrap + { + Install = _ => Task.FromResult(new InstallResult + { + Success = launchSucceeds, + FailureReason = launchSucceeds ? UpdateFailureReason.None : UpdateFailureReason.InstallLaunchFailed + }) + }; + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + var result = await coordinator.RunAsync("1.0"); + Assert.Equal(launchSucceeds ? UpdateCoordinatorOutcome.RecoveryRequired : UpdateCoordinatorOutcome.Failed, result.Outcome); + Assert.Equal(PendingUpdatePhase.IntentPersisted, store.Pending!.Phase); + Assert.Equal(UpdateCoordinatorOutcome.AwaitingInstallation, (await coordinator.ReconcileAsync("1.0")).Outcome); + } + + [Fact] + public async Task ThrowingInstallerLeavesUncertainIntentInsteadOfClaimingFailureWasSafe() + { + var store = new MemoryStore(); + var bootstrap = new StubBootstrap { Install = _ => throw new InvalidOperationException("unknown external outcome") }; + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + Assert.Equal(UpdateCoordinatorOutcome.RecoveryRequired, (await coordinator.RunAsync("1.0")).Outcome); + Assert.Equal(PendingUpdatePhase.IntentPersisted, store.Pending!.Phase); + } + + [Fact] + public async Task ExistingPendingCannotBeOverwrittenByRun() + { + var pending = Intent(); + var store = new MemoryStore { Pending = pending }; + var bootstrap = new StubBootstrap(); + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + Assert.Equal(UpdateCoordinatorOutcome.PendingUpdateExists, (await coordinator.RunAsync("1.0")).Outcome); + Assert.Same(pending, store.Pending); + Assert.Empty(bootstrap.Calls); + } + + [Fact] + public async Task FailedAcknowledgementRetainsRecordAndCanBeReconciledAgain() + { + var pending = Intent(); + var store = new MemoryStore { Pending = pending, FailClear = true }; + await using var coordinator = new AndroidUpdateCoordinator(new StubBootstrap(), store); + var failed = await coordinator.ReconcileAsync("2.0"); + Assert.Equal(UpdateCoordinatorOutcome.Failed, failed.Outcome); + Assert.Equal(UpdateFailureReason.FileIoError, failed.FailureReason); + Assert.Same(pending, store.Pending); + store.FailClear = false; + Assert.Equal(UpdateCoordinatorOutcome.Updated, (await coordinator.ReconcileAsync("2.0")).Outcome); + Assert.Null(store.Pending); + } + + [Theory] + [InlineData("not a version", "2.0")] + [InlineData("1.0", "not a version")] + public async Task InvalidVersionFailsExplicitlyAndRetainsPending(string actual, string target) + { + var store = new MemoryStore { Pending = Intent() with { TargetVersion = target } }; + await using var coordinator = new AndroidUpdateCoordinator(new StubBootstrap(), store); + var result = await coordinator.ReconcileAsync(actual); + Assert.Equal(UpdateCoordinatorOutcome.Failed, result.Outcome); + Assert.NotEqual(UpdateFailureReason.None, result.FailureReason); + Assert.NotNull(store.Pending); + } + + [Theory] + [InlineData("2.0")] + [InlineData("02.0")] + public async Task RetryRequeriesAndReverifiesUsingOnlyFreshPath(string equivalentTarget) + { + var store = new MemoryStore { Pending = Intent(PendingUpdatePhase.Retryable) }; + var previousId = store.Pending.AttemptId; + var bootstrap = new StubBootstrap { Available = Package(equivalentTarget), PreparedPath = "newly-verified.apk" }; + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + var result = await coordinator.RetryAsync("1.0"); + Assert.Equal(UpdateCoordinatorOutcome.InstallerLaunched, result.Outcome); + Assert.Equal(new[] { "check", "download-verify", "install" }, bootstrap.Calls); + Assert.Equal("newly-verified.apk", bootstrap.InstalledPath); + Assert.Equal(equivalentTarget, store.Pending!.TargetVersion); + Assert.NotEqual(previousId, store.Pending.AttemptId); + } + + [Theory] + [InlineData("3.0", "2.0")] + [InlineData("3.0", "3.0")] + [InlineData("1.5", "2.0")] + public async Task RetryRefusesDifferentDiscoveredTargetBeforeDownloadingAndPreservesEarlierHandoff( + string discoveredVersion, string eventuallyInstalledVersion) + { + var original = Intent(PendingUpdatePhase.InstallerLaunched); + var store = new MemoryStore { Pending = original }; + var bootstrap = new StubBootstrap { Available = Package(discoveredVersion) }; + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + + var result = await coordinator.RetryAsync("1.0"); + + Assert.Equal(UpdateCoordinatorOutcome.RecoveryRequired, result.Outcome); + Assert.Contains("different target", result.Message); + Assert.Contains("abandon", result.Message); + Assert.Equal(new[] { "check" }, bootstrap.Calls); + Assert.Equal(0, store.Writes); + Assert.Same(original, store.Pending); + Assert.Same(original, result.PendingUpdate); + Assert.Equal(UpdateCoordinatorOutcome.Updated, (await coordinator.ReconcileAsync(eventuallyInstalledVersion)).Outcome); + Assert.Null(store.Pending); + } + + [Theory] + [InlineData(UpdateFailureReason.Canceled)] + [InlineData(UpdateFailureReason.InstallPermissionDenied)] + public async Task FailedSameTargetRetryKeepsEarlierHandoffTargetReconcilable(UpdateFailureReason failure) + { + var store = new MemoryStore { Pending = Intent(PendingUpdatePhase.InstallerLaunched) }; + var bootstrap = new StubBootstrap + { + Install = _ => Task.FromResult(new InstallResult + { + State = failure == UpdateFailureReason.Canceled ? UpdateState.Canceled : UpdateState.Failed, + FailureReason = failure + }) + }; + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + var result = await coordinator.RetryAsync("1.0"); + Assert.Equal(failure, result.FailureReason); + Assert.Equal("2.0", store.Pending!.TargetVersion); + Assert.Equal(UpdateCoordinatorOutcome.Updated, (await coordinator.ReconcileAsync("2.0")).Outcome); + Assert.Null(store.Pending); + } + + [Theory] + [InlineData("2.0")] + [InlineData("3.0")] + public async Task RetryDoesNotInstallIfTargetAlreadyInstalled(string current) + { + var bootstrap = new StubBootstrap(); + var store = new MemoryStore { Pending = Intent() }; + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + Assert.Equal(UpdateCoordinatorOutcome.Updated, (await coordinator.RetryAsync(current)).Outcome); + Assert.Empty(bootstrap.Calls); + Assert.Null(store.Pending); + } + + [Fact] + public async Task RetryWithNoPendingDoesNotStartAnUpdate() + { + var bootstrap = new StubBootstrap(); + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, new MemoryStore()); + Assert.Equal(UpdateCoordinatorOutcome.NoPendingUpdate, (await coordinator.RetryAsync("1.0")).Outcome); + Assert.Empty(bootstrap.Calls); + } + + [Fact] + public async Task RetryFailureRetainsOriginalAttempt() + { + var pending = Intent(); + var store = new MemoryStore { Pending = pending }; + var bootstrap = new StubBootstrap + { + Download = _ => Task.FromResult(new UpdateOperationResult { FailureReason = UpdateFailureReason.HashMismatch }) + }; + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + Assert.Equal(UpdateFailureReason.HashMismatch, (await coordinator.RetryAsync("1.0")).FailureReason); + Assert.Same(pending, store.Pending); + Assert.DoesNotContain("install", bootstrap.Calls); + } + + [Fact] + public async Task AbandonForgetsTrackingButLeavesApkIntact() + { + using var directory = new TestDirectory(); + var apk = Path.Combine(directory.Path, "app.apk"); + await File.WriteAllTextAsync(apk, "apk"); + var store = new MemoryStore { Pending = Intent() }; + var bootstrap = new StubBootstrap(); + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + var result = await coordinator.AbandonAsync(); + Assert.Equal(UpdateCoordinatorOutcome.Abandoned, result.Outcome); + Assert.Contains("not canceled", result.Message); + Assert.True(File.Exists(apk)); + Assert.Null(store.Pending); + Assert.Empty(bootstrap.Calls); + } + + [Fact] + public async Task FailedAbandonRetainsPending() + { + var pending = Intent(); + var store = new MemoryStore { Pending = pending, FailClear = true }; + await using var coordinator = new AndroidUpdateCoordinator(new StubBootstrap(), store); + Assert.Equal(UpdateFailureReason.FileIoError, (await coordinator.AbandonAsync()).FailureReason); + Assert.Same(pending, store.Pending); + } + + [Fact] + public async Task CancellationBeforeStartIsTypedAndDoesNotTouchStore() + { + var store = new MemoryStore(); + var bootstrap = new StubBootstrap(); + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + var result = await coordinator.RunAsync("1.0", new CancellationToken(true)); + Assert.Equal(UpdateCoordinatorOutcome.Canceled, result.Outcome); + Assert.Equal(0, store.Reads); + Assert.Empty(bootstrap.Calls); + } + + [Fact] + public async Task CancellationDuringDownloadDoesNotPersistOrLaunch() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellation = new CancellationTokenSource(); + var store = new MemoryStore(); + var bootstrap = new StubBootstrap + { + Download = async token => + { + entered.SetResult(); + await Task.Delay(Timeout.Infinite, token); + return new UpdateOperationResult(); + } + }; + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + var running = coordinator.RunAsync("1.0", cancellation.Token); + await entered.Task; + cancellation.Cancel(); + Assert.Equal(UpdateCoordinatorOutcome.Canceled, (await running).Outcome); + Assert.Null(store.Pending); + Assert.DoesNotContain("install", bootstrap.Calls); + } + + [Fact] + public async Task CancellationAfterPersistenceRetainsRetryableIntentAndDoesNotLaunch() + { + using var cancellation = new CancellationTokenSource(); + var store = new MemoryStore(); + var bootstrap = new StubBootstrap(); + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + coordinator.StateChanged += (_, args) => + { + if (args.Result.Stage == UpdateCoordinatorStage.LaunchingInstaller) cancellation.Cancel(); + }; + Assert.Equal(UpdateCoordinatorOutcome.Canceled, (await coordinator.RunAsync("1.0", cancellation.Token)).Outcome); + Assert.Equal(PendingUpdatePhase.Retryable, store.Pending!.Phase); + Assert.DoesNotContain("install", bootstrap.Calls); + } + + [Fact] + public async Task SuccessfulExternalHandoffWinsOverLateCancellation() + { + using var cancellation = new CancellationTokenSource(); + var store = new MemoryStore(); + var bootstrap = new StubBootstrap + { + Install = _ => + { + cancellation.Cancel(); + return Task.FromResult(new InstallResult { Success = true }); + } + }; + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + Assert.Equal(UpdateCoordinatorOutcome.InstallerLaunched, (await coordinator.RunAsync("1.0", cancellation.Token)).Outcome); + Assert.Equal(PendingUpdatePhase.InstallerLaunched, store.Pending!.Phase); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CancellationInsideExternalInstallerRetainsUncertainIntent(bool throws) + { + using var cancellation = new CancellationTokenSource(); + var store = new MemoryStore(); + var bootstrap = new StubBootstrap + { + Install = token => + { + cancellation.Cancel(); + if (throws) throw new OperationCanceledException(token); + return Task.FromResult(new InstallResult + { + State = UpdateState.Canceled, + FailureReason = UpdateFailureReason.Canceled + }); + } + }; + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + Assert.Equal(UpdateCoordinatorOutcome.Canceled, (await coordinator.RunAsync("1.0", cancellation.Token)).Outcome); + Assert.Equal(PendingUpdatePhase.IntentPersisted, store.Pending!.Phase); + Assert.Equal(UpdateCoordinatorOutcome.AwaitingInstallation, (await coordinator.ReconcileAsync("1.0")).Outcome); + } + + [Fact] + public async Task CanceledWaiterDoesNotAffectActiveAttemptAndWholeRunsAreSerialized() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var store = new MemoryStore(); + var bootstrap = new StubBootstrap + { + Download = async _ => + { + entered.SetResult(); + await release.Task; + return new UpdateOperationResult { Success = true, FilePath = "verified.apk" }; + } + }; + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, store); + var active = coordinator.RunAsync("1.0"); + await entered.Task; + using var cancellation = new CancellationTokenSource(); + var waiter = coordinator.RunAsync("1.0", cancellation.Token); + var next = coordinator.RunAsync("1.0"); + cancellation.Cancel(); + Assert.Equal(UpdateCoordinatorOutcome.Canceled, (await waiter).Outcome); + Assert.Equal(1, store.Reads); + Assert.False(active.IsCompleted); + release.SetResult(); + Assert.Equal(UpdateCoordinatorOutcome.InstallerLaunched, (await active).Outcome); + Assert.Equal(UpdateCoordinatorOutcome.PendingUpdateExists, (await next).Outcome); + Assert.Equal(new[] { "check", "download-verify", "install" }, bootstrap.Calls); + } + + [Fact] + public async Task SeparateCoordinatorsShareWorkflowLeaseAndCanceledLeaseWaitersDoNotTouchIntent() + { + using var directory = new TestDirectory(); + var file = Path.Combine(directory.Path, "pending.json"); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstBootstrap = new StubBootstrap + { + Download = async _ => + { + entered.SetResult(); + await release.Task; + return new UpdateOperationResult { Success = true, FilePath = "verified.apk" }; + } + }; + var secondBootstrap = new StubBootstrap(); + await using var first = new AndroidUpdateCoordinator(firstBootstrap, new JsonPendingUpdateStore(file)); + await using var second = new AndroidUpdateCoordinator(secondBootstrap, new JsonPendingUpdateStore(file)); + await using var third = new AndroidUpdateCoordinator(new StubBootstrap(), new JsonPendingUpdateStore(file)); + var active = first.RunAsync("1.0"); + await entered.Task; + using var cancellation = new CancellationTokenSource(); + var canceledWaiter = third.RunAsync("1.0", cancellation.Token); + var competing = second.RunAsync("1.0"); + cancellation.Cancel(); + UpdateCoordinatorResult canceled; + try + { + canceled = await canceledWaiter.WaitAsync(TimeSpan.FromSeconds(5)); + } + finally + { + release.SetResult(); + } + Assert.Equal(UpdateCoordinatorOutcome.Canceled, canceled.Outcome); + Assert.Equal(UpdateCoordinatorOutcome.InstallerLaunched, (await active).Outcome); + Assert.Equal(UpdateCoordinatorOutcome.PendingUpdateExists, (await competing).Outcome); + Assert.Empty(secondBootstrap.Calls); + Assert.Equal(PendingUpdatePhase.InstallerLaunched, (await new JsonPendingUpdateStore(file).ReadAsync())!.Phase); + } + + [Fact] + public async Task FileLeaseIsExclusiveAndCanBeReacquiredAfterReleaseWithoutDeletingLockFile() + { + using var directory = new TestDirectory(); + var file = Path.Combine(directory.Path, "pending.json"); + var firstStore = new JsonPendingUpdateStore(file); + var secondStore = new JsonPendingUpdateStore(file); + await using (await firstStore.AcquireLeaseAsync()) + { + Assert.Throws(() => + { + using var competingFile = new FileStream(file + ".lock", FileMode.Open, FileAccess.ReadWrite, FileShare.None); + }); + using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + await Assert.ThrowsAnyAsync(() => secondStore.AcquireLeaseAsync(cancellation.Token).AsTask()); + } + Assert.True(File.Exists(file + ".lock")); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await using var reacquired = await secondStore.AcquireLeaseAsync(timeout.Token); + } + + [Fact] + public async Task ListenerAndDispatcherFailuresCannotChangeFlow() + { + var dispatcher = new RecordingDispatcher(); + await using var coordinator = new AndroidUpdateCoordinator(new StubBootstrap(), new MemoryStore(), eventDispatcher: dispatcher); + var received = 0; + coordinator.StateChanged += (_, _) => throw new InvalidOperationException("listener"); + coordinator.StateChanged += (_, _) => received++; + Assert.Equal(UpdateCoordinatorOutcome.InstallerLaunched, (await coordinator.RunAsync("1.0")).Outcome); + Assert.Equal(6, received); + Assert.Equal(6, dispatcher.Calls); + dispatcher.Throw = true; + Assert.Equal(UpdateCoordinatorOutcome.Updated, (await coordinator.ReconcileAsync("2.0")).Outcome); + } + + [Fact] + public async Task ProgressUsesCoordinatorDispatcherAndDetachesOnNonOwningDisposal() + { + var bootstrap = new StubBootstrap(); + var dispatcher = new RecordingDispatcher(); + var coordinator = new AndroidUpdateCoordinator(bootstrap, new MemoryStore(), eventDispatcher: dispatcher); + var received = 0; + coordinator.AddListenerDownloadProgressChanged += (_, _) => throw new InvalidOperationException("progress listener"); + coordinator.AddListenerDownloadProgressChanged += (_, _) => received++; + bootstrap.RaiseProgress(); + Assert.Equal(1, dispatcher.Calls); + Assert.Equal(1, received); + await coordinator.DisposeAsync(); + bootstrap.RaiseProgress(); + Assert.Equal(1, received); + Assert.False(bootstrap.Disposed); + Assert.Throws(() => coordinator.AddListenerUpdatePrecheck(_ => false)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DisposalFromCallbackIsNonblockingAndOwnershipIsExplicit(bool ownsBootstrap) + { + var bootstrap = new StubBootstrap(); + var coordinator = new AndroidUpdateCoordinator(bootstrap, new MemoryStore(), ownsBootstrap: ownsBootstrap); + coordinator.StateChanged += (_, args) => + { + if (args.Result.Stage == UpdateCoordinatorStage.Checking) coordinator.Dispose(); + }; + Assert.Equal(UpdateCoordinatorOutcome.Canceled, (await coordinator.RunAsync("1.0")).Outcome); + await coordinator.DisposeAsync(); + Assert.Equal(ownsBootstrap, bootstrap.Disposed); + Assert.Empty(bootstrap.Calls); + await Assert.ThrowsAsync(() => coordinator.RunAsync("1.0")); + } + + [Fact] + public async Task DisposeWaitsForActiveOperationBeforeReleasingOwnedBootstrap() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var bootstrap = new StubBootstrap + { + Download = async _ => + { + entered.SetResult(); + await release.Task; + return new UpdateOperationResult { Success = true, FilePath = "verified.apk" }; + } + }; + var coordinator = new AndroidUpdateCoordinator(bootstrap, new MemoryStore(), ownsBootstrap: true); + var active = coordinator.RunAsync("1.0"); + await entered.Task; + var waiting = coordinator.RunAsync("1.0"); + var disposing = coordinator.DisposeAsync().AsTask(); + Assert.False(disposing.IsCompleted); + Assert.False(bootstrap.Disposed); + Assert.Equal(UpdateCoordinatorOutcome.Canceled, (await waiting).Outcome); + release.SetResult(); + Assert.Equal(UpdateCoordinatorOutcome.Canceled, (await active).Outcome); + await disposing; + Assert.True(bootstrap.Disposed); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DisposeDoesNotWaitForBlockingCancellationCallbacksAndIsolatesTheirExceptions(bool callbackThrows) + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var callbackEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var finish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var callbackRelease = new ManualResetEventSlim(); + var bootstrap = new StubBootstrap + { + Download = async token => + { + using var registration = token.Register(() => + { + callbackEntered.TrySetResult(); + callbackRelease.Wait(); + if (callbackThrows) throw new InvalidOperationException("cancellation callback failed"); + }); + started.TrySetResult(); + await finish.Task; + token.ThrowIfCancellationRequested(); + return new UpdateOperationResult { Success = true, FilePath = "verified.apk" }; + } + }; + var coordinator = new AndroidUpdateCoordinator(bootstrap, new MemoryStore(), ownsBootstrap: true); + var active = coordinator.RunAsync("1.0"); + await started.Task; + var synchronousDispose = Task.Run(coordinator.Dispose); + try + { + await synchronousDispose.WaitAsync(TimeSpan.FromSeconds(5)); + await callbackEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(bootstrap.Disposed); + Assert.False(coordinator.DisposeAsync().IsCompleted); + } + finally + { + callbackRelease.Set(); + finish.TrySetResult(); + await coordinator.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5)); + } + Assert.Equal(UpdateCoordinatorOutcome.Canceled, (await active).Outcome); + Assert.True(bootstrap.Disposed); + } + + [Fact] + public async Task JsonContainsOnlySafeMetadataAndNoCredentialsUrlPathOrExceptions() + { + using var directory = new TestDirectory(); + var file = Path.Combine(directory.Path, "pending.json"); + await using var coordinator = new AndroidUpdateCoordinator(new StubBootstrap(), new JsonPendingUpdateStore(file)); + await coordinator.RunAsync("1.0"); + var json = await File.ReadAllTextAsync(file); + foreach (var forbidden in new[] { "private-", "https:", "DownloadUrl", "Auth", "Password", "FilePath", "apk", "Exception" }) + Assert.DoesNotContain(forbidden, json); + using var document = JsonDocument.Parse(json); + Assert.Equal(new[] { "AttemptId", "CreatedAtUtc", "OriginalVersion", "Phase", "SchemaVersion", "TargetVersion" }, + document.RootElement.EnumerateObject().Select(property => property.Name).Order().ToArray()); + } + + [Theory] + [InlineData("{broken")] + [InlineData("null")] + [InlineData("{}")] + [InlineData("{\"SchemaVersion\":99}")] + public async Task CorruptStateFailsClosedWithoutLaunchingAndCanBeExplicitlyAbandoned(string json) + { + using var directory = new TestDirectory(); + var file = Path.Combine(directory.Path, "pending.json"); + await File.WriteAllTextAsync(file, json); + var bootstrap = new StubBootstrap(); + await using var coordinator = new AndroidUpdateCoordinator(bootstrap, new JsonPendingUpdateStore(file)); + foreach (var result in new[] + { + await coordinator.RunAsync("1.0"), await coordinator.ReconcileAsync("1.0"), await coordinator.RetryAsync("1.0") + }) + { + Assert.Equal(UpdateCoordinatorOutcome.Failed, result.Outcome); + Assert.Equal(UpdateFailureReason.InvalidMetadata, result.FailureReason); + } + Assert.Empty(bootstrap.Calls); + Assert.Equal(json, await File.ReadAllTextAsync(file)); + Assert.Equal(UpdateCoordinatorOutcome.Abandoned, (await coordinator.AbandonAsync()).Outcome); + Assert.False(File.Exists(file)); + } + + [Fact] + public async Task UnsupportedSchemaMissingSchemaAndPersistedPathAreRejected() + { + using var directory = new TestDirectory(); + var file = Path.Combine(directory.Path, "pending.json"); + var valid = JsonSerializer.Serialize(Intent()); + var invalidRecords = new[] + { + valid.Replace("\"SchemaVersion\":1", "\"SchemaVersion\":99"), + valid.Replace("\"SchemaVersion\":1,", ""), + valid.Insert(1, "\"SchemaVersion\":99,"), + valid.Insert(1, "\"FilePath\":\"untrusted.apk\",") + }; + foreach (var json in invalidRecords) + { + await File.WriteAllTextAsync(file, json); + await Assert.ThrowsAsync(() => new JsonPendingUpdateStore(file).ReadAsync()); + } + } + + [Fact] + public async Task CanceledOrInvalidWritesPreservePreviousRecordWithoutTemporaryFiles() + { + using var directory = new TestDirectory(); + var file = Path.Combine(directory.Path, "pending.json"); + var store = new JsonPendingUpdateStore(file); + var original = Intent(); + await store.WriteAsync(original); + await Assert.ThrowsAnyAsync(() => store.WriteAsync(Intent(), new CancellationToken(true))); + await Assert.ThrowsAsync(() => store.WriteAsync(Intent() with { SchemaVersion = 99 })); + Assert.Equal(original, await store.ReadAsync()); + Assert.Single(Directory.GetFiles(directory.Path)); + await store.ClearAsync(); + Assert.Null(await store.ReadAsync()); + await store.ClearAsync(); + } + + private static UpdateServerOptions Server() => new() + { + RequestUrl = "https://updates.example/metadata", + UseJsonEndpoint = true + }; + + private sealed class MemoryStore : IPendingUpdateStore + { + public PendingUpdateAttempt? Pending { get; set; } + public int Reads { get; private set; } + public int Writes { get; private set; } + public int FailWriteNumber { get; init; } + public bool FailClear { get; set; } + public Action? OnWrite { get; init; } + public Task ReadAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Reads++; + return Task.FromResult(Pending); + } + public Task WriteAsync(PendingUpdateAttempt attempt, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (++Writes == FailWriteNumber) throw new IOException("store failed"); + OnWrite?.Invoke(attempt); + Pending = attempt; + return Task.CompletedTask; + } + public Task ClearAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (FailClear) throw new IOException("clear failed"); + Pending = null; + return Task.CompletedTask; + } + } + + private sealed class StubBootstrap : IAndroidBootstrap + { + public List Calls { get; } = []; + public UpdatePackageInfo Available { get; init; } = Package(); + public string PreparedPath { get; init; } = "verified.apk"; + public string? InstalledPath { get; private set; } + public bool Disposed { get; private set; } + public Func>? Download { get; init; } + public Func>? Install { get; init; } + public event EventHandler? AddListenerValidate { add { } remove { } } + public event EventHandler? AddListenerDownloadProgressChanged; + public event EventHandler? AddListenerUpdateCompleted { add { } remove { } } + public event EventHandler? AddListenerUpdateFailed { add { } remove { } } + public UpdateStateSnapshot GetSnapshot() => new(UpdateState.None, UpdateFailureReason.None, null); + public IAndroidBootstrap AddListenerUpdatePrecheck(Func func) => this; + public void RaiseProgress() => AddListenerDownloadProgressChanged?.Invoke(this, + new DownloadProgressChangedEventArgs(new DownloadProgressInfo + { + DownloadSpeedBytesPerSecond = 1, + DownloadedBytes = 1, + RemainingBytes = 0, + TotalBytes = 1, + ProgressPercentage = 100, + PackageInfo = Available, + StatusDescription = "Downloaded." + })); + public Task ValidateAsync(string currentVersion, CancellationToken cancellationToken = default) + { + Calls.Add("check"); + return Task.FromResult(new UpdateCheckResult { Success = true, UpdateFound = true, PackageInfo = Available }); + } + public Task DownloadAndVerifyAsync(UpdatePackageInfo packageInfo, CancellationToken cancellationToken = default) + { + Calls.Add("download-verify"); + return Download?.Invoke(cancellationToken) ?? + Task.FromResult(new UpdateOperationResult { Success = true, FilePath = PreparedPath }); + } + public Task LaunchInstallerAsync(UpdatePackageInfo packageInfo, string apkFilePath, CancellationToken cancellationToken = default) + { + Calls.Add("install"); + InstalledPath = apkFilePath; + return Install?.Invoke(cancellationToken) ?? Task.FromResult(new InstallResult { Success = true }); + } + public void Dispose() => Disposed = true; + } + + private sealed class RecordingDownloader(List calls) : IUpdateDownloader + { + public Task DownloadAsync(UpdatePackageInfo packageInfo, + Action? progressCallback, CancellationToken cancellationToken = default) + { + calls.Add("download"); + return Task.FromResult(new DownloadResult { Success = true, FilePath = "verified.apk" }); + } + } + + private sealed class RecordingValidator(List calls) : IHashValidator + { + public Task ValidateSha256Async(string filePath, string expectedSha256, CancellationToken cancellationToken = default) + { + calls.Add("verify"); + return Task.FromResult(new HashValidationResult { Success = true }); + } + } + + private sealed class RecordingInstaller(List calls, IPendingUpdateStore store) : IApkInstaller + { + public async Task LaunchInstallAsync(UpdatePackageInfo packageInfo, string apkFilePath, CancellationToken cancellationToken = default) + { + var pending = await store.ReadAsync(cancellationToken); + Assert.NotNull(pending); + Assert.Equal(PendingUpdatePhase.IntentPersisted, pending.Phase); + calls.Add("install"); + return new InstallResult { Success = true }; + } + } + + private sealed class TrackingDownloader(IUpdateDownloader inner, List calls) : IUpdateDownloader + { + public Task DownloadAsync(UpdatePackageInfo packageInfo, Action? progressCallback, + CancellationToken cancellationToken = default) + { + calls.Add("download"); + return inner.DownloadAsync(packageInfo, progressCallback, cancellationToken); + } + } + + private sealed class TrackingValidator(IHashValidator inner, List calls) : IHashValidator + { + public Task ValidateSha256Async(string filePath, string expectedSha256, CancellationToken cancellationToken = default) + { + calls.Add("verify"); + return inner.ValidateSha256Async(filePath, expectedSha256, cancellationToken); + } + } + + private sealed class PackageHandler(UpdatePackageInfo package, byte[] apk, List calls) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.RequestUri!.AbsolutePath == "/metadata") + { + calls.Add("check"); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(package)) + }); + } + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(apk) }); + } + } + + private sealed class MetadataHandler(Func getPackage) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var package = getPackage(); + return Task.FromResult(new HttpResponseMessage(package is null ? HttpStatusCode.NoContent : HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(package)) + }); + } + } + + private sealed class RecordingDispatcher : IUpdateEventDispatcher + { + public int Calls { get; private set; } + public bool Throw { get; set; } + public void Dispatch(Action callback) + { + Calls++; + if (Throw) throw new InvalidOperationException("dispatcher"); + callback(); + } + } + + private sealed class TestDirectory : IDisposable + { + public string Path { get; } = System.IO.Path.Combine(Directory.GetCurrentDirectory(), "coordinator-tests-" + Guid.NewGuid().ToString("N")); + public TestDirectory() => Directory.CreateDirectory(Path); + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/tests/GeneralUpdate.Avalonia.Android.Tests/GeneralUpdate.Avalonia.Android.Tests.csproj b/tests/GeneralUpdate.Avalonia.Android.Tests/GeneralUpdate.Avalonia.Android.Tests.csproj index 737bf6a..7fe840e 100644 --- a/tests/GeneralUpdate.Avalonia.Android.Tests/GeneralUpdate.Avalonia.Android.Tests.csproj +++ b/tests/GeneralUpdate.Avalonia.Android.Tests/GeneralUpdate.Avalonia.Android.Tests.csproj @@ -14,6 +14,17 @@ + + + + + + + + + + +