From 6ca8ac48f6b8ab2e14066c61d4b9dc15c7dcd78f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 19 Sep 2026 13:29:55 +0000
Subject: [PATCH 1/3] Initial plan
From 348acc41f938c80f6357baccbf2e1f5c3bd8f448 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 19 Sep 2026 13:37:07 +0000
Subject: [PATCH 2/3] Add pre-validation package discovery and close downloads
before finalizing
Co-authored-by: JusterZhu <11714536+JusterZhu@users.noreply.github.com>
---
README-EN.md | 8 +
README.md | 72 +++++
.../Models/UpdatePackageRequest.cs | 14 +
.../README.en.md | 6 +
src/GeneralUpdate.Avalonia.Android/README.md | 72 +++++
.../README.zh-CN.md | 6 +
.../Services/HttpResumableApkDownloader.cs | 23 +-
.../Services/HttpUpdatePackageClient.cs | 205 ++++++++++++
...eneralUpdate.Avalonia.Android.Tests.csproj | 11 +
.../HttpUpdatePackageClientTests.cs | 298 ++++++++++++++++++
10 files changed, 704 insertions(+), 11 deletions(-)
create mode 100644 src/GeneralUpdate.Avalonia.Android/Models/UpdatePackageRequest.cs
create mode 100644 src/GeneralUpdate.Avalonia.Android/Services/HttpUpdatePackageClient.cs
create mode 100644 tests/GeneralUpdate.Avalonia.Android.Tests/HttpUpdatePackageClientTests.cs
diff --git a/README-EN.md b/README-EN.md
index d714fa1..e283344 100644
--- a/README-EN.md
+++ b/README-EN.md
@@ -88,6 +88,14 @@ if (check.UpdateFound)
}
```
+### Fetch server metadata before ValidateAsync
+
+Use `HttpUpdatePackageClient.GetPackageInfoAsync` to retrieve `UpdatePackageInfo` before calling `ValidateAsync`.
+It supports a GET JSON metadata endpoint and a POST overload for the GeneralUpdate reference verification protocol,
+with cancellation and existing authentication providers. Manual metadata construction remains supported.
+See [metadata retrieval and GeneralSpacestation compatibility](src/GeneralUpdate.Avalonia.Android/README.md#fetch-metadata-before-validation)
+for examples, response formats and deployment requirements.
+
## Directory Structure
```text
diff --git a/README.md b/README.md
index 304417c..57bf151 100644
--- a/README.md
+++ b/README.md
@@ -88,6 +88,78 @@ if (check.UpdateFound)
}
```
+### 在 ValidateAsync 前获取服务器包信息
+
+`ValidateAsync` 只比较传入的版本,`AddListenerUpdatePrecheck` 也不会查询服务器。
+使用独立的 `HttpUpdatePackageClient` 即可先获取、查看 `UpdatePackageInfo`,无需手动填写每次发布的包信息:
+
+```csharp
+using GeneralUpdate.Avalonia.Android.Services;
+
+// HttpClient 由宿主管理,可复用并配置超时、代理和 TLS。
+using var httpClient = new HttpClient();
+var packageClient = new HttpUpdatePackageClient(httpClient);
+var packageInfo = await packageClient.GetPackageInfoAsync(
+ "https://example.com/android/latest.json", CancellationToken.None);
+if (packageInfo is null) return; // 没有可用包,不要传 null 给 ValidateAsync
+
+// 此处可读取版本说明等信息,尚未比较版本或触发更新事件。
+var check = await bootstrap.ValidateAsync(packageInfo, "2.2.1", CancellationToken.None);
+if (check.Success && check.UpdateFound)
+{
+ var prepared = await bootstrap.DownloadAndVerifyAsync(packageInfo, CancellationToken.None);
+ if (prepared.Success && prepared.FilePath is not null)
+ {
+ var install = await bootstrap.LaunchInstallerAsync(packageInfo, prepared.FilePath, CancellationToken.None);
+ // install.Success 表示已拉起系统安装器,不代表用户已完成安装。
+ }
+}
+```
+
+GET 地址返回 `UpdatePackageInfo` JSON(字段名不区分大小写),例如:
+
+```json
+{
+ "version": "2.3.0",
+ "downloadUrl": "https://example.com/app-release.apk",
+ "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+ "description": "更新说明",
+ "isForced": false
+}
+```
+
+`sha256` 必须替换为实际 APK 的 SHA-256,不能使用 MD5。`fileSize` 可省略或为 0(未知),已知时单位为字节。
+HTTP 204 或 JSON `null` 表示无包;HTTP 错误、错误 JSON、缺失/无效元数据及取消操作会抛出异常,宿主应处理,不能视为“无更新”。
+仅应查询可信服务器,生产环境使用 HTTPS。
+
+#### GeneralSpacestation / GeneralUpdate 验证协议
+
+对于采用 [GeneralUpdate 示例服务器](https://github.com/GeneralLibrary/GeneralUpdate-Samples/tree/main/src/Server)
+`POST /Upgrade/Verification`(或 `/Update/Verification`)协议的部署,使用另一重载:
+
+```csharp
+var request = new UpdatePackageRequest
+{
+ Version = currentVersion,
+ AppKey = appKey,
+ AppType = 1,
+ Platform = serverPlatformId,
+ ProductId = productId
+};
+var packageInfo = await packageClient.GetPackageInfoAsync(verificationUrl, request, cancellationToken);
+```
+
+请求发送 `version/appKey/appType/platform/productId`,响应为 `{"code":200,"body":[...]}`。
+客户端映射 `version/url/hash/size/name/updateLog/releaseDate/isForcibly/authScheme/authToken`,
+按版本选取最新的非冻结完整 APK(`packageType` 为 2、0 或省略;`format` 为 `apk`/`.apk`,省略时 URL 路径须以 `.apk` 结尾)。
+ZIP、差分包、驱动包不会交给 Android 安装器。`body: []` 或没有符合条件的包返回 `null`;
+非 200 业务码、缺失或 `null` 的 `body` 会报错。默认使用 `System.Version` 比较版本,也可在构造客户端时传入 `IVersionComparer`。
+
+GeneralSpacestation 商业服务的接口并未公开,**请核对实际部署的地址、响应格式和 Android 平台编号,不要假定固定编号**;
+协议不同时可由服务器提供上面的标准 JSON 端点。查询认证通过构造参数 `authProvider` 配置,可复用现有
+`BearerTokenAuthProvider`、`ApiKeyAuthProvider`、`BasicAuthProvider` 或 `HmacAuthProvider`。
+`AppKey` 仅是请求字段,不会自动启用 HMAC;`HttpDownloadOptions` 不会自动作用于独立查询客户端。
+
## 目录结构
```text
diff --git a/src/GeneralUpdate.Avalonia.Android/Models/UpdatePackageRequest.cs b/src/GeneralUpdate.Avalonia.Android/Models/UpdatePackageRequest.cs
new file mode 100644
index 0000000..fcad074
--- /dev/null
+++ b/src/GeneralUpdate.Avalonia.Android/Models/UpdatePackageRequest.cs
@@ -0,0 +1,14 @@
+namespace GeneralUpdate.Avalonia.Android.Models;
+
+///
+/// Request body for the GeneralUpdate /Upgrade/Verification reference protocol.
+/// Platform must match the identifier configured by the server.
+///
+public sealed record UpdatePackageRequest
+{
+ public required string Version { get; init; }
+ public required string AppKey { get; init; }
+ public int AppType { get; init; } = 1;
+ public required int Platform { get; init; }
+ public required string ProductId { get; init; }
+}
diff --git a/src/GeneralUpdate.Avalonia.Android/README.en.md b/src/GeneralUpdate.Avalonia.Android/README.en.md
index e0093be..e44281f 100644
--- a/src/GeneralUpdate.Avalonia.Android/README.en.md
+++ b/src/GeneralUpdate.Avalonia.Android/README.en.md
@@ -72,6 +72,12 @@ Default wiring:
| `IApkInstaller` | `AndroidApkInstaller` |
| `IVersionComparer` | `SystemVersionComparer` |
+### Query metadata before ValidateAsync
+
+`HttpUpdatePackageClient.GetPackageInfoAsync` supports GET JSON metadata and the POST GeneralUpdate verification reference protocol,
+independently of version comparison, with cancellation and existing authentication providers.
+See [examples and GeneralSpacestation compatibility](README.md#fetch-metadata-before-validation).
+
### IAndroidBootstrap Methods
| Method | Returns |
diff --git a/src/GeneralUpdate.Avalonia.Android/README.md b/src/GeneralUpdate.Avalonia.Android/README.md
index 1eba42f..124575c 100644
--- a/src/GeneralUpdate.Avalonia.Android/README.md
+++ b/src/GeneralUpdate.Avalonia.Android/README.md
@@ -58,6 +58,78 @@ if (check.UpdateFound)
}
```
+## Fetch metadata before validation
+
+`ValidateAsync` only compares supplied versions; the pre-check hook does not query the server.
+Use `HttpUpdatePackageClient` to inspect server metadata before either operation:
+
+```csharp
+using GeneralUpdate.Avalonia.Android.Services;
+
+using var httpClient = new HttpClient();
+var packageClient = new HttpUpdatePackageClient(httpClient);
+var packageInfo = await packageClient.GetPackageInfoAsync(
+ "https://example.com/android/latest.json", ct);
+if (packageInfo is null) return;
+
+var check = await bootstrap.ValidateAsync(packageInfo, currentVersion, ct);
+if (check.Success && check.UpdateFound)
+{
+ var prepared = await bootstrap.DownloadAndVerifyAsync(packageInfo, ct);
+ if (prepared.Success && prepared.FilePath is not null)
+ {
+ var install = await bootstrap.LaunchInstallerAsync(packageInfo, prepared.FilePath, ct);
+ // Success means the system installer was launched, not that installation completed.
+ }
+}
+```
+
+The GET endpoint returns a JSON `UpdatePackageInfo` (case-insensitive property names):
+
+```json
+{
+ "version": "2.3.0",
+ "downloadUrl": "https://example.com/app-release.apk",
+ "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+ "description": "Release notes",
+ "isForced": false
+}
+```
+
+Replace `sha256` with the actual APK's SHA-256, never an MD5 digest. Optional `fileSize` is in bytes; zero means unknown.
+HTTP 204 or JSON `null` returns `null`. HTTP failures, malformed JSON, invalid metadata and cancellation propagate as exceptions
+for the host to handle, not as "no update". Fetching does not change bootstrap state or raise update events.
+Use trusted endpoints and HTTPS in production. The caller owns and may reuse the `HttpClient`, configuring timeouts, TLS and proxy settings.
+
+### GeneralSpacestation / GeneralUpdate reference protocol
+
+For deployments implementing the [GeneralUpdate sample server](https://github.com/GeneralLibrary/GeneralUpdate-Samples/tree/main/src/Server)
+`POST /Upgrade/Verification` (or `/Update/Verification`) contract:
+
+```csharp
+var packageInfo = await packageClient.GetPackageInfoAsync(verificationUrl,
+ new UpdatePackageRequest
+ {
+ Version = currentVersion,
+ AppKey = appKey,
+ AppType = 1,
+ Platform = serverPlatformId,
+ ProductId = productId
+ }, ct);
+```
+
+The request uses `version/appKey/appType/platform/productId`. The response must be `{"code":200,"body":[...]}`.
+The client maps `version/url/hash/size/name/updateLog/releaseDate/isForcibly/authScheme/authToken` to package metadata and selects
+the newest non-frozen full APK. `packageType` must be 2, 0 or absent; `format` must be `apk`/`.apk`, or, if omitted, the URL path
+must end in `.apk`. ZIP, differential and driver packages are ignored. An empty eligible list returns `null`;
+an unsuccessful application code or missing/null `body` throws. Version ordering defaults to `System.Version`;
+pass an `IVersionComparer` to the client for other version schemes.
+
+The commercial GeneralSpacestation API is not publicly specified: **verify your deployment's route, schema and Android platform identifier**.
+Do not assume a fixed Android platform number. For other protocols, expose the standard JSON endpoint shown above.
+Pass an existing `IHttpAuthProvider` to the client constructor for metadata authentication (Bearer, API key, Basic or HMAC).
+`AppKey` is only a request field; it does not automatically enable HMAC. `HttpDownloadOptions` does not configure this separate metadata client.
+
## API
### Static Factory
diff --git a/src/GeneralUpdate.Avalonia.Android/README.zh-CN.md b/src/GeneralUpdate.Avalonia.Android/README.zh-CN.md
index 29451e3..861889b 100644
--- a/src/GeneralUpdate.Avalonia.Android/README.zh-CN.md
+++ b/src/GeneralUpdate.Avalonia.Android/README.zh-CN.md
@@ -72,6 +72,12 @@ if (check.UpdateFound)
| `IApkInstaller` | `AndroidApkInstaller` |
| `IVersionComparer` | `SystemVersionComparer` |
+### 在 ValidateAsync 前查询包信息
+
+`HttpUpdatePackageClient.GetPackageInfoAsync` 支持 GET 标准 JSON 元数据,以及 POST GeneralUpdate 验证协议,
+无需手动填写每次发布的 `UpdatePackageInfo`。查询与版本比较相互独立,支持取消和现有认证接口。
+参见[中文使用示例与 GeneralSpacestation 兼容性说明](../../README.md#在-validateasync-前获取服务器包信息)。
+
### IAndroidBootstrap 方法
| 方法 | 返回类型 |
diff --git a/src/GeneralUpdate.Avalonia.Android/Services/HttpResumableApkDownloader.cs b/src/GeneralUpdate.Avalonia.Android/Services/HttpResumableApkDownloader.cs
index ffa90d6..8480941 100644
--- a/src/GeneralUpdate.Avalonia.Android/Services/HttpResumableApkDownloader.cs
+++ b/src/GeneralUpdate.Avalonia.Android/Services/HttpResumableApkDownloader.cs
@@ -140,27 +140,28 @@ public async Task DownloadAsync(UpdatePackageInfo packageInfo, A
await _fileStorage.WriteAllTextAsync(sidecarPath, JsonSerializer.Serialize(metadataWithResponse), cancellationToken).ConfigureAwait(false);
await using var contentStream = await response.Content.ReadAsStreamAsync(effectiveCt).ConfigureAwait(false);
- await using var fileStream = _fileStorage.OpenWrite(tempFilePath, append: existingLength > 0);
-
var buffer = new byte[_options.DownloadBufferSize];
var downloaded = existingLength;
var speedMeter = new SmoothedSpeedMeter(Math.Max(3, _options.SpeedSmoothingWindowSeconds));
progressCallback?.Invoke(CreateProgress(packageInfo, downloaded, totalBytes, speedMeter.GetSpeed(downloaded), existingLength > 0 ? "Resuming" : "Downloading"));
- while (true)
+ 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 contentStream.ReadAsync(buffer.AsMemory(0, buffer.Length), effectiveCt).ConfigureAwait(false);
+ if (read <= 0)
+ {
+ break;
+ }
- await fileStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
- downloaded += read;
- var speed = speedMeter.GetSpeed(downloaded);
+ await fileStream.WriteAsync(buffer.AsMemory(0, read), effectiveCt).ConfigureAwait(false);
+ downloaded += read;
+ var speed = speedMeter.GetSpeed(downloaded);
- progressCallback?.Invoke(CreateProgress(packageInfo, downloaded, totalBytes, speed, "Downloading"));
+ progressCallback?.Invoke(CreateProgress(packageInfo, downloaded, totalBytes, speed, "Downloading"));
+ }
}
if (_fileStorage.FileExists(finalFilePath))
diff --git a/src/GeneralUpdate.Avalonia.Android/Services/HttpUpdatePackageClient.cs b/src/GeneralUpdate.Avalonia.Android/Services/HttpUpdatePackageClient.cs
new file mode 100644
index 0000000..e267e2f
--- /dev/null
+++ b/src/GeneralUpdate.Avalonia.Android/Services/HttpUpdatePackageClient.cs
@@ -0,0 +1,205 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using GeneralUpdate.Avalonia.Android.Abstractions;
+using GeneralUpdate.Avalonia.Android.Enums;
+using GeneralUpdate.Avalonia.Android.Models;
+
+namespace GeneralUpdate.Avalonia.Android.Services;
+
+///
+/// Retrieves package metadata without validating the installed version or starting a download.
+/// The caller owns the supplied HttpClient, including its timeout and transport configuration.
+///
+public sealed class HttpUpdatePackageClient
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
+ {
+ Converters = { new JsonStringEnumConverter() }
+ };
+
+ private readonly HttpClient _httpClient;
+ private readonly IHttpAuthProvider? _authProvider;
+ private readonly IVersionComparer _versionComparer;
+
+ public HttpUpdatePackageClient(
+ HttpClient httpClient,
+ IHttpAuthProvider? authProvider = null,
+ IVersionComparer? versionComparer = null)
+ {
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ _authProvider = authProvider;
+ _versionComparer = versionComparer ?? new SystemVersionComparer();
+ }
+
+ ///
+ /// GETs a JSON UpdatePackageInfo. HTTP 204 or JSON null means no package.
+ /// HTTP, JSON and metadata errors are propagated; cancellation is not treated as no update.
+ ///
+ public async Task GetPackageInfoAsync(
+ string requestUrl, CancellationToken cancellationToken = default)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, GetHttpUri(requestUrl));
+ using var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
+ if (response.StatusCode == HttpStatusCode.NoContent)
+ {
+ return null;
+ }
+
+ var package = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false);
+ return package is null ? null : ValidateMetadata(package);
+ }
+
+ ///
+ /// POSTs the GeneralUpdate verification request and returns the newest non-frozen full APK.
+ /// An empty eligible package list or HTTP 204 means no package. Other package formats are ignored.
+ /// This reference protocol must be checked against the deployed GeneralSpacestation API.
+ ///
+ public async Task GetPackageInfoAsync(
+ string requestUrl, UpdatePackageRequest packageRequest, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(packageRequest);
+ ArgumentException.ThrowIfNullOrWhiteSpace(packageRequest.Version);
+ using var request = new HttpRequestMessage(HttpMethod.Post, GetHttpUri(requestUrl))
+ {
+ Content = JsonContent.Create(packageRequest, options: JsonOptions)
+ };
+ using var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
+ if (response.StatusCode == HttpStatusCode.NoContent)
+ {
+ return null;
+ }
+
+ var result = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false);
+ if (result?.Code != 200 || result.Body is null)
+ {
+ throw new InvalidDataException("The update server returned an unsuccessful or invalid verification response.");
+ }
+
+ UpdatePackageInfo? latest = null;
+ foreach (var entry in result.Body)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (entry is null)
+ {
+ throw new InvalidDataException("The update server returned a null package entry.");
+ }
+
+ if (entry.IsFreeze == true || entry.PackageType is not (null or 0 or 2) || !IsApk(entry))
+ {
+ continue;
+ }
+
+ var package = ValidateMetadata(new UpdatePackageInfo
+ {
+ Version = entry.Version!,
+ VersionName = entry.Name,
+ Description = entry.UpdateLog,
+ DownloadUrl = entry.Url!,
+ Sha256 = entry.Hash!,
+ FileSize = entry.Size ?? 0,
+ PublishTime = entry.ReleaseDate,
+ IsForced = entry.IsForcibly == true,
+ AuthScheme = entry.AuthScheme,
+ AuthToken = entry.AuthToken
+ });
+
+ if (latest is null)
+ {
+ latest = package;
+ }
+ else if (!_versionComparer.TryCompare(latest.Version, package.Version, out var compare, out _))
+ {
+ throw new InvalidDataException("The update server returned versions that cannot be compared.");
+ }
+ else if (compare > 0)
+ {
+ latest = package;
+ }
+ }
+
+ return latest;
+ }
+
+ private async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ request.Headers.Accept.ParseAdd("application/json");
+ if (_authProvider is not null)
+ {
+ await _authProvider.ApplyAuthAsync(request, cancellationToken).ConfigureAwait(false);
+ }
+
+ var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ try
+ {
+ response.EnsureSuccessStatusCode();
+ return response;
+ }
+ catch
+ {
+ response.Dispose();
+ throw;
+ }
+ }
+
+ private static Uri GetHttpUri(string url)
+ {
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
+ (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp))
+ {
+ throw new ArgumentException("An absolute HTTP or HTTPS URL is required.", nameof(url));
+ }
+
+ return uri;
+ }
+
+ private static UpdatePackageInfo ValidateMetadata(UpdatePackageInfo package)
+ {
+ if (string.IsNullOrWhiteSpace(package.Version) ||
+ !Uri.TryCreate(package.DownloadUrl, UriKind.Absolute, out var uri) ||
+ (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp) ||
+ package.FileSize < 0 || package.Sha256 is not { Length: 64 } ||
+ !package.Sha256.All(Uri.IsHexDigit) ||
+ (package.AuthScheme.HasValue && !Enum.IsDefined(package.AuthScheme.Value)))
+ {
+ throw new InvalidDataException("Package metadata requires a version, HTTP(S) download URL, non-negative size and SHA256 checksum.");
+ }
+
+ return package;
+ }
+
+ private static bool IsApk(VerificationEntry entry)
+ {
+ if (!string.IsNullOrWhiteSpace(entry.Format))
+ {
+ return string.Equals(entry.Format.TrimStart('.'), "apk", StringComparison.OrdinalIgnoreCase);
+ }
+
+ return Uri.TryCreate(entry.Url, UriKind.Absolute, out var uri) &&
+ uri.AbsolutePath.EndsWith(".apk", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private sealed record VerificationResponse
+ {
+ public int Code { get; init; }
+ public List? Body { get; init; }
+ }
+
+ private sealed record VerificationEntry
+ {
+ public string? Version { get; init; }
+ public string? Name { get; init; }
+ public string? UpdateLog { get; init; }
+ public string? Url { get; init; }
+ public string? Hash { get; init; }
+ public long? Size { get; init; }
+ public DateTimeOffset? ReleaseDate { get; init; }
+ public bool? IsForcibly { get; init; }
+ public bool? IsFreeze { get; init; }
+ public int? PackageType { get; init; }
+ public string? Format { get; init; }
+ public AuthScheme? AuthScheme { get; init; }
+ public string? AuthToken { get; init; }
+ }
+}
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 9d21bc3..c326269 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 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/GeneralUpdate.Avalonia.Android.Tests/HttpUpdatePackageClientTests.cs b/tests/GeneralUpdate.Avalonia.Android.Tests/HttpUpdatePackageClientTests.cs
new file mode 100644
index 0000000..9b3e8bd
--- /dev/null
+++ b/tests/GeneralUpdate.Avalonia.Android.Tests/HttpUpdatePackageClientTests.cs
@@ -0,0 +1,298 @@
+using System.Net;
+using System.Security.Cryptography;
+using System.Text;
+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 HttpUpdatePackageClientTests
+{
+ private const string Endpoint = "https://example.com/Upgrade/Verification";
+ private static readonly string Hash = new('a', 64);
+ private static readonly UpdatePackageRequest VerificationRequest = new()
+ {
+ Version = "1.0.0",
+ AppKey = "test-app",
+ Platform = 42,
+ ProductId = "test-product"
+ };
+
+ [Fact]
+ public async Task GetPackageInfoAsync_Get_MapsMetadataAndAppliesAuthentication()
+ {
+ var token = Guid.NewGuid().ToString("N");
+ using var http = new HttpClient(new TestHandler((request, _) =>
+ {
+ Assert.Equal(HttpMethod.Get, request.Method);
+ Assert.Equal(Endpoint, request.RequestUri!.AbsoluteUri);
+ Assert.Equal("Bearer", request.Headers.Authorization!.Scheme);
+ Assert.Equal(token, request.Headers.Authorization.Parameter);
+ Assert.Contains(request.Headers.Accept, header => header.MediaType == "application/json");
+ return Task.FromResult(JsonResponse($$"""
+ {
+ "version": "2.0.0", "versionName": "Release", "description": "Fixes",
+ "downloadUrl": "https://example.com/app.apk", "sha256": "{{Hash}}",
+ "fileSize": 123, "fileName": "release.apk", "isForced": true,
+ "publishTime": "2026-01-01T00:00:00Z", "authScheme": "Bearer"
+ }
+ """));
+ }));
+ var client = new HttpUpdatePackageClient(http, new BearerTokenAuthProvider(token));
+
+ var package = await client.GetPackageInfoAsync(Endpoint);
+
+ Assert.NotNull(package);
+ Assert.Equal("2.0.0", package.Version);
+ Assert.Equal("Release", package.VersionName);
+ Assert.Equal("Fixes", package.Description);
+ Assert.Equal("https://example.com/app.apk", package.DownloadUrl);
+ Assert.Equal(Hash, package.Sha256);
+ Assert.Equal(123, package.FileSize);
+ Assert.Equal("release.apk", package.FileName);
+ Assert.True(package.IsForced);
+ Assert.Equal(DateTimeOffset.Parse("2026-01-01T00:00:00Z"), package.PublishTime);
+ Assert.Equal(AuthScheme.Bearer, package.AuthScheme);
+ }
+
+ [Fact]
+ public async Task GetPackageInfoAsync_Post_SendsReferenceContractAndSelectsNewestFullApk()
+ {
+ using var http = new HttpClient(new TestHandler(async (request, ct) =>
+ {
+ Assert.Equal(HttpMethod.Post, request.Method);
+ Assert.Equal("application/json", request.Content!.Headers.ContentType!.MediaType);
+ using var body = JsonDocument.Parse(await request.Content.ReadAsStringAsync(ct));
+ Assert.Equal("1.0.0", body.RootElement.GetProperty("version").GetString());
+ Assert.Equal("test-app", body.RootElement.GetProperty("appKey").GetString());
+ Assert.Equal(1, body.RootElement.GetProperty("appType").GetInt32());
+ Assert.Equal(42, body.RootElement.GetProperty("platform").GetInt32());
+ Assert.Equal("test-product", body.RootElement.GetProperty("productId").GetString());
+ Assert.Equal(5, body.RootElement.EnumerateObject().Count());
+ return JsonResponse($$"""
+ {"code":200,"body":[
+ {"version":"99.0","format":".zip","packageType":2},
+ {"version":"98.0","format":".apk","packageType":1},
+ {"version":"97.0","format":".apk","isFreeze":true},
+ {"version":"2.9.0","url":"https://example.com/old.apk","hash":"{{Hash}}"},
+ {"version":"2.10.0","name":"Release","updateLog":"Fixes","format":".APK",
+ "url":"https://example.com/File/Download/hash","hash":"{{Hash}}","size":123,
+ "packageType":2,"isForcibly":true,"releaseDate":"2026-01-01T00:00:00Z",
+ "authScheme":"Bearer"},
+ {"version":"2.8.0","format":"apk","url":"https://example.com/older.apk",
+ "hash":"{{Hash}}","isFreeze":null,"isForcibly":null}
+ ]}
+ """);
+ }));
+
+ var package = await new HttpUpdatePackageClient(http).GetPackageInfoAsync(Endpoint, VerificationRequest);
+
+ Assert.NotNull(package);
+ Assert.Equal("2.10.0", package.Version);
+ Assert.Equal("https://example.com/File/Download/hash", package.DownloadUrl);
+ Assert.Equal("Release", package.VersionName);
+ Assert.Equal("Fixes", package.Description);
+ Assert.Equal(Hash, package.Sha256);
+ Assert.Equal(123, package.FileSize);
+ Assert.True(package.IsForced);
+ Assert.Equal(AuthScheme.Bearer, package.AuthScheme);
+ Assert.Null(package.FileName);
+ }
+
+ [Theory]
+ [InlineData(false, HttpStatusCode.NoContent, "")]
+ [InlineData(true, HttpStatusCode.NoContent, "")]
+ [InlineData(false, HttpStatusCode.OK, "null")]
+ [InlineData(true, HttpStatusCode.OK, "{\"code\":200,\"body\":[]}")]
+ [InlineData(true, HttpStatusCode.OK, "{\"code\":200,\"body\":[{\"format\":\"zip\"}]}")]
+ public async Task GetPackageInfoAsync_NoPackage_ReturnsNull(bool post, HttpStatusCode status, string json)
+ {
+ using var http = CreateHttp(json, status);
+ var client = new HttpUpdatePackageClient(http);
+
+ var package = post
+ ? await client.GetPackageInfoAsync(Endpoint, VerificationRequest)
+ : await client.GetPackageInfoAsync(Endpoint);
+
+ Assert.Null(package);
+ }
+
+ [Theory]
+ [InlineData("{\"code\":500,\"body\":[]}")]
+ [InlineData("{\"code\":200}")]
+ [InlineData("{\"code\":200,\"body\":null}")]
+ [InlineData("{\"code\":200,\"body\":[null]}")]
+ [InlineData("null")]
+ public async Task GetPackageInfoAsync_InvalidEnvelope_DoesNotReportNoUpdate(string json)
+ {
+ using var http = CreateHttp(json);
+ await Assert.ThrowsAsync(() =>
+ new HttpUpdatePackageClient(http).GetPackageInfoAsync(Endpoint, VerificationRequest));
+ }
+
+ [Theory]
+ [InlineData("", "https://example.com/app.apk", 0, 64)]
+ [InlineData("2.0", "file:///tmp/app.apk", 0, 64)]
+ [InlineData("2.0", "/app.apk", 0, 64)]
+ [InlineData("2.0", "https://example.com/app.apk", -1, 64)]
+ [InlineData("2.0", "https://example.com/app.apk", 0, 32)]
+ [InlineData("2.0", "https://example.com/app.apk", 0, 0)]
+ public async Task GetPackageInfoAsync_InvalidMetadata_IsRejected(string version, string url, long size, int hashLength)
+ {
+ using var http = CreateHttp(JsonSerializer.Serialize(new UpdatePackageInfo
+ {
+ Version = version, DownloadUrl = url, FileSize = size, Sha256 = new string('a', hashLength)
+ }));
+ await Assert.ThrowsAsync(() =>
+ new HttpUpdatePackageClient(http).GetPackageInfoAsync(Endpoint));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task GetPackageInfoAsync_HttpFailure_IsNotNoUpdate(bool post)
+ {
+ using var http = CreateHttp("{}", HttpStatusCode.Unauthorized);
+ var client = new HttpUpdatePackageClient(http);
+ await Assert.ThrowsAsync(() => post
+ ? client.GetPackageInfoAsync(Endpoint, VerificationRequest)
+ : client.GetPackageInfoAsync(Endpoint));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task GetPackageInfoAsync_MalformedJson_IsRejected(bool post)
+ {
+ using var http = CreateHttp("{");
+ var client = new HttpUpdatePackageClient(http);
+ await Assert.ThrowsAsync(() => post
+ ? client.GetPackageInfoAsync(Endpoint, VerificationRequest)
+ : client.GetPackageInfoAsync(Endpoint));
+ }
+
+ [Fact]
+ public async Task GetPackageInfoAsync_Cancellation_ReachesHttpRequest()
+ {
+ using var cts = new CancellationTokenSource();
+ using var http = new HttpClient(new TestHandler(async (_, ct) =>
+ {
+ cts.Cancel();
+ await Task.Delay(Timeout.Infinite, ct);
+ return JsonResponse("null");
+ }));
+
+ await Assert.ThrowsAnyAsync(() =>
+ new HttpUpdatePackageClient(http).GetPackageInfoAsync(Endpoint, cts.Token));
+ }
+
+ [Fact]
+ public async Task DiscoveredPackage_CompletesDownloadVerificationAndInstallerHandoff()
+ {
+ var directory = Path.Combine(Path.GetTempPath(), $"gu-{Guid.NewGuid():N}");
+ var bytes = Encoding.UTF8.GetBytes("APK test content");
+ var hash = Convert.ToHexString(SHA256.HashData(bytes));
+ var packageInfo = new UpdatePackageInfo
+ {
+ Version = "2.0.0", DownloadUrl = "https://example.com/app.apk",
+ Sha256 = hash, FileSize = bytes.Length, FileName = "app.apk"
+ };
+ using var http = new HttpClient(new TestHandler((request, _) =>
+ Task.FromResult(request.RequestUri!.AbsolutePath == "/metadata"
+ ? JsonResponse(JsonSerializer.Serialize(packageInfo))
+ : new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) })));
+ try
+ {
+ var storage = new ClosedFileStorage();
+ var installer = new RecordingInstaller();
+ using var downloader = new HttpResumableApkDownloader(http, storage,
+ new AndroidUpdateOptions { DownloadDirectoryPath = directory });
+ using var bootstrap = new AndroidBootstrap(new SystemVersionComparer(), downloader,
+ new Sha256HashValidator(), installer, storage);
+ var validationRaised = false;
+ bootstrap.AddListenerValidate += (_, _) => validationRaised = true;
+
+ var package = await new HttpUpdatePackageClient(http).GetPackageInfoAsync("https://example.com/metadata");
+ Assert.NotNull(package);
+ Assert.False(validationRaised);
+ Assert.Equal(UpdateState.None, bootstrap.GetSnapshot().State);
+
+ var check = await bootstrap.ValidateAsync(package, "1.0.0");
+ Assert.True(check.UpdateFound);
+ Assert.True(validationRaised);
+ var prepared = await bootstrap.DownloadAndVerifyAsync(package);
+ Assert.True(prepared.Success, prepared.Message);
+ Assert.Equal(UpdateState.ReadyToInstall, prepared.State);
+ Assert.Equal(bytes, await File.ReadAllBytesAsync(prepared.FilePath!));
+ Assert.False(File.Exists(prepared.FilePath + ".part"));
+ Assert.False(File.Exists(prepared.FilePath + ".part.json"));
+
+ var installed = await bootstrap.LaunchInstallerAsync(package, prepared.FilePath!);
+ Assert.True(installed.Success);
+ Assert.Equal(UpdateState.Installing, bootstrap.GetSnapshot().State);
+ Assert.Same(package, installer.Package);
+ Assert.Equal(prepared.FilePath, installer.Path);
+ }
+ finally
+ {
+ if (Directory.Exists(directory))
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+ }
+
+ private static HttpClient CreateHttp(string json, HttpStatusCode status = HttpStatusCode.OK) =>
+ new(new TestHandler((_, _) => Task.FromResult(JsonResponse(json, status))));
+
+ private static HttpResponseMessage JsonResponse(string json, HttpStatusCode status = HttpStatusCode.OK) =>
+ new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
+
+ private sealed class TestHandler(Func> send) : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
+ send(request, cancellationToken);
+ }
+
+ private sealed class RecordingInstaller : IApkInstaller
+ {
+ public UpdatePackageInfo? Package { get; private set; }
+ public string? Path { get; private set; }
+
+ public Task LaunchInstallAsync(UpdatePackageInfo packageInfo, string apkFilePath, CancellationToken cancellationToken = default)
+ {
+ Package = packageInfo;
+ Path = apkFilePath;
+ return Task.FromResult(new InstallResult { Success = true, State = UpdateState.Installing });
+ }
+ }
+
+ private sealed class ClosedFileStorage : IFileStorage
+ {
+ private readonly PhysicalFileStorage _inner = new();
+ private Stream? _writer;
+
+ public Stream OpenWrite(string path, bool append) => _writer = _inner.OpenWrite(path, append);
+
+ public void MoveFile(string sourcePath, string destinationPath, bool overwrite)
+ {
+ Assert.NotNull(_writer);
+ Assert.False(_writer.CanWrite);
+ _inner.MoveFile(sourcePath, destinationPath, overwrite);
+ }
+
+ public bool FileExists(string path) => _inner.FileExists(path);
+ public void EnsureDirectory(string path) => _inner.EnsureDirectory(path);
+ public long GetFileLength(string path) => _inner.GetFileLength(path);
+ public Stream OpenRead(string path) => _inner.OpenRead(path);
+ public void DeleteFile(string path) => _inner.DeleteFile(path);
+ public Task ReadAllTextAsync(string path, CancellationToken cancellationToken) =>
+ _inner.ReadAllTextAsync(path, cancellationToken);
+ public Task WriteAllTextAsync(string path, string content, CancellationToken cancellationToken) =>
+ _inner.WriteAllTextAsync(path, content, cancellationToken);
+ }
+}
From cbfb17da504c666ff192f7d40b6931dcab37ce46 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 19 Sep 2026 13:52:45 +0000
Subject: [PATCH 3/3] Integrate server requests into validation and precheck
decisions
Co-authored-by: JusterZhu <11714536+JusterZhu@users.noreply.github.com>
---
README-EN.md | 10 +-
README.md | 73 +++--
.../Abstractions/IAndroidBootstrap.cs | 9 +
.../GeneralUpdateBootstrap.cs | 5 +-
.../Models/AndroidUpdateOptions.cs | 1 +
.../Models/HttpDownloadOptions.cs | 8 +-
.../Models/UpdatePackageRequest.cs | 2 +-
.../Models/UpdateServerOptions.cs | 18 ++
.../README.en.md | 9 +-
src/GeneralUpdate.Avalonia.Android/README.md | 77 ++---
.../README.zh-CN.md | 9 +-
.../Services/AndroidBootstrap.cs | 79 ++++-
.../Services/HttpUpdatePackageClient.cs | 31 +-
...eneralUpdate.Avalonia.Android.Tests.csproj | 1 +
.../HttpUpdatePackageClientTests.cs | 281 +++++++++++++++++-
15 files changed, 517 insertions(+), 96 deletions(-)
create mode 100644 src/GeneralUpdate.Avalonia.Android/Models/UpdateServerOptions.cs
diff --git a/README-EN.md b/README-EN.md
index e283344..13de1cb 100644
--- a/README-EN.md
+++ b/README-EN.md
@@ -88,12 +88,12 @@ if (check.UpdateFound)
}
```
-### Fetch server metadata before ValidateAsync
+### Automatic server verification
-Use `HttpUpdatePackageClient.GetPackageInfoAsync` to retrieve `UpdatePackageInfo` before calling `ValidateAsync`.
-It supports a GET JSON metadata endpoint and a POST overload for the GeneralUpdate reference verification protocol,
-with cancellation and existing authentication providers. Manual metadata construction remains supported.
-See [metadata retrieval and GeneralSpacestation compatibility](src/GeneralUpdate.Avalonia.Android/README.md#fetch-metadata-before-validation)
+Configure `AndroidUpdateOptions.UpdateServer` and call `ValidateAsync(currentVersion)`.
+Server requests happen internally; `AddListenerUpdatePrecheck` receives the latest package and returns `true` to skip
+or `false` to continue, matching GeneralUpdate. Forced updates bypass pre-check. Manual metadata remains supported.
+See [automatic verification and GeneralSpacestation compatibility](src/GeneralUpdate.Avalonia.Android/README.md#automatic-server-verification-and-pre-check)
for examples, response formats and deployment requirements.
## Directory Structure
diff --git a/README.md b/README.md
index 57bf151..6ac060e 100644
--- a/README.md
+++ b/README.md
@@ -88,24 +88,35 @@ if (check.UpdateFound)
}
```
-### 在 ValidateAsync 前获取服务器包信息
+### 自动查询服务器并通过 precheck 决定是否更新
-`ValidateAsync` 只比较传入的版本,`AddListenerUpdatePrecheck` 也不会查询服务器。
-使用独立的 `HttpUpdatePackageClient` 即可先获取、查看 `UpdatePackageInfo`,无需手动填写每次发布的包信息:
+配置 `AndroidUpdateOptions.UpdateServer` 后,`ValidateAsync(currentVersion)` 会在组件内部请求服务器、
+选择最新完整 APK 并比较版本。有更新时,通过 `AddListenerUpdatePrecheck` 提供最新包信息,由回调决定是否继续。
+不需要创建或显式调用 HTTP 查询组件。
```csharp
-using GeneralUpdate.Avalonia.Android.Services;
-
-// HttpClient 由宿主管理,可复用并配置超时、代理和 TLS。
-using var httpClient = new HttpClient();
-var packageClient = new HttpUpdatePackageClient(httpClient);
-var packageInfo = await packageClient.GetPackageInfoAsync(
- "https://example.com/android/latest.json", CancellationToken.None);
-if (packageInfo is null) return; // 没有可用包,不要传 null 给 ValidateAsync
+var options = new AndroidUpdateOptions
+{
+ FileProviderAuthority = "com.example.app.generalupdate.fileprovider",
+ UpdateServer = new UpdateServerOptions
+ {
+ RequestUrl = verificationUrl,
+ AppKey = appKey,
+ AppType = 1,
+ Platform = serverPlatformId,
+ ProductId = productId
+ }
+};
+using var bootstrap = GeneralUpdateBootstrap.CreateDefault(options);
+bootstrap.AddListenerUpdatePrecheck(args =>
+{
+ // args.PackageInfo:最新版本、下载地址、SHA256、Description 更新说明等。
+ // 与 GeneralUpdate 一致:true 跳过,false 继续;强制更新不调用此回调。
+ return false;
+});
-// 此处可读取版本说明等信息,尚未比较版本或触发更新事件。
-var check = await bootstrap.ValidateAsync(packageInfo, "2.2.1", CancellationToken.None);
-if (check.Success && check.UpdateFound)
+var check = await bootstrap.ValidateAsync("2.2.1", CancellationToken.None);
+if (check.Success && check.UpdateFound && check.PackageInfo is { } packageInfo)
{
var prepared = await bootstrap.DownloadAndVerifyAsync(packageInfo, CancellationToken.None);
if (prepared.Success && prepared.FilePath is not null)
@@ -116,7 +127,12 @@ if (check.Success && check.UpdateFound)
}
```
-GET 地址返回 `UpdatePackageInfo` JSON(字段名不区分大小写),例如:
+回调返回 `true` 时,结果的 `UpdateFound` 为 `false`,不会触发 `AddListenerValidate`。
+没有更新时也不调用 precheck。回调同步执行,不会自动切换到 UI 线程。
+旧的 `ValidateAsync(packageInfo, currentVersion)` 重载保留,不请求服务器。
+
+如果服务器提供静态 JSON,设置 `UpdateServer.UseJsonEndpoint = true` 并将 `RequestUrl` 指向 JSON 地址。
+组件自动 GET `UpdatePackageInfo` JSON(字段名不区分大小写),例如:
```json
{
@@ -129,36 +145,29 @@ GET 地址返回 `UpdatePackageInfo` JSON(字段名不区分大小写),例
```
`sha256` 必须替换为实际 APK 的 SHA-256,不能使用 MD5。`fileSize` 可省略或为 0(未知),已知时单位为字节。
-HTTP 204 或 JSON `null` 表示无包;HTTP 错误、错误 JSON、缺失/无效元数据及取消操作会抛出异常,宿主应处理,不能视为“无更新”。
+HTTP 204 或 GET JSON `null` 表示无包;请求/协议错误通过 `UpdateCheckResult.Success = false`、
+`FailureReason` 和 `AddListenerUpdateFailed` 通知,不会触发 precheck。请求期间取消会返回 `Canceled`;
+等待操作锁时取消会抛出 `OperationCanceledException`,与已有重载一致。
仅应查询可信服务器,生产环境使用 HTTPS。
#### GeneralSpacestation / GeneralUpdate 验证协议
对于采用 [GeneralUpdate 示例服务器](https://github.com/GeneralLibrary/GeneralUpdate-Samples/tree/main/src/Server)
-`POST /Upgrade/Verification`(或 `/Update/Verification`)协议的部署,使用另一重载:
-
-```csharp
-var request = new UpdatePackageRequest
-{
- Version = currentVersion,
- AppKey = appKey,
- AppType = 1,
- Platform = serverPlatformId,
- ProductId = productId
-};
-var packageInfo = await packageClient.GetPackageInfoAsync(verificationUrl, request, cancellationToken);
-```
+`POST /Upgrade/Verification`(或 `/Update/Verification`)协议的部署,使用上述默认配置即可。
请求发送 `version/appKey/appType/platform/productId`,响应为 `{"code":200,"body":[...]}`。
客户端映射 `version/url/hash/size/name/updateLog/releaseDate/isForcibly/authScheme/authToken`,
按版本选取最新的非冻结完整 APK(`packageType` 为 2、0 或省略;`format` 为 `apk`/`.apk`,省略时 URL 路径须以 `.apk` 结尾)。
ZIP、差分包、驱动包不会交给 Android 安装器。`body: []` 或没有符合条件的包返回 `null`;
-非 200 业务码、缺失或 `null` 的 `body` 会报错。默认使用 `System.Version` 比较版本,也可在构造客户端时传入 `IVersionComparer`。
+非 200 业务码、缺失或 `null` 的 `body` 会报告失败。默认使用 `System.Version` 比较版本,
+也可通过 `CreateDefault` 的 `versionComparer` 同时替换包选择和本地版本比较逻辑。
GeneralSpacestation 商业服务的接口并未公开,**请核对实际部署的地址、响应格式和 Android 平台编号,不要假定固定编号**;
-协议不同时可由服务器提供上面的标准 JSON 端点。查询认证通过构造参数 `authProvider` 配置,可复用现有
+协议不同时可由服务器提供上面的标准 JSON 端点。查询与下载均使用 `CreateDefault` 的 `httpOptions`,
+其中 `AuthProvider` 可使用现有
`BearerTokenAuthProvider`、`ApiKeyAuthProvider`、`BasicAuthProvider` 或 `HmacAuthProvider`。
-`AppKey` 仅是请求字段,不会自动启用 HMAC;`HttpDownloadOptions` 不会自动作用于独立查询客户端。
+`AppKey` 仅是请求字段,不会自动启用 HMAC。查询使用 `RequestTimeout`、代理和 TLS 配置;
+不提供 `httpOptions` 时可复用传入的 `httpClient`,其生命周期仍由宿主管理。
## 目录结构
diff --git a/src/GeneralUpdate.Avalonia.Android/Abstractions/IAndroidBootstrap.cs b/src/GeneralUpdate.Avalonia.Android/Abstractions/IAndroidBootstrap.cs
index 5b8a800..d527dbe 100644
--- a/src/GeneralUpdate.Avalonia.Android/Abstractions/IAndroidBootstrap.cs
+++ b/src/GeneralUpdate.Avalonia.Android/Abstractions/IAndroidBootstrap.cs
@@ -32,6 +32,15 @@ public interface IAndroidBootstrap : IDisposable
/// Thrown when is null.
IAndroidBootstrap AddListenerUpdatePrecheck(Func func);
+ ///
+ /// Queries the configured update server, compares versions and invokes the pre-check callback.
+ /// Returning true from pre-check skips the update; false continues. Forced updates bypass pre-check.
+ /// The returned PackageInfo can be passed to download and installation.
+ ///
+ Task ValidateAsync(
+ string currentVersion,
+ CancellationToken cancellationToken = default);
+
Task ValidateAsync(
UpdatePackageInfo packageInfo,
string currentVersion,
diff --git a/src/GeneralUpdate.Avalonia.Android/GeneralUpdateBootstrap.cs b/src/GeneralUpdate.Avalonia.Android/GeneralUpdateBootstrap.cs
index 94fca60..09a813f 100644
--- a/src/GeneralUpdate.Avalonia.Android/GeneralUpdateBootstrap.cs
+++ b/src/GeneralUpdate.Avalonia.Android/GeneralUpdateBootstrap.cs
@@ -63,6 +63,9 @@ public static IAndroidBootstrap CreateDefault(
installer,
usedStorage,
eventDispatcher,
- usedLogger);
+ usedLogger,
+ options.UpdateServer,
+ httpClient,
+ httpOptions);
}
}
diff --git a/src/GeneralUpdate.Avalonia.Android/Models/AndroidUpdateOptions.cs b/src/GeneralUpdate.Avalonia.Android/Models/AndroidUpdateOptions.cs
index d450fd1..5b73d55 100644
--- a/src/GeneralUpdate.Avalonia.Android/Models/AndroidUpdateOptions.cs
+++ b/src/GeneralUpdate.Avalonia.Android/Models/AndroidUpdateOptions.cs
@@ -2,6 +2,7 @@ namespace GeneralUpdate.Avalonia.Android.Models;
public sealed record AndroidUpdateOptions
{
+ public UpdateServerOptions? UpdateServer { get; init; }
public string DownloadDirectoryPath { get; init; } = string.Empty;
public string TemporaryFileExtension { get; init; } = ".part";
public string SidecarExtension { get; init; } = ".json";
diff --git a/src/GeneralUpdate.Avalonia.Android/Models/HttpDownloadOptions.cs b/src/GeneralUpdate.Avalonia.Android/Models/HttpDownloadOptions.cs
index b9383f6..056a04f 100644
--- a/src/GeneralUpdate.Avalonia.Android/Models/HttpDownloadOptions.cs
+++ b/src/GeneralUpdate.Avalonia.Android/Models/HttpDownloadOptions.cs
@@ -4,7 +4,7 @@
namespace GeneralUpdate.Avalonia.Android.Models;
///
-/// Configures HTTP transport behavior for update downloads:
+/// Configures HTTP transport behavior for update verification and downloads:
/// SSL/TLS certificate validation, timeouts, proxy, retry, and authentication.
///
/// When provided to ,
@@ -23,7 +23,7 @@ public sealed record HttpDownloadOptions
public ISslValidationPolicy? SslValidationPolicy { get; init; }
///
- /// Timeout for individual HTTP requests (HEAD probes, etc.).
+ /// Timeout for server verification requests and download HEAD probes.
/// Default is 30 seconds.
///
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(30);
@@ -61,8 +61,8 @@ public sealed record HttpDownloadOptions
public TimeSpan RetryBaseDelay { get; init; } = TimeSpan.FromSeconds(1);
///
- /// Global authentication provider applied to all download requests.
- /// Per-package authentication on takes precedence.
+ /// Global authentication provider applied to verification and download requests.
+ /// Per-package authentication on takes precedence for downloads.
///
public IHttpAuthProvider? AuthProvider { get; init; }
diff --git a/src/GeneralUpdate.Avalonia.Android/Models/UpdatePackageRequest.cs b/src/GeneralUpdate.Avalonia.Android/Models/UpdatePackageRequest.cs
index fcad074..0f30de0 100644
--- a/src/GeneralUpdate.Avalonia.Android/Models/UpdatePackageRequest.cs
+++ b/src/GeneralUpdate.Avalonia.Android/Models/UpdatePackageRequest.cs
@@ -4,7 +4,7 @@ namespace GeneralUpdate.Avalonia.Android.Models;
/// Request body for the GeneralUpdate /Upgrade/Verification reference protocol.
/// Platform must match the identifier configured by the server.
///
-public sealed record UpdatePackageRequest
+internal sealed record UpdatePackageRequest
{
public required string Version { get; init; }
public required string AppKey { get; init; }
diff --git a/src/GeneralUpdate.Avalonia.Android/Models/UpdateServerOptions.cs b/src/GeneralUpdate.Avalonia.Android/Models/UpdateServerOptions.cs
new file mode 100644
index 0000000..01c8a6e
--- /dev/null
+++ b/src/GeneralUpdate.Avalonia.Android/Models/UpdateServerOptions.cs
@@ -0,0 +1,18 @@
+namespace GeneralUpdate.Avalonia.Android.Models;
+
+///
+/// Configures the server queried internally by version validation.
+///
+public sealed record UpdateServerOptions
+{
+ public required string RequestUrl { get; init; }
+ public string AppKey { get; init; } = string.Empty;
+ public int AppType { get; init; } = 1;
+ public int Platform { get; init; }
+ public string ProductId { get; init; } = string.Empty;
+
+ ///
+ /// GET a JSON UpdatePackageInfo instead of POSTing the GeneralUpdate verification protocol.
+ ///
+ public bool UseJsonEndpoint { get; init; }
+}
diff --git a/src/GeneralUpdate.Avalonia.Android/README.en.md b/src/GeneralUpdate.Avalonia.Android/README.en.md
index e44281f..fdceb15 100644
--- a/src/GeneralUpdate.Avalonia.Android/README.en.md
+++ b/src/GeneralUpdate.Avalonia.Android/README.en.md
@@ -72,16 +72,17 @@ Default wiring:
| `IApkInstaller` | `AndroidApkInstaller` |
| `IVersionComparer` | `SystemVersionComparer` |
-### Query metadata before ValidateAsync
+### Automatic server verification
-`HttpUpdatePackageClient.GetPackageInfoAsync` supports GET JSON metadata and the POST GeneralUpdate verification reference protocol,
-independently of version comparison, with cancellation and existing authentication providers.
-See [examples and GeneralSpacestation compatibility](README.md#fetch-metadata-before-validation).
+Configure `AndroidUpdateOptions.UpdateServer` and call `ValidateAsync(currentVersion)` to query the server internally.
+`AddListenerUpdatePrecheck` receives the latest package; return `true` to skip or `false` to continue (forced updates bypass it).
+See [examples and GeneralSpacestation compatibility](README.md#automatic-server-verification-and-pre-check).
### IAndroidBootstrap Methods
| Method | Returns |
|---|---|
+| `ValidateAsync(currentVersion, ct)` | `UpdateCheckResult`, including the discovered `PackageInfo` |
| `ValidateAsync(packageInfo, currentVersion, ct)` | `UpdateCheckResult` |
| `DownloadAndVerifyAsync(packageInfo, ct)` | `UpdateOperationResult` |
| `LaunchInstallerAsync(packageInfo, apkFilePath, ct)` | `InstallResult` |
diff --git a/src/GeneralUpdate.Avalonia.Android/README.md b/src/GeneralUpdate.Avalonia.Android/README.md
index 124575c..4a97432 100644
--- a/src/GeneralUpdate.Avalonia.Android/README.md
+++ b/src/GeneralUpdate.Avalonia.Android/README.md
@@ -58,22 +58,33 @@ if (check.UpdateFound)
}
```
-## Fetch metadata before validation
+## Automatic server verification and pre-check
-`ValidateAsync` only compares supplied versions; the pre-check hook does not query the server.
-Use `HttpUpdatePackageClient` to inspect server metadata before either operation:
+Configure `AndroidUpdateOptions.UpdateServer`, then call `ValidateAsync(currentVersion)`.
+The component requests metadata internally, selects the latest full APK and compares versions before invoking
+`AddListenerUpdatePrecheck` with the latest package information. No explicit HTTP client calls are required.
```csharp
-using GeneralUpdate.Avalonia.Android.Services;
-
-using var httpClient = new HttpClient();
-var packageClient = new HttpUpdatePackageClient(httpClient);
-var packageInfo = await packageClient.GetPackageInfoAsync(
- "https://example.com/android/latest.json", ct);
-if (packageInfo is null) return;
-
-var check = await bootstrap.ValidateAsync(packageInfo, currentVersion, ct);
-if (check.Success && check.UpdateFound)
+var options = new AndroidUpdateOptions
+{
+ FileProviderAuthority = "com.example.app.generalupdate.fileprovider",
+ UpdateServer = new UpdateServerOptions
+ {
+ RequestUrl = verificationUrl,
+ AppKey = appKey,
+ AppType = 1,
+ Platform = serverPlatformId,
+ ProductId = productId
+ }
+};
+using var bootstrap = GeneralUpdateBootstrap.CreateDefault(options);
+bootstrap.AddListenerUpdatePrecheck(args =>
+{
+ // Inspect args.PackageInfo: latest version, Description/release notes, URL, SHA256, etc.
+ return false; // true skips, false continues; forced updates bypass this callback.
+});
+var check = await bootstrap.ValidateAsync(currentVersion, ct);
+if (check.Success && check.UpdateFound && check.PackageInfo is { } packageInfo)
{
var prepared = await bootstrap.DownloadAndVerifyAsync(packageInfo, ct);
if (prepared.Success && prepared.FilePath is not null)
@@ -84,7 +95,12 @@ if (check.Success && check.UpdateFound)
}
```
-The GET endpoint returns a JSON `UpdatePackageInfo` (case-insensitive property names):
+Returning `true` produces `UpdateFound == false` and suppresses `AddListenerValidate`. No-update checks do not invoke pre-check.
+The callback is synchronous and is not automatically dispatched to the UI thread.
+The existing `ValidateAsync(packageInfo, currentVersion)` overload remains available and never queries the server.
+
+For a static JSON endpoint, set `UpdateServer.UseJsonEndpoint = true` and point `RequestUrl` at a JSON `UpdatePackageInfo`
+(case-insensitive property names). The component performs the GET internally:
```json
{
@@ -97,38 +113,28 @@ The GET endpoint returns a JSON `UpdatePackageInfo` (case-insensitive property n
```
Replace `sha256` with the actual APK's SHA-256, never an MD5 digest. Optional `fileSize` is in bytes; zero means unknown.
-HTTP 204 or JSON `null` returns `null`. HTTP failures, malformed JSON, invalid metadata and cancellation propagate as exceptions
-for the host to handle, not as "no update". Fetching does not change bootstrap state or raise update events.
-Use trusted endpoints and HTTPS in production. The caller owns and may reuse the `HttpClient`, configuring timeouts, TLS and proxy settings.
+HTTP 204 or GET JSON `null` produces a successful no-update result. HTTP/protocol/metadata failures produce
+`Success == false`, a `FailureReason` and `AddListenerUpdateFailed`, not pre-check.
+Cancellation during the request returns `Canceled`; cancellation while waiting for the operation lock throws
+`OperationCanceledException`, matching the existing overload. Use trusted endpoints and HTTPS in production.
### GeneralSpacestation / GeneralUpdate reference protocol
For deployments implementing the [GeneralUpdate sample server](https://github.com/GeneralLibrary/GeneralUpdate-Samples/tree/main/src/Server)
-`POST /Upgrade/Verification` (or `/Update/Verification`) contract:
-
-```csharp
-var packageInfo = await packageClient.GetPackageInfoAsync(verificationUrl,
- new UpdatePackageRequest
- {
- Version = currentVersion,
- AppKey = appKey,
- AppType = 1,
- Platform = serverPlatformId,
- ProductId = productId
- }, ct);
-```
+`POST /Upgrade/Verification` (or `/Update/Verification`) contract, use the default configuration shown above.
The request uses `version/appKey/appType/platform/productId`. The response must be `{"code":200,"body":[...]}`.
The client maps `version/url/hash/size/name/updateLog/releaseDate/isForcibly/authScheme/authToken` to package metadata and selects
the newest non-frozen full APK. `packageType` must be 2, 0 or absent; `format` must be `apk`/`.apk`, or, if omitted, the URL path
-must end in `.apk`. ZIP, differential and driver packages are ignored. An empty eligible list returns `null`;
-an unsuccessful application code or missing/null `body` throws. Version ordering defaults to `System.Version`;
-pass an `IVersionComparer` to the client for other version schemes.
+must end in `.apk`. ZIP, differential and driver packages are ignored. An empty eligible list means no update;
+an unsuccessful application code or missing/null `body` reports a failure. Version ordering defaults to `System.Version`;
+pass `versionComparer` to `CreateDefault` to customize both remote selection and local comparison.
The commercial GeneralSpacestation API is not publicly specified: **verify your deployment's route, schema and Android platform identifier**.
Do not assume a fixed Android platform number. For other protocols, expose the standard JSON endpoint shown above.
-Pass an existing `IHttpAuthProvider` to the client constructor for metadata authentication (Bearer, API key, Basic or HMAC).
-`AppKey` is only a request field; it does not automatically enable HMAC. `HttpDownloadOptions` does not configure this separate metadata client.
+`CreateDefault`'s `httpOptions` configures both requests and downloads: `AuthProvider` (Bearer, API key, Basic or HMAC),
+TLS and proxy settings. Verification uses `RequestTimeout`. `AppKey` is only a request field; it does not automatically enable HMAC.
+Without `httpOptions`, an injected `httpClient` is reused and remains owned by the caller.
## API
@@ -160,6 +166,7 @@ public static IAndroidBootstrap CreateDefault(
| Method | Description |
|---|---|
+| `ValidateAsync(currentVersion, ct)` | Query the configured server internally, compare versions and apply pre-check; return metadata in `UpdateCheckResult.PackageInfo` |
| `ValidateAsync(packageInfo, currentVersion, ct)` | Compare versions, fire `AddListenerValidate` or `AddListenerUpdateFailed`, return `UpdateCheckResult` |
| `DownloadAndVerifyAsync(packageInfo, ct)` | Resume-download APK, SHA-256 verify, fire progress/completed/failed events, return `UpdateOperationResult` |
| `LaunchInstallerAsync(packageInfo, apkFilePath, ct)` | Launch Android `ACTION_VIEW` intent via FileProvider, return `InstallResult` |
diff --git a/src/GeneralUpdate.Avalonia.Android/README.zh-CN.md b/src/GeneralUpdate.Avalonia.Android/README.zh-CN.md
index 861889b..ce2ea6f 100644
--- a/src/GeneralUpdate.Avalonia.Android/README.zh-CN.md
+++ b/src/GeneralUpdate.Avalonia.Android/README.zh-CN.md
@@ -72,16 +72,17 @@ if (check.UpdateFound)
| `IApkInstaller` | `AndroidApkInstaller` |
| `IVersionComparer` | `SystemVersionComparer` |
-### 在 ValidateAsync 前查询包信息
+### 自动查询服务器
-`HttpUpdatePackageClient.GetPackageInfoAsync` 支持 GET 标准 JSON 元数据,以及 POST GeneralUpdate 验证协议,
-无需手动填写每次发布的 `UpdatePackageInfo`。查询与版本比较相互独立,支持取消和现有认证接口。
-参见[中文使用示例与 GeneralSpacestation 兼容性说明](../../README.md#在-validateasync-前获取服务器包信息)。
+配置 `AndroidUpdateOptions.UpdateServer` 后调用 `ValidateAsync(currentVersion)`,组件内部自动请求服务器。
+`AddListenerUpdatePrecheck` 回调提供最新包信息,返回 `true` 跳过、`false` 继续(强制更新不调用此回调)。
+参见[中文使用示例与 GeneralSpacestation 兼容性说明](../../README.md#自动查询服务器并通过-precheck-决定是否更新)。
### IAndroidBootstrap 方法
| 方法 | 返回类型 |
|---|---|
+| `ValidateAsync(currentVersion, ct)` | `UpdateCheckResult`,包含查询到的 `PackageInfo` |
| `ValidateAsync(packageInfo, currentVersion, ct)` | `UpdateCheckResult` |
| `DownloadAndVerifyAsync(packageInfo, ct)` | `UpdateOperationResult` |
| `LaunchInstallerAsync(packageInfo, apkFilePath, ct)` | `InstallResult` |
diff --git a/src/GeneralUpdate.Avalonia.Android/Services/AndroidBootstrap.cs b/src/GeneralUpdate.Avalonia.Android/Services/AndroidBootstrap.cs
index 94cc25f..4ed6b06 100644
--- a/src/GeneralUpdate.Avalonia.Android/Services/AndroidBootstrap.cs
+++ b/src/GeneralUpdate.Avalonia.Android/Services/AndroidBootstrap.cs
@@ -1,3 +1,4 @@
+using System.Text.Json;
using GeneralUpdate.Avalonia.Android.Abstractions;
using GeneralUpdate.Avalonia.Android.Events;
using GeneralUpdate.Avalonia.Android.Models;
@@ -13,6 +14,8 @@ public sealed class AndroidBootstrap : IAndroidBootstrap
private readonly IFileStorage _fileStorage;
private readonly IUpdateEventDispatcher _eventDispatcher;
private readonly IUpdateLogger _logger;
+ private readonly UpdateServerOptions? _updateServer;
+ private readonly HttpUpdatePackageClient? _packageClient;
private readonly SemaphoreSlim _operationGate = new(1, 1);
private bool _disposed;
@@ -27,7 +30,10 @@ public AndroidBootstrap(
IApkInstaller apkInstaller,
IFileStorage fileStorage,
IUpdateEventDispatcher? eventDispatcher = null,
- IUpdateLogger? logger = null)
+ IUpdateLogger? logger = null,
+ UpdateServerOptions? updateServer = null,
+ HttpClient? httpClient = null,
+ HttpDownloadOptions? httpOptions = null)
{
_versionComparer = versionComparer ?? throw new ArgumentNullException(nameof(versionComparer));
_downloader = downloader ?? throw new ArgumentNullException(nameof(downloader));
@@ -36,6 +42,11 @@ public AndroidBootstrap(
_fileStorage = fileStorage ?? throw new ArgumentNullException(nameof(fileStorage));
_eventDispatcher = eventDispatcher ?? new ImmediateEventDispatcher();
_logger = logger ?? new NoOpUpdateLogger();
+ _updateServer = updateServer;
+ if (updateServer is not null)
+ {
+ _packageClient = HttpUpdatePackageClient.Create(httpClient, httpOptions, _versionComparer);
+ }
}
public event EventHandler? AddListenerValidate;
@@ -59,7 +70,17 @@ public IAndroidBootstrap AddListenerUpdatePrecheck(Func ValidateAsync(UpdatePackageInfo packageInfo, string currentVersion, CancellationToken cancellationToken = default)
+ public Task ValidateAsync(string currentVersion, CancellationToken cancellationToken = default)
+ => ValidateCoreAsync(null, currentVersion, queryServer: true, cancellationToken);
+
+ public Task ValidateAsync(UpdatePackageInfo packageInfo, string currentVersion, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(packageInfo);
+ return ValidateCoreAsync(packageInfo, currentVersion, queryServer: false, cancellationToken);
+ }
+
+ private async Task ValidateCoreAsync(
+ UpdatePackageInfo? packageInfo, string currentVersion, bool queryServer, CancellationToken cancellationToken)
{
ThrowIfDisposed();
await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
@@ -68,6 +89,59 @@ public async Task ValidateAsync(UpdatePackageInfo packageInfo
cancellationToken.ThrowIfCancellationRequested();
SetState(UpdateState.Checking, UpdateFailureReason.None, "Checking for updates.");
+ if (queryServer)
+ {
+ try
+ {
+ if (_updateServer is null || _packageClient is null || string.IsNullOrWhiteSpace(currentVersion))
+ {
+ throw new InvalidDataException("An update server and current version must be configured.");
+ }
+
+ packageInfo = _updateServer.UseJsonEndpoint
+ ? await _packageClient.GetPackageInfoAsync(_updateServer.RequestUrl, cancellationToken).ConfigureAwait(false)
+ : await _packageClient.GetPackageInfoAsync(_updateServer.RequestUrl, new UpdatePackageRequest
+ {
+ Version = currentVersion,
+ AppKey = _updateServer.AppKey,
+ AppType = _updateServer.AppType,
+ Platform = _updateServer.Platform,
+ ProductId = _updateServer.ProductId
+ }, cancellationToken).ConfigureAwait(false);
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+ catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException or
+ JsonException or InvalidDataException or IOException or ArgumentException)
+ {
+ var canceled = ex is OperationCanceledException && cancellationToken.IsCancellationRequested;
+ var failure = new UpdateCheckResult
+ {
+ State = canceled ? UpdateState.Canceled : UpdateState.Failed,
+ FailureReason = canceled ? UpdateFailureReason.Canceled
+ : ex is HttpRequestException or OperationCanceledException or IOException ? UpdateFailureReason.NetworkError
+ : UpdateFailureReason.InvalidMetadata,
+ Message = canceled ? "Update check canceled." : "Failed to query update metadata.",
+ CurrentVersion = currentVersion,
+ Exception = ex
+ };
+ HandleFailure(failure);
+ return failure;
+ }
+
+ if (packageInfo is null)
+ {
+ SetState(UpdateState.Completed, UpdateFailureReason.None, "No update available.");
+ return new UpdateCheckResult
+ {
+ Success = true,
+ State = UpdateState.Completed,
+ CurrentVersion = currentVersion,
+ Message = "No update available."
+ };
+ }
+ }
+
+ ArgumentNullException.ThrowIfNull(packageInfo);
if (string.IsNullOrWhiteSpace(currentVersion) || string.IsNullOrWhiteSpace(packageInfo.Version))
{
var invalid = new UpdateCheckResult
@@ -318,6 +392,7 @@ public void Dispose()
}
_operationGate.Dispose();
+ _packageClient?.Dispose();
if (_downloader is IDisposable disposableDownloader)
{
diff --git a/src/GeneralUpdate.Avalonia.Android/Services/HttpUpdatePackageClient.cs b/src/GeneralUpdate.Avalonia.Android/Services/HttpUpdatePackageClient.cs
index e267e2f..891079f 100644
--- a/src/GeneralUpdate.Avalonia.Android/Services/HttpUpdatePackageClient.cs
+++ b/src/GeneralUpdate.Avalonia.Android/Services/HttpUpdatePackageClient.cs
@@ -12,7 +12,7 @@ namespace GeneralUpdate.Avalonia.Android.Services;
/// Retrieves package metadata without validating the installed version or starting a download.
/// The caller owns the supplied HttpClient, including its timeout and transport configuration.
///
-public sealed class HttpUpdatePackageClient
+internal sealed class HttpUpdatePackageClient : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
@@ -22,15 +22,42 @@ public sealed class HttpUpdatePackageClient
private readonly HttpClient _httpClient;
private readonly IHttpAuthProvider? _authProvider;
private readonly IVersionComparer _versionComparer;
+ private readonly bool _ownsClient;
+
+ internal static HttpUpdatePackageClient Create(
+ HttpClient? httpClient, HttpDownloadOptions? httpOptions, IVersionComparer versionComparer)
+ {
+ if (httpOptions is not null)
+ {
+ var client = new HttpClient(httpOptions.BuildHandler())
+ {
+ Timeout = httpOptions.RequestTimeout
+ };
+ return new HttpUpdatePackageClient(client, httpOptions.AuthProvider, versionComparer, ownsClient: true);
+ }
+
+ return new HttpUpdatePackageClient(httpClient ?? new HttpClient(),
+ versionComparer: versionComparer, ownsClient: httpClient is null);
+ }
public HttpUpdatePackageClient(
HttpClient httpClient,
IHttpAuthProvider? authProvider = null,
- IVersionComparer? versionComparer = null)
+ IVersionComparer? versionComparer = null,
+ bool ownsClient = false)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_authProvider = authProvider;
_versionComparer = versionComparer ?? new SystemVersionComparer();
+ _ownsClient = ownsClient;
+ }
+
+ public void Dispose()
+ {
+ if (_ownsClient)
+ {
+ _httpClient.Dispose();
+ }
}
///
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 c326269..1fb0022 100644
--- a/tests/GeneralUpdate.Avalonia.Android.Tests/GeneralUpdate.Avalonia.Android.Tests.csproj
+++ b/tests/GeneralUpdate.Avalonia.Android.Tests/GeneralUpdate.Avalonia.Android.Tests.csproj
@@ -20,6 +20,7 @@
+
diff --git a/tests/GeneralUpdate.Avalonia.Android.Tests/HttpUpdatePackageClientTests.cs b/tests/GeneralUpdate.Avalonia.Android.Tests/HttpUpdatePackageClientTests.cs
index 9b3e8bd..75806aa 100644
--- a/tests/GeneralUpdate.Avalonia.Android.Tests/HttpUpdatePackageClientTests.cs
+++ b/tests/GeneralUpdate.Avalonia.Android.Tests/HttpUpdatePackageClientTests.cs
@@ -1,4 +1,5 @@
using System.Net;
+using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -22,6 +23,263 @@ public sealed class HttpUpdatePackageClientTests
ProductId = "test-product"
};
+ [Theory]
+ [InlineData(true, false, false)]
+ [InlineData(false, false, true)]
+ [InlineData(true, true, true)]
+ public async Task ValidateAsync_QueriesInternallyThenAppliesPrecheck(bool skip, bool forced, bool expectedUpdate)
+ {
+ var order = new List();
+ using var http = new HttpClient(new TestHandler(async (request, ct) =>
+ {
+ order.Add("request");
+ using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(ct));
+ Assert.Equal("1.0.0", body.RootElement.GetProperty("version").GetString());
+ Assert.Equal(VerificationRequest.Platform, body.RootElement.GetProperty("platform").GetInt32());
+ return JsonResponse($$"""
+ {"code":200,"body":[
+ {"version":"2.0.0","url":"https://example.com/app.apk","hash":"{{Hash}}",
+ "updateLog":"Latest release","isForcibly":{{forced.ToString().ToLowerInvariant()}}}
+ ]}
+ """);
+ }));
+ using var bootstrap = CreateBootstrap(http);
+ bootstrap.AddListenerUpdatePrecheck(args =>
+ {
+ order.Add("precheck");
+ Assert.Equal("2.0.0", args.PackageInfo.Version);
+ Assert.Equal("Latest release", args.PackageInfo.Description);
+ Assert.Equal("1.0.0", args.CurrentVersion);
+ Assert.True(args.Result.UpdateFound);
+ return skip;
+ });
+ bootstrap.AddListenerValidate += (_, _) => order.Add("validate");
+
+ var result = await bootstrap.ValidateAsync("1.0.0");
+
+ Assert.True(result.Success);
+ Assert.Equal(expectedUpdate, result.UpdateFound);
+ Assert.Equal(expectedUpdate ? UpdateState.UpdateAvailable : UpdateState.Completed, result.State);
+ Assert.Equal(result.State, bootstrap.GetSnapshot().State);
+ Assert.NotNull(result.PackageInfo);
+ Assert.Equal(forced ? new[] { "request", "validate" }
+ : skip ? new[] { "request", "precheck" }
+ : new[] { "request", "precheck", "validate" }, order);
+ }
+
+ [Theory]
+ [InlineData("{\"code\":200,\"body\":[]}", HttpStatusCode.OK, UpdateFailureReason.None)]
+ [InlineData("{\"code\":500,\"body\":[]}", HttpStatusCode.OK, UpdateFailureReason.InvalidMetadata)]
+ [InlineData("{", HttpStatusCode.OK, UpdateFailureReason.InvalidMetadata)]
+ [InlineData("{}", HttpStatusCode.Unauthorized, UpdateFailureReason.NetworkError)]
+ public async Task ValidateAsync_QueryResults_UpdateStateWithoutInvokingPrecheck(
+ string json, HttpStatusCode status, UpdateFailureReason failureReason)
+ {
+ using var http = CreateHttp(json, status);
+ using var bootstrap = CreateBootstrap(http);
+ var failed = 0;
+ bootstrap.AddListenerUpdatePrecheck(_ => throw new InvalidOperationException("Unexpected precheck."));
+ bootstrap.AddListenerValidate += (_, _) => throw new InvalidOperationException("Unexpected validation event.");
+ bootstrap.AddListenerUpdateFailed += (_, _) => failed++;
+
+ var result = await bootstrap.ValidateAsync("1.0.0");
+
+ Assert.Equal(failureReason == UpdateFailureReason.None, result.Success);
+ Assert.False(result.UpdateFound);
+ Assert.Equal(failureReason, result.FailureReason);
+ Assert.Equal(result.State, bootstrap.GetSnapshot().State);
+ Assert.Equal(result.Success ? 0 : 1, failed);
+ }
+
+ [Fact]
+ public async Task ValidateAsync_CurrentPackage_DoesNotInvokePrecheck()
+ {
+ using var http = CreateHttp($$"""
+ {"code":200,"body":[{"version":"1.0.0","url":"https://example.com/app.apk","hash":"{{Hash}}"}]}
+ """);
+ using var bootstrap = CreateBootstrap(http);
+ bootstrap.AddListenerUpdatePrecheck(_ => throw new InvalidOperationException("Unexpected precheck."));
+
+ var result = await bootstrap.ValidateAsync("1.0.0");
+
+ Assert.True(result.Success);
+ Assert.False(result.UpdateFound);
+ Assert.Equal(UpdateState.Completed, result.State);
+ }
+
+ [Fact]
+ public async Task ValidateAsync_CanceledRequest_ReportsCancellationAndReleasesGate()
+ {
+ using var cts = new CancellationTokenSource();
+ using var http = new HttpClient(new TestHandler(async (_, ct) =>
+ {
+ cts.Cancel();
+ await Task.Delay(Timeout.Infinite, ct);
+ return JsonResponse("null");
+ }));
+ using var bootstrap = CreateBootstrap(http);
+ var failed = 0;
+ bootstrap.AddListenerUpdateFailed += (_, _) => failed++;
+
+ var result = await bootstrap.ValidateAsync("1.0.0", cts.Token);
+
+ Assert.Equal(UpdateState.Canceled, result.State);
+ Assert.Equal(UpdateFailureReason.Canceled, result.FailureReason);
+ Assert.Equal(1, failed);
+ var manual = await bootstrap.ValidateAsync(new UpdatePackageInfo
+ {
+ Version = "1.0.0", DownloadUrl = "https://example.com/app.apk", Sha256 = Hash
+ }, "1.0.0").WaitAsync(TimeSpan.FromSeconds(3));
+ Assert.True(manual.Success);
+ }
+
+ [Fact]
+ public async Task ValidateAsync_MissingServer_ReportsConfigurationFailure()
+ {
+ using var bootstrap = new AndroidBootstrap(new SystemVersionComparer(), new UnexpectedDownloader(),
+ new Sha256HashValidator(), new RecordingInstaller(), new PhysicalFileStorage());
+
+ var result = await bootstrap.ValidateAsync("1.0.0");
+
+ Assert.False(result.Success);
+ Assert.Equal(UpdateFailureReason.InvalidMetadata, result.FailureReason);
+ Assert.Equal(UpdateState.Failed, bootstrap.GetSnapshot().State);
+ }
+
+ [Fact]
+ public async Task ValidateAsync_ConcurrentChecks_AreSerializedIncludingRequests()
+ {
+ var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var finish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var calls = 0;
+ using var http = new HttpClient(new TestHandler(async (_, ct) =>
+ {
+ Interlocked.Increment(ref calls);
+ started.TrySetResult();
+ await finish.Task.WaitAsync(ct);
+ return JsonResponse("{\"code\":200,\"body\":[]}");
+ }));
+ using var bootstrap = CreateBootstrap(http);
+ var first = bootstrap.ValidateAsync("1.0.0");
+ await started.Task.WaitAsync(TimeSpan.FromSeconds(3));
+ var second = bootstrap.ValidateAsync("1.0.0");
+ Assert.False(second.IsCompleted);
+ Assert.Equal(1, calls);
+ finish.SetResult();
+ await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(3));
+ Assert.Equal(2, calls);
+ }
+
+ [Fact]
+ public async Task Dispose_DoesNotDisposeInjectedHttpClient()
+ {
+ using var http = CreateHttp("{\"code\":200,\"body\":[]}");
+ var bootstrap = CreateBootstrap(http);
+ bootstrap.Dispose();
+
+ using var response = await http.GetAsync(Endpoint);
+ Assert.True(response.IsSuccessStatusCode);
+ await Assert.ThrowsAsync(() => bootstrap.ValidateAsync("1.0.0"));
+ }
+
+ [Fact]
+ public async Task ValidateAsync_HttpOptions_AuthenticatesInternalRequestToServer()
+ {
+ using var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var address = (IPEndPoint)listener.LocalEndpoint;
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ var token = Guid.NewGuid().ToString("N");
+ using var bootstrap = new AndroidBootstrap(new SystemVersionComparer(), new UnexpectedDownloader(),
+ new Sha256HashValidator(), new RecordingInstaller(), new PhysicalFileStorage(),
+ updateServer: new UpdateServerOptions
+ {
+ RequestUrl = $"http://127.0.0.1:{address.Port}/Upgrade/Verification",
+ Platform = 42
+ },
+ httpOptions: new HttpDownloadOptions
+ {
+ AuthProvider = new BearerTokenAuthProvider(token),
+ RequestTimeout = TimeSpan.FromSeconds(5)
+ });
+
+ var checkTask = bootstrap.ValidateAsync("1.2.3", timeout.Token);
+ using var connection = await listener.AcceptTcpClientAsync(timeout.Token);
+ await using var stream = connection.GetStream();
+ using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
+ Assert.Equal("POST /Upgrade/Verification HTTP/1.1", await reader.ReadLineAsync(timeout.Token));
+ var headers = new List();
+ while (await reader.ReadLineAsync(timeout.Token) is { Length: > 0 } header)
+ {
+ headers.Add(header);
+ }
+ var authorization = System.Net.Http.Headers.AuthenticationHeaderValue.Parse(
+ headers.Single(h => h.StartsWith("Authorization:", StringComparison.OrdinalIgnoreCase))
+ .Split(':', 2)[1].Trim());
+ Assert.Equal("Bearer", authorization.Scheme);
+ Assert.Equal(token, authorization.Parameter);
+ Assert.Contains("Transfer-Encoding: chunked", headers);
+ var requestBody = new StringBuilder();
+ while (true)
+ {
+ var chunkLength = Convert.ToInt32(await reader.ReadLineAsync(timeout.Token), 16);
+ if (chunkLength == 0)
+ {
+ break;
+ }
+ var chunk = new char[chunkLength];
+ Assert.Equal(chunkLength, await reader.ReadBlockAsync(chunk, timeout.Token));
+ requestBody.Append(chunk);
+ Assert.Equal(string.Empty, await reader.ReadLineAsync(timeout.Token));
+ }
+ using var json = JsonDocument.Parse(requestBody.ToString());
+ Assert.Equal("1.2.3", json.RootElement.GetProperty("version").GetString());
+ Assert.Equal(42, json.RootElement.GetProperty("platform").GetInt32());
+
+ const string body = "{\"code\":200,\"body\":[]}";
+ var response = Encoding.ASCII.GetBytes(
+ $"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {body.Length}\r\nConnection: close\r\n\r\n{body}");
+ await stream.WriteAsync(response, timeout.Token);
+ await stream.FlushAsync(timeout.Token);
+
+ var result = await checkTask;
+ Assert.True(result.Success);
+ Assert.False(result.UpdateFound);
+ }
+
+ [Fact]
+ public async Task ValidateAsync_RequestTimeout_IsNetworkFailureNotUserCancellation()
+ {
+ using var http = new HttpClient(new TestHandler((_, _) =>
+ throw new TaskCanceledException("Simulated request timeout.")));
+ using var bootstrap = CreateBootstrap(http);
+
+ var result = await bootstrap.ValidateAsync("1.0.0");
+
+ Assert.False(result.Success);
+ Assert.Equal(UpdateState.Failed, result.State);
+ Assert.Equal(UpdateFailureReason.NetworkError, result.FailureReason);
+ }
+
+ private static AndroidBootstrap CreateBootstrap(HttpClient http) =>
+ new(new SystemVersionComparer(), new UnexpectedDownloader(), new Sha256HashValidator(),
+ new RecordingInstaller(), new PhysicalFileStorage(),
+ updateServer: new UpdateServerOptions
+ {
+ RequestUrl = Endpoint,
+ AppKey = VerificationRequest.AppKey,
+ AppType = VerificationRequest.AppType,
+ Platform = VerificationRequest.Platform,
+ ProductId = VerificationRequest.ProductId
+ }, httpClient: http);
+
+ private sealed class UnexpectedDownloader : IUpdateDownloader
+ {
+ public Task DownloadAsync(UpdatePackageInfo packageInfo,
+ Action? progressCallback, CancellationToken cancellationToken = default) =>
+ throw new InvalidOperationException("Validation must not download a package.");
+ }
+
[Fact]
public async Task GetPackageInfoAsync_Get_MapsMetadataAndAppliesAuthentication()
{
@@ -212,18 +470,29 @@ public async Task DiscoveredPackage_CompletesDownloadVerificationAndInstallerHan
using var downloader = new HttpResumableApkDownloader(http, storage,
new AndroidUpdateOptions { DownloadDirectoryPath = directory });
using var bootstrap = new AndroidBootstrap(new SystemVersionComparer(), downloader,
- new Sha256HashValidator(), installer, storage);
+ new Sha256HashValidator(), installer, storage,
+ updateServer: new UpdateServerOptions
+ {
+ RequestUrl = "https://example.com/metadata",
+ UseJsonEndpoint = true
+ }, httpClient: http);
var validationRaised = false;
bootstrap.AddListenerValidate += (_, _) => validationRaised = true;
-
- var package = await new HttpUpdatePackageClient(http).GetPackageInfoAsync("https://example.com/metadata");
- Assert.NotNull(package);
- Assert.False(validationRaised);
+ UpdatePackageInfo? precheckPackage = null;
+ bootstrap.AddListenerUpdatePrecheck(args =>
+ {
+ Assert.False(validationRaised);
+ precheckPackage = args.PackageInfo;
+ return false;
+ });
Assert.Equal(UpdateState.None, bootstrap.GetSnapshot().State);
- var check = await bootstrap.ValidateAsync(package, "1.0.0");
+ var check = await bootstrap.ValidateAsync("1.0.0");
Assert.True(check.UpdateFound);
Assert.True(validationRaised);
+ var package = check.PackageInfo;
+ Assert.NotNull(package);
+ Assert.Same(package, precheckPackage);
var prepared = await bootstrap.DownloadAndVerifyAsync(package);
Assert.True(prepared.Success, prepared.Message);
Assert.Equal(UpdateState.ReadyToInstall, prepared.State);