From 2ed4ec1514c2be3de2544564d8d3fa15dcfabf5f Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Sat, 13 Jun 2026 19:06:20 +0800 Subject: [PATCH 1/2] feat: add HTTP/HTTPS compatibility and multiple authentication support Add authentication providers (Bearer, Basic, ApiKey, HMAC), SSL validation policies, retry with exponential backoff, download timeout, and proxy support. All new APIs are backward-compatible with existing code. New: - IHttpAuthProvider / ISslValidationPolicy interfaces - AuthScheme enum (Hmac/Bearer/ApiKey/Basic) - 5 auth providers + HttpAuthProviderFactory - StrictSslValidationPolicy / AllowAllSslValidationPolicy - HttpDownloadOptions configuration record - Per-package auth fields on UpdatePackageInfo - Auth injection, retry, and timeout in HttpResumableApkDownloader Closes #14 Co-Authored-By: Claude --- .../Abstractions/IHttpAuthProvider.cs | 11 + .../Abstractions/ISslValidationPolicy.cs | 16 ++ .../Enums/AuthScheme.cs | 28 +++ .../GeneralUpdateBootstrap.cs | 20 +- .../Models/HttpDownloadOptions.cs | 95 +++++++ .../Models/UpdatePackageInfo.cs | 30 +++ .../Services/AndroidBootstrap.cs | 6 + .../Services/AuthProviders.cs | 231 ++++++++++++++++++ .../Services/HttpResumableApkDownloader.cs | 136 ++++++++++- .../Services/SslValidationPolicies.cs | 33 +++ ...eneralUpdate.Avalonia.Android.Tests.csproj | 1 + 11 files changed, 594 insertions(+), 13 deletions(-) create mode 100644 src/GeneralUpdate.Avalonia.Android/Abstractions/IHttpAuthProvider.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Abstractions/ISslValidationPolicy.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Enums/AuthScheme.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Models/HttpDownloadOptions.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Services/AuthProviders.cs create mode 100644 src/GeneralUpdate.Avalonia.Android/Services/SslValidationPolicies.cs diff --git a/src/GeneralUpdate.Avalonia.Android/Abstractions/IHttpAuthProvider.cs b/src/GeneralUpdate.Avalonia.Android/Abstractions/IHttpAuthProvider.cs new file mode 100644 index 0000000..07e1240 --- /dev/null +++ b/src/GeneralUpdate.Avalonia.Android/Abstractions/IHttpAuthProvider.cs @@ -0,0 +1,11 @@ +namespace GeneralUpdate.Avalonia.Android.Abstractions; + +/// +/// Provides authentication for HTTP requests. +/// Implementations can add headers, modify the request, or perform +/// any other authentication flow before the request is sent. +/// +public interface IHttpAuthProvider +{ + Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default); +} diff --git a/src/GeneralUpdate.Avalonia.Android/Abstractions/ISslValidationPolicy.cs b/src/GeneralUpdate.Avalonia.Android/Abstractions/ISslValidationPolicy.cs new file mode 100644 index 0000000..1ce5ea8 --- /dev/null +++ b/src/GeneralUpdate.Avalonia.Android/Abstractions/ISslValidationPolicy.cs @@ -0,0 +1,16 @@ +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; + +namespace GeneralUpdate.Avalonia.Android.Abstractions; + +/// +/// Provides custom SSL/TLS certificate validation logic. +/// Used to configure . +/// +public interface ISslValidationPolicy +{ + bool ValidateCertificate( + X509Certificate2? certificate, + X509Chain? chain, + SslPolicyErrors sslPolicyErrors); +} diff --git a/src/GeneralUpdate.Avalonia.Android/Enums/AuthScheme.cs b/src/GeneralUpdate.Avalonia.Android/Enums/AuthScheme.cs new file mode 100644 index 0000000..b0594a0 --- /dev/null +++ b/src/GeneralUpdate.Avalonia.Android/Enums/AuthScheme.cs @@ -0,0 +1,28 @@ +namespace GeneralUpdate.Avalonia.Android.Enums; + +/// +/// Defines the supported HTTP authentication schemes for update downloads. +/// +public enum AuthScheme +{ + /// + /// HMAC-SHA256 signature-based authentication. + /// Adds X-Update-Timestamp and X-Update-Signature headers. + /// + Hmac = 0, + + /// + /// Bearer token authentication via Authorization header. + /// + Bearer = 1, + + /// + /// API key authentication via a custom header (default: X-Api-Key). + /// + ApiKey = 2, + + /// + /// HTTP Basic authentication via Authorization header. + /// + Basic = 3 +} diff --git a/src/GeneralUpdate.Avalonia.Android/GeneralUpdateBootstrap.cs b/src/GeneralUpdate.Avalonia.Android/GeneralUpdateBootstrap.cs index 3727375..94fca60 100644 --- a/src/GeneralUpdate.Avalonia.Android/GeneralUpdateBootstrap.cs +++ b/src/GeneralUpdate.Avalonia.Android/GeneralUpdateBootstrap.cs @@ -14,7 +14,8 @@ public static IAndroidBootstrap CreateDefault( HttpClient? httpClient = null, IVersionComparer? versionComparer = null, IUpdateEventDispatcher? eventDispatcher = null, - IUpdateLogger? logger = null) + IUpdateLogger? logger = null, + HttpDownloadOptions? httpOptions = null) { var usedContextProvider = contextProvider ?? new DefaultAndroidContextProvider(); var context = usedContextProvider.GetContext(); @@ -32,9 +33,22 @@ public static IAndroidBootstrap CreateDefault( var effectiveOptions = options with { DownloadDirectoryPath = effectiveDownloadDirectory }; var usedLogger = logger ?? new NoOpUpdateLogger(); var usedStorage = new PhysicalFileStorage(); - var usedClient = httpClient ?? new HttpClient(); - var downloader = new HttpResumableApkDownloader(usedClient, usedStorage, effectiveOptions, usedLogger); + HttpResumableApkDownloader downloader; + if (httpOptions != null) + { + // Use internal constructor that builds HttpClient from HttpDownloadOptions + // (SSL validation, proxy, auth, timeouts) + downloader = new HttpResumableApkDownloader( + usedStorage, effectiveOptions, httpOptions, usedLogger); + } + else + { + // Legacy path: use injected httpClient or a bare new one + var usedClient = httpClient ?? new HttpClient(); + downloader = new HttpResumableApkDownloader( + usedClient, usedStorage, effectiveOptions, usedLogger); + } var validator = new Sha256HashValidator(); var installer = new AndroidApkInstaller( usedContextProvider, diff --git a/src/GeneralUpdate.Avalonia.Android/Models/HttpDownloadOptions.cs b/src/GeneralUpdate.Avalonia.Android/Models/HttpDownloadOptions.cs new file mode 100644 index 0000000..b9383f6 --- /dev/null +++ b/src/GeneralUpdate.Avalonia.Android/Models/HttpDownloadOptions.cs @@ -0,0 +1,95 @@ +using System.Net; +using GeneralUpdate.Avalonia.Android.Abstractions; + +namespace GeneralUpdate.Avalonia.Android.Models; + +/// +/// Configures HTTP transport behavior for update downloads: +/// SSL/TLS certificate validation, timeouts, proxy, retry, and authentication. +/// +/// When provided to , +/// the library constructs an internal from these settings. +/// When null, the existing behavior is preserved (bare HttpClient, no auth, system SSL). +/// +/// +public sealed record HttpDownloadOptions +{ + /// + /// Custom SSL/TLS certificate validation policy. + /// Defaults to null, which uses the system's default certificate validation. + /// Set to for self-signed certificates + /// in development environments only. + /// + public ISslValidationPolicy? SslValidationPolicy { get; init; } + + /// + /// Timeout for individual HTTP requests (HEAD probes, etc.). + /// Default is 30 seconds. + /// + public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(30); + + /// + /// Overall timeout for the entire download operation. + /// Default is 10 minutes. + /// + public TimeSpan DownloadTimeout { get; init; } = TimeSpan.FromMinutes(10); + + /// + /// Optional web proxy for HTTP requests. + /// When set, must also be true for the proxy to take effect. + /// + public IWebProxy? Proxy { get; init; } + + /// + /// Whether to use the configured . + /// Default is false. + /// + public bool UseProxy { get; init; } + + /// + /// Maximum number of retry attempts for transient failures. + /// Default is 3 (meaning 1 initial attempt + 2 retries). + /// Set to 1 to disable retry. + /// + public int MaxRetryAttempts { get; init; } = 3; + + /// + /// Base delay for exponential backoff retry. + /// Actual delays are: baseDelay * 2^attempt. + /// Default is 1 second. + /// + public TimeSpan RetryBaseDelay { get; init; } = TimeSpan.FromSeconds(1); + + /// + /// Global authentication provider applied to all download requests. + /// Per-package authentication on takes precedence. + /// + public IHttpAuthProvider? AuthProvider { get; init; } + + /// + /// Builds an from the configured options. + /// Applies SSL validation policy and proxy settings. + /// + internal HttpClientHandler BuildHandler() + { + var handler = new HttpClientHandler(); + + if (SslValidationPolicy != null) + { + handler.ServerCertificateCustomValidationCallback = + (_, cert, chain, errors) => SslValidationPolicy.ValidateCertificate(cert, chain, errors); + } + + if (UseProxy && Proxy != null) + { + handler.Proxy = Proxy; + handler.UseProxy = true; + } + else + { + handler.UseProxy = false; + } + + return handler; + } +} diff --git a/src/GeneralUpdate.Avalonia.Android/Models/UpdatePackageInfo.cs b/src/GeneralUpdate.Avalonia.Android/Models/UpdatePackageInfo.cs index 6e1abe5..ecb56bc 100644 --- a/src/GeneralUpdate.Avalonia.Android/Models/UpdatePackageInfo.cs +++ b/src/GeneralUpdate.Avalonia.Android/Models/UpdatePackageInfo.cs @@ -1,3 +1,5 @@ +using GeneralUpdate.Avalonia.Android.Enums; + namespace GeneralUpdate.Avalonia.Android.Models; public sealed record UpdatePackageInfo @@ -11,4 +13,32 @@ public sealed record UpdatePackageInfo public DateTimeOffset? PublishTime { get; init; } public bool IsForced { get; init; } public string? FileName { get; init; } + + /// + /// Per-package authentication scheme. + /// When set, takes precedence over the global . + /// + public Enums.AuthScheme? AuthScheme { get; init; } + + /// + /// Token value used by Bearer or ApiKey authentication. + /// For Bearer: the Bearer token string. + /// For ApiKey: the API key value. + /// + public string? AuthToken { get; init; } + + /// + /// Secret key used by HMAC-SHA256 signature authentication. + /// + public string? AuthSecretKey { get; init; } + + /// + /// Username used by Basic authentication. + /// + public string? BasicUsername { get; init; } + + /// + /// Password used by Basic authentication. + /// + public string? BasicPassword { get; init; } } diff --git a/src/GeneralUpdate.Avalonia.Android/Services/AndroidBootstrap.cs b/src/GeneralUpdate.Avalonia.Android/Services/AndroidBootstrap.cs index ca61425..aff7a1c 100644 --- a/src/GeneralUpdate.Avalonia.Android/Services/AndroidBootstrap.cs +++ b/src/GeneralUpdate.Avalonia.Android/Services/AndroidBootstrap.cs @@ -282,6 +282,12 @@ public void Dispose() } _operationGate.Dispose(); + + if (_downloader is IDisposable disposableDownloader) + { + disposableDownloader.Dispose(); + } + _disposed = true; } diff --git a/src/GeneralUpdate.Avalonia.Android/Services/AuthProviders.cs b/src/GeneralUpdate.Avalonia.Android/Services/AuthProviders.cs new file mode 100644 index 0000000..f78389f --- /dev/null +++ b/src/GeneralUpdate.Avalonia.Android/Services/AuthProviders.cs @@ -0,0 +1,231 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using GeneralUpdate.Avalonia.Android.Abstractions; +using GeneralUpdate.Avalonia.Android.Enums; + +namespace GeneralUpdate.Avalonia.Android.Services; + +/// +/// No-op authentication provider. Does not modify the request. +/// Used as the default when no authentication is configured. +/// +public sealed class NoOpAuthProvider : IHttpAuthProvider +{ + public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + => Task.CompletedTask; +} + +/// +/// Bearer token authentication provider. +/// Adds Authorization: Bearer <token> header to the request. +/// +public sealed class BearerTokenAuthProvider : IHttpAuthProvider +{ + private readonly string _token; + + public BearerTokenAuthProvider(string token) + { +#if NET6_0_OR_GREATER + ArgumentException.ThrowIfNullOrWhiteSpace(token); +#else + if (string.IsNullOrWhiteSpace(token)) + throw new ArgumentException("Token cannot be null or whitespace.", nameof(token)); +#endif + _token = token; + } + + public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + return Task.CompletedTask; + } +} + +/// +/// API key authentication provider. +/// Adds a custom header (default: X-Api-Key) with the API key value. +/// +public sealed class ApiKeyAuthProvider : IHttpAuthProvider +{ + private readonly string _apiKey; + private readonly string _headerName; + + public ApiKeyAuthProvider(string apiKey, string headerName = "X-Api-Key") + { +#if NET6_0_OR_GREATER + ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); + ArgumentException.ThrowIfNullOrWhiteSpace(headerName); +#else + if (string.IsNullOrWhiteSpace(apiKey)) + throw new ArgumentException("API key cannot be null or whitespace.", nameof(apiKey)); + if (string.IsNullOrWhiteSpace(headerName)) + throw new ArgumentException("Header name cannot be null or whitespace.", nameof(headerName)); +#endif + _apiKey = apiKey; + _headerName = headerName; + } + + public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + { + request.Headers.Remove(_headerName); + request.Headers.Add(_headerName, _apiKey); + return Task.CompletedTask; + } +} + +/// +/// HTTP Basic authentication provider. +/// Adds Authorization: Basic <base64> header to the request. +/// +public sealed class BasicAuthProvider : IHttpAuthProvider +{ + private readonly string _credential; + + public BasicAuthProvider(string credential) + { +#if NET6_0_OR_GREATER + ArgumentException.ThrowIfNullOrWhiteSpace(credential); +#else + if (string.IsNullOrWhiteSpace(credential)) + throw new ArgumentException("Credential cannot be null or whitespace.", nameof(credential)); +#endif + _credential = credential; + } + + /// + /// Creates a Basic authentication header value from username and password. + /// + public static string EncodeCredential(string username, string password) + { +#if NET6_0_OR_GREATER + ArgumentException.ThrowIfNullOrWhiteSpace(username); +#else + if (string.IsNullOrWhiteSpace(username)) + throw new ArgumentException("Username cannot be null or whitespace.", nameof(username)); +#endif + return Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password ?? string.Empty}")); + } + + /// + /// Creates a BasicAuthProvider from username and password credentials. + /// + public static BasicAuthProvider FromCredentials(string username, string password) + => new BasicAuthProvider(EncodeCredential(username, password)); + + public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", _credential); + return Task.CompletedTask; + } +} + +/// +/// HMAC-SHA256 signature authentication provider. +/// Adds X-Update-Timestamp and X-Update-Signature headers. +/// The signature is computed as HMACSHA256 over body|timestamp using the secret key. +/// +public sealed class HmacAuthProvider : IHttpAuthProvider +{ + private readonly string _secretKey; + + public HmacAuthProvider(string secretKey) + { +#if NET6_0_OR_GREATER + ArgumentException.ThrowIfNullOrWhiteSpace(secretKey); +#else + if (string.IsNullOrWhiteSpace(secretKey)) + throw new ArgumentException("Secret key cannot be null or whitespace.", nameof(secretKey)); +#endif + _secretKey = secretKey; + } + + public async Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + var body = string.Empty; + + if (request.Content != null) + { + body = await request.Content.ReadAsStringAsync(token).ConfigureAwait(false); + } + + var message = $"{body}|{timestamp}"; + var keyBytes = Encoding.UTF8.GetBytes(_secretKey); + +#if NET6_0_OR_GREATER + var hash = HMACSHA256.HashData(keyBytes, Encoding.UTF8.GetBytes(message)); +#else + using var hmac = new HMACSHA256(keyBytes); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message)); +#endif + var signature = Convert.ToHexString(hash).ToLowerInvariant(); + + request.Headers.Remove("X-Update-Timestamp"); + request.Headers.Remove("X-Update-Signature"); + request.Headers.Add("X-Update-Timestamp", timestamp); + request.Headers.Add("X-Update-Signature", signature); + } +} + +/// +/// Factory for creating instances +/// based on the specified authentication scheme and credentials. +/// +public static class HttpAuthProviderFactory +{ + /// + /// Creates an from an with the given credentials. + /// Returns when scheme is null or unrecognized. + /// + public static IHttpAuthProvider Create( + AuthScheme? scheme, + string? token = null, + string? secretKey = null, + string? basicUsername = null, + string? basicPassword = null) + { + return scheme switch + { + AuthScheme.Bearer when !string.IsNullOrWhiteSpace(token) + => new BearerTokenAuthProvider(token), + AuthScheme.ApiKey when !string.IsNullOrWhiteSpace(token) + => new ApiKeyAuthProvider(token), + AuthScheme.Basic when !string.IsNullOrWhiteSpace(basicUsername) + => BasicAuthProvider.FromCredentials(basicUsername, basicPassword ?? string.Empty), + AuthScheme.Hmac when !string.IsNullOrWhiteSpace(secretKey) + => new HmacAuthProvider(secretKey), + _ => new NoOpAuthProvider() + }; + } + + /// + /// Creates an from a string-based scheme identifier + /// with the given credentials. Supports "bearer", "apikey", "basic", "hmac" (case-insensitive). + /// Returns when scheme is null or unrecognized. + /// + public static IHttpAuthProvider Create( + string? scheme, + string? token = null, + string? secretKey = null, + string? basicUsername = null, + string? basicPassword = null) + { + if (string.IsNullOrWhiteSpace(scheme)) + return new NoOpAuthProvider(); + + return scheme.ToLowerInvariant() switch + { + "bearer" when !string.IsNullOrWhiteSpace(token) + => new BearerTokenAuthProvider(token), + "apikey" when !string.IsNullOrWhiteSpace(token) + => new ApiKeyAuthProvider(token), + "basic" when !string.IsNullOrWhiteSpace(basicUsername) + => BasicAuthProvider.FromCredentials(basicUsername, basicPassword ?? string.Empty), + "hmac" when !string.IsNullOrWhiteSpace(secretKey) + => new HmacAuthProvider(secretKey), + _ => new NoOpAuthProvider() + }; + } +} diff --git a/src/GeneralUpdate.Avalonia.Android/Services/HttpResumableApkDownloader.cs b/src/GeneralUpdate.Avalonia.Android/Services/HttpResumableApkDownloader.cs index 159ab5c..84674a8 100644 --- a/src/GeneralUpdate.Avalonia.Android/Services/HttpResumableApkDownloader.cs +++ b/src/GeneralUpdate.Avalonia.Android/Services/HttpResumableApkDownloader.cs @@ -3,11 +3,12 @@ using System.Diagnostics; using System.Text.Json; using GeneralUpdate.Avalonia.Android.Abstractions; +using GeneralUpdate.Avalonia.Android.Enums; using GeneralUpdate.Avalonia.Android.Models; namespace GeneralUpdate.Avalonia.Android.Services; -public sealed class HttpResumableApkDownloader : IUpdateDownloader +public sealed class HttpResumableApkDownloader : IUpdateDownloader, IDisposable { private static readonly HashSet InvalidFileNameChars = Path.GetInvalidFileNameChars().ToHashSet(); @@ -17,12 +18,39 @@ public sealed class HttpResumableApkDownloader : IUpdateDownloader private readonly IUpdateLogger _logger; private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true }; + private readonly HttpDownloadOptions? _httpOptions; + private readonly IHttpAuthProvider? _globalAuthProvider; + private readonly bool _ownsClient; + + /// + /// Creates a downloader with an externally-provided HttpClient. + /// No authentication or custom HTTP options are applied. + /// public HttpResumableApkDownloader(HttpClient httpClient, IFileStorage fileStorage, AndroidUpdateOptions options, IUpdateLogger? logger = null) { - _httpClient = httpClient; - _fileStorage = fileStorage; - _options = options; + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _fileStorage = fileStorage ?? throw new ArgumentNullException(nameof(fileStorage)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? new NoOpUpdateLogger(); + _httpOptions = null; + _globalAuthProvider = null; + } + + /// + /// Creates a downloader with HTTP options that configure SSL, proxy, auth, and timeouts. + /// The HttpClient is constructed internally from the provided options. + /// + internal HttpResumableApkDownloader(IFileStorage fileStorage, AndroidUpdateOptions options, HttpDownloadOptions httpOptions, IUpdateLogger? logger = null) + { + _fileStorage = fileStorage ?? throw new ArgumentNullException(nameof(fileStorage)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _httpOptions = httpOptions ?? throw new ArgumentNullException(nameof(httpOptions)); _logger = logger ?? new NoOpUpdateLogger(); + + var handler = httpOptions.BuildHandler(); + _httpClient = new HttpClient(handler, disposeHandler: true); + _globalAuthProvider = httpOptions.AuthProvider; + _ownsClient = true; } public async Task DownloadAsync(UpdatePackageInfo packageInfo, Action? progressCallback, CancellationToken cancellationToken = default) @@ -47,7 +75,18 @@ public async Task DownloadAsync(UpdatePackageInfo packageInfo, A var tempFilePath = finalFilePath + _options.TemporaryFileExtension; var sidecarPath = tempFilePath + _options.SidecarExtension; - var remoteInfo = await GetRemoteInfoAsync(packageInfo.DownloadUrl, cancellationToken).ConfigureAwait(false); + // Resolve download timeout: use configured value or infinite + using var timeoutCts = _httpOptions != null + ? new CancellationTokenSource(_httpOptions.DownloadTimeout) + : null; + using var linkedCts = timeoutCts != null + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token) + : null; + var effectiveCt = linkedCts?.Token ?? cancellationToken; + + var remoteInfo = await WithRetryAsync( + ct => GetRemoteInfoAsync(packageInfo, ct), + effectiveCt).ConfigureAwait(false); var expectedMetadata = CreateMetadata(packageInfo, finalName, remoteInfo); var canResume = await EnsureResumeConsistencyAsync(tempFilePath, sidecarPath, expectedMetadata, cancellationToken).ConfigureAwait(false); @@ -65,7 +104,9 @@ public async Task DownloadAsync(UpdatePackageInfo packageInfo, A request.Headers.Range = new RangeHeaderValue(existingLength, null); } - using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + await ApplyAuthAsync(request, packageInfo, effectiveCt).ConfigureAwait(false); + + using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, effectiveCt).ConfigureAwait(false); if (existingLength > 0 && response.StatusCode == HttpStatusCode.OK) { _logger.LogWarning("Server did not honor range request. Restarting download from zero."); @@ -84,7 +125,7 @@ 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(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]; @@ -95,7 +136,7 @@ public async Task DownloadAsync(UpdatePackageInfo packageInfo, A while (true) { - var read = await contentStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false); + var read = await contentStream.ReadAsync(buffer.AsMemory(0, buffer.Length), effectiveCt).ConfigureAwait(false); if (read <= 0) { break; @@ -177,9 +218,10 @@ public async Task DownloadAsync(UpdatePackageInfo packageInfo, A } } - private async Task<(string? ETag, string? LastModified, long? ContentLength, bool AcceptRanges)> GetRemoteInfoAsync(string downloadUrl, CancellationToken cancellationToken) + private async Task<(string? ETag, string? LastModified, long? ContentLength, bool AcceptRanges)> GetRemoteInfoAsync(UpdatePackageInfo packageInfo, CancellationToken cancellationToken) { - using var headRequest = new HttpRequestMessage(HttpMethod.Head, downloadUrl); + using var headRequest = new HttpRequestMessage(HttpMethod.Head, packageInfo.DownloadUrl); + await ApplyAuthAsync(headRequest, packageInfo, cancellationToken).ConfigureAwait(false); using var headResponse = await _httpClient.SendAsync(headRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); if (!headResponse.IsSuccessStatusCode) { @@ -194,6 +236,72 @@ public async Task DownloadAsync(UpdatePackageInfo packageInfo, A acceptRanges); } + private async Task ApplyAuthAsync(HttpRequestMessage request, UpdatePackageInfo packageInfo, CancellationToken cancellationToken) + { + IHttpAuthProvider? provider = null; + + // Per-package auth takes precedence + if (packageInfo.AuthScheme.HasValue) + { + provider = HttpAuthProviderFactory.Create( + packageInfo.AuthScheme.Value, + packageInfo.AuthToken, + packageInfo.AuthSecretKey, + packageInfo.BasicUsername, + packageInfo.BasicPassword); + } + else if (_globalAuthProvider != null) + { + provider = _globalAuthProvider; + } + + if (provider != null) + { + await provider.ApplyAuthAsync(request, cancellationToken).ConfigureAwait(false); + } + } + + private async Task WithRetryAsync(Func> action, CancellationToken cancellationToken) + { + if (_httpOptions == null || _httpOptions.MaxRetryAttempts <= 1) + { + return await action(cancellationToken).ConfigureAwait(false); + } + + var maxAttempts = _httpOptions.MaxRetryAttempts; + + for (var attempt = 0; ; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + return await action(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (attempt < maxAttempts - 1 && IsTransient(ex)) + { + var delay = TimeSpan.FromMilliseconds( + _httpOptions.RetryBaseDelay.TotalMilliseconds * Math.Pow(2, attempt)); + _logger.LogWarning($"Download attempt {attempt + 1} failed with transient error. Retrying in {delay.TotalMilliseconds}ms. {ex.GetType().Name}: {ex.Message}"); + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + } + } + + private static bool IsTransient(Exception ex) => ex switch + { + TimeoutException => true, + OperationCanceledException => false, + IOException ioe when ioe.InnerException is TimeoutException => true, + HttpRequestException hre => hre.StatusCode is + HttpStatusCode.RequestTimeout or + HttpStatusCode.InternalServerError or + HttpStatusCode.BadGateway or + HttpStatusCode.ServiceUnavailable or + HttpStatusCode.GatewayTimeout, + _ => false + }; + private async Task EnsureResumeConsistencyAsync( string tempFilePath, string sidecarPath, @@ -315,6 +423,14 @@ private static string ResolveFileName(UpdatePackageInfo packageInfo) return sanitized; } + public void Dispose() + { + if (_ownsClient) + { + _httpClient.Dispose(); + } + } + private sealed class SmoothedSpeedMeter { private readonly TimeSpan _window; diff --git a/src/GeneralUpdate.Avalonia.Android/Services/SslValidationPolicies.cs b/src/GeneralUpdate.Avalonia.Android/Services/SslValidationPolicies.cs new file mode 100644 index 0000000..97a76e3 --- /dev/null +++ b/src/GeneralUpdate.Avalonia.Android/Services/SslValidationPolicies.cs @@ -0,0 +1,33 @@ +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using GeneralUpdate.Avalonia.Android.Abstractions; + +namespace GeneralUpdate.Avalonia.Android.Services; + +/// +/// Strict SSL validation policy: only accepts certificates with no policy errors. +/// This is the default and recommended policy for production use. +/// +public sealed class StrictSslValidationPolicy : ISslValidationPolicy +{ + public bool ValidateCertificate( + X509Certificate2? certificate, + X509Chain? chain, + SslPolicyErrors sslPolicyErrors) + => sslPolicyErrors == SslPolicyErrors.None; +} + +/// +/// Permissive SSL validation policy: accepts all certificates regardless of errors. +/// WARNING: This bypasses certificate validation and should ONLY be used +/// in development/testing environments with self-signed certificates. +/// Never use this in production. +/// +public sealed class AllowAllSslValidationPolicy : ISslValidationPolicy +{ + public bool ValidateCertificate( + X509Certificate2? certificate, + X509Chain? chain, + SslPolicyErrors sslPolicyErrors) + => true; +} diff --git a/tests/GeneralUpdate.Avalonia.Android.Tests/GeneralUpdate.Avalonia.Android.Tests.csproj b/tests/GeneralUpdate.Avalonia.Android.Tests/GeneralUpdate.Avalonia.Android.Tests.csproj index 97bd23b..757c343 100644 --- a/tests/GeneralUpdate.Avalonia.Android.Tests/GeneralUpdate.Avalonia.Android.Tests.csproj +++ b/tests/GeneralUpdate.Avalonia.Android.Tests/GeneralUpdate.Avalonia.Android.Tests.csproj @@ -22,6 +22,7 @@ + From 12556038cde8870689271205aa91f31d636943f0 Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Sat, 13 Jun 2026 19:12:47 +0800 Subject: [PATCH 2/2] fix: address Copilot review comments - Set _ownsClient = false in public constructor (prevents disposing external client) - Set HttpClient.Timeout = InfiniteTimeSpan when created internally (timeout managed via CancellationTokenSource) - Fall back to global auth provider when per-package AuthScheme has missing credentials + log warning - Wire RequestTimeout into HEAD probe request (separate from DownloadTimeout) - Simplify Enums.AuthScheme? to AuthScheme? (using already imported) Co-Authored-By: Claude --- .../Models/UpdatePackageInfo.cs | 2 +- .../Services/HttpResumableApkDownloader.cs | 26 ++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/GeneralUpdate.Avalonia.Android/Models/UpdatePackageInfo.cs b/src/GeneralUpdate.Avalonia.Android/Models/UpdatePackageInfo.cs index ecb56bc..9b09253 100644 --- a/src/GeneralUpdate.Avalonia.Android/Models/UpdatePackageInfo.cs +++ b/src/GeneralUpdate.Avalonia.Android/Models/UpdatePackageInfo.cs @@ -18,7 +18,7 @@ public sealed record UpdatePackageInfo /// Per-package authentication scheme. /// When set, takes precedence over the global . /// - public Enums.AuthScheme? AuthScheme { get; init; } + public AuthScheme? AuthScheme { get; init; } /// /// Token value used by Bearer or ApiKey authentication. diff --git a/src/GeneralUpdate.Avalonia.Android/Services/HttpResumableApkDownloader.cs b/src/GeneralUpdate.Avalonia.Android/Services/HttpResumableApkDownloader.cs index 84674a8..ffa90d6 100644 --- a/src/GeneralUpdate.Avalonia.Android/Services/HttpResumableApkDownloader.cs +++ b/src/GeneralUpdate.Avalonia.Android/Services/HttpResumableApkDownloader.cs @@ -34,6 +34,7 @@ public HttpResumableApkDownloader(HttpClient httpClient, IFileStorage fileStorag _logger = logger ?? new NoOpUpdateLogger(); _httpOptions = null; _globalAuthProvider = null; + _ownsClient = false; } /// @@ -48,7 +49,11 @@ internal HttpResumableApkDownloader(IFileStorage fileStorage, AndroidUpdateOptio _logger = logger ?? new NoOpUpdateLogger(); var handler = httpOptions.BuildHandler(); - _httpClient = new HttpClient(handler, disposeHandler: true); + _httpClient = new HttpClient(handler, disposeHandler: true) + { + // Timeout is managed per-request via CancellationTokenSource linked to DownloadTimeout + Timeout = System.Threading.Timeout.InfiniteTimeSpan + }; _globalAuthProvider = httpOptions.AuthProvider; _ownsClient = true; } @@ -84,9 +89,18 @@ public async Task DownloadAsync(UpdatePackageInfo packageInfo, A : null; var effectiveCt = linkedCts?.Token ?? cancellationToken; + // Use RequestTimeout for the HEAD probe (quick server info check) + using var probeCts = _httpOptions != null + ? new CancellationTokenSource(_httpOptions.RequestTimeout) + : null; + using var probeLinkedCts = probeCts != null + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, probeCts.Token) + : null; + var probeCt = probeLinkedCts?.Token ?? cancellationToken; + var remoteInfo = await WithRetryAsync( ct => GetRemoteInfoAsync(packageInfo, ct), - effectiveCt).ConfigureAwait(false); + probeCt).ConfigureAwait(false); var expectedMetadata = CreateMetadata(packageInfo, finalName, remoteInfo); var canResume = await EnsureResumeConsistencyAsync(tempFilePath, sidecarPath, expectedMetadata, cancellationToken).ConfigureAwait(false); @@ -250,8 +264,14 @@ private async Task ApplyAuthAsync(HttpRequestMessage request, UpdatePackageInfo packageInfo.BasicUsername, packageInfo.BasicPassword); } - else if (_globalAuthProvider != null) + + // Fall back to global auth when per-package is not set or not configured + if ((provider is null || provider is NoOpAuthProvider) && _globalAuthProvider != null) { + if (packageInfo.AuthScheme.HasValue) + { + _logger.LogWarning($"AuthScheme '{packageInfo.AuthScheme}' is set but credentials are missing. Falling back to global auth provider."); + } provider = _globalAuthProvider; }