diff --git a/UI/AndroidUpdate/AndroidUpdate.slnx b/UI/AndroidUpdate/AndroidUpdate.slnx
new file mode 100644
index 0000000..0c4c7b4
--- /dev/null
+++ b/UI/AndroidUpdate/AndroidUpdate.slnx
@@ -0,0 +1,3 @@
+
+
+
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/AndroidApp.cs b/UI/AndroidUpdate/src/AndroidUpdate.Android/AndroidApp.cs
new file mode 100644
index 0000000..169a1dd
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/AndroidApp.cs
@@ -0,0 +1,33 @@
+using Android.App;
+using Android.Runtime;
+using AndroidUpdate.ViewModels;
+using Avalonia;
+using Avalonia.Android;
+
+namespace AndroidUpdate.Android;
+
+[global::Android.App.Application]
+public class AndroidApp : AvaloniaAndroidApplication
+{
+ protected AndroidApp(nint javaReference, JniHandleOwnership transfer) : base(javaReference, transfer)
+ {
+ // Read real app version from package manager as early as possible
+ try
+ {
+ var pkgInfo = PackageManager?.GetPackageInfo(PackageName!, 0);
+ if (pkgInfo?.VersionName != null)
+ App.DeviceVersion = pkgInfo.VersionName;
+ }
+ catch { /* fallback to default "1.0.0.0" */ }
+ }
+
+ protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
+ {
+ // Register the Android-specific handler factory before Avalonia starts
+ App.HandlerFactory = (packageInfo, currentVersion) =>
+ new Services.AndroidUpdateHandler(packageInfo, currentVersion);
+
+ return base.CustomizeAppBuilder(builder)
+ .WithInterFont();
+ }
+}
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/AndroidUpdate.Android.csproj b/UI/AndroidUpdate/src/AndroidUpdate.Android/AndroidUpdate.Android.csproj
new file mode 100644
index 0000000..6954e51
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/AndroidUpdate.Android.csproj
@@ -0,0 +1,42 @@
+
+
+ Exe
+ net10.0-android
+ 23
+ enable
+ enable
+ latest
+ com.generalupdate.androidupdate
+ 7
+ 1.0.0.0
+ AndroidUpdate
+ apk
+ false
+ true
+
+
+
+
+
+
+
+
+ None
+ All
+
+
+
+
+ Resources\drawable\Icon.png
+
+
+
+
+
+
+
+
+ libs\GeneralUpdate.Avalonia.Android.dll
+
+
+
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/App.axaml b/UI/AndroidUpdate/src/AndroidUpdate.Android/App.axaml
new file mode 100644
index 0000000..ecdf2ed
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/App.axaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/App.axaml.cs b/UI/AndroidUpdate/src/AndroidUpdate.Android/App.axaml.cs
new file mode 100644
index 0000000..072edfb
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/App.axaml.cs
@@ -0,0 +1,59 @@
+using System;
+using System.Net.Http;
+using AndroidUpdate.ViewModels;
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+
+namespace AndroidUpdate;
+
+public partial class App : Avalonia.Application
+{
+ ///
+ /// Static factory for creating platform-specific update handlers.
+ /// Set by the Android project (or other platform projects) during startup.
+ ///
+ public static Func? HandlerFactory { get; set; }
+
+ ///
+ /// The device's currently installed app version.
+ /// Set by the platform project on startup; falls back to "1.0.0.0".
+ ///
+ public static string DeviceVersion { get; set; } = "1.0.0.0";
+
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ var httpClient = new HttpClient();
+ httpClient.Timeout = TimeSpan.FromSeconds(30);
+
+ MainViewViewModel CreateViewModel() =>
+ new(httpClient, (pkg, ver) =>
+ {
+ if (HandlerFactory == null)
+ throw new InvalidOperationException(
+ "HandlerFactory not set. Ensure the platform project initializes it.");
+ return HandlerFactory(pkg, ver);
+ });
+
+ if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactory)
+ {
+ // Android: use MainViewFactory for Activity-based lifetime
+ singleViewFactory.MainViewFactory = () =>
+ new Views.MainView { DataContext = CreateViewModel() };
+ }
+ else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
+ {
+ singleViewPlatform.MainView = new Views.MainView
+ {
+ DataContext = CreateViewModel()
+ };
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/Icon.png b/UI/AndroidUpdate/src/AndroidUpdate.Android/Icon.png
new file mode 100644
index 0000000..3c39845
Binary files /dev/null and b/UI/AndroidUpdate/src/AndroidUpdate.Android/Icon.png differ
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/MainActivity.cs b/UI/AndroidUpdate/src/AndroidUpdate.Android/MainActivity.cs
new file mode 100644
index 0000000..7875452
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/MainActivity.cs
@@ -0,0 +1,15 @@
+using Android.App;
+using Android.Content.PM;
+using Avalonia;
+using Avalonia.Android;
+
+namespace AndroidUpdate.Android;
+
+[Activity(
+ Label = "AndroidUpdate",
+ Theme = "@style/MyTheme.NoActionBar",
+ MainLauncher = true,
+ ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
+public class MainActivity : AvaloniaMainActivity
+{
+}
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/Properties/AndroidManifest.xml b/UI/AndroidUpdate/src/AndroidUpdate.Android/Properties/AndroidManifest.xml
new file mode 100644
index 0000000..27ec047
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/Properties/AndroidManifest.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/drawable-night-v31/avalonia_anim.xml b/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/drawable-night-v31/avalonia_anim.xml
new file mode 100644
index 0000000..dde4b5a
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/drawable-night-v31/avalonia_anim.xml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/drawable-v31/avalonia_anim.xml b/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/drawable-v31/avalonia_anim.xml
new file mode 100644
index 0000000..94f27d9
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/drawable-v31/avalonia_anim.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/drawable/splash_screen.xml b/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/drawable/splash_screen.xml
new file mode 100644
index 0000000..2e920b4
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/drawable/splash_screen.xml
@@ -0,0 +1,13 @@
+
+
+
+ -
+
+
+
+
+
+
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/values-night/colors.xml b/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/values-night/colors.xml
new file mode 100644
index 0000000..3d47b6f
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/values-night/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #212121
+
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/values-v31/styles.xml b/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/values-v31/styles.xml
new file mode 100644
index 0000000..d5ecec4
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/values-v31/styles.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/values/colors.xml b/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/values/colors.xml
new file mode 100644
index 0000000..59279d5
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #FFFFFF
+
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/values/styles.xml b/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/values/styles.xml
new file mode 100644
index 0000000..6e534de
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/values/styles.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/xml/file_paths.xml b/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/xml/file_paths.xml
new file mode 100644
index 0000000..767c20f
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/xml/file_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/xml/network_security_config.xml b/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/xml/network_security_config.xml
new file mode 100644
index 0000000..c7f8784
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/Resources/xml/network_security_config.xml
@@ -0,0 +1,7 @@
+
+
+
+ 192.168.50.204
+ localhost
+
+
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/Services/AndroidUpdateHandler.cs b/UI/AndroidUpdate/src/AndroidUpdate.Android/Services/AndroidUpdateHandler.cs
new file mode 100644
index 0000000..8136482
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/Services/AndroidUpdateHandler.cs
@@ -0,0 +1,118 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Android.Content;
+using Android.OS;
+using AndroidUpdate.ViewModels;
+using GeneralUpdate.Avalonia.Android;
+using GeneralUpdate.Avalonia.Android.Abstractions;
+using GeneralUpdate.Avalonia.Android.Models;
+
+namespace AndroidUpdate.Android.Services;
+
+public sealed class AndroidUpdateHandler : IAndroidUpdateHandler
+{
+ private readonly IAndroidBootstrap _bootstrap;
+ private readonly UpdatePackageDto _packageInfo;
+ private bool _disposed;
+
+ public event EventHandler? ProgressChanged;
+ public event EventHandler? StatusChanged;
+
+ public AndroidUpdateHandler(UpdatePackageDto packageInfo, string currentVersion)
+ {
+ _packageInfo = packageInfo ?? throw new ArgumentNullException(nameof(packageInfo));
+ _ = currentVersion ?? throw new ArgumentNullException(nameof(currentVersion));
+
+ var options = new AndroidUpdateOptions
+ {
+ FileProviderAuthority = "com.generalupdate.androidupdate.fileprovider"
+ };
+
+ _bootstrap = GeneralUpdateBootstrap.CreateDefault(options);
+ WireEvents();
+ }
+
+ public async Task ExecuteAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var updatePackage = new UpdatePackageInfo
+ {
+ Version = _packageInfo.Version,
+ DownloadUrl = _packageInfo.DownloadUrl,
+ Sha256 = _packageInfo.Sha256,
+ FileSize = _packageInfo.FileSize,
+ FileName = $"app_update_{_packageInfo.Version}.apk",
+ IsForced = _packageInfo.IsForced
+ };
+
+ // Step 1: Download and verify
+ StatusChanged?.Invoke(this, "Downloading package...");
+ var downloadResult = await _bootstrap.DownloadAndVerifyAsync(updatePackage, cancellationToken);
+ if (!downloadResult.Success)
+ {
+ StatusChanged?.Invoke(this, $"Download failed: {downloadResult.Message} (reason: {downloadResult.FailureReason})");
+ return false;
+ }
+
+ StatusChanged?.Invoke(this, "Download OK, checking install permission...");
+
+ // Step 2: Check "Install unknown apps" permission (Android 8+)
+ if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
+ {
+ var ctx = global::Android.App.Application.Context;
+ var pm = ctx.PackageManager;
+ if (pm != null && !pm.CanRequestPackageInstalls())
+ {
+ StatusChanged?.Invoke(this, "Tap Download again after allowing install permission.");
+ var intent = new Intent(global::Android.Provider.Settings.ActionManageUnknownAppSources)
+ .SetData(global::Android.Net.Uri.Parse("package:" + ctx.PackageName));
+ intent.AddFlags(ActivityFlags.NewTask);
+ ctx.StartActivity(intent);
+ return false;
+ }
+ }
+
+ // Step 3: Launch Android installer
+ StatusChanged?.Invoke(this, "Installing...");
+ var installResult = await _bootstrap.LaunchInstallerAsync(updatePackage, downloadResult.FilePath!, cancellationToken);
+ if (!installResult.Success)
+ {
+ StatusChanged?.Invoke(this, $"Install failed: {installResult.Message} (reason: {installResult.FailureReason})");
+ return false;
+ }
+ return true;
+ }
+ catch (System.OperationCanceledException)
+ {
+ StatusChanged?.Invoke(this, "Cancelled.");
+ return false;
+ }
+ catch (Exception ex)
+ {
+ StatusChanged?.Invoke(this, $"Error: {ex.Message}");
+ return false;
+ }
+ }
+
+ private void WireEvents()
+ {
+ _bootstrap.AddListenerDownloadProgressChanged += (_, args) =>
+ {
+ ProgressChanged?.Invoke(this, args.ProgressPercentage);
+ };
+
+ _bootstrap.AddListenerUpdateFailed += (_, args) =>
+ {
+ StatusChanged?.Invoke(this, $"Failed: {args.Result.Message}");
+ };
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _bootstrap.Dispose();
+ _disposed = true;
+ }
+}
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/ViewLocator.cs b/UI/AndroidUpdate/src/AndroidUpdate.Android/ViewLocator.cs
new file mode 100644
index 0000000..5e5a2e8
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/ViewLocator.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using AndroidUpdate.ViewModels;
+
+namespace AndroidUpdate;
+
+[RequiresUnreferencedCode(
+ "Default implementation of ViewLocator involves reflection which may be trimmed away.",
+ Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
+public class ViewLocator : IDataTemplate
+{
+ public Control? Build(object? param)
+ {
+ if (param is null)
+ return null;
+
+ var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
+ var type = Type.GetType(name);
+
+ if (type != null)
+ {
+ return (Control)Activator.CreateInstance(type)!;
+ }
+
+ return new TextBlock { Text = "Not Found: " + name };
+ }
+
+ public bool Match(object? data)
+ {
+ return data is ViewModelBase;
+ }
+}
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/ViewModels/IAndroidUpdateHandler.cs b/UI/AndroidUpdate/src/AndroidUpdate.Android/ViewModels/IAndroidUpdateHandler.cs
new file mode 100644
index 0000000..6225e8f
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/ViewModels/IAndroidUpdateHandler.cs
@@ -0,0 +1,13 @@
+namespace AndroidUpdate.ViewModels;
+
+///
+/// Abstraction for the platform-specific update download/install handler.
+/// The Android project provides the implementation using GeneralUpdate.Avalonia.Android.
+///
+public interface IAndroidUpdateHandler : IDisposable
+{
+ event EventHandler? ProgressChanged;
+ event EventHandler? StatusChanged;
+
+ Task ExecuteAsync(CancellationToken cancellationToken = default);
+}
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/ViewModels/MainViewViewModel.cs b/UI/AndroidUpdate/src/AndroidUpdate.Android/ViewModels/MainViewViewModel.cs
new file mode 100644
index 0000000..03ff3ba
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/ViewModels/MainViewViewModel.cs
@@ -0,0 +1,273 @@
+using System;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace AndroidUpdate.ViewModels;
+
+public partial class MainViewViewModel : ViewModelBase
+{
+ private readonly HttpClient _httpClient;
+ private readonly Func _handlerFactory;
+
+ private IAndroidUpdateHandler? _currentHandler;
+ private CancellationTokenSource? _cts;
+
+ // ── Bindable properties ────────────────────────────────────────
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(CheckUpdateCommand))]
+ private string _currentVersion = AndroidUpdate.App.DeviceVersion;
+
+ [ObservableProperty]
+ private string _statusText = "Ready";
+
+ [ObservableProperty]
+ private double _downloadProgress;
+
+ [ObservableProperty]
+ private string _progressText = "";
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(CheckUpdateCommand))]
+ private bool _isChecking;
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(CheckUpdateCommand))]
+ [NotifyCanExecuteChangedFor(nameof(DownloadUpdateCommand))]
+ private bool _isDownloading;
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(DownloadUpdateCommand))]
+ private bool _hasUpdate;
+
+ [ObservableProperty]
+ private bool _updateChecked;
+
+ [ObservableProperty]
+ private bool _showNoUpdateMessage;
+
+ [ObservableProperty]
+ private string _newVersion = "";
+
+ [ObservableProperty]
+ private string _updateDescription = "";
+
+ // ── Dialog ─────────────────────────────────────────────────────
+
+ [ObservableProperty]
+ private bool _isDialogVisible;
+
+ [ObservableProperty]
+ private string _dialogTitle = "";
+
+ [ObservableProperty]
+ private string _dialogMessage = "";
+
+ private TaskCompletionSource? _dialogTcs;
+
+ private UpdatePackageDto? _pendingUpdate;
+
+ public MainViewViewModel(HttpClient httpClient, Func handlerFactory)
+ {
+ _httpClient = httpClient;
+ _handlerFactory = handlerFactory;
+ }
+
+ ///
+ /// Configurable server URL for the sample. Defaults to localhost:5000;
+ /// override via environment variable ANDROID_UPDATE_SERVER_URL.
+ ///
+ private static string ServerBaseUrl =>
+ Environment.GetEnvironmentVariable("ANDROID_UPDATE_SERVER_URL")
+ ?? "http://localhost:5000";
+
+ // ── Dialog helpers ─────────────────────────────────────────────
+
+ [RelayCommand]
+ private void DialogConfirm()
+ {
+ IsDialogVisible = false;
+ _dialogTcs?.TrySetResult(true);
+ }
+
+ [RelayCommand]
+ private void DialogCancel()
+ {
+ IsDialogVisible = false;
+ _dialogTcs?.TrySetResult(false);
+ }
+
+ private Task ShowConfirmAsync(string title, string message)
+ {
+ DialogTitle = title;
+ DialogMessage = message;
+ IsDialogVisible = true;
+ _dialogTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ return _dialogTcs.Task;
+ }
+
+ // ── Commands ───────────────────────────────────────────────────
+
+ private bool CanCheck => !IsChecking && !IsDownloading;
+
+ [RelayCommand(CanExecute = nameof(CanCheck))]
+ private async Task CheckUpdateAsync()
+ {
+ var confirmed = await ShowConfirmAsync(
+ "GeneralUpdate.Avalonia",
+ "This app uses GeneralUpdate.Avalonia to check for Android APK updates.\n\n" +
+ "The library provides version comparison, resumable download with SHA256 verification, and automated APK installation.\n\n" +
+ "Do you want to check for updates now?");
+
+ if (!confirmed) return;
+
+ IsChecking = true;
+ HasUpdate = false;
+ UpdateChecked = false;
+ ShowNoUpdateMessage = false;
+ StatusText = "Checking for updates...";
+ _cts = new CancellationTokenSource();
+
+ try
+ {
+ var result = await CheckServerAsync(_cts.Token);
+
+ if (result == null)
+ {
+ StatusText = "No update available.";
+ UpdateChecked = true;
+ ShowNoUpdateMessage = true;
+ return;
+ }
+
+ NewVersion = result.Version;
+ UpdateDescription = result.Description ?? $"Version {result.Version} is available.";
+ _pendingUpdate = result;
+ HasUpdate = true;
+ UpdateChecked = true;
+ StatusText = $"Update v{result.Version} available!";
+ }
+ catch (OperationCanceledException)
+ {
+ StatusText = "Cancelled.";
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"Error: {ex.Message}";
+ }
+ finally
+ {
+ IsChecking = false;
+ _cts?.Dispose();
+ _cts = null;
+ }
+ }
+
+ private bool CanDownload => !IsDownloading && HasUpdate;
+
+ [RelayCommand(CanExecute = nameof(CanDownload))]
+ private async Task DownloadUpdateAsync()
+ {
+ if (_pendingUpdate == null) return;
+
+ var confirmed = await ShowConfirmAsync(
+ "GeneralUpdate.Avalonia",
+ $"GeneralUpdate.Avalonia will now download version {_pendingUpdate.Version} APK,\n" +
+ $"verify its SHA256 checksum, and launch the Android package installer.\n\n" +
+ $"Target version: {_pendingUpdate.Version}\n" +
+ $"Package size: {_pendingUpdate.FileSize / 1024 / 1024:F1} MB\n\n" +
+ "Do you want to proceed with the update?");
+
+ if (!confirmed) return;
+
+ IsDownloading = true;
+ HasUpdate = false;
+ StatusText = "Downloading...";
+ DownloadProgress = 0;
+ ProgressText = "0%";
+ _cts = new CancellationTokenSource();
+
+ try
+ {
+ _currentHandler = _handlerFactory(_pendingUpdate, CurrentVersion);
+
+ _currentHandler.ProgressChanged += (_, p) =>
+ {
+ DownloadProgress = p;
+ ProgressText = $"{p:F1}%";
+ };
+
+ _currentHandler.StatusChanged += (_, s) =>
+ {
+ StatusText = s;
+ };
+
+ var success = await _currentHandler.ExecuteAsync(_cts.Token);
+
+ if (success)
+ {
+ CurrentVersion = _pendingUpdate.Version;
+ _pendingUpdate = null; // clear so retry won't use stale data
+ StatusText = $"Updated to v{CurrentVersion}!";
+ DownloadProgress = 100;
+ ProgressText = "Done";
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ StatusText = "Cancelled.";
+ }
+ finally
+ {
+ IsDownloading = false;
+ _cts?.Dispose();
+ _cts = null;
+ _currentHandler?.Dispose();
+ _currentHandler = null;
+
+ // Restore the Download button if an update is still available for retry
+ if (_pendingUpdate != null)
+ HasUpdate = true;
+ }
+ }
+
+ // ── Server interaction ─────────────────────────────────────────
+
+ private async Task CheckServerAsync(CancellationToken ct)
+ {
+ var request = new
+ {
+ Version = CurrentVersion,
+ AppType = 1,
+ Platform = 4,
+ ProductId = "2d974e2a-31e6-4887-9bb1-b4689e98c77a"
+ };
+
+ var response = await _httpClient.PostAsJsonAsync(
+ $"{ServerBaseUrl}/Upgrade/Verification",
+ request, ct);
+
+ response.EnsureSuccessStatusCode();
+
+ using var doc = await JsonDocument.ParseAsync(
+ await response.Content.ReadAsStreamAsync(ct), cancellationToken: ct);
+
+ var body = doc.RootElement.GetProperty("body");
+ if (body.GetArrayLength() == 0) return null;
+
+ var entry = body[0];
+ return new UpdatePackageDto(
+ Version: entry.GetProperty("version").GetString()!,
+ DownloadUrl: entry.GetProperty("url").GetString()!,
+ Sha256: entry.GetProperty("hash").GetString()!,
+ FileSize: entry.TryGetProperty("size", out var s) ? s.GetInt64() : 0,
+ Description: entry.TryGetProperty("name", out var n) ? n.GetString() : null,
+ IsForced: entry.TryGetProperty("isForcibly", out var f) && f.GetBoolean()
+ );
+ }
+}
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/ViewModels/UpdatePackageDto.cs b/UI/AndroidUpdate/src/AndroidUpdate.Android/ViewModels/UpdatePackageDto.cs
new file mode 100644
index 0000000..8ba7af0
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/ViewModels/UpdatePackageDto.cs
@@ -0,0 +1,12 @@
+namespace AndroidUpdate.ViewModels;
+
+///
+/// Describes an update package returned by the server's verification endpoint.
+///
+public sealed record UpdatePackageDto(
+ string Version,
+ string DownloadUrl,
+ string Sha256,
+ long FileSize,
+ string? Description,
+ bool IsForced);
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/ViewModels/ViewModelBase.cs b/UI/AndroidUpdate/src/AndroidUpdate.Android/ViewModels/ViewModelBase.cs
new file mode 100644
index 0000000..b96636b
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/ViewModels/ViewModelBase.cs
@@ -0,0 +1,7 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace AndroidUpdate.ViewModels;
+
+public abstract class ViewModelBase : ObservableObject
+{
+}
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/Views/MainView.axaml b/UI/AndroidUpdate/src/AndroidUpdate.Android/Views/MainView.axaml
new file mode 100644
index 0000000..f201bc3
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/Views/MainView.axaml
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/Views/MainView.axaml.cs b/UI/AndroidUpdate/src/AndroidUpdate.Android/Views/MainView.axaml.cs
new file mode 100644
index 0000000..26b9a01
--- /dev/null
+++ b/UI/AndroidUpdate/src/AndroidUpdate.Android/Views/MainView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace AndroidUpdate.Views;
+
+public partial class MainView : UserControl
+{
+ public MainView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/UI/AndroidUpdate/src/AndroidUpdate.Android/libs/GeneralUpdate.Avalonia.Android.dll b/UI/AndroidUpdate/src/AndroidUpdate.Android/libs/GeneralUpdate.Avalonia.Android.dll
new file mode 100644
index 0000000..b6ec94d
Binary files /dev/null and b/UI/AndroidUpdate/src/AndroidUpdate.Android/libs/GeneralUpdate.Avalonia.Android.dll differ
diff --git a/UI/MauiUpdate/MauiUpdate.slnx b/UI/MauiUpdate/MauiUpdate.slnx
new file mode 100644
index 0000000..f75af3c
--- /dev/null
+++ b/UI/MauiUpdate/MauiUpdate.slnx
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/UI/MauiUpdate/nuget.config b/UI/MauiUpdate/nuget.config
new file mode 100644
index 0000000..8fb8e8a
--- /dev/null
+++ b/UI/MauiUpdate/nuget.config
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/UI/MauiUpdate/server/Program.cs b/UI/MauiUpdate/server/Program.cs
new file mode 100644
index 0000000..7509d5b
--- /dev/null
+++ b/UI/MauiUpdate/server/Program.cs
@@ -0,0 +1,117 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.WebHost.UseUrls("http://0.0.0.0:5000");
+
+// Allow all origins for local testing
+builder.Services.AddCors(options =>
+{
+ options.AddDefaultPolicy(policy => policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod());
+});
+
+var app = builder.Build();
+app.UseCors();
+
+var packagesDir = Path.Combine(app.Environment.ContentRootPath, "packages");
+Directory.CreateDirectory(packagesDir);
+
+// GET /packages/{filename} - Serve APK files with range support
+app.MapGet("/packages/{filename}", (string filename) =>
+{
+ var sanitized = Path.GetFileName(filename);
+ var filePath = Path.Combine(packagesDir, sanitized);
+ if (!File.Exists(filePath))
+ {
+ return Results.NotFound(new { error = "Package not found.", filename = sanitized });
+ }
+
+ return Results.File(
+ filePath,
+ contentType: "application/vnd.android.package-archive",
+ fileDownloadName: sanitized,
+ enableRangeProcessing: true);
+});
+
+// POST /Upgrade/Verification - Version check API (matches GeneralUpdate server format)
+app.MapPost("/Upgrade/Verification", async (HttpContext context) =>
+{
+ try
+ {
+ using var reader = new StreamReader(context.Request.Body);
+ var body = await reader.ReadToEndAsync();
+
+ // Parse the request to extract current version
+ using var doc = JsonDocument.Parse(body);
+ var requestVersion = doc.RootElement.GetProperty("Version").GetString() ?? "0.0.0.0";
+
+ // Read versions.json
+ var versionsPath = Path.Combine(packagesDir, "versions.json");
+ if (!File.Exists(versionsPath))
+ {
+ return Results.Ok(new { code = 1, message = "No versions file.", body = new List() });
+ }
+
+ var json = await File.ReadAllTextAsync(versionsPath);
+ var packageOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
+ var packages = JsonSerializer.Deserialize>(json, packageOptions);
+
+ if (packages is null || packages.Count == 0)
+ {
+ return Results.Ok(new { code = 1, message = "No packages configured.", body = new List() });
+ }
+
+ // Find the latest version that is newer than the request
+ var current = Version.TryParse(requestVersion, out var cv) ? cv : new Version(0, 0, 0, 0);
+ var available = packages
+ .Select(p => new { Package = p, ParsedVersion = Version.TryParse(p.Version, out var v) ? v : new Version(0, 0, 0, 0) })
+ .Where(x => x.ParsedVersion > current)
+ .OrderByDescending(x => x.ParsedVersion)
+ .ToList();
+
+ if (available.Count == 0)
+ {
+ return Results.Ok(new { code = 1, message = "No updates available.", body = new List() });
+ }
+
+ var latest = available[0].Package;
+ return Results.Ok(new { code = 0, message = "Success", body = new[] { latest } });
+ }
+ catch (Exception ex)
+ {
+ return Results.Ok(new { code = -1, message = ex.Message, body = new List() });
+ }
+});
+
+// POST /Upgrade/Report - Report update result
+app.MapPost("/Upgrade/Report", async (HttpContext context) =>
+{
+ using var reader = new StreamReader(context.Request.Body);
+ var body = await reader.ReadToEndAsync();
+ Console.WriteLine($"[Report] {body}");
+ return Results.Ok(new { code = 0, message = "Report received." });
+});
+
+// Health check
+app.MapGet("/", () => Results.Ok(new { status = "running", server = "MauiUpdate Server" }));
+
+Console.WriteLine("MauiUpdate Server running on http://0.0.0.0:5000");
+app.Run();
+
+///
+/// Package entry matching the versions.json schema.
+/// JsonPropertyName attributes ensure correct serialization for the client.
+///
+internal sealed record PackageEntry
+{
+ [JsonPropertyName("PacketName")] public string PacketName { get; init; } = string.Empty;
+ [JsonPropertyName("Hash")] public string Hash { get; init; } = string.Empty;
+ [JsonPropertyName("Version")] public string Version { get; init; } = string.Empty;
+ [JsonPropertyName("PubTime")] public string PubTime { get; init; } = string.Empty;
+ [JsonPropertyName("AppType")] public int AppType { get; init; }
+ [JsonPropertyName("Platform")] public int Platform { get; init; }
+ [JsonPropertyName("ProductId")] public string ProductId { get; init; } = string.Empty;
+ [JsonPropertyName("IsForcibly")] public bool IsForcibly { get; init; }
+ [JsonPropertyName("Format")] public string Format { get; init; } = ".apk";
+ [JsonPropertyName("Size")] public long Size { get; init; }
+}
diff --git a/UI/MauiUpdate/server/Server.csproj b/UI/MauiUpdate/server/Server.csproj
new file mode 100644
index 0000000..6bd42a4
--- /dev/null
+++ b/UI/MauiUpdate/server/Server.csproj
@@ -0,0 +1,8 @@
+
+
+ net10.0
+ enable
+ enable
+ MauiUpdate.Server
+
+
diff --git a/UI/MauiUpdate/src/MauiUpdate.Android/App.xaml b/UI/MauiUpdate/src/MauiUpdate.Android/App.xaml
new file mode 100644
index 0000000..4f38471
--- /dev/null
+++ b/UI/MauiUpdate/src/MauiUpdate.Android/App.xaml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/UI/MauiUpdate/src/MauiUpdate.Android/App.xaml.cs b/UI/MauiUpdate/src/MauiUpdate.Android/App.xaml.cs
new file mode 100644
index 0000000..a4701a9
--- /dev/null
+++ b/UI/MauiUpdate/src/MauiUpdate.Android/App.xaml.cs
@@ -0,0 +1,22 @@
+using MauiUpdate.Views;
+
+namespace MauiUpdate;
+
+public partial class App : Application
+{
+ public App(MainPage mainPage)
+ {
+ InitializeComponent();
+ _mainPage = mainPage;
+ }
+
+ private readonly MainPage _mainPage;
+
+ protected override Window CreateWindow(IActivationState? activationState)
+ {
+ return new Window(_mainPage)
+ {
+ Title = "MauiUpdate",
+ };
+ }
+}
diff --git a/UI/MauiUpdate/src/MauiUpdate.Android/MauiProgram.cs b/UI/MauiUpdate/src/MauiUpdate.Android/MauiProgram.cs
new file mode 100644
index 0000000..28ee14a
--- /dev/null
+++ b/UI/MauiUpdate/src/MauiUpdate.Android/MauiProgram.cs
@@ -0,0 +1,35 @@
+using MauiUpdate.Services;
+using MauiUpdate.ViewModels;
+using MauiUpdate.Views;
+
+namespace MauiUpdate;
+
+public static class MauiProgram
+{
+ public static MauiApp CreateMauiApp()
+ {
+ var builder = MauiApp.CreateBuilder();
+ builder
+ .UseMauiApp()
+ .ConfigureFonts(fonts => { });
+
+ // Register HttpClient
+ builder.Services.AddSingleton(_ =>
+ {
+ var client = new HttpClient();
+ client.Timeout = TimeSpan.FromMinutes(10);
+ return client;
+ });
+
+ // Register services
+ builder.Services.AddSingleton();
+
+ // Register ViewModels
+ builder.Services.AddSingleton();
+
+ // Register Pages
+ builder.Services.AddSingleton();
+
+ return builder.Build();
+ }
+}
diff --git a/UI/MauiUpdate/src/MauiUpdate.Android/MauiUpdate.Android.csproj b/UI/MauiUpdate/src/MauiUpdate.Android/MauiUpdate.Android.csproj
new file mode 100644
index 0000000..d0b3006
--- /dev/null
+++ b/UI/MauiUpdate/src/MauiUpdate.Android/MauiUpdate.Android.csproj
@@ -0,0 +1,46 @@
+
+
+
+ Exe
+ net10.0-android
+ 23
+ enable
+ enable
+ latest
+ com.generalupdate.mauiupdate
+ 1
+ 1.0.0.0
+ MauiUpdate
+ apk
+ false
+ true
+ true
+ MauiUpdate
+ MauiUpdate
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/UI/MauiUpdate/src/MauiUpdate.Android/Models/UpdatePackageDto.cs b/UI/MauiUpdate/src/MauiUpdate.Android/Models/UpdatePackageDto.cs
new file mode 100644
index 0000000..2b5d1c4
--- /dev/null
+++ b/UI/MauiUpdate/src/MauiUpdate.Android/Models/UpdatePackageDto.cs
@@ -0,0 +1,40 @@
+using GeneralUpdate.Maui.Android.Enums;
+
+namespace MauiUpdate.Models;
+
+///
+/// Update package info returned by the server version API.
+/// Manually parsed from JSON to avoid AOT/trimming issues.
+///
+public sealed class UpdatePackageDto
+{
+ public string PacketName { get; set; } = string.Empty;
+ public string Hash { get; set; } = string.Empty;
+ public string Version { get; set; } = string.Empty;
+ public string PubTime { get; set; } = string.Empty;
+ public int AppType { get; set; }
+ public int Platform { get; set; }
+ public string ProductId { get; set; } = string.Empty;
+ public bool IsForcibly { get; set; }
+ public string Format { get; set; } = ".apk";
+ public long Size { get; set; }
+ public string DownloadUrl { get; set; } = string.Empty;
+
+ // --- Per-package authentication fields ---
+ // Set these when the server requires authentication for update downloads.
+
+ /// Authentication scheme (Bearer, ApiKey, Basic, Hmac).
+ public AuthScheme? AuthScheme { get; set; }
+
+ /// Token/key for Bearer or ApiKey authentication.
+ public string? AuthToken { get; set; }
+
+ /// Secret key for HMAC-SHA256 authentication.
+ public string? AuthSecretKey { get; set; }
+
+ /// Username for Basic authentication.
+ public string? BasicUsername { get; set; }
+
+ /// Password for Basic authentication.
+ public string? BasicPassword { get; set; }
+}
diff --git a/UI/MauiUpdate/src/MauiUpdate.Android/Platforms/Android/AndroidManifest.xml b/UI/MauiUpdate/src/MauiUpdate.Android/Platforms/Android/AndroidManifest.xml
new file mode 100644
index 0000000..c2442ba
--- /dev/null
+++ b/UI/MauiUpdate/src/MauiUpdate.Android/Platforms/Android/AndroidManifest.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/UI/MauiUpdate/src/MauiUpdate.Android/Platforms/Android/MainActivity.cs b/UI/MauiUpdate/src/MauiUpdate.Android/Platforms/Android/MainActivity.cs
new file mode 100644
index 0000000..f5a80e4
--- /dev/null
+++ b/UI/MauiUpdate/src/MauiUpdate.Android/Platforms/Android/MainActivity.cs
@@ -0,0 +1,19 @@
+using Android.App;
+using Android.Content.PM;
+using Android.OS;
+
+namespace MauiUpdate;
+
+[Activity(
+ Theme = "@style/Maui.SplashTheme",
+ MainLauncher = true,
+ LaunchMode = LaunchMode.SingleTop,
+ ConfigurationChanges = ConfigChanges.ScreenSize
+ | ConfigChanges.Orientation
+ | ConfigChanges.UiMode
+ | ConfigChanges.ScreenLayout
+ | ConfigChanges.SmallestScreenSize
+ | ConfigChanges.Density)]
+public class MainActivity : MauiAppCompatActivity
+{
+}
diff --git a/UI/MauiUpdate/src/MauiUpdate.Android/Platforms/Android/MainApplication.cs b/UI/MauiUpdate/src/MauiUpdate.Android/Platforms/Android/MainApplication.cs
new file mode 100644
index 0000000..3f07b69
--- /dev/null
+++ b/UI/MauiUpdate/src/MauiUpdate.Android/Platforms/Android/MainApplication.cs
@@ -0,0 +1,15 @@
+using Android.App;
+using Android.Runtime;
+
+namespace MauiUpdate;
+
+[Application]
+public class MainApplication : MauiApplication
+{
+ public MainApplication(IntPtr handle, JniHandleOwnership ownership)
+ : base(handle, ownership)
+ {
+ }
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/UI/MauiUpdate/src/MauiUpdate.Android/Platforms/Android/Resources/xml/file_paths.xml b/UI/MauiUpdate/src/MauiUpdate.Android/Platforms/Android/Resources/xml/file_paths.xml
new file mode 100644
index 0000000..1cab972
--- /dev/null
+++ b/UI/MauiUpdate/src/MauiUpdate.Android/Platforms/Android/Resources/xml/file_paths.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/UI/MauiUpdate/src/MauiUpdate.Android/Platforms/Android/Resources/xml/network_security_config.xml b/UI/MauiUpdate/src/MauiUpdate.Android/Platforms/Android/Resources/xml/network_security_config.xml
new file mode 100644
index 0000000..675746f
--- /dev/null
+++ b/UI/MauiUpdate/src/MauiUpdate.Android/Platforms/Android/Resources/xml/network_security_config.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ 10.0.2.2
+ localhost
+
+
+
+
+
+
diff --git a/UI/MauiUpdate/src/MauiUpdate.Android/Resources/AppIcon/appicon.svg b/UI/MauiUpdate/src/MauiUpdate.Android/Resources/AppIcon/appicon.svg
new file mode 100644
index 0000000..af31297
--- /dev/null
+++ b/UI/MauiUpdate/src/MauiUpdate.Android/Resources/AppIcon/appicon.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/UI/MauiUpdate/src/MauiUpdate.Android/Resources/Splash/splash.svg b/UI/MauiUpdate/src/MauiUpdate.Android/Resources/Splash/splash.svg
new file mode 100644
index 0000000..6dd9e62
--- /dev/null
+++ b/UI/MauiUpdate/src/MauiUpdate.Android/Resources/Splash/splash.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/UI/MauiUpdate/src/MauiUpdate.Android/Resources/Styles/Colors.xaml b/UI/MauiUpdate/src/MauiUpdate.Android/Resources/Styles/Colors.xaml
new file mode 100644
index 0000000..5591c58
--- /dev/null
+++ b/UI/MauiUpdate/src/MauiUpdate.Android/Resources/Styles/Colors.xaml
@@ -0,0 +1,30 @@
+
+
+
+
+
+ #6200EE
+ #3700B3
+ #BB86FC
+
+
+ #FF5722
+ #FF8A65
+
+
+ #2E7D32
+ #FF8F00
+ #D32F2F
+
+
+ White
+ Black
+ #F5F5F5
+ #E0E0E0
+ #BDBDBD
+ #666666
+ #212121
+
+
diff --git a/UI/MauiUpdate/src/MauiUpdate.Android/Resources/Styles/Styles.xaml b/UI/MauiUpdate/src/MauiUpdate.Android/Resources/Styles/Styles.xaml
new file mode 100644
index 0000000..89af5a1
--- /dev/null
+++ b/UI/MauiUpdate/src/MauiUpdate.Android/Resources/Styles/Styles.xaml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/UI/MauiUpdate/src/MauiUpdate.Android/Services/MauiUpdateHandler.cs b/UI/MauiUpdate/src/MauiUpdate.Android/Services/MauiUpdateHandler.cs
new file mode 100644
index 0000000..2b4082d
--- /dev/null
+++ b/UI/MauiUpdate/src/MauiUpdate.Android/Services/MauiUpdateHandler.cs
@@ -0,0 +1,159 @@
+using GeneralUpdate.Maui.Android.Abstractions;
+using GeneralUpdate.Maui.Android.Models;
+using GeneralUpdate.Maui.Android.Services;
+using MauiUpdate.Models;
+
+namespace MauiUpdate.Services;
+
+///
+/// Platform-specific update handler that wraps GeneralUpdate.Maui.Android.
+/// Supports optional authentication via HttpDownloadOptions.
+///
+public sealed class MauiUpdateHandler : IDisposable
+{
+ private IAndroidBootstrap? _bootstrap;
+ private string? _fileProviderAuthority;
+ private bool _disposed;
+
+ /// Fires with download progress percentage (0.0 to 100.0).
+ public event EventHandler? ProgressChanged;
+
+ /// Fires with status text for UI updates.
+ public event EventHandler? StatusChanged;
+
+ ///
+ /// Initializes the handler with the given FileProvider authority.
+ /// Uses default HTTP settings (no auth, system SSL).
+ ///
+ public void Initialize(string fileProviderAuthority)
+ {
+ Initialize(fileProviderAuthority, httpOptions: null);
+ }
+
+ ///
+ /// Initializes the handler with FileProvider authority and optional HTTP configuration.
+ ///
+ /// The FileProvider authority from AndroidManifest.
+ ///
+ /// Optional HTTP configuration (SSL validation, proxy, timeouts, authentication).
+ /// When null, default HttpClient settings are used (no auth, system SSL).
+ ///
+ public void Initialize(string fileProviderAuthority, HttpDownloadOptions? httpOptions)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (_bootstrap is not null)
+ return;
+
+ _fileProviderAuthority = fileProviderAuthority;
+ _bootstrap = GeneralUpdateBootstrap.CreateDefault(
+ httpClient: null,
+ logger: null,
+ httpOptions: httpOptions);
+ WireEvents();
+ }
+
+ ///
+ /// Phase 1: Check if the given package version is newer than the current version.
+ ///
+ public async Task CheckForUpdateAsync(
+ UpdatePackageDto dto,
+ string currentVersion,
+ CancellationToken ct = default)
+ {
+ EnsureInitialized();
+ var packageInfo = MapToPackageInfo(dto);
+ var options = CreateOptions(currentVersion);
+ return await _bootstrap!.ValidateAsync(packageInfo, options, ct);
+ }
+
+ ///
+ /// Phase 2: Download, verify SHA256, and trigger installation.
+ ///
+ public async Task ExecuteUpdateAsync(
+ UpdatePackageDto dto,
+ string currentVersion,
+ CancellationToken ct = default)
+ {
+ EnsureInitialized();
+
+ var packageInfo = MapToPackageInfo(dto);
+ var options = CreateOptions(currentVersion);
+
+ var result = await _bootstrap!.ExecuteUpdateAsync(packageInfo, options, ct);
+ return result.IsSuccess;
+ }
+
+ private UpdateOptions CreateOptions(string currentVersion)
+ {
+ return new UpdateOptions
+ {
+ CurrentVersion = currentVersion,
+ InstallOptions = new AndroidInstallOptions
+ {
+ FileProviderAuthority = _fileProviderAuthority ?? string.Empty
+ }
+ };
+ }
+
+ private static UpdatePackageInfo MapToPackageInfo(UpdatePackageDto dto)
+ {
+ return new UpdatePackageInfo
+ {
+ Version = dto.Version,
+ DownloadUrl = dto.DownloadUrl,
+ Sha256 = dto.Hash,
+ PackageSize = dto.Size > 0 ? dto.Size : null,
+ ApkFileName = $"{dto.PacketName}.apk",
+ // Per-package auth (set by the consumer if needed)
+ AuthScheme = dto.AuthScheme,
+ AuthToken = dto.AuthToken,
+ AuthSecretKey = dto.AuthSecretKey,
+ BasicUsername = dto.BasicUsername,
+ BasicPassword = dto.BasicPassword
+ };
+ }
+
+ private void WireEvents()
+ {
+ if (_bootstrap is null) return;
+
+ _bootstrap.AddListenerDownloadProgressChanged += (_, args) =>
+ {
+ ProgressChanged?.Invoke(this, args.Statistics.ProgressPercentage);
+ };
+
+ _bootstrap.AddListenerUpdateFailed += (_, args) =>
+ {
+ StatusChanged?.Invoke(this, $"Failed: {args.Message}");
+ };
+
+ _bootstrap.AddListenerUpdateCompleted += (_, args) =>
+ {
+ StatusChanged?.Invoke(this, $"Stage: {args.Stage}");
+ };
+
+ _bootstrap.AddListenerValidate += (_, args) =>
+ {
+ StatusChanged?.Invoke(this, $"Update found: v{args.PackageInfo.Version}");
+ };
+ }
+
+ private void EnsureInitialized()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (_bootstrap is null)
+ throw new InvalidOperationException("Handler not initialized. Call Initialize() first.");
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+
+ if (_bootstrap is IDisposable disposable)
+ {
+ disposable.Dispose();
+ }
+
+ _disposed = true;
+ }
+}
diff --git a/UI/MauiUpdate/src/MauiUpdate.Android/ViewModels/MainViewModel.cs b/UI/MauiUpdate/src/MauiUpdate.Android/ViewModels/MainViewModel.cs
new file mode 100644
index 0000000..7313cf3
--- /dev/null
+++ b/UI/MauiUpdate/src/MauiUpdate.Android/ViewModels/MainViewModel.cs
@@ -0,0 +1,287 @@
+using System.Text.Json;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using MauiUpdate.Models;
+using MauiUpdate.Services;
+
+namespace MauiUpdate.ViewModels;
+
+public partial class MainViewModel : ObservableObject
+{
+ private readonly HttpClient _httpClient;
+ private readonly MauiUpdateHandler _handler;
+ private string _serverUrl = "http://localhost:5000";
+
+ [ObservableProperty]
+ private string _statusText = "Ready — tap Check for Updates.";
+
+ [ObservableProperty]
+ private string _currentVersion = GetInstalledVersion();
+
+ [ObservableProperty]
+ private string? _updateVersion;
+
+ [ObservableProperty]
+ private string? _updateDescription;
+
+ [ObservableProperty]
+ private bool _hasUpdate;
+
+ [ObservableProperty]
+ private bool _isChecking;
+
+ [ObservableProperty]
+ private bool _isDownloading;
+
+ [ObservableProperty]
+ private double _progressValue;
+
+ [ObservableProperty]
+ private string _progressText = string.Empty;
+
+ [ObservableProperty]
+ private bool _showNoUpdateMessage;
+
+ private UpdatePackageDto? _pendingUpdate;
+
+ public MainViewModel(HttpClient httpClient, MauiUpdateHandler handler)
+ {
+ _httpClient = httpClient;
+ _handler = handler;
+
+ // Initialize the handler once at startup
+ _handler.Initialize("com.generalupdate.mauiupdate.fileprovider");
+
+ _handler.ProgressChanged += (_, progress) =>
+ {
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
+ ProgressValue = progress;
+ ProgressText = $"{progress:F1}%";
+ });
+ };
+
+ _handler.StatusChanged += (_, status) =>
+ {
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
+ StatusText = status;
+ });
+ };
+ }
+
+ [RelayCommand]
+ private async Task CheckForUpdatesAsync()
+ {
+ if (IsChecking || IsDownloading) return;
+
+ IsChecking = true;
+ HasUpdate = false;
+ ShowNoUpdateMessage = false;
+ ProgressValue = 0;
+ ProgressText = string.Empty;
+ UpdateVersion = null;
+ UpdateDescription = null;
+ _pendingUpdate = null;
+
+ try
+ {
+ _serverUrl = Preferences.Get("ServerUrl", "http://localhost:5000");
+
+ using var response = await _httpClient.PostAsync(
+ $"{_serverUrl}/Upgrade/Verification",
+ new StringContent(
+ JsonSerializer.Serialize(new
+ {
+ Version = CurrentVersion,
+ AppType = 1,
+ Platform = 4,
+ ProductId = "2d974e2a-31e6-4887-9bb1-b4689e98c77a"
+ }),
+ System.Text.Encoding.UTF8,
+ "application/json"));
+
+ response.EnsureSuccessStatusCode();
+
+ var json = await response.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ var packages = ParseBodyPackages(root);
+ if (packages is null || packages.Count == 0)
+ {
+ StatusText = "No updates available.";
+ ShowNoUpdateMessage = true;
+ return;
+ }
+
+ var latest = packages[0];
+ var ext = FormatExtension(latest.Format);
+ latest.DownloadUrl = $"{_serverUrl}/packages/{latest.PacketName}{ext}";
+
+ var checkResult = await _handler.CheckForUpdateAsync(latest, CurrentVersion);
+
+ if (checkResult.IsUpdateAvailable)
+ {
+ _pendingUpdate = latest;
+ HasUpdate = true;
+ UpdateVersion = $"v{latest.Version}";
+ UpdateDescription = $"{latest.PacketName}\n{FormatSize(latest.Size)}";
+ StatusText = $"Update v{latest.Version} available!";
+ }
+ else
+ {
+ ShowNoUpdateMessage = true;
+ StatusText = "You're up to date!";
+ }
+ }
+ catch (HttpRequestException ex)
+ {
+ StatusText = $"Connection: {ex.Message}";
+ }
+ catch (TaskCanceledException)
+ {
+ StatusText = "Request timed out.";
+ }
+ catch (JsonException ex)
+ {
+ StatusText = $"JSON: {ex.Message}";
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"{ex.GetType().Name}: {ex.Message}";
+ }
+ finally
+ {
+ IsChecking = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task DownloadAndInstallAsync()
+ {
+ if (_pendingUpdate is null || IsDownloading) return;
+
+ IsDownloading = true;
+ ProgressValue = 0;
+ ProgressText = "Starting...";
+ StatusText = "Downloading...";
+
+ string? handlerError = null;
+ void OnStatusChanged(object? s, string msg)
+ {
+ if (msg.StartsWith("Failed:"))
+ handlerError = msg;
+ }
+ _handler.StatusChanged += OnStatusChanged;
+
+ try
+ {
+ var dto = new UpdatePackageDto
+ {
+ PacketName = _pendingUpdate.PacketName,
+ Hash = _pendingUpdate.Hash,
+ Version = _pendingUpdate.Version,
+ PubTime = _pendingUpdate.PubTime,
+ AppType = _pendingUpdate.AppType,
+ Platform = _pendingUpdate.Platform,
+ ProductId = _pendingUpdate.ProductId,
+ IsForcibly = _pendingUpdate.IsForcibly,
+ Format = _pendingUpdate.Format,
+ Size = _pendingUpdate.Size,
+ DownloadUrl = $"{_serverUrl}/packages/{_pendingUpdate.PacketName}{FormatExtension(_pendingUpdate.Format)}"
+ };
+
+ // The library (AndroidApkInstaller) automatically checks
+ // CanRequestPackageInstalls() and throws a descriptive error if denied,
+ // guiding the user to enable the permission in system settings.
+ var success = await _handler.ExecuteUpdateAsync(dto, CurrentVersion);
+
+ if (success)
+ {
+ StatusText = "Installer launched! Complete install on device.";
+ ProgressText = "Done!";
+ }
+ else
+ {
+ StatusText = handlerError ?? "Update failed (no details).";
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"Error: {ex.Message}";
+ }
+ finally
+ {
+ _handler.StatusChanged -= OnStatusChanged;
+ IsDownloading = false;
+ }
+ }
+
+ private static List? ParseBodyPackages(JsonElement root)
+ {
+ if (!root.TryGetProperty("body", out var bodyEl) || bodyEl.ValueKind != JsonValueKind.Array)
+ return null;
+
+ if (bodyEl.GetArrayLength() == 0)
+ return null;
+
+ var result = new List();
+ foreach (var item in bodyEl.EnumerateArray())
+ {
+ result.Add(new UpdatePackageDto
+ {
+ PacketName = GetString(item, "PacketName") ?? string.Empty,
+ Hash = GetString(item, "Hash") ?? string.Empty,
+ Version = GetString(item, "Version") ?? string.Empty,
+ PubTime = GetString(item, "PubTime") ?? string.Empty,
+ AppType = GetInt(item, "AppType"),
+ Platform = GetInt(item, "Platform"),
+ ProductId = GetString(item, "ProductId") ?? string.Empty,
+ IsForcibly = item.TryGetProperty("IsForcibly", out var f) && f.ValueKind == JsonValueKind.True,
+ Format = GetString(item, "Format") ?? ".apk",
+ Size = GetLong(item, "Size")
+ });
+ }
+ return result;
+ }
+
+ private static string? GetString(JsonElement el, string n) =>
+ el.TryGetProperty(n, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
+ private static int GetInt(JsonElement el, string n) =>
+ el.TryGetProperty(n, out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : 0;
+ private static long GetLong(JsonElement el, string n) =>
+ el.TryGetProperty(n, out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
+
+ private static string FormatExtension(string? format) =>
+ string.IsNullOrWhiteSpace(format) ? ".apk" : format;
+
+ private static string FormatSize(long bytes) => bytes switch
+ {
+ < 1024 => $"{bytes} B",
+ < 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
+ _ => $"{bytes / (1024.0 * 1024.0):F1} MB"
+ };
+
+ private static string GetInstalledVersion()
+ {
+ try
+ {
+ return Microsoft.Maui.ApplicationModel.AppInfo.VersionString;
+ }
+ catch
+ {
+ try
+ {
+ var ctx = Android.App.Application.Context;
+ var pm = ctx.PackageManager!;
+ var pkg = pm.GetPackageInfo(ctx.PackageName!, 0);
+ return pkg?.VersionName ?? "1.0.0.0";
+ }
+ catch
+ {
+ return "1.0.0.0";
+ }
+ }
+ }
+}
diff --git a/UI/MauiUpdate/src/MauiUpdate.Android/Views/MainPage.xaml b/UI/MauiUpdate/src/MauiUpdate.Android/Views/MainPage.xaml
new file mode 100644
index 0000000..da2b7ca
--- /dev/null
+++ b/UI/MauiUpdate/src/MauiUpdate.Android/Views/MainPage.xaml
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/UI/MauiUpdate/src/MauiUpdate.Android/Views/MainPage.xaml.cs b/UI/MauiUpdate/src/MauiUpdate.Android/Views/MainPage.xaml.cs
new file mode 100644
index 0000000..0a62fa8
--- /dev/null
+++ b/UI/MauiUpdate/src/MauiUpdate.Android/Views/MainPage.xaml.cs
@@ -0,0 +1,12 @@
+using MauiUpdate.ViewModels;
+
+namespace MauiUpdate.Views;
+
+public partial class MainPage : ContentPage
+{
+ public MainPage(MainViewModel viewModel)
+ {
+ InitializeComponent();
+ BindingContext = viewModel;
+ }
+}
diff --git a/src/Server/Program.cs b/src/Server/Program.cs
index df4119d..cd1269e 100644
--- a/src/Server/Program.cs
+++ b/src/Server/Program.cs
@@ -118,13 +118,17 @@
var entry = versionStore.FirstOrDefault(v =>
string.Equals(v.Hash, hash, StringComparison.OrdinalIgnoreCase));
- var fileName = entry != null ? $"{entry.PacketName}.zip" : $"{hash}.zip";
+ var ext = entry?.Format ?? ".zip";
+ var fileName = entry != null ? $"{entry.PacketName}{ext}" : $"{hash}{ext}";
var filePath = Path.Combine(contentRoot, "packages", fileName);
if (!File.Exists(filePath))
{
// Try finding by hash as filename
- var candidates = Directory.GetFiles(Path.Combine(contentRoot, "packages"), "*.zip");
+ var candidates = Directory.GetFiles(Path.Combine(contentRoot, "packages"), $"{hash}.*")
+ .Concat(Directory.GetFiles(Path.Combine(contentRoot, "packages"), "*.zip"))
+ .Concat(Directory.GetFiles(Path.Combine(contentRoot, "packages"), "*.apk"))
+ .Distinct();
var matched = candidates.FirstOrDefault(f =>
{
using var sha = SHA256.Create();
@@ -194,14 +198,15 @@ static List LoadVersionStore(IWebHostEnvironment env)
var packagesDir = Path.Combine(env.ContentRootPath, "wwwroot", "packages");
foreach (var e in entries)
{
- var zipPath = Path.Combine(packagesDir, $"{e.PacketName}.zip");
- if (File.Exists(zipPath))
+ var ext = e.Format ?? ".zip";
+ var filePath = Path.Combine(packagesDir, $"{e.PacketName}{ext}");
+ if (File.Exists(filePath))
{
- e.Size ??= new FileInfo(zipPath).Length;
+ e.Size ??= new FileInfo(filePath).Length;
if (string.IsNullOrEmpty(e.Hash))
{
using var sha256 = SHA256.Create();
- using var stream = File.OpenRead(zipPath);
+ using var stream = File.OpenRead(filePath);
e.Hash = Convert.ToHexStringLower(sha256.ComputeHash(stream));
}
}
diff --git a/src/Server/appsettings.json b/src/Server/appsettings.json
index 10f68b8..4cf5145 100644
--- a/src/Server/appsettings.json
+++ b/src/Server/appsettings.json
@@ -5,5 +5,7 @@
"Microsoft.AspNetCore": "Warning"
}
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "Urls": "http://0.0.0.0:5000",
+ "BaseUrl": "http://localhost:5000"
}
diff --git a/src/Server/wwwroot/packages/versions.json b/src/Server/wwwroot/packages/versions.json
index 6b9dc5d..5d39a51 100644
--- a/src/Server/wwwroot/packages/versions.json
+++ b/src/Server/wwwroot/packages/versions.json
@@ -1,104 +1,113 @@
-[
+[
{
- "PubTime": "2026-06-02T21:00:52.9368082+08:00",
- "Version": "2.0.0.0",
- "Format": ".zip",
- "ProductId": "2d974e2a-31e6-4887-9bb1-b4689e98c77a",
- "AppType": 1,
- "PacketName": "packet_20260602210052838_full_client_2.0.0.0",
- "ToVersion": null,
- "Url": "http://localhost:5000/File/Download/a1f180bfc0a58429a42be59d15421b810c993274af65b23a0b049880bb415d47",
- "IsFreeze": false,
- "IsCrossVersion": false,
- "FromVersion": null,
- "Platform": 1,
- "IsForcibly": false,
- "Size": 1068,
- "Hash": "a1f180bfc0a58429a42be59d15421b810c993274af65b23a0b049880bb415d47"
+ "PubTime": "2026-06-02T21:00:52.9368082+08:00",
+ "Version": "2.0.0.0",
+ "Format": ".zip",
+ "ProductId": "2d974e2a-31e6-4887-9bb1-b4689e98c77a",
+ "AppType": 1,
+ "PacketName": "packet_20260602210052838_full_client_2.0.0.0",
+ "Url": "http://localhost:5000/File/Download/a1f180bfc0a58429a42be59d15421b810c993274af65b23a0b049880bb415d47",
+ "IsFreeze": false,
+ "IsCrossVersion": false,
+ "FromVersion": null,
+ "Platform": 1,
+ "IsForcibly": false,
+ "Size": 1068,
+ "Hash": "a1f180bfc0a58429a42be59d15421b810c993274af65b23a0b049880bb415d47"
},
{
- "PubTime": "2026-06-02T21:00:52.9527877+08:00",
- "Version": "2.0.0.0",
- "Format": ".zip",
- "ProductId": "2d974e2a-31e6-4887-9bb1-b4689e98c77a",
- "AppType": 2,
- "PacketName": "packet_20260602210052838_full_upgrade_2.0.0.0",
- "ToVersion": null,
- "Url": "http://localhost:5000/File/Download/0d7727be7ea8520fe60ce37ce30b1a956ee6e33b252489045b4d852134b4abc6",
- "IsFreeze": false,
- "IsCrossVersion": false,
- "FromVersion": null,
- "Platform": 1,
- "IsForcibly": false,
- "Size": 227,
- "Hash": "0d7727be7ea8520fe60ce37ce30b1a956ee6e33b252489045b4d852134b4abc6"
+ "PubTime": "2026-06-02T21:00:52.9527877+08:00",
+ "Version": "2.0.0.0",
+ "Format": ".zip",
+ "ProductId": "2d974e2a-31e6-4887-9bb1-b4689e98c77a",
+ "AppType": 2,
+ "PacketName": "packet_20260602210052838_full_upgrade_2.0.0.0",
+ "Url": "http://localhost:5000/File/Download/0d7727be7ea8520fe60ce37ce30b1a956ee6e33b252489045b4d852134b4abc6",
+ "IsFreeze": false,
+ "IsCrossVersion": false,
+ "FromVersion": null,
+ "Platform": 1,
+ "IsForcibly": false,
+ "Size": 227,
+ "Hash": "0d7727be7ea8520fe60ce37ce30b1a956ee6e33b252489045b4d852134b4abc6"
},
{
- "PubTime": "2026-06-02T21:00:52.9705223+08:00",
- "Version": "1.0.0.1",
- "Format": ".zip",
- "ProductId": "2d974e2a-31e6-4887-9bb1-b4689e98c77a",
- "AppType": 1,
- "PacketName": "packet_20260602210052838_full_client_1.0.0.1",
- "ToVersion": null,
- "Url": "http://localhost:5000/File/Download/47e693ac91230db216cf4ece7d6775e7bc73b79028eb76f847f74ad01d125ef6",
- "IsFreeze": false,
- "IsCrossVersion": false,
- "FromVersion": null,
- "Platform": 1,
- "IsForcibly": false,
- "Size": 140,
- "Hash": "47e693ac91230db216cf4ece7d6775e7bc73b79028eb76f847f74ad01d125ef6"
+ "PubTime": "2026-06-02T21:00:52.9705223+08:00",
+ "Version": "1.0.0.1",
+ "Format": ".zip",
+ "ProductId": "2d974e2a-31e6-4887-9bb1-b4689e98c77a",
+ "AppType": 1,
+ "PacketName": "packet_20260602210052838_full_client_1.0.0.1",
+ "Url": "http://localhost:5000/File/Download/47e693ac91230db216cf4ece7d6775e7bc73b79028eb76f847f74ad01d125ef6",
+ "IsFreeze": false,
+ "IsCrossVersion": false,
+ "FromVersion": null,
+ "Platform": 1,
+ "IsForcibly": false,
+ "Size": 140,
+ "Hash": "47e693ac91230db216cf4ece7d6775e7bc73b79028eb76f847f74ad01d125ef6"
},
{
- "PubTime": "2026-06-02T21:00:52.9735836+08:00",
- "Version": "1.0.0.1",
- "Format": ".zip",
- "ProductId": "2d974e2a-31e6-4887-9bb1-b4689e98c77a",
- "AppType": 2,
- "PacketName": "packet_20260602210052838_full_upgrade_1.0.0.1",
- "ToVersion": null,
- "Url": "http://localhost:5000/File/Download/47e693ac91230db216cf4ece7d6775e7bc73b79028eb76f847f74ad01d125ef6",
- "IsFreeze": false,
- "IsCrossVersion": false,
- "FromVersion": null,
- "Platform": 1,
- "IsForcibly": false,
- "Size": 140,
- "Hash": "47e693ac91230db216cf4ece7d6775e7bc73b79028eb76f847f74ad01d125ef6"
+ "PubTime": "2026-06-02T21:00:52.9735836+08:00",
+ "Version": "1.0.0.1",
+ "Format": ".zip",
+ "ProductId": "2d974e2a-31e6-4887-9bb1-b4689e98c77a",
+ "AppType": 2,
+ "PacketName": "packet_20260602210052838_full_upgrade_1.0.0.1",
+ "Url": "http://localhost:5000/File/Download/47e693ac91230db216cf4ece7d6775e7bc73b79028eb76f847f74ad01d125ef6",
+ "IsFreeze": false,
+ "IsCrossVersion": false,
+ "FromVersion": null,
+ "Platform": 1,
+ "IsForcibly": false,
+ "Size": 140,
+ "Hash": "47e693ac91230db216cf4ece7d6775e7bc73b79028eb76f847f74ad01d125ef6"
},
{
- "PubTime": "2026-06-02T21:00:52.9849748+08:00",
- "Version": "1.0.0.2",
- "Format": ".zip",
- "ProductId": "2d974e2a-31e6-4887-9bb1-b4689e98c77a",
- "AppType": 1,
- "PacketName": "packet_20260602210052838_full_client_1.0.0.2",
- "ToVersion": null,
- "Url": "http://localhost:5000/File/Download/d0e74a16a230eb53cfd6d3d955582f3b1c22f838822e61a3148afcd2336ef94c",
- "IsFreeze": false,
- "IsCrossVersion": false,
- "FromVersion": null,
- "Platform": 1,
- "IsForcibly": false,
- "Size": 140,
- "Hash": "d0e74a16a230eb53cfd6d3d955582f3b1c22f838822e61a3148afcd2336ef94c"
+ "PubTime": "2026-06-02T21:00:52.9849748+08:00",
+ "Version": "1.0.0.2",
+ "Format": ".zip",
+ "ProductId": "2d974e2a-31e6-4887-9bb1-b4689e98c77a",
+ "AppType": 1,
+ "PacketName": "packet_20260602210052838_full_client_1.0.0.2",
+ "Url": "http://localhost:5000/File/Download/d0e74a16a230eb53cfd6d3d955582f3b1c22f838822e61a3148afcd2336ef94c",
+ "IsFreeze": false,
+ "IsCrossVersion": false,
+ "FromVersion": null,
+ "Platform": 1,
+ "IsForcibly": false,
+ "Size": 140,
+ "Hash": "d0e74a16a230eb53cfd6d3d955582f3b1c22f838822e61a3148afcd2336ef94c"
},
{
- "PubTime": "2026-06-02T21:00:52.9889760+08:00",
- "Version": "1.0.0.2",
- "Format": ".zip",
- "ProductId": "2d974e2a-31e6-4887-9bb1-b4689e98c77a",
- "AppType": 2,
- "PacketName": "packet_20260602210052838_full_upgrade_1.0.0.2",
- "ToVersion": null,
- "Url": "http://localhost:5000/File/Download/d0e74a16a230eb53cfd6d3d955582f3b1c22f838822e61a3148afcd2336ef94c",
- "IsFreeze": false,
- "IsCrossVersion": false,
- "FromVersion": null,
- "Platform": 1,
- "IsForcibly": false,
- "Size": 140,
- "Hash": "d0e74a16a230eb53cfd6d3d955582f3b1c22f838822e61a3148afcd2336ef94c"
+ "PubTime": "2026-06-02T21:00:52.9889760+08:00",
+ "Version": "1.0.0.2",
+ "Format": ".zip",
+ "ProductId": "2d974e2a-31e6-4887-9bb1-b4689e98c77a",
+ "AppType": 2,
+ "PacketName": "packet_20260602210052838_full_upgrade_1.0.0.2",
+ "Url": "http://localhost:5000/File/Download/d0e74a16a230eb53cfd6d3d955582f3b1c22f838822e61a3148afcd2336ef94c",
+ "IsFreeze": false,
+ "IsCrossVersion": false,
+ "FromVersion": null,
+ "Platform": 1,
+ "IsForcibly": false,
+ "Size": 140,
+ "Hash": "d0e74a16a230eb53cfd6d3d955582f3b1c22f838822e61a3148afcd2336ef94c"
+ },
+ {
+ "PacketName": "appupdate_2.0.0.0",
+ "Hash": "12702ecb17f0734b83a35a3b5964f689bf18a6e310cd5f75a0f89b8998608b3a",
+ "Version": "2.0.0.0",
+ "PubTime": "2026-06-13T00:00:00Z",
+ "AppType": 1,
+ "Platform": 4,
+ "ProductId": "2d974e2a-31e6-4887-9bb1-b4689e98c77a",
+ "IsForcibly": false,
+ "Format": ".apk",
+ "Size": 43346849,
+ "IsFreeze": false,
+ "IsCrossVersion": false,
+ "FromVersion": null
}
-]
\ No newline at end of file
+]