From a3b95c4f81d205229796ed5de6d68a91b9acb51f Mon Sep 17 00:00:00 2001 From: "Juster.zhu" Date: Sat, 3 Oct 2026 15:32:22 +0800 Subject: [PATCH 1/5] fix: close Android update loop and improve integration reliability Track installation outcomes across restarts, normalize cancellation and retries, manage HTTP ownership, and extract injectable package and persistence services. Simplify the sample with a testable view model and preparation/reset APIs. Refs #20 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci-cd.yml | 3 + README-EN.md | 105 +++- README.md | 98 ++- ...neralUpdate.Avalonia.Android.Sample.csproj | 2 + .../Infrastructure/AndroidUpdateHost.cs | 90 +++ .../Infrastructure/IUpdateHost.cs | 14 + .../README.md | 29 +- .../ViewModels/UpdateViewModel.cs | 249 ++++++++ .../Views/MainView.axaml | 32 +- .../Views/MainView.axaml.cs | 315 +--------- .../Abstractions/IAndroidBootstrap.cs | 29 +- .../Abstractions/IInstallationStore.cs | 11 + .../Abstractions/IUpdatePackageSource.cs | 9 + .../Enums/UpdateState.cs | 4 +- .../Events/InstallationConfirmedEventArgs.cs | 8 + .../Events/UpdateCompletedEventArgs.cs | 1 + .../GeneralUpdateBootstrap.cs | 56 +- .../Models/AndroidUpdateOptions.cs | 9 +- .../Models/HttpDownloadOptions.cs | 26 +- .../Models/InstallationCheckResult.cs | 9 + .../Models/InstallationRecord.cs | 13 + .../Models/UpdatePreparationResult.cs | 20 + .../README.en.md | 64 +- src/GeneralUpdate.Avalonia.Android/README.md | 67 ++- .../README.zh-CN.md | 63 +- .../Services/AndroidBootstrap.cs | 563 +++++++++++++----- .../Services/HttpResumableApkDownloader.cs | 347 +++++------ .../Services/HttpUpdatePackageClient.cs | 59 +- .../Services/JsonFileInstallationStore.cs | 86 +++ .../Services/UpdateHttpClientFactory.cs | 27 + .../DownloadReliabilityTests.cs | 285 +++++++++ ...eneralUpdate.Avalonia.Android.Tests.csproj | 10 + .../InstallationTrackingTests.cs | 304 ++++++++++ .../UpdateFlowEndToEndTests.cs | 56 +- .../UpdatePreparationTests.cs | 223 +++++++ .../UpdateTestDoubles.cs | 93 +++ .../UpdateViewModelTests.cs | 188 ++++++ 37 files changed, 2826 insertions(+), 741 deletions(-) create mode 100644 samples/GeneralUpdate.Avalonia.Android.Sample/Infrastructure/AndroidUpdateHost.cs create mode 100644 samples/GeneralUpdate.Avalonia.Android.Sample/Infrastructure/IUpdateHost.cs create mode 100644 samples/GeneralUpdate.Avalonia.Android.Sample/ViewModels/UpdateViewModel.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Abstractions/IInstallationStore.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Abstractions/IUpdatePackageSource.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Events/InstallationConfirmedEventArgs.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Models/InstallationCheckResult.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Models/InstallationRecord.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Models/UpdatePreparationResult.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Services/JsonFileInstallationStore.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Services/UpdateHttpClientFactory.cs create mode 100644 tests/GeneralUpdate.Avalonia.Android.Tests/DownloadReliabilityTests.cs create mode 100644 tests/GeneralUpdate.Avalonia.Android.Tests/InstallationTrackingTests.cs create mode 100644 tests/GeneralUpdate.Avalonia.Android.Tests/UpdatePreparationTests.cs create mode 100644 tests/GeneralUpdate.Avalonia.Android.Tests/UpdateTestDoubles.cs create mode 100644 tests/GeneralUpdate.Avalonia.Android.Tests/UpdateViewModelTests.cs diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 2d36f45..3b9e26a 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -58,6 +58,9 @@ jobs: - name: Build Android project run: dotnet build src/GeneralUpdate.Avalonia.Android/GeneralUpdate.Avalonia.Android.csproj --configuration Release --no-restore + - name: Build Android upgrade sample + run: dotnet build samples/GeneralUpdate.Avalonia.Android.Sample/GeneralUpdate.Avalonia.Android.Sample.csproj --configuration Release + - name: Pack NuGet run: dotnet pack src/GeneralUpdate.Avalonia.Android/GeneralUpdate.Avalonia.Android.csproj --configuration Release --no-build -o artifacts diff --git a/README-EN.md b/README-EN.md index c98cb1a..69b216c 100644 --- a/README-EN.md +++ b/README-EN.md @@ -55,6 +55,8 @@ dotnet test tests/GeneralUpdate.Avalonia.Android.Tests/GeneralUpdate.Avalonia.An ### Basic Usage +Supply `androidPlatformId` from the host configuration to match the deployed server. + ```csharp using GeneralUpdate.Avalonia.Android; using GeneralUpdate.Avalonia.Android.Models; @@ -80,17 +82,27 @@ var options = new AndroidUpdateOptions using var bootstrap = GeneralUpdateBootstrap.CreateDefault(options); -var check = await bootstrap.ValidateAsync("2.2.1", CancellationToken.None); -if (check.Success && check.UpdateFound && check.PackageInfo is { } packageInfo) +bootstrap.AddListenerUpdateFailed += (_, args) => Console.Error.WriteLine(args.Result.Message); +var context = global::Android.App.Application.Context; +var currentVersion = context.PackageManager?.GetPackageInfo(context.PackageName!, + global::Android.Content.PM.PackageInfoFlags.Activities)?.VersionName + ?? throw new InvalidOperationException("Cannot read the installed version."); + +var installation = await bootstrap.CheckInstallationAsync(currentVersion); +if (!installation.Success) return; + +var prepared = await bootstrap.PrepareUpdateAsync(currentVersion, CancellationToken.None); +if (prepared.IsReadyToInstall && prepared.PackageInfo is { } package && prepared.FilePath is { } path) { - var prepared = await bootstrap.DownloadAndVerifyAsync(packageInfo, CancellationToken.None); - if (prepared.Success && prepared.FilePath is not null) - { - await bootstrap.LaunchInstallerAsync(packageInfo, prepared.FilePath, CancellationToken.None); - } + await bootstrap.LaunchInstallerAsync(package, path, CancellationToken.None); } ``` +`PrepareUpdateAsync` holds one operation lock across query, comparison, pre-check, download and hash verification. +No update or a skipped update returns `Success = true`, `IsReadyToInstall = false`. It never opens an installer or +permission screen. Keep using `ValidateAsync` / `DownloadAndVerifyAsync` when separate stages are needed. +Use `IUpdateEventDispatcher` to marshal UI events; see permission handling below. + ### Server-Driven Version Validation `ValidateAsync(currentVersion, cancellationToken)` only needs the version installed on the device: the component queries @@ -123,9 +135,15 @@ then GETs a single `UpdatePackageInfo` (property names are case-insensitive), fo `UpdateCheckResult.Success = false`, `FailureReason` and `AddListenerUpdateFailed`, and never invoke the pre-check callback. - Cancellation during the request returns `UpdateState.Canceled`; cancelling while waiting on the operation gate throws `OperationCanceledException`. -- Validation and downloads share the `httpOptions` passed to `CreateDefault` (`RequestTimeout`, proxy, TLS, `AuthProvider`). - Without `httpOptions` the supplied `httpClient` is reused and its lifetime stays with the host. -- Calling `ValidateAsync` without `UpdateServer` fails with `UpdateFailureReason.InvalidMetadata`. +- Validation and downloads share one HTTP client. A supplied `httpClient` is always borrowed and preserved, including its + handler and `Timeout`; request timeout, retry and authentication policies may be supplied alongside it. The earlier timeout wins. + Configure TLS/proxy on that client's handler: combining those handler settings in `httpOptions` with an external client + throws `ArgumentException` instead of silently replacing it. Otherwise the library creates and disposes its own client. +- Factory defaults: 30-second query/HEAD timeout, 10-minute total download timeout (including backoff), up to 3 download + attempts. Transient HEAD/GET/response-stream failures retry with resume; HEAD 405/501 falls back to GET. + Permanent errors such as 401 and user cancellation do not retry. `MaxRetryAttempts` includes the initial attempt and does + not apply to metadata queries. Timeouts report `Failed/NetworkError`; user cancellation reports `Canceled`. +- Validation without either `UpdateServer` or an injected `IUpdatePackageSource` fails with `InvalidMetadata`. - Only query trusted servers and use HTTPS in production. Once a newer version is found, the `AddListenerUpdatePrecheck` callback receives the discovered package metadata and returns @@ -165,12 +183,71 @@ configures all four items below: using var bootstrap = GeneralUpdateBootstrap.CreateDefault(options, activityProvider: myActivityProvider); ``` -4. **Server** — `AndroidUpdateOptions.UpdateServer` must be configured (or use the static JSON endpoint through - `UseJsonEndpoint`), and `sha256` must be a 64-character hexadecimal SHA-256. +4. **Server** — configure `AndroidUpdateOptions.UpdateServer` (or a static JSON endpoint / custom `IUpdatePackageSource`), + and provide a 64-character hexadecimal SHA-256. `LaunchInstallerAsync` returning `Success = true` only means the installer intent was launched; it **does not** mean the user -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. +finished installing. The process is killed on completion, so call `CheckInstallationAsync` with the actual installed version +on the next launch to confirm the update took effect. + +### Closing the Installation Loop + +`CreateDefault` atomically records the latest installation target **before** launching the installer in +`/update/installation.json`, outside the disposable APK cache. Override `InstallationStateFilePath` if needed, +and use only one bootstrap per journal. If persistence fails, installation is not launched and `FileIoError` is reported. +The journal contains no download credentials. + +At startup or when returning from the installer, read the actual version from Android `PackageManager` and call: + +```csharp +bootstrap.AddListenerInstallationConfirmed += (_, args) => +{ + // args.Result.Record.TargetVersion is the previous target. + // args.Result.CurrentVersion is the version read from the device. +}; +var installation = await bootstrap.CheckInstallationAsync(currentVersion, CancellationToken.None); +``` + +`Success` with `State == None` means no recorded attempt, not a successful installation. +`HasPendingInstallation` means the installed version is below the target: installation may be pending, canceled or failed. +`IsInstalled` means the installed version reached or exceeded the target (`Installed` state); the outcome is persisted. +Invalid versions or storage errors return `Success == false` and raise `AddListenerUpdateFailed`, never a silent empty result. + +Reconciliation works offline; query `ValidateAsync` separately for further updates. `AddListenerInstallationConfirmed` fires +only on the first durable confirmation, not on repeated checks or restarts. It is not a reliable message queue; restore UI +from the returned durable result. The legacy `AddListenerUpdateCompleted` remains a phase notification +(`ReadyToInstall` / `Installing`), **not installation success**. The compatibility constructor now uses +`LocalApplicationData/update/installation.json` when `installationStateFilePath` is omitted, rather than disabling tracking. +The new dependency-injection constructor requires an explicit `IInstallationStore`. + +For corrupt records, ask the user before calling `await bootstrap.ResetInstallationAsync()` and check its `Success`. +Reset only removes the journal, not the installed app, downloads or server settings. Never reset automatically on every failure. + +### Extension Points and Lifetime + +Implement `IUpdatePackageSource.GetLatestAsync(currentVersion, ct)` for a custom protocol and +`IInstallationStore.LoadAsync/SaveAsync/ClearAsync` for custom persistence, without duplicating orchestration: + +```csharp +using var bootstrap = GeneralUpdateBootstrap.CreateDefault(options, + packageSource: myPackageSource, installationStore: myInstallationStore); +``` + +For full dependency injection, use `AndroidBootstrap(versionComparer, downloader, hashValidator, apkInstaller, +fileStorage, packageSource, installationStore, eventDispatcher?, logger?)`. The default implementations are +`HttpUpdatePackageClient` and `JsonFileInstallationStore`; the orchestrator no longer depends on their concrete protocols/storage. +A source returns `null` only for no update, and throws transport/protocol exceptions for failures. +A store must provide atomic durable writes and report corruption rather than return an empty record. + +Use one bootstrap per journal. Bootstrap owns disposable downloader/package-source services; the host owns other injected +dependencies, including the store. Do not share bootstrap-owned service instances across updaters. +Cancel and await active work before disposal. Early disposal rejects new/queued operations and defers cleanup until the active +operation exits; it does not cancel that operation or dispose its semaphore while it is still in use. + +The sample persists server settings, reconciles the previous attempt and checks the server at startup, without automatically +reopening the installer. Users still confirm installation and reopen the app. APKs must have the same package ID, compatible +signatures and an increasing `versionCode`. Version reconciliation is not APK signature verification or an application health +check; silent installation, automatic restart and rollback are not provided. ## Directory Structure diff --git a/README.md b/README.md index 5a64fcd..777b65e 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ dotnet test tests/GeneralUpdate.Avalonia.Android.Tests/GeneralUpdate.Avalonia.An ### 基本使用示例 +示例中的 `androidPlatformId` 由宿主配置为服务端实际使用的 Android 平台编号。 + ```csharp using GeneralUpdate.Avalonia.Android; using GeneralUpdate.Avalonia.Android.Models; @@ -80,17 +82,27 @@ var options = new AndroidUpdateOptions using var bootstrap = GeneralUpdateBootstrap.CreateDefault(options); -var check = await bootstrap.ValidateAsync("2.2.1", CancellationToken.None); -if (check.Success && check.UpdateFound && check.PackageInfo is { } packageInfo) +bootstrap.AddListenerUpdateFailed += (_, args) => Console.Error.WriteLine(args.Result.Message); +var context = global::Android.App.Application.Context; +var currentVersion = context.PackageManager?.GetPackageInfo(context.PackageName!, + global::Android.Content.PM.PackageInfoFlags.Activities)?.VersionName + ?? throw new InvalidOperationException("无法读取本机版本。"); + +// 启动时先离线核对上次安装;记录损坏时明确报错,不自动清空。 +var installation = await bootstrap.CheckInstallationAsync(currentVersion); +if (!installation.Success) return; + +var prepared = await bootstrap.PrepareUpdateAsync(currentVersion, CancellationToken.None); +if (prepared.IsReadyToInstall && prepared.PackageInfo is { } package && prepared.FilePath is { } path) { - var prepared = await bootstrap.DownloadAndVerifyAsync(packageInfo, CancellationToken.None); - if (prepared.Success && prepared.FilePath is not null) - { - await bootstrap.LaunchInstallerAsync(packageInfo, prepared.FilePath, CancellationToken.None); - } + await bootstrap.LaunchInstallerAsync(package, path, CancellationToken.None); } ``` +`PrepareUpdateAsync` 在一个操作锁内完成查询、版本比较、pre-check、下载和哈希校验;没有更新或被跳过时 +`Success = true`、`IsReadyToInstall = false`。它不会打开安装器或权限页面。需要只检查版本或分阶段控制时, +仍可使用 `ValidateAsync` / `DownloadAndVerifyAsync`。UI 线程切换使用 `IUpdateEventDispatcher`,权限处理见下文。 + ### 服务端版本校验 `ValidateAsync(currentVersion, cancellationToken)` 只需要当前应用的版本号:组件按 @@ -121,9 +133,14 @@ ZIP、差分包、驱动包不会交给 Android 安装器;`body` 为空数组 - HTTP 204 或 GET JSON `null` 表示无包;请求、协议与元数据错误通过 `UpdateCheckResult.Success = false`、 `FailureReason` 和 `AddListenerUpdateFailed` 上报,并且不会触发 pre-check。 - 请求期间取消返回 `UpdateState.Canceled`;等待操作锁时取消会抛出 `OperationCanceledException`。 -- 查询与下载共用 `CreateDefault` 的 `httpOptions`(`RequestTimeout`、代理、TLS 与 `AuthProvider`); - 未提供 `httpOptions` 时复用传入的 `httpClient`,其生命周期仍由宿主管理。 -- 未配置 `UpdateServer` 时调用 `ValidateAsync` 会以 `UpdateFailureReason.InvalidMetadata` 失败。 +- 查询与下载共用 `CreateDefault` 的一个 HTTP 客户端。外部 `httpClient` 始终保留并由宿主管理, + 可同时传入超时、重试和认证策略;不会改写该客户端的 `Timeout`,实际生效的是较早到期的限制。 + 外部客户端的 TLS/代理必须配置在其 handler 上;同时通过 `httpOptions` 指定 TLS/代理会明确抛出 + `ArgumentException`,不会静默替换客户端。未传客户端时,组件创建并释放自己的客户端。 +- 工厂默认查询/HEAD 超时 30 秒,整个下载(含重试等待)超时 10 分钟,最多 3 次下载尝试。 + HEAD/GET/响应流的瞬态网络失败会重试并续传,HEAD 返回 405/501 时改用 GET;401 等永久错误及主动取消不重试。 + `MaxRetryAttempts` 包含首次尝试,不作用于元数据查询。超时报告 `Failed/NetworkError`,主动取消报告 `Canceled`。 +- 未配置 `UpdateServer` 且未注入 `IUpdatePackageSource` 时,版本查询以 `InvalidMetadata` 失败。 - 仅应查询可信服务器,生产环境请使用 HTTPS。 发现新版本后,`AddListenerUpdatePrecheck` 回调会拿到最新包信息,返回 `true` 跳过、`false` 继续 @@ -162,11 +179,68 @@ ZIP、差分包、驱动包不会交给 Android 安装器;`body` 为空数组 using var bootstrap = GeneralUpdateBootstrap.CreateDefault(options, activityProvider: myActivityProvider); ``` -4. **服务端**:必须配置 `AndroidUpdateOptions.UpdateServer`(或改用 `UseJsonEndpoint` 的静态 JSON), +4. **服务端**:配置 `AndroidUpdateOptions.UpdateServer`(或静态 JSON / 自定义 `IUpdatePackageSource`), 且 `sha256` 为 64 位十六进制 SHA-256。 `LaunchInstallerAsync` 返回 `Success = true` 只表示安装器已拉起,**不代表用户已完成安装**:安装完成后进程会被 -系统结束,下次启动时请自行比较本机版本与服务端版本,以确认这次更新是否真正生效。 +系统结束,下次启动时调用 `CheckInstallationAsync` 核对本机实际版本,以确认这次更新是否真正生效。 + +### 升级结果闭环 + +`CreateDefault` 默认在 `/update/installation.json` 保存最近一次安装目标,而不是保存在可被清理的 +APK 缓存中。可以通过 `InstallationStateFilePath` 指定其他持久化位置。同一文件只使用一个 bootstrap 实例。 +安装目标在拉起安装器**之前**原子写入;写入失败会返回 `FileIoError`,不会继续拉起安装器。记录不包含下载令牌。 + +宿主在启动或从安装器返回时,从 Android `PackageManager` 读取当前版本,再调用: + +```csharp +bootstrap.AddListenerInstallationConfirmed += (_, args) => +{ + // args.Result.Record.TargetVersion:上次安装目标 + // args.Result.CurrentVersion:本次从设备读取的实际版本 +}; +var installation = await bootstrap.CheckInstallationAsync(currentVersion, CancellationToken.None); +``` + +| 结果 | 含义 | +|---|---| +| `Success && State == None` | 没有安装记录,不表示安装成功 | +| `HasPendingInstallation` | 当前版本低于目标,安装尚未确认;可能取消、失败或仍在进行,可重新检查并重试升级 | +| `IsInstalled` | 本机版本已达到或超过目标,状态为 `Installed`,确认结果已持久化 | +| `!Success` | 本机版本无效或记录读取/写入失败,通过 `AddListenerUpdateFailed` 报错,不伪装成“无记录” | + +该核对不依赖网络;随后可调用 `ValidateAsync` 查询是否还有更新。`AddListenerInstallationConfirmed` +只在首次持久化确认时触发,重复核对或进程重启不会重复发送;它不是可靠消息队列,界面恢复应以返回的持久化结果为准。 +原有 `AddListenerUpdateCompleted` 保持兼容,仍表示 `ReadyToInstall` / `Installing` 阶段完成,**不能用作安装成功通知**。 +兼容构造函数不再隐式禁用跟踪:未指定 `installationStateFilePath` 时使用应用 +`LocalApplicationData/update/installation.json`。新依赖注入构造函数要求显式提供 `IInstallationStore`。 + +记录损坏时,由宿主明确提示用户后调用 `await bootstrap.ResetInstallationAsync()`,并检查返回的 `Success`。 +重置只删除升级记录,不修改已安装应用、下载文件或服务端配置;不要在遇到任何错误时自动重置。 + +### 扩展与生命周期 + +自有协议实现 `IUpdatePackageSource.GetLatestAsync(currentVersion, ct)`,数据库等存储实现 +`IInstallationStore.LoadAsync/SaveAsync/ClearAsync`,通过工厂的命名参数注入即可,不必复制流程代码: + +```csharp +using var bootstrap = GeneralUpdateBootstrap.CreateDefault(options, + packageSource: myPackageSource, installationStore: myInstallationStore); +``` + +需要替换全部环节时,使用 `AndroidBootstrap(versionComparer, downloader, hashValidator, apkInstaller, +fileStorage, packageSource, installationStore, eventDispatcher?, logger?)`。HTTP 协议和 JSON 持久化分别由 +`HttpUpdatePackageClient`、`JsonFileInstallationStore` 实现,流程类不再绑定这些具体实现。 +自定义源返回 `null` 表示无更新,网络/协议错误应抛出对应异常;存储须持久化原子写入,损坏数据应报错,不能返回空记录。 + +一个升级器对应一个安装记录。Bootstrap 负责释放实现 `IDisposable` 的 downloader/package source; +其余注入依赖(包括 store)由宿主管理,不要跨升级器共享由 Bootstrap 拥有的服务实例。 +关闭页面时先取消并等待当前操作,再 `Dispose`。提前 Dispose 会拒绝新调用和排队调用,并延迟到当前操作退出后释放资源, +不会自动取消当前操作,也不会在其 `finally` 释放信号量时抛异常。 + +示例会保存服务端配置,启动时核对上次升级并自动检查服务端,单独显示升级结果;它不会自动重复弹出安装器。 +用户仍需确认系统安装并重新打开应用。生产 APK 必须保持相同包名、兼容签名和递增的 `versionCode`; +本机版本核对不是 APK 签名校验或应用健康检查,也不提供静默安装、自动重启或回滚。 ## 目录结构 diff --git a/samples/GeneralUpdate.Avalonia.Android.Sample/GeneralUpdate.Avalonia.Android.Sample.csproj b/samples/GeneralUpdate.Avalonia.Android.Sample/GeneralUpdate.Avalonia.Android.Sample.csproj index 09954d1..e0f57a5 100644 --- a/samples/GeneralUpdate.Avalonia.Android.Sample/GeneralUpdate.Avalonia.Android.Sample.csproj +++ b/samples/GeneralUpdate.Avalonia.Android.Sample/GeneralUpdate.Avalonia.Android.Sample.csproj @@ -11,6 +11,8 @@ apk true 26 + + false diff --git a/samples/GeneralUpdate.Avalonia.Android.Sample/Infrastructure/AndroidUpdateHost.cs b/samples/GeneralUpdate.Avalonia.Android.Sample/Infrastructure/AndroidUpdateHost.cs new file mode 100644 index 0000000..0f860e7 --- /dev/null +++ b/samples/GeneralUpdate.Avalonia.Android.Sample/Infrastructure/AndroidUpdateHost.cs @@ -0,0 +1,90 @@ +using Android.Content; +using Android.Content.PM; +using Android.Provider; +using GeneralUpdate.Avalonia.Android.Abstractions; +using GeneralUpdate.Avalonia.Android.Models; + +namespace GeneralUpdate.Avalonia.Android.Sample.Infrastructure; + +internal sealed class AndroidUpdateHost(IUpdateLogger logger) : IUpdateHost +{ + private static Context Context => global::Android.App.Application.Context; + + public IAndroidBootstrap CreateBootstrap(UpdateServerOptions? options) => + GeneralUpdateBootstrap.CreateDefault(new AndroidUpdateOptions + { + FileProviderAuthority = $"{Context.PackageName}.generalupdate.fileprovider", + UpdateServer = options + }, activityProvider: new CurrentActivityProvider(), + eventDispatcher: new AvaloniaUpdateEventDispatcher(), logger: logger, + httpOptions: new HttpDownloadOptions { DownloadTimeout = TimeSpan.FromMinutes(15) }); + + public string GetCurrentVersion() + { + if (Context.PackageManager is not { } manager || string.IsNullOrWhiteSpace(Context.PackageName)) + throw new InvalidOperationException("无法读取本机应用版本。"); + try + { + var version = manager.GetPackageInfo(Context.PackageName, PackageInfoFlags.Activities)?.VersionName; + return !string.IsNullOrWhiteSpace(version) ? version + : throw new InvalidOperationException("本机应用未提供版本号,不能确认升级结果。"); + } + catch (PackageManager.NameNotFoundException ex) + { + throw new InvalidOperationException("无法读取本机应用版本。", ex); + } + } + + public bool CanRequestInstalls() => + global::Android.OS.Build.VERSION.SdkInt < global::Android.OS.BuildVersionCodes.O || + Context.PackageManager?.CanRequestPackageInstalls() == true; + + public void RequestInstallPermission() + { + if (string.IsNullOrWhiteSpace(Context.PackageName)) + throw new InvalidOperationException("无法读取应用包名。"); + using var intent = new Intent(Settings.ActionManageUnknownAppSources, + global::Android.Net.Uri.Parse($"package:{Context.PackageName}")); + try + { + if (MainActivity.Current is { } activity) + activity.StartActivity(intent); + else + Context.StartActivity(intent.AddFlags(ActivityFlags.NewTask)); + } + catch (Exception ex) when (ex is ActivityNotFoundException or Java.Lang.SecurityException) + { + throw new InvalidOperationException("无法打开未知来源安装授权页面。", ex); + } + } + + public UpdateServerOptions LoadServerOptions() + { + using var preferences = Context.GetSharedPreferences("generalupdate-server", FileCreationMode.Private) + ?? throw new IOException("无法读取更新服务配置。"); + var platform = preferences.GetString("platform", "3"); + if (!int.TryParse(platform, out var platformId) || platformId <= 0) + throw new InvalidDataException("保存的平台编号无效,请重新填写并保存。"); + return new UpdateServerOptions + { + RequestUrl = preferences.GetString("url", "http://127.0.0.1:5080/Upgrade/Verification")!, + AppKey = preferences.GetString("appKey", "demo-client")!, + Platform = platformId, + ProductId = preferences.GetString("productId", "demo-product")!, + AppType = 1 + }; + } + + public void SaveServerOptions(UpdateServerOptions options) + { + using var preferences = Context.GetSharedPreferences("generalupdate-server", FileCreationMode.Private) + ?? throw new IOException("无法读取更新服务配置。"); + using var editor = preferences.Edit() ?? throw new IOException("无法保存更新服务配置。"); + editor.PutString("url", options.RequestUrl); + editor.PutString("appKey", options.AppKey); + editor.PutString("platform", options.Platform.ToString(System.Globalization.CultureInfo.InvariantCulture)); + editor.PutString("productId", options.ProductId); + if (!editor.Commit()) + throw new IOException("保存更新服务配置失败,未开始更新。"); + } +} diff --git a/samples/GeneralUpdate.Avalonia.Android.Sample/Infrastructure/IUpdateHost.cs b/samples/GeneralUpdate.Avalonia.Android.Sample/Infrastructure/IUpdateHost.cs new file mode 100644 index 0000000..0e45fb8 --- /dev/null +++ b/samples/GeneralUpdate.Avalonia.Android.Sample/Infrastructure/IUpdateHost.cs @@ -0,0 +1,14 @@ +using GeneralUpdate.Avalonia.Android.Abstractions; +using GeneralUpdate.Avalonia.Android.Models; + +namespace GeneralUpdate.Avalonia.Android.Sample.Infrastructure; + +internal interface IUpdateHost +{ + string GetCurrentVersion(); + UpdateServerOptions LoadServerOptions(); + void SaveServerOptions(UpdateServerOptions options); + IAndroidBootstrap CreateBootstrap(UpdateServerOptions? options); + bool CanRequestInstalls(); + void RequestInstallPermission(); +} diff --git a/samples/GeneralUpdate.Avalonia.Android.Sample/README.md b/samples/GeneralUpdate.Avalonia.Android.Sample/README.md index cf26d4f..892acdc 100644 --- a/samples/GeneralUpdate.Avalonia.Android.Sample/README.md +++ b/samples/GeneralUpdate.Avalonia.Android.Sample/README.md @@ -8,6 +8,18 @@ 3. 断点续传下载完整 APK,并校验服务端提供的 SHA-256。 4. 请求“允许安装未知应用”权限。 5. 返回应用后自动拉起 Android 系统安装器。 +6. 在应用持久化目录保存升级目标,重新打开应用后自动核对实际版本并显示“升级已确认”或“升级尚未确认”。 +7. 保存服务端配置,启动时自动检查服务端是否还有新版本(不会自动重复弹出安装器)。 + +## 集成结构 + +- `Views/MainView`:编译绑定、按钮事件和页面生命周期,不承担升级业务或文件读写。 +- `ViewModels/UpdateViewModel`:通过 `PrepareUpdateAsync` 准备升级包,管理权限返回、结果展示与操作取消。 +- `Infrastructure/IUpdateHost` / `AndroidUpdateHost`:隔离 PackageManager、SharedPreferences、权限 Intent 和组件组装。 +- 组件内 `IUpdatePackageSource` / `IInstallationStore`:分别负责包信息查询和安装记录持久化,可替换为自有服务。 + +ViewModel 不依赖 Android/Avalonia API,可独立测试。启动时先离线核对安装结果,配置或网络失败不会遮蔽已确认结果。 +页面卸载时取消并等待当前操作后释放升级器;从授权页返回仅重试尚在等待授权的安装,不重复下载或反复弹安装器。 ## GeneralSpacestation 配置 @@ -48,11 +60,26 @@ Android Studio 的 Device Manager 中创建一个 API 26 或更高版本的虚 `start-demo-now.cmd` 是同一个脚本的入口,便于从任意工作目录直接运行。 在设备中点击“检查并自动升级”,按系统提示允许未知来源安装并确认安装。重新打开应用后, -“当前版本”应为 `2.0.0`。 +“当前版本”应为 `2.0.0`,升级结果应显示“升级已确认:目标 2.0.0,当前 2.0.0”,再次检查应显示已是最新版本。 +首次启动的自动检查只发现目标,仍需点击按钮开始下载。 + +### 闭环验收 + +- 在系统安装器中取消安装,返回或重新打开应用:仍为 `1.0.0`,显示目标 `2.0.0` 尚未确认;点击按钮可以重试。 +- 在未知来源授权页面结束应用进程,授权后重新打开:服务端配置和目标仍保留,点击按钮重新下载校验并安装。 +- 完成安装后断网再打开:本机核对仍可确认 `2.0.0`;服务端查询失败单独显示,不会把已确认结果改为升级失败。 +- 再次打开 `2.0.0`:仍显示上次确认结果,但不会重复发送首次安装确认事件。 +- 安装记录损坏:显示错误并停止升级。由用户点击“重置升级记录”清除损坏记录后,再点击升级按钮恢复。 + 重置不会修改已安装应用、下载缓存或服务端设置,也不代表安装成功。 + +记录默认位于 `/update/installation.json`,不随下载缓存清理;卸载应用或清除数据会删除记录和服务端配置。 +“尚未确认”不区分用户取消、系统拒绝或安装仍在进行,不能当作安装失败回执。更新包必须同包名、兼容签名且 +`versionCode` 更高。这里不提供静默安装、安装后自动拉起应用、健康检查或自动回滚。 下载过程中点击“取消”只会中止当前请求:`/update` 下的 `.part` 片段和续传元数据仍然保留, 再次点击“检查并自动升级”会从断点继续。演示服务的下载地址支持 Range 请求,因此续传时不会重新 下载整个 APK;把演示服务换成正式部署的 GeneralSpacestation 时,同样要求下载地址支持断点续传。 +瞬态下载错误最多尝试 3 次(包含首次请求),重试等待包含在总下载超时内;超时和主动取消分别展示为失败与取消。 演示服务的固定参数为: diff --git a/samples/GeneralUpdate.Avalonia.Android.Sample/ViewModels/UpdateViewModel.cs b/samples/GeneralUpdate.Avalonia.Android.Sample/ViewModels/UpdateViewModel.cs new file mode 100644 index 0000000..31ded43 --- /dev/null +++ b/samples/GeneralUpdate.Avalonia.Android.Sample/ViewModels/UpdateViewModel.cs @@ -0,0 +1,249 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using GeneralUpdate.Avalonia.Android.Abstractions; +using GeneralUpdate.Avalonia.Android.Models; +using GeneralUpdate.Avalonia.Android.Sample.Infrastructure; + +namespace GeneralUpdate.Avalonia.Android.Sample.ViewModels; + +internal sealed class UpdateViewModel(IUpdateHost host, IUpdateLogger logger) : INotifyPropertyChanged +{ + private IAndroidBootstrap? _bootstrap; + private CancellationTokenSource? _cancellation; + private Task _activeTask = Task.CompletedTask; + private UpdatePreparationResult? _pending; + private bool _waitingForPermission; + private bool _forced; + private bool _running; + private string _requestUrl = "", _appKey = "", _platform = "", _productId = ""; + private string _currentVersion = "", _targetVersion = "尚未检查", _releaseNotes = "检查到新版本后显示"; + private string _status = "等待检查更新", _installationStatus = "正在核对上次升级结果...", _progressText = "0%"; + private double _progress; + + public event PropertyChangedEventHandler? PropertyChanged; + public string RequestUrl { get => _requestUrl; set => Set(ref _requestUrl, value ?? string.Empty); } + public string AppKey { get => _appKey; set => Set(ref _appKey, value ?? string.Empty); } + public string Platform { get => _platform; set => Set(ref _platform, value ?? string.Empty); } + public string ProductId { get => _productId; set => Set(ref _productId, value ?? string.Empty); } + public string CurrentVersion { get => _currentVersion; private set => Set(ref _currentVersion, value); } + public string TargetVersion { get => _targetVersion; private set => Set(ref _targetVersion, value); } + public string ReleaseNotes { get => _releaseNotes; private set => Set(ref _releaseNotes, value); } + public string Status { get => _status; private set => Set(ref _status, value); } + public string InstallationStatus { get => _installationStatus; private set => Set(ref _installationStatus, value); } + public string ProgressText { get => _progressText; private set => Set(ref _progressText, value); } + public double Progress { get => _progress; private set => Set(ref _progress, value); } + public bool CanStart => !_running; + public bool CanCancel => _running && !_forced; + + public Task InitializeAsync() => RunAsync(async ct => + { + ReplaceBootstrap(null); + var reconciled = await RefreshInstallationAsync(ct); + var options = host.LoadServerOptions(); + RequestUrl = options.RequestUrl; + AppKey = options.AppKey; + Platform = options.Platform.ToString(System.Globalization.CultureInfo.InvariantCulture); + ProductId = options.ProductId; + ReplaceBootstrap(options); + if (!reconciled) return; + Status = "正在自动检查服务端版本..."; + var check = await _bootstrap!.ValidateAsync(CurrentVersion, ct); + if (!check.Success) { Status = Describe(check); return; } + TargetVersion = check.UpdateFound ? check.PackageInfo!.Version : "已是最新版本"; + ReleaseNotes = check.PackageInfo?.Description ?? "服务端未提供更新说明。"; + Status = check.UpdateFound ? "发现新版本,点击“检查并自动升级”开始下载和安装。" : "当前已经是最新版本。"; + }); + + public Task StartAsync() => RunAsync(async ct => + { + _pending = null; + _waitingForPermission = false; + var options = ReadOptions(); + host.SaveServerOptions(options); + ReplaceBootstrap(options); + Progress = 0; + ProgressText = "0%"; + if (!await RefreshInstallationAsync(ct)) return; + + Status = "正在检查并准备升级包..."; + var prepared = await _bootstrap!.PrepareUpdateAsync(CurrentVersion, ct); + if (!prepared.Success) { Status = Describe(prepared); return; } + if (!prepared.IsReadyToInstall) + { + TargetVersion = "已是最新版本"; + Status = "当前已经是最新版本。"; + return; + } + _pending = prepared; + await LaunchPendingAsync(ct); + }); + + public Task ResumeAsync() => RunAsync(async ct => + { + if (_bootstrap is null) return; + if (_waitingForPermission && _pending is not null && host.CanRequestInstalls()) + { + _waitingForPermission = false; + await LaunchPendingAsync(ct); + } + else + { + await RefreshInstallationAsync(ct); + } + }); + + public Task ResetInstallationAsync() => RunAsync(async ct => + { + if (_bootstrap is null) ReplaceBootstrap(null); + var result = await _bootstrap!.ResetInstallationAsync(ct); + if (!result.Success) { Status = Describe(result); return; } + _pending = null; + _waitingForPermission = false; + if (await RefreshInstallationAsync(ct)) + Status = "升级记录已重置,未修改已安装应用。可以重新检查升级。"; + }); + + public void Cancel() => _cancellation?.Cancel(); + + public async Task DeactivateAsync() + { + Cancel(); + await _activeTask; + _bootstrap?.Dispose(); + _bootstrap = null; + _pending = null; + _waitingForPermission = false; + } + + private Task RunAsync(Func operation) + { + if (_running) return Task.CompletedTask; + _activeTask = RunCoreAsync(operation); + return _activeTask; + } + + private async Task RunCoreAsync(Func operation) + { + _running = true; + _forced = false; + NotifyAvailability(); + using var cancellation = new CancellationTokenSource(); + _cancellation = cancellation; + try + { + await operation(cancellation.Token); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + Status = "更新操作已取消。"; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or + InvalidOperationException or ArgumentException) + { + logger.LogError("Update host operation failed.", ex); + Status = $"更新操作失败:{ex.Message}"; + } + finally + { + _cancellation = null; + _running = false; + NotifyAvailability(); + } + } + + private void ReplaceBootstrap(UpdateServerOptions? options) + { + var bootstrap = host.CreateBootstrap(options); + _bootstrap?.Dispose(); + _bootstrap = bootstrap; + _bootstrap.AddListenerValidate += (_, args) => + { + if (!ReferenceEquals(_bootstrap, bootstrap)) return; + _forced = args.PackageInfo.IsForced; + NotifyAvailability(); + TargetVersion = args.PackageInfo.Version; + ReleaseNotes = args.PackageInfo.Description ?? "服务端未提供更新说明。"; + }; + _bootstrap.AddListenerDownloadProgressChanged += (_, args) => + { + if (!ReferenceEquals(_bootstrap, bootstrap)) return; + Progress = args.ProgressPercentage; + ProgressText = $"{args.ProgressPercentage:F1}% {args.DownloadSpeedBytesPerSecond / 1024:F1} KB/s"; + }; + _bootstrap.AddListenerInstallationConfirmed += (_, args) => + logger.LogInformation($"Installation confirmed: {args.Result.CurrentVersion}."); + } + + private async Task LaunchPendingAsync(CancellationToken ct) + { + var pending = _pending; + if (_bootstrap is null || pending is not { IsReadyToInstall: true }) return; + var result = await _bootstrap.LaunchInstallerAsync(pending.PackageInfo!, pending.FilePath!, ct); + var reconciled = await RefreshInstallationAsync(CancellationToken.None); + if (result.Success) + { + _pending = null; + _waitingForPermission = false; + if (reconciled) Status = "系统安装器已打开,请确认安装。重新打开应用后会核对升级结果。"; + } + else if (reconciled && result.FailureReason == UpdateFailureReason.InstallPermissionDenied) + { + _waitingForPermission = true; + Status = "请开启“允许安装未知应用”,返回后会重试安装。"; + host.RequestInstallPermission(); + } + else if (reconciled) + { + _waitingForPermission = false; + Status = Describe(result); + } + } + + private async Task RefreshInstallationAsync(CancellationToken ct) + { + CurrentVersion = host.GetCurrentVersion(); + var result = await _bootstrap!.CheckInstallationAsync(CurrentVersion, ct); + InstallationStatus = !result.Success ? Describe(result) + : result.IsInstalled ? $"升级已确认:目标 {result.Record!.TargetVersion},当前 {CurrentVersion}。" + : result.HasPendingInstallation ? $"升级尚未确认:目标 {result.Record!.TargetVersion},当前 {CurrentVersion}。可以重新检查并重试。" + : "暂无升级记录。"; + if (!result.Success) Status = Describe(result) + " 如记录损坏,可显式重置升级记录。"; + return result.Success; + } + + private UpdateServerOptions ReadOptions() + { + if (!Uri.TryCreate(RequestUrl, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + throw new ArgumentException("请输入有效的 HTTP 或 HTTPS 验证接口地址。"); + if (!int.TryParse(Platform, out var platform) || platform <= 0) + throw new ArgumentException("Platform 必须是服务端配置的正整数平台编号。"); + if (string.IsNullOrWhiteSpace(ProductId)) + throw new ArgumentException("请输入 ProductId。"); + return new UpdateServerOptions + { + RequestUrl = uri.AbsoluteUri, + AppKey = AppKey.Trim(), + AppType = 1, + Platform = platform, + ProductId = ProductId.Trim() + }; + } + + private static string Describe(UpdateOperationResult result) => + result.State == UpdateState.Canceled ? "更新操作已取消。" + : $"更新失败:{result.FailureReason}。{result.Exception?.Message ?? result.Message}"; + + private void NotifyAvailability() + { + PropertyChanged?.Invoke(this, new(nameof(CanStart))); + PropertyChanged?.Invoke(this, new(nameof(CanCancel))); + } + + private void Set(ref T field, T value, [CallerMemberName] string? name = null) + { + if (EqualityComparer.Default.Equals(field, value)) return; + field = value; + PropertyChanged?.Invoke(this, new(name)); + } +} diff --git a/samples/GeneralUpdate.Avalonia.Android.Sample/Views/MainView.axaml b/samples/GeneralUpdate.Avalonia.Android.Sample/Views/MainView.axaml index 0f2623d..b843b21 100644 --- a/samples/GeneralUpdate.Avalonia.Android.Sample/Views/MainView.axaml +++ b/samples/GeneralUpdate.Avalonia.Android.Sample/Views/MainView.axaml @@ -1,5 +1,8 @@ @@ -17,6 +20,7 @@ RowDefinitions="Auto,Auto"> @@ -24,10 +28,14 @@ + Text="{Binding TargetVersion}" /> + + @@ -63,24 +71,24 @@ FontWeight="SemiBold" Margin="0,8,0,0" /> @@ -91,14 +99,18 @@ Grid.Column="0" Content="检查并自动升级" HorizontalAlignment="Stretch" + IsEnabled="{Binding CanStart}" Click="OnStartUpdate" />