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/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
+]