diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml
new file mode 100644
index 0000000..052552c
--- /dev/null
+++ b/.github/workflows/publish-nuget.yml
@@ -0,0 +1,103 @@
+name: Publish NuGet
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'NuGet package version (SemVer 2.0, e.g. 1.0.0, 1.0.0-beta.1)'
+ required: true
+ type: string
+ push-to-nuget:
+ description: 'Push package to NuGet.org'
+ required: false
+ type: boolean
+ default: true
+
+permissions:
+ contents: write
+ id-token: write
+
+jobs:
+ publish:
+ name: Build and Publish
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Validate SemVer 2.0
+ run: |
+ if ! echo "${{ inputs.version }}" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' > /dev/null; then
+ echo "::error::Invalid SemVer 2.0 version: '${{ inputs.version }}'"
+ exit 1
+ fi
+ echo "Version ${{ inputs.version }} is valid SemVer 2.0"
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: |
+ 8.0.x
+ 10.0.x
+
+ - name: Install Android workload
+ run: dotnet workload install android
+
+ - name: Restore
+ run: dotnet restore src/GeneralUpdate.Maui.sln
+
+ - name: Build
+ run: dotnet build src/GeneralUpdate.Maui.sln --configuration Release --no-restore /p:Version=${{ inputs.version }}
+
+ - name: Run tests
+ run: dotnet test src/GeneralUpdate.Maui.sln --configuration Release --no-restore --no-build /p:Version=${{ inputs.version }}
+
+ - name: Pack NuGet
+ run: dotnet pack src/GeneralUpdate.Maui.sln --configuration Release --no-restore --no-build -o artifacts /p:Version=${{ inputs.version }}
+
+ - name: List artifacts
+ run: ls -la artifacts/
+
+ - name: Create git tag
+ run: |
+ git config user.name "github-actions"
+ git config user.email "github-actions@github.com"
+ TAG="v${{ inputs.version }}"
+ if git rev-parse "$TAG" >/dev/null 2>&1; then
+ echo "::error::Tag $TAG already exists. Use a different version or delete the existing tag first."
+ exit 1
+ fi
+ git tag "$TAG"
+ git push --force-with-lease origin "$TAG"
+
+ - name: Create GitHub Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ TAG="v${{ inputs.version }}"
+ if gh release view "$TAG" --json tagName &>/dev/null; then
+ gh release delete "$TAG" --yes
+ fi
+ gh release create "$TAG" \
+ artifacts/*.nupkg \
+ --title "$TAG" \
+ --generate-notes \
+ --verify-tag
+
+ - name: NuGet login via Trusted Publishing (OIDC → temp API key)
+ if: ${{ inputs.push-to-nuget == true }}
+ id: nuget-login
+ uses: NuGet/login@v1
+ with:
+ user: juster.chu
+
+ - name: Push NuGet package to NuGet.org
+ if: ${{ inputs.push-to-nuget == true }}
+ run: |
+ dotnet nuget push artifacts/*.nupkg \
+ --source https://api.nuget.org/v3/index.json \
+ --api-key "${{ steps.nuget-login.outputs.NUGET_API_KEY }}" \
+ --skip-duplicate
diff --git a/src/GeneralUpdate.Maui.Android/Abstractions/IHttpAuthProvider.cs b/src/GeneralUpdate.Maui.Android/Abstractions/IHttpAuthProvider.cs
new file mode 100644
index 0000000..9f7ef05
--- /dev/null
+++ b/src/GeneralUpdate.Maui.Android/Abstractions/IHttpAuthProvider.cs
@@ -0,0 +1,11 @@
+namespace GeneralUpdate.Maui.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.Maui.Android/Abstractions/ISslValidationPolicy.cs b/src/GeneralUpdate.Maui.Android/Abstractions/ISslValidationPolicy.cs
new file mode 100644
index 0000000..0627c0a
--- /dev/null
+++ b/src/GeneralUpdate.Maui.Android/Abstractions/ISslValidationPolicy.cs
@@ -0,0 +1,16 @@
+using System.Net.Security;
+using System.Security.Cryptography.X509Certificates;
+
+namespace GeneralUpdate.Maui.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.Maui.Android/Enums/AuthScheme.cs b/src/GeneralUpdate.Maui.Android/Enums/AuthScheme.cs
new file mode 100644
index 0000000..e348bfd
--- /dev/null
+++ b/src/GeneralUpdate.Maui.Android/Enums/AuthScheme.cs
@@ -0,0 +1,28 @@
+namespace GeneralUpdate.Maui.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.Maui.Android/Models/HttpDownloadOptions.cs b/src/GeneralUpdate.Maui.Android/Models/HttpDownloadOptions.cs
new file mode 100644
index 0000000..01a5e81
--- /dev/null
+++ b/src/GeneralUpdate.Maui.Android/Models/HttpDownloadOptions.cs
@@ -0,0 +1,77 @@
+using System.Net;
+using GeneralUpdate.Maui.Android.Abstractions;
+
+namespace GeneralUpdate.Maui.Android.Models;
+
+///
+/// Configures HTTP transport behavior for update downloads:
+/// SSL/TLS certificate validation, proxy, timeouts, 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; }
+
+ ///
+ /// 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; }
+
+ ///
+ /// Global authentication provider applied to all download requests.
+ /// Per-package authentication on takes precedence.
+ /// When is explicitly set,
+ /// per-package credentials are used exclusively (no fallback to global).
+ ///
+ 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.Maui.Android/Models/UpdatePackageInfo.cs b/src/GeneralUpdate.Maui.Android/Models/UpdatePackageInfo.cs
index 96f9688..298662c 100644
--- a/src/GeneralUpdate.Maui.Android/Models/UpdatePackageInfo.cs
+++ b/src/GeneralUpdate.Maui.Android/Models/UpdatePackageInfo.cs
@@ -1,3 +1,5 @@
+using GeneralUpdate.Maui.Android.Enums;
+
namespace GeneralUpdate.Maui.Android.Models;
///
@@ -22,4 +24,32 @@ public sealed class UpdatePackageInfo
public bool ForceUpdate { get; init; }
public string? ApkFileName { get; init; }
+
+ ///
+ /// Per-package authentication scheme.
+ /// When set, takes precedence over the global .
+ ///
+ public 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.Maui.Android/Services/AndroidBootstrap.cs b/src/GeneralUpdate.Maui.Android/Services/AndroidBootstrap.cs
index f12d33b..aafe89c 100644
--- a/src/GeneralUpdate.Maui.Android/Services/AndroidBootstrap.cs
+++ b/src/GeneralUpdate.Maui.Android/Services/AndroidBootstrap.cs
@@ -8,7 +8,7 @@ namespace GeneralUpdate.Maui.Android.Services;
///
/// Orchestrates update discovery, package download, integrity validation, and APK installation triggering.
///
-public sealed class AndroidBootstrap : IAndroidBootstrap
+public sealed class AndroidBootstrap : IAndroidBootstrap, IDisposable
{
private const string UpdateInProgressMessage = "An update execution is already in progress.";
private readonly IUpdateDownloader _downloader;
@@ -222,4 +222,18 @@ private static UpdateFailureReason MapFailureReason(Exception ex)
_ => UpdateFailureReason.Unknown
};
}
+
+ private bool _disposed;
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+
+ if (_downloader is IDisposable disposableDownloader)
+ {
+ disposableDownloader.Dispose();
+ }
+
+ _disposed = true;
+ }
}
diff --git a/src/GeneralUpdate.Maui.Android/Services/AuthProviders.cs b/src/GeneralUpdate.Maui.Android/Services/AuthProviders.cs
new file mode 100644
index 0000000..61a48b0
--- /dev/null
+++ b/src/GeneralUpdate.Maui.Android/Services/AuthProviders.cs
@@ -0,0 +1,197 @@
+using System.Net.Http.Headers;
+using System.Security.Cryptography;
+using System.Text;
+using GeneralUpdate.Maui.Android.Abstractions;
+using GeneralUpdate.Maui.Android.Enums;
+
+namespace GeneralUpdate.Maui.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)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(token);
+ _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")
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(apiKey);
+ ArgumentException.ThrowIfNullOrWhiteSpace(headerName);
+ _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)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(credential);
+ _credential = credential;
+ }
+
+ ///
+ /// Creates a Basic authentication header value from username and password.
+ ///
+ public static string EncodeCredential(string username, string password)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(username);
+ 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(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)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(secretKey);
+ _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);
+ var hash = HMACSHA256.HashData(keyBytes, Encoding.UTF8.GetBytes(message));
+ 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.Maui.Android/Services/GeneralUpdateBootstrap.cs b/src/GeneralUpdate.Maui.Android/Services/GeneralUpdateBootstrap.cs
index 7241c76..1c5b3ec 100644
--- a/src/GeneralUpdate.Maui.Android/Services/GeneralUpdateBootstrap.cs
+++ b/src/GeneralUpdate.Maui.Android/Services/GeneralUpdateBootstrap.cs
@@ -1,4 +1,5 @@
using GeneralUpdate.Maui.Android.Abstractions;
+using GeneralUpdate.Maui.Android.Models;
using GeneralUpdate.Maui.Android.Platform.Android;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -33,11 +34,42 @@ public static IServiceCollection AddGeneralUpdateMauiAndroid(
return services;
}
- public static IAndroidBootstrap CreateDefault(HttpClient? httpClient = null, IUpdateLogger? logger = null)
+ ///
+ /// Creates a default with built-in service implementations.
+ ///
+ /// Optional external HttpClient. Ignored when is provided.
+ /// Optional logger.
+ ///
+ /// Optional HTTP configuration (SSL, proxy, auth, timeouts).
+ /// When set, an internal HttpClient is constructed from these options
+ /// and is not used.
+ ///
+ public static IAndroidBootstrap CreateDefault(
+ HttpClient? httpClient = null,
+ IUpdateLogger? logger = null,
+ HttpDownloadOptions? httpOptions = null)
{
- var client = httpClient ?? new HttpClient();
+ if (httpOptions != null)
+ {
+ // Build HttpClient from HttpDownloadOptions (SSL, proxy, auth, timeouts)
+ // Note: when httpOptions is provided, the httpClient parameter is NOT used.
+ var handler = httpOptions.BuildHandler();
+ var client = new HttpClient(handler, disposeHandler: true)
+ {
+ Timeout = System.Threading.Timeout.InfiniteTimeSpan
+ };
+ return new AndroidBootstrap(
+ new HttpRangeDownloader(client, httpOptions),
+ new Sha256Validator(),
+ new AndroidApkInstaller(),
+ new UpdateFileStore(),
+ logger);
+ }
+
+ // Legacy path: bare HttpClient
+ var usedClient = httpClient ?? new HttpClient();
return new AndroidBootstrap(
- new HttpRangeDownloader(client),
+ new HttpRangeDownloader(usedClient),
new Sha256Validator(),
new AndroidApkInstaller(),
new UpdateFileStore(),
diff --git a/src/GeneralUpdate.Maui.Android/Services/HttpRangeDownloader.cs b/src/GeneralUpdate.Maui.Android/Services/HttpRangeDownloader.cs
index 65f2bbf..b703809 100644
--- a/src/GeneralUpdate.Maui.Android/Services/HttpRangeDownloader.cs
+++ b/src/GeneralUpdate.Maui.Android/Services/HttpRangeDownloader.cs
@@ -7,11 +7,38 @@
namespace GeneralUpdate.Maui.Android.Services;
///
-/// HTTP downloader that supports range-based resume and progress statistics.
+/// HTTP downloader that supports range-based resume, authentication, and progress statistics.
///
-public sealed class HttpRangeDownloader(HttpClient httpClient) : IUpdateDownloader
+public sealed class HttpRangeDownloader : IUpdateDownloader, IDisposable
{
- private readonly HttpClient _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ private readonly HttpClient _httpClient;
+ private readonly HttpDownloadOptions? _httpOptions;
+ private readonly IHttpAuthProvider? _globalAuthProvider;
+ private readonly bool _ownsClient;
+ private bool _disposed;
+
+ ///
+ /// Creates a downloader with an externally-provided HttpClient.
+ /// No authentication or custom HTTP options are applied.
+ ///
+ public HttpRangeDownloader(HttpClient httpClient)
+ {
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ _httpOptions = null;
+ _globalAuthProvider = null;
+ _ownsClient = false;
+ }
+
+ ///
+ /// Creates a downloader with HTTP options that configure SSL, proxy, auth, and timeouts.
+ ///
+ internal HttpRangeDownloader(HttpClient httpClient, HttpDownloadOptions httpOptions)
+ {
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ _httpOptions = httpOptions ?? throw new ArgumentNullException(nameof(httpOptions));
+ _globalAuthProvider = httpOptions.AuthProvider;
+ _ownsClient = true;
+ }
public async Task DownloadAsync(
UpdatePackageInfo packageInfo,
@@ -22,14 +49,19 @@ public async Task DownloadAsync(
CancellationToken cancellationToken)
{
if (packageInfo is null)
- {
throw new ArgumentNullException(nameof(packageInfo));
- }
if (!Uri.TryCreate(packageInfo.DownloadUrl, UriKind.Absolute, out var requestUri))
- {
throw new ArgumentException("The download url is invalid.", nameof(packageInfo));
- }
+
+ // Resolve download timeout
+ 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 existingLength = File.Exists(temporaryFilePath) ? new FileInfo(temporaryFilePath).Length : 0L;
var fallbackToFullDownload = false;
@@ -42,7 +74,10 @@ public async Task DownloadAsync(
request.Headers.Range = new RangeHeaderValue(existingLength, null);
}
- using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+ // Apply authentication
+ await ApplyAuthAsync(request, packageInfo, effectiveCt).ConfigureAwait(false);
+
+ using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, effectiveCt).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable && existingLength > 0 && !fallbackToFullDownload)
{
@@ -65,15 +100,15 @@ public async Task DownloadAsync(
var nextReportAt = DateTimeOffset.UtcNow;
var downloadedBytes = existingLength;
- await using var networkStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using var networkStream = await response.Content.ReadAsStreamAsync(effectiveCt).ConfigureAwait(false);
await using var fileStream = new FileStream(temporaryFilePath, mode, FileAccess.Write, FileShare.None, 81920, useAsync: true);
var buffer = new byte[81920];
int read;
- while ((read = await networkStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0)
+ while ((read = await networkStream.ReadAsync(buffer.AsMemory(0, buffer.Length), effectiveCt).ConfigureAwait(false)) > 0)
{
- await fileStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
+ await fileStream.WriteAsync(buffer.AsMemory(0, read), effectiveCt).ConfigureAwait(false);
downloadedBytes += read;
speedCalculator.AddSample(downloadedBytes);
@@ -97,19 +132,51 @@ public async Task DownloadAsync(
}
}
+ ///
+ /// Applies authentication to the HTTP request.
+ /// Per-package auth takes precedence over global auth.
+ /// When is explicitly set,
+ /// only per-package credentials are used (no fallback to global).
+ /// When AuthScheme is null, the global provider is used if configured.
+ ///
+ private async Task ApplyAuthAsync(HttpRequestMessage request, UpdatePackageInfo packageInfo, CancellationToken cancellationToken)
+ {
+ IHttpAuthProvider? provider = null;
+
+ // Per-package auth takes full precedence when explicitly set
+ if (packageInfo.AuthScheme.HasValue)
+ {
+ provider = HttpAuthProviderFactory.Create(
+ packageInfo.AuthScheme.Value,
+ packageInfo.AuthToken,
+ packageInfo.AuthSecretKey,
+ packageInfo.BasicUsername,
+ packageInfo.BasicPassword);
+
+ // When per-package scheme is set, do NOT fall back to global,
+ // even if credentials are missing (NoOpAuthProvider).
+ }
+ else
+ {
+ // Only fall back to global when no per-package scheme is specified
+ provider = _globalAuthProvider;
+ }
+
+ if (provider != null)
+ {
+ await provider.ApplyAuthAsync(request, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
private static long ResolveTotalBytes(HttpResponseMessage response, long existingLength, long? fallbackTotal)
{
if (response.Content.Headers.ContentRange?.Length is long contentRangeLength)
- {
return contentRangeLength;
- }
if (response.Content.Headers.ContentLength is long contentLength)
- {
return response.StatusCode == HttpStatusCode.PartialContent
? existingLength + contentLength
: contentLength;
- }
return Math.Max(existingLength, fallbackTotal ?? 0L);
}
@@ -128,4 +195,14 @@ private static DownloadStatistics CreateStatistics(long downloadedBytes, long to
BytesPerSecond = speedCalculator.GetBytesPerSecond()
};
}
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ if (_ownsClient)
+ {
+ _httpClient.Dispose();
+ }
+ _disposed = true;
+ }
}
diff --git a/src/GeneralUpdate.Maui.Android/Services/SslValidationPolicies.cs b/src/GeneralUpdate.Maui.Android/Services/SslValidationPolicies.cs
new file mode 100644
index 0000000..04d75a8
--- /dev/null
+++ b/src/GeneralUpdate.Maui.Android/Services/SslValidationPolicies.cs
@@ -0,0 +1,33 @@
+using System.Net.Security;
+using System.Security.Cryptography.X509Certificates;
+using GeneralUpdate.Maui.Android.Abstractions;
+
+namespace GeneralUpdate.Maui.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;
+}