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);