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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
105 changes: 91 additions & 14 deletions README-EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
`<FilesDir>/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

Expand Down
98 changes: 86 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)` 只需要当前应用的版本号:组件按
Expand Down Expand Up @@ -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` 继续
Expand Down Expand Up @@ -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` 默认在 `<FilesDir>/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 签名校验或应用健康检查,也不提供静默安装、自动重启或回滚。

## 目录结构

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
<AndroidPackageFormat>apk</AndroidPackageFormat>
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
<SupportedOSPlatformVersion>26</SupportedOSPlatformVersion>
<!-- Trimmed multi-ABI Android builds must not infer the desktop host's publish RID. -->
<UseDefaultPublishRuntimeIdentifier>false</UseDefaultPublishRuntimeIdentifier>
</PropertyGroup>

<ItemGroup>
Expand Down
Loading
Loading