diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 2d36f45..3b9e26a 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -58,6 +58,9 @@ jobs: - name: Build Android project run: dotnet build src/GeneralUpdate.Avalonia.Android/GeneralUpdate.Avalonia.Android.csproj --configuration Release --no-restore + - name: Build Android upgrade sample + run: dotnet build samples/GeneralUpdate.Avalonia.Android.Sample/GeneralUpdate.Avalonia.Android.Sample.csproj --configuration Release + - name: Pack NuGet run: dotnet pack src/GeneralUpdate.Avalonia.Android/GeneralUpdate.Avalonia.Android.csproj --configuration Release --no-build -o artifacts diff --git a/README-EN.md b/README-EN.md index c98cb1a..69b216c 100644 --- a/README-EN.md +++ b/README-EN.md @@ -55,6 +55,8 @@ dotnet test tests/GeneralUpdate.Avalonia.Android.Tests/GeneralUpdate.Avalonia.An ### Basic Usage +Supply `androidPlatformId` from the host configuration to match the deployed server. + ```csharp using GeneralUpdate.Avalonia.Android; using GeneralUpdate.Avalonia.Android.Models; @@ -80,17 +82,27 @@ var options = new AndroidUpdateOptions using var bootstrap = GeneralUpdateBootstrap.CreateDefault(options); -var check = await bootstrap.ValidateAsync("2.2.1", CancellationToken.None); -if (check.Success && check.UpdateFound && check.PackageInfo is { } packageInfo) +bootstrap.AddListenerUpdateFailed += (_, args) => Console.Error.WriteLine(args.Result.Message); +var context = global::Android.App.Application.Context; +var currentVersion = context.PackageManager?.GetPackageInfo(context.PackageName!, + global::Android.Content.PM.PackageInfoFlags.Activities)?.VersionName + ?? throw new InvalidOperationException("Cannot read the installed version."); + +var installation = await bootstrap.CheckInstallationAsync(currentVersion); +if (!installation.Success) return; + +var prepared = await bootstrap.PrepareUpdateAsync(currentVersion, CancellationToken.None); +if (prepared.IsReadyToInstall && prepared.PackageInfo is { } package && prepared.FilePath is { } path) { - var prepared = await bootstrap.DownloadAndVerifyAsync(packageInfo, CancellationToken.None); - if (prepared.Success && prepared.FilePath is not null) - { - await bootstrap.LaunchInstallerAsync(packageInfo, prepared.FilePath, CancellationToken.None); - } + await bootstrap.LaunchInstallerAsync(package, path, CancellationToken.None); } ``` +`PrepareUpdateAsync` holds one operation lock across query, comparison, pre-check, download and hash verification. +No update or a skipped update returns `Success = true`, `IsReadyToInstall = false`. It never opens an installer or +permission screen. Keep using `ValidateAsync` / `DownloadAndVerifyAsync` when separate stages are needed. +Use `IUpdateEventDispatcher` to marshal UI events; see permission handling below. + ### Server-Driven Version Validation `ValidateAsync(currentVersion, cancellationToken)` only needs the version installed on the device: the component queries @@ -123,9 +135,15 @@ then GETs a single `UpdatePackageInfo` (property names are case-insensitive), fo `UpdateCheckResult.Success = false`, `FailureReason` and `AddListenerUpdateFailed`, and never invoke the pre-check callback. - Cancellation during the request returns `UpdateState.Canceled`; cancelling while waiting on the operation gate throws `OperationCanceledException`. -- Validation and downloads share the `httpOptions` passed to `CreateDefault` (`RequestTimeout`, proxy, TLS, `AuthProvider`). - Without `httpOptions` the supplied `httpClient` is reused and its lifetime stays with the host. -- Calling `ValidateAsync` without `UpdateServer` fails with `UpdateFailureReason.InvalidMetadata`. +- Validation and downloads share one HTTP client. A supplied `httpClient` is always borrowed and preserved, including its + handler and `Timeout`; request timeout, retry and authentication policies may be supplied alongside it. The earlier timeout wins. + Configure TLS/proxy on that client's handler: combining those handler settings in `httpOptions` with an external client + throws `ArgumentException` instead of silently replacing it. Otherwise the library creates and disposes its own client. +- Factory defaults: 30-second query/HEAD timeout, 10-minute total download timeout (including backoff), up to 3 download + attempts. Transient HEAD/GET/response-stream failures retry with resume; HEAD 405/501 falls back to GET. + Permanent errors such as 401 and user cancellation do not retry. `MaxRetryAttempts` includes the initial attempt and does + not apply to metadata queries. Timeouts report `Failed/NetworkError`; user cancellation reports `Canceled`. +- Validation without either `UpdateServer` or an injected `IUpdatePackageSource` fails with `InvalidMetadata`. - Only query trusted servers and use HTTPS in production. Once a newer version is found, the `AddListenerUpdatePrecheck` callback receives the discovered package metadata and returns @@ -165,12 +183,71 @@ configures all four items below: using var bootstrap = GeneralUpdateBootstrap.CreateDefault(options, activityProvider: myActivityProvider); ``` -4. **Server** — `AndroidUpdateOptions.UpdateServer` must be configured (or use the static JSON endpoint through - `UseJsonEndpoint`), and `sha256` must be a 64-character hexadecimal SHA-256. +4. **Server** — configure `AndroidUpdateOptions.UpdateServer` (or a static JSON endpoint / custom `IUpdatePackageSource`), + and provide a 64-character hexadecimal SHA-256. `LaunchInstallerAsync` returning `Success = true` only means the installer intent was launched; it **does not** mean the user -finished installing. The process is killed on completion, so compare the installed version with the server again on the next -launch to confirm the update actually took effect. +finished installing. The process is killed on completion, so call `CheckInstallationAsync` with the actual installed version +on the next launch to confirm the update took effect. + +### Closing the Installation Loop + +`CreateDefault` atomically records the latest installation target **before** launching the installer in +`/update/installation.json`, outside the disposable APK cache. Override `InstallationStateFilePath` if needed, +and use only one bootstrap per journal. If persistence fails, installation is not launched and `FileIoError` is reported. +The journal contains no download credentials. + +At startup or when returning from the installer, read the actual version from Android `PackageManager` and call: + +```csharp +bootstrap.AddListenerInstallationConfirmed += (_, args) => +{ + // args.Result.Record.TargetVersion is the previous target. + // args.Result.CurrentVersion is the version read from the device. +}; +var installation = await bootstrap.CheckInstallationAsync(currentVersion, CancellationToken.None); +``` + +`Success` with `State == None` means no recorded attempt, not a successful installation. +`HasPendingInstallation` means the installed version is below the target: installation may be pending, canceled or failed. +`IsInstalled` means the installed version reached or exceeded the target (`Installed` state); the outcome is persisted. +Invalid versions or storage errors return `Success == false` and raise `AddListenerUpdateFailed`, never a silent empty result. + +Reconciliation works offline; query `ValidateAsync` separately for further updates. `AddListenerInstallationConfirmed` fires +only on the first durable confirmation, not on repeated checks or restarts. It is not a reliable message queue; restore UI +from the returned durable result. The legacy `AddListenerUpdateCompleted` remains a phase notification +(`ReadyToInstall` / `Installing`), **not installation success**. The compatibility constructor now uses +`LocalApplicationData/update/installation.json` when `installationStateFilePath` is omitted, rather than disabling tracking. +The new dependency-injection constructor requires an explicit `IInstallationStore`. + +For corrupt records, ask the user before calling `await bootstrap.ResetInstallationAsync()` and check its `Success`. +Reset only removes the journal, not the installed app, downloads or server settings. Never reset automatically on every failure. + +### Extension Points and Lifetime + +Implement `IUpdatePackageSource.GetLatestAsync(currentVersion, ct)` for a custom protocol and +`IInstallationStore.LoadAsync/SaveAsync/ClearAsync` for custom persistence, without duplicating orchestration: + +```csharp +using var bootstrap = GeneralUpdateBootstrap.CreateDefault(options, + packageSource: myPackageSource, installationStore: myInstallationStore); +``` + +For full dependency injection, use `AndroidBootstrap(versionComparer, downloader, hashValidator, apkInstaller, +fileStorage, packageSource, installationStore, eventDispatcher?, logger?)`. The default implementations are +`HttpUpdatePackageClient` and `JsonFileInstallationStore`; the orchestrator no longer depends on their concrete protocols/storage. +A source returns `null` only for no update, and throws transport/protocol exceptions for failures. +A store must provide atomic durable writes and report corruption rather than return an empty record. + +Use one bootstrap per journal. Bootstrap owns disposable downloader/package-source services; the host owns other injected +dependencies, including the store. Do not share bootstrap-owned service instances across updaters. +Cancel and await active work before disposal. Early disposal rejects new/queued operations and defers cleanup until the active +operation exits; it does not cancel that operation or dispose its semaphore while it is still in use. + +The sample persists server settings, reconciles the previous attempt and checks the server at startup, without automatically +reopening the installer. Users still confirm installation and reopen the app. APKs must have the same package ID, compatible +signatures and an increasing `versionCode`. Version reconciliation is not APK signature verification or an application health +check; silent installation, automatic restart and rollback are not provided. ## Directory Structure diff --git a/README.md b/README.md index 5a64fcd..777b65e 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ dotnet test tests/GeneralUpdate.Avalonia.Android.Tests/GeneralUpdate.Avalonia.An ### 基本使用示例 +示例中的 `androidPlatformId` 由宿主配置为服务端实际使用的 Android 平台编号。 + ```csharp using GeneralUpdate.Avalonia.Android; using GeneralUpdate.Avalonia.Android.Models; @@ -80,17 +82,27 @@ var options = new AndroidUpdateOptions using var bootstrap = GeneralUpdateBootstrap.CreateDefault(options); -var check = await bootstrap.ValidateAsync("2.2.1", CancellationToken.None); -if (check.Success && check.UpdateFound && check.PackageInfo is { } packageInfo) +bootstrap.AddListenerUpdateFailed += (_, args) => Console.Error.WriteLine(args.Result.Message); +var context = global::Android.App.Application.Context; +var currentVersion = context.PackageManager?.GetPackageInfo(context.PackageName!, + global::Android.Content.PM.PackageInfoFlags.Activities)?.VersionName + ?? throw new InvalidOperationException("无法读取本机版本。"); + +// 启动时先离线核对上次安装;记录损坏时明确报错,不自动清空。 +var installation = await bootstrap.CheckInstallationAsync(currentVersion); +if (!installation.Success) return; + +var prepared = await bootstrap.PrepareUpdateAsync(currentVersion, CancellationToken.None); +if (prepared.IsReadyToInstall && prepared.PackageInfo is { } package && prepared.FilePath is { } path) { - var prepared = await bootstrap.DownloadAndVerifyAsync(packageInfo, CancellationToken.None); - if (prepared.Success && prepared.FilePath is not null) - { - await bootstrap.LaunchInstallerAsync(packageInfo, prepared.FilePath, CancellationToken.None); - } + await bootstrap.LaunchInstallerAsync(package, path, CancellationToken.None); } ``` +`PrepareUpdateAsync` 在一个操作锁内完成查询、版本比较、pre-check、下载和哈希校验;没有更新或被跳过时 +`Success = true`、`IsReadyToInstall = false`。它不会打开安装器或权限页面。需要只检查版本或分阶段控制时, +仍可使用 `ValidateAsync` / `DownloadAndVerifyAsync`。UI 线程切换使用 `IUpdateEventDispatcher`,权限处理见下文。 + ### 服务端版本校验 `ValidateAsync(currentVersion, cancellationToken)` 只需要当前应用的版本号:组件按 @@ -121,9 +133,14 @@ ZIP、差分包、驱动包不会交给 Android 安装器;`body` 为空数组 - HTTP 204 或 GET JSON `null` 表示无包;请求、协议与元数据错误通过 `UpdateCheckResult.Success = false`、 `FailureReason` 和 `AddListenerUpdateFailed` 上报,并且不会触发 pre-check。 - 请求期间取消返回 `UpdateState.Canceled`;等待操作锁时取消会抛出 `OperationCanceledException`。 -- 查询与下载共用 `CreateDefault` 的 `httpOptions`(`RequestTimeout`、代理、TLS 与 `AuthProvider`); - 未提供 `httpOptions` 时复用传入的 `httpClient`,其生命周期仍由宿主管理。 -- 未配置 `UpdateServer` 时调用 `ValidateAsync` 会以 `UpdateFailureReason.InvalidMetadata` 失败。 +- 查询与下载共用 `CreateDefault` 的一个 HTTP 客户端。外部 `httpClient` 始终保留并由宿主管理, + 可同时传入超时、重试和认证策略;不会改写该客户端的 `Timeout`,实际生效的是较早到期的限制。 + 外部客户端的 TLS/代理必须配置在其 handler 上;同时通过 `httpOptions` 指定 TLS/代理会明确抛出 + `ArgumentException`,不会静默替换客户端。未传客户端时,组件创建并释放自己的客户端。 +- 工厂默认查询/HEAD 超时 30 秒,整个下载(含重试等待)超时 10 分钟,最多 3 次下载尝试。 + HEAD/GET/响应流的瞬态网络失败会重试并续传,HEAD 返回 405/501 时改用 GET;401 等永久错误及主动取消不重试。 + `MaxRetryAttempts` 包含首次尝试,不作用于元数据查询。超时报告 `Failed/NetworkError`,主动取消报告 `Canceled`。 +- 未配置 `UpdateServer` 且未注入 `IUpdatePackageSource` 时,版本查询以 `InvalidMetadata` 失败。 - 仅应查询可信服务器,生产环境请使用 HTTPS。 发现新版本后,`AddListenerUpdatePrecheck` 回调会拿到最新包信息,返回 `true` 跳过、`false` 继续 @@ -162,11 +179,68 @@ ZIP、差分包、驱动包不会交给 Android 安装器;`body` 为空数组 using var bootstrap = GeneralUpdateBootstrap.CreateDefault(options, activityProvider: myActivityProvider); ``` -4. **服务端**:必须配置 `AndroidUpdateOptions.UpdateServer`(或改用 `UseJsonEndpoint` 的静态 JSON), +4. **服务端**:配置 `AndroidUpdateOptions.UpdateServer`(或静态 JSON / 自定义 `IUpdatePackageSource`), 且 `sha256` 为 64 位十六进制 SHA-256。 `LaunchInstallerAsync` 返回 `Success = true` 只表示安装器已拉起,**不代表用户已完成安装**:安装完成后进程会被 -系统结束,下次启动时请自行比较本机版本与服务端版本,以确认这次更新是否真正生效。 +系统结束,下次启动时调用 `CheckInstallationAsync` 核对本机实际版本,以确认这次更新是否真正生效。 + +### 升级结果闭环 + +`CreateDefault` 默认在 `/update/installation.json` 保存最近一次安装目标,而不是保存在可被清理的 +APK 缓存中。可以通过 `InstallationStateFilePath` 指定其他持久化位置。同一文件只使用一个 bootstrap 实例。 +安装目标在拉起安装器**之前**原子写入;写入失败会返回 `FileIoError`,不会继续拉起安装器。记录不包含下载令牌。 + +宿主在启动或从安装器返回时,从 Android `PackageManager` 读取当前版本,再调用: + +```csharp +bootstrap.AddListenerInstallationConfirmed += (_, args) => +{ + // args.Result.Record.TargetVersion:上次安装目标 + // args.Result.CurrentVersion:本次从设备读取的实际版本 +}; +var installation = await bootstrap.CheckInstallationAsync(currentVersion, CancellationToken.None); +``` + +| 结果 | 含义 | +|---|---| +| `Success && State == None` | 没有安装记录,不表示安装成功 | +| `HasPendingInstallation` | 当前版本低于目标,安装尚未确认;可能取消、失败或仍在进行,可重新检查并重试升级 | +| `IsInstalled` | 本机版本已达到或超过目标,状态为 `Installed`,确认结果已持久化 | +| `!Success` | 本机版本无效或记录读取/写入失败,通过 `AddListenerUpdateFailed` 报错,不伪装成“无记录” | + +该核对不依赖网络;随后可调用 `ValidateAsync` 查询是否还有更新。`AddListenerInstallationConfirmed` +只在首次持久化确认时触发,重复核对或进程重启不会重复发送;它不是可靠消息队列,界面恢复应以返回的持久化结果为准。 +原有 `AddListenerUpdateCompleted` 保持兼容,仍表示 `ReadyToInstall` / `Installing` 阶段完成,**不能用作安装成功通知**。 +兼容构造函数不再隐式禁用跟踪:未指定 `installationStateFilePath` 时使用应用 +`LocalApplicationData/update/installation.json`。新依赖注入构造函数要求显式提供 `IInstallationStore`。 + +记录损坏时,由宿主明确提示用户后调用 `await bootstrap.ResetInstallationAsync()`,并检查返回的 `Success`。 +重置只删除升级记录,不修改已安装应用、下载文件或服务端配置;不要在遇到任何错误时自动重置。 + +### 扩展与生命周期 + +自有协议实现 `IUpdatePackageSource.GetLatestAsync(currentVersion, ct)`,数据库等存储实现 +`IInstallationStore.LoadAsync/SaveAsync/ClearAsync`,通过工厂的命名参数注入即可,不必复制流程代码: + +```csharp +using var bootstrap = GeneralUpdateBootstrap.CreateDefault(options, + packageSource: myPackageSource, installationStore: myInstallationStore); +``` + +需要替换全部环节时,使用 `AndroidBootstrap(versionComparer, downloader, hashValidator, apkInstaller, +fileStorage, packageSource, installationStore, eventDispatcher?, logger?)`。HTTP 协议和 JSON 持久化分别由 +`HttpUpdatePackageClient`、`JsonFileInstallationStore` 实现,流程类不再绑定这些具体实现。 +自定义源返回 `null` 表示无更新,网络/协议错误应抛出对应异常;存储须持久化原子写入,损坏数据应报错,不能返回空记录。 + +一个升级器对应一个安装记录。Bootstrap 负责释放实现 `IDisposable` 的 downloader/package source; +其余注入依赖(包括 store)由宿主管理,不要跨升级器共享由 Bootstrap 拥有的服务实例。 +关闭页面时先取消并等待当前操作,再 `Dispose`。提前 Dispose 会拒绝新调用和排队调用,并延迟到当前操作退出后释放资源, +不会自动取消当前操作,也不会在其 `finally` 释放信号量时抛异常。 + +示例会保存服务端配置,启动时核对上次升级并自动检查服务端,单独显示升级结果;它不会自动重复弹出安装器。 +用户仍需确认系统安装并重新打开应用。生产 APK 必须保持相同包名、兼容签名和递增的 `versionCode`; +本机版本核对不是 APK 签名校验或应用健康检查,也不提供静默安装、自动重启或回滚。 ## 目录结构 diff --git a/samples/GeneralUpdate.Avalonia.Android.Sample/GeneralUpdate.Avalonia.Android.Sample.csproj b/samples/GeneralUpdate.Avalonia.Android.Sample/GeneralUpdate.Avalonia.Android.Sample.csproj index 09954d1..e0f57a5 100644 --- a/samples/GeneralUpdate.Avalonia.Android.Sample/GeneralUpdate.Avalonia.Android.Sample.csproj +++ b/samples/GeneralUpdate.Avalonia.Android.Sample/GeneralUpdate.Avalonia.Android.Sample.csproj @@ -11,6 +11,8 @@ apk true 26 + + false diff --git a/samples/GeneralUpdate.Avalonia.Android.Sample/Infrastructure/AndroidUpdateHost.cs b/samples/GeneralUpdate.Avalonia.Android.Sample/Infrastructure/AndroidUpdateHost.cs new file mode 100644 index 0000000..d822b5d --- /dev/null +++ b/samples/GeneralUpdate.Avalonia.Android.Sample/Infrastructure/AndroidUpdateHost.cs @@ -0,0 +1,113 @@ +using Android.Content; +using Android.Content.PM; +using Android.Provider; +using GeneralUpdate.Avalonia.Android.Abstractions; +using GeneralUpdate.Avalonia.Android.Models; + +namespace GeneralUpdate.Avalonia.Android.Sample.Infrastructure; + +internal sealed class AndroidUpdateHost(IUpdateLogger logger) : IUpdateHost +{ + private static Context Context => global::Android.App.Application.Context; + + public IAndroidBootstrap CreateBootstrap(UpdateServerOptions? options, UpdateLanguage language) => + GeneralUpdateBootstrap.CreateDefault(new AndroidUpdateOptions + { + FileProviderAuthority = $"{Context.PackageName}.generalupdate.fileprovider", + UpdateServer = options, + Language = language, + AllowInsecureHttpDownloads = options?.RequestUrl.StartsWith("http://127.0.0.1:", StringComparison.OrdinalIgnoreCase) == true + }, activityProvider: new CurrentActivityProvider(), + eventDispatcher: new AvaloniaUpdateEventDispatcher(), logger: logger, + httpOptions: new HttpDownloadOptions { DownloadTimeout = TimeSpan.FromMinutes(15) }); + + public UpdateLanguage LoadLanguage() + { + using var preferences = GetPreferences(); + var language = preferences.GetInt("language", (int)UpdateLanguage.English); + return Enum.IsDefined((UpdateLanguage)language) ? (UpdateLanguage)language : UpdateLanguage.English; + } + + public void SaveLanguage(UpdateLanguage language) + { + using var preferences = GetPreferences(language); + using var editor = preferences.Edit() ?? throw new IOException(UpdateViewText.Get(language, "Unable to save language preference.")); + if (!editor.PutInt("language", (int)language).Commit()) + throw new IOException(UpdateViewText.Get(language, "Unable to save language preference.")); + } + + public string GetCurrentVersion(UpdateLanguage language) + { + if (Context.PackageManager is not { } manager || string.IsNullOrWhiteSpace(Context.PackageName)) + throw new InvalidOperationException(UpdateViewText.Get(language, "Unable to read installed app version.")); + try + { + var version = manager.GetPackageInfo(Context.PackageName, PackageInfoFlags.Activities)?.VersionName; + return !string.IsNullOrWhiteSpace(version) ? version + : throw new InvalidOperationException(UpdateViewText.Get(language, + "The installed app does not provide a version, so update confirmation is unavailable.")); + } + catch (PackageManager.NameNotFoundException ex) + { + throw new InvalidOperationException(UpdateViewText.Get(language, "Unable to read installed app version."), ex); + } + } + + public bool CanRequestInstalls() => + global::Android.OS.Build.VERSION.SdkInt < global::Android.OS.BuildVersionCodes.O || + Context.PackageManager?.CanRequestPackageInstalls() == true; + + public void RequestInstallPermission(UpdateLanguage language) + { + if (string.IsNullOrWhiteSpace(Context.PackageName)) + throw new InvalidOperationException(UpdateViewText.Get(language, "Unable to read app package name.")); + using var intent = new Intent(Settings.ActionManageUnknownAppSources, + global::Android.Net.Uri.Parse($"package:{Context.PackageName}")); + try + { + if (MainActivity.Current is { } activity) + activity.StartActivity(intent); + else + Context.StartActivity(intent.AddFlags(ActivityFlags.NewTask)); + } + catch (Exception ex) when (ex is ActivityNotFoundException or Java.Lang.SecurityException) + { + throw new InvalidOperationException(UpdateViewText.Get(language, + "Unable to open the unknown sources installation permission page."), ex); + } + } + + public UpdateServerOptions LoadServerOptions(UpdateLanguage language) + { + using var preferences = GetPreferences(language); + var platform = preferences.GetString("platform", "3"); + if (!int.TryParse(platform, out var platformId) || platformId <= 0) + throw new InvalidDataException(UpdateViewText.Get(language, "Saved platform number is invalid. Re-enter and save it.")); + return new UpdateServerOptions + { + RequestUrl = preferences.GetString("url", "http://127.0.0.1:5080/Upgrade/Verification")!, + AppKey = preferences.GetString("appKey", "demo-client")!, + Platform = platformId, + ProductId = preferences.GetString("productId", "demo-product")!, + AppType = 1 + }; + } + + public void SaveServerOptions(UpdateServerOptions options, UpdateLanguage language) + { + using var preferences = GetPreferences(language); + using var editor = preferences.Edit() ?? throw new IOException(UpdateViewText.Get(language, "Unable to save update server settings.")); + editor.PutString("url", options.RequestUrl); + editor.PutString("appKey", options.AppKey); + editor.PutString("platform", options.Platform.ToString(System.Globalization.CultureInfo.InvariantCulture)); + editor.PutString("productId", options.ProductId); + if (!editor.Commit()) + throw new IOException(UpdateViewText.Get(language, "Saving update server settings failed; update was not started.")); + } + + private static ISharedPreferences GetPreferences(UpdateLanguage language) => + Context.GetSharedPreferences("generalupdate-server", FileCreationMode.Private) + ?? throw new IOException(UpdateViewText.Get(language, "Unable to read update server settings.")); + + private static ISharedPreferences GetPreferences() => GetPreferences(UpdateLanguage.English); +} diff --git a/samples/GeneralUpdate.Avalonia.Android.Sample/Infrastructure/IUpdateHost.cs b/samples/GeneralUpdate.Avalonia.Android.Sample/Infrastructure/IUpdateHost.cs new file mode 100644 index 0000000..196fee8 --- /dev/null +++ b/samples/GeneralUpdate.Avalonia.Android.Sample/Infrastructure/IUpdateHost.cs @@ -0,0 +1,16 @@ +using GeneralUpdate.Avalonia.Android.Abstractions; +using GeneralUpdate.Avalonia.Android.Models; + +namespace GeneralUpdate.Avalonia.Android.Sample.Infrastructure; + +internal interface IUpdateHost +{ + UpdateLanguage LoadLanguage(); + void SaveLanguage(UpdateLanguage language); + string GetCurrentVersion(UpdateLanguage language); + UpdateServerOptions LoadServerOptions(UpdateLanguage language); + void SaveServerOptions(UpdateServerOptions options, UpdateLanguage language); + IAndroidBootstrap CreateBootstrap(UpdateServerOptions? options, UpdateLanguage language); + bool CanRequestInstalls(); + void RequestInstallPermission(UpdateLanguage language); +} diff --git a/samples/GeneralUpdate.Avalonia.Android.Sample/Infrastructure/UpdateViewText.cs b/samples/GeneralUpdate.Avalonia.Android.Sample/Infrastructure/UpdateViewText.cs new file mode 100644 index 0000000..77b0c27 --- /dev/null +++ b/samples/GeneralUpdate.Avalonia.Android.Sample/Infrastructure/UpdateViewText.cs @@ -0,0 +1,66 @@ +using System.Globalization; +using GeneralUpdate.Avalonia.Android; + +namespace GeneralUpdate.Avalonia.Android.Sample.Infrastructure; + +internal static class UpdateViewText +{ + private static readonly IReadOnlyDictionary Chinese = new Dictionary + { + ["Language"] = "语言", + ["GeneralUpdate mobile updater"] = "GeneralUpdate 移动端自动升级", + ["Connect to GeneralSpacestation to check versions, download APKs, verify SHA-256, and install."] = "连接 GeneralSpacestation,完成版本校验、APK 下载、SHA-256 校验和安装。", + ["Current version:"] = "当前版本:", + ["Target version:"] = "目标版本:", + ["GeneralSpacestation settings"] = "GeneralSpacestation 配置", + ["Verification endpoint"] = "验证接口", + ["AppKey configured in admin console"] = "管理端配置的 AppKey", + ["Android platform number"] = "Android 平台编号", + ["Product ID"] = "产品编号", + ["Release notes"] = "更新说明", + ["Not checked"] = "尚未检查", + ["Shown when an update is found"] = "检查到新版本后显示", + ["Waiting for update check"] = "等待检查更新", + ["Reconciling previous update..."] = "正在核对上次升级结果...", + ["Checking server version automatically..."] = "正在自动检查服务端版本...", + ["Latest version"] = "已是最新版本", + ["The server did not provide release notes."] = "服务端未提供更新说明。", + ["New version found. Select “Check and update” to download and install."] = "发现新版本,点击“检查并自动升级”开始下载和安装。", + ["The app is up to date."] = "当前已经是最新版本。", + ["Checking and preparing update..."] = "正在检查并准备升级包...", + ["Update record reset. The installed app was not changed. You can check for updates again."] = "升级记录已重置,未修改已安装应用。可以重新检查升级。", + ["Update operation canceled."] = "更新操作已取消。", + ["Update operation failed: {0}"] = "更新操作失败:{0}", + ["System installer opened. Confirm installation; the app will check the result when reopened."] = "系统安装器已打开,请确认安装。重新打开应用后会核对升级结果。", + ["Allow installation from unknown sources, then return to retry."] = "请开启“允许安装未知应用”,返回后会重试安装。", + ["Update confirmed: target {0}, current {1}."] = "升级已确认:目标 {0},当前 {1}。", + ["Update not confirmed: target {0}, current {1}. You can check and retry."] = "升级尚未确认:目标 {0},当前 {1}。可以重新检查并重试。", + ["No update record."] = "暂无升级记录。", + ["If the record is corrupt, explicitly reset the update record."] = "如记录损坏,可显式重置升级记录。", + ["Enter a valid HTTP or HTTPS verification URL."] = "请输入有效的 HTTP 或 HTTPS 验证接口地址。", + ["Platform must be a positive integer configured on the server."] = "Platform 必须是服务端配置的正整数平台编号。", + ["Enter a ProductId."] = "请输入 ProductId。", + ["Update failed ({0}): {1}"] = "更新失败:{0}。{1}", + ["Update error: {0}. If the record is corrupt, explicitly reset the update record."] = "更新错误:{0}。如记录损坏,可显式重置升级记录。", + ["Check and update"] = "检查并自动升级", + ["Cancel"] = "取消", + ["Reset update record (does not modify installed app)"] = "重置升级记录(不修改已安装应用)", + ["Unable to read installed app version."] = "无法读取本机应用版本。", + ["The installed app does not provide a version, so update confirmation is unavailable."] = "本机应用未提供版本号,不能确认升级结果。", + ["Unable to read app package name."] = "无法读取应用包名。", + ["Unable to open the unknown sources installation permission page."] = "无法打开未知来源安装授权页面。", + ["Unable to read update server settings."] = "无法读取更新服务配置。", + ["Saved platform number is invalid. Re-enter and save it."] = "保存的平台编号无效,请重新填写并保存。", + ["Unable to save update server settings."] = "无法保存更新服务配置。", + ["Saving update server settings failed; update was not started."] = "保存更新服务配置失败,未开始更新。", + ["Unable to save language preference."] = "无法保存语言设置。" + }; + + public static string Get(UpdateLanguage language, string key) => + language == UpdateLanguage.Chinese && Chinese.TryGetValue(key, out var translated) + ? translated + : key; + + public static string Format(UpdateLanguage language, string format, params object?[] arguments) => + string.Format(CultureInfo.CurrentCulture, Get(language, format), arguments); +} diff --git a/samples/GeneralUpdate.Avalonia.Android.Sample/README.md b/samples/GeneralUpdate.Avalonia.Android.Sample/README.md index cf26d4f..47613c7 100644 --- a/samples/GeneralUpdate.Avalonia.Android.Sample/README.md +++ b/samples/GeneralUpdate.Avalonia.Android.Sample/README.md @@ -8,6 +8,18 @@ 3. 断点续传下载完整 APK,并校验服务端提供的 SHA-256。 4. 请求“允许安装未知应用”权限。 5. 返回应用后自动拉起 Android 系统安装器。 +6. 在应用持久化目录保存升级目标,重新打开应用后自动核对实际版本并显示“升级已确认”或“升级尚未确认”。 +7. 保存服务端配置,启动时自动检查服务端是否还有新版本(不会自动重复弹出安装器)。 + +## 集成结构 + +- `Views/MainView`:编译绑定、按钮事件和页面生命周期,不承担升级业务或文件读写。 +- `ViewModels/UpdateViewModel`:通过 `PrepareUpdateAsync` 准备升级包,管理权限返回、结果展示与操作取消。 +- `Infrastructure/IUpdateHost` / `AndroidUpdateHost`:隔离 PackageManager、SharedPreferences、权限 Intent 和组件组装。 +- 组件内 `IUpdatePackageSource` / `IInstallationStore`:分别负责包信息查询和安装记录持久化,可替换为自有服务。 + +ViewModel 不依赖 Android/Avalonia API,可独立测试。启动时先离线核对安装结果,配置或网络失败不会遮蔽已确认结果。 +页面卸载时取消并等待当前操作后释放升级器;从授权页返回仅重试尚在等待授权的安装,不重复下载或反复弹安装器。 ## GeneralSpacestation 配置 @@ -20,7 +32,8 @@ - `Hash` 是 APK 文件的 64 位十六进制 SHA-256。 - `Version` 高于示例工程的 `ApplicationDisplayVersion`。 -启动应用后填写验证接口、`AppKey`、`Platform` 和 `ProductId`,点击“检查并自动升级”。 +启动应用后可在页面顶部选择中文或英文(选择会保存),然后填写验证接口、`AppKey`、`Platform` 和 `ProductId`, +点击“检查并自动升级”。界面提示、表单校验和 Android 平台错误会按所选语言显示。 ## 运行 @@ -48,11 +61,26 @@ Android Studio 的 Device Manager 中创建一个 API 26 或更高版本的虚 `start-demo-now.cmd` 是同一个脚本的入口,便于从任意工作目录直接运行。 在设备中点击“检查并自动升级”,按系统提示允许未知来源安装并确认安装。重新打开应用后, -“当前版本”应为 `2.0.0`。 +“当前版本”应为 `2.0.0`,升级结果应显示“升级已确认:目标 2.0.0,当前 2.0.0”,再次检查应显示已是最新版本。 +首次启动的自动检查只发现目标,仍需点击按钮开始下载。 + +### 闭环验收 + +- 在系统安装器中取消安装,返回或重新打开应用:仍为 `1.0.0`,显示目标 `2.0.0` 尚未确认;点击按钮可以重试。 +- 在未知来源授权页面结束应用进程,授权后重新打开:服务端配置和目标仍保留,点击按钮重新下载校验并安装。 +- 完成安装后断网再打开:本机核对仍可确认 `2.0.0`;服务端查询失败单独显示,不会把已确认结果改为升级失败。 +- 再次打开 `2.0.0`:仍显示上次确认结果,但不会重复发送首次安装确认事件。 +- 安装记录损坏:显示错误并停止升级。由用户点击“重置升级记录”清除损坏记录后,再点击升级按钮恢复。 + 重置不会修改已安装应用、下载缓存或服务端设置,也不代表安装成功。 + +记录默认位于 `/update/installation.json`,不随下载缓存清理;卸载应用或清除数据会删除记录和服务端配置。 +“尚未确认”不区分用户取消、系统拒绝或安装仍在进行,不能当作安装失败回执。更新包必须同包名、兼容签名且 +`versionCode` 更高。这里不提供静默安装、安装后自动拉起应用、健康检查或自动回滚。 下载过程中点击“取消”只会中止当前请求:`/update` 下的 `.part` 片段和续传元数据仍然保留, 再次点击“检查并自动升级”会从断点继续。演示服务的下载地址支持 Range 请求,因此续传时不会重新 下载整个 APK;把演示服务换成正式部署的 GeneralSpacestation 时,同样要求下载地址支持断点续传。 +瞬态下载错误最多尝试 3 次(包含首次请求),重试等待包含在总下载超时内;超时和主动取消分别展示为失败与取消。 演示服务的固定参数为: diff --git a/samples/GeneralUpdate.Avalonia.Android.Sample/ViewModels/UpdateViewModel.cs b/samples/GeneralUpdate.Avalonia.Android.Sample/ViewModels/UpdateViewModel.cs new file mode 100644 index 0000000..fc47d5e --- /dev/null +++ b/samples/GeneralUpdate.Avalonia.Android.Sample/ViewModels/UpdateViewModel.cs @@ -0,0 +1,367 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using GeneralUpdate.Avalonia.Android.Abstractions; +using GeneralUpdate.Avalonia.Android.Models; +using GeneralUpdate.Avalonia.Android.Sample.Infrastructure; + +namespace GeneralUpdate.Avalonia.Android.Sample.ViewModels; + +internal sealed class UpdateViewModel(IUpdateHost host, IUpdateLogger logger) : INotifyPropertyChanged +{ + private IAndroidBootstrap? _bootstrap; + private CancellationTokenSource? _cancellation; + private Task _activeTask = Task.CompletedTask; + private UpdatePreparationResult? _pending; + private bool _waitingForPermission; + private bool _forced; + private bool _running; + private bool _initialized; + private UpdateLanguage _language = UpdateLanguage.English; + private UpdateServerOptions? _serverOptions; + private string _requestUrl = "", _appKey = "", _platform = "", _productId = ""; + private string _currentVersion = "", _progressText = "0%"; + private string? _targetVersion, _releaseNotes; + private string _targetVersionFallback = "Not checked", _releaseNotesFallback = "Shown when an update is found"; + private string _statusKey = "Waiting for update check", _installationStatusKey = "Reconciling previous update..."; + private object?[] _statusArguments = [], _installationStatusArguments = []; + private double _progress; + + public event PropertyChangedEventHandler? PropertyChanged; + public UpdateLanguage Language + { + get => _language; + set + { + if (_language == value || _running) return; + _language = value; + NotifyLanguage(); + if (!_initialized) return; + try + { + host.SaveLanguage(value); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + logger.LogError("Unable to save language preference.", ex); + SetStatus("Unable to save language preference."); + } + if (_bootstrap is not null) ReplaceBootstrap(_serverOptions); + } + } + public int LanguageIndex + { + get => Language == UpdateLanguage.Chinese ? 1 : 0; + set => Language = value == 1 ? UpdateLanguage.Chinese : UpdateLanguage.English; + } + public string LanguageLabel => Text("Language"); + public string Title => Text("GeneralUpdate mobile updater"); + public string Description => Text("Connect to GeneralSpacestation to check versions, download APKs, verify SHA-256, and install."); + public string CurrentVersionLabel => Text("Current version:"); + public string TargetVersionLabel => Text("Target version:"); + public string SettingsTitle => Text("GeneralSpacestation settings"); + public string VerificationEndpointLabel => Text("Verification endpoint"); + public string AppKeyPlaceholder => Text("AppKey configured in admin console"); + public string PlatformPlaceholder => Text("Android platform number"); + public string ProductIdPlaceholder => Text("Product ID"); + public string ReleaseNotesLabel => Text("Release notes"); + public string StartButtonText => Text("Check and update"); + public string CancelButtonText => Text("Cancel"); + public string ResetButtonText => Text("Reset update record (does not modify installed app)"); + public bool CanChangeLanguage => !_running; + public string RequestUrl { get => _requestUrl; set => Set(ref _requestUrl, value ?? string.Empty); } + public string AppKey { get => _appKey; set => Set(ref _appKey, value ?? string.Empty); } + public string Platform { get => _platform; set => Set(ref _platform, value ?? string.Empty); } + public string ProductId { get => _productId; set => Set(ref _productId, value ?? string.Empty); } + public string CurrentVersion { get => _currentVersion; private set => Set(ref _currentVersion, value); } + public string TargetVersion => _targetVersion ?? Text(_targetVersionFallback); + public string ReleaseNotes => _releaseNotes ?? Text(_releaseNotesFallback); + public string Status => UpdateViewText.Format(Language, _statusKey, _statusArguments); + public string InstallationStatus => UpdateViewText.Format(Language, _installationStatusKey, _installationStatusArguments); + public string ProgressText { get => _progressText; private set => Set(ref _progressText, value); } + public double Progress { get => _progress; private set => Set(ref _progress, value); } + public bool CanStart => !_running; + public bool CanCancel => _running && !_forced; + + public Task InitializeAsync() => RunAsync(async ct => + { + _language = host.LoadLanguage(); + NotifyLanguage(); + _initialized = true; + ReplaceBootstrap(null); + var reconciled = await RefreshInstallationAsync(ct); + var options = host.LoadServerOptions(Language); + RequestUrl = options.RequestUrl; + AppKey = options.AppKey; + Platform = options.Platform.ToString(System.Globalization.CultureInfo.InvariantCulture); + ProductId = options.ProductId; + _serverOptions = options; + ReplaceBootstrap(options); + if (!reconciled) return; + SetStatus("Checking server version automatically..."); + var check = await _bootstrap!.ValidateAsync(CurrentVersion, ct); + if (!check.Success) { SetFailureStatus(check); return; } + SetTargetVersion(check.UpdateFound ? check.PackageInfo!.Version : null, "Latest version"); + SetReleaseNotes(check.PackageInfo?.Description, "The server did not provide release notes."); + SetStatus(check.UpdateFound + ? "New version found. Select “Check and update” to download and install." + : "The app is up to date."); + }); + + public Task StartAsync() => RunAsync(async ct => + { + _pending = null; + _waitingForPermission = false; + var options = ReadOptions(); + host.SaveServerOptions(options, Language); + _serverOptions = options; + ReplaceBootstrap(options); + Progress = 0; + ProgressText = "0%"; + if (!await RefreshInstallationAsync(ct)) return; + + SetStatus("Checking and preparing update..."); + var prepared = await _bootstrap!.PrepareUpdateAsync(CurrentVersion, ct); + if (!prepared.Success) { SetFailureStatus(prepared); return; } + if (!prepared.IsReadyToInstall) + { + SetTargetVersion(null, "Latest version"); + SetStatus("The app is up to date."); + return; + } + _pending = prepared; + await LaunchPendingAsync(ct); + }); + + public Task ResumeAsync() => RunAsync(async ct => + { + if (_bootstrap is null) return; + if (_waitingForPermission && _pending is not null && host.CanRequestInstalls()) + { + _waitingForPermission = false; + await LaunchPendingAsync(ct); + } + else + { + await RefreshInstallationAsync(ct); + } + }); + + public Task ResetInstallationAsync() => RunAsync(async ct => + { + if (_bootstrap is null) ReplaceBootstrap(null); + var result = await _bootstrap!.ResetInstallationAsync(ct); + if (!result.Success) { SetFailureStatus(result); return; } + _pending = null; + _waitingForPermission = false; + if (await RefreshInstallationAsync(ct)) + SetStatus("Update record reset. The installed app was not changed. You can check for updates again."); + }); + + public void Cancel() => _cancellation?.Cancel(); + + public async Task DeactivateAsync() + { + Cancel(); + await _activeTask; + _bootstrap?.Dispose(); + _bootstrap = null; + _pending = null; + _waitingForPermission = false; + } + + private Task RunAsync(Func operation) + { + if (_running) return Task.CompletedTask; + _activeTask = RunCoreAsync(operation); + return _activeTask; + } + + private async Task RunCoreAsync(Func operation) + { + _running = true; + _forced = false; + NotifyAvailability(); + using var cancellation = new CancellationTokenSource(); + _cancellation = cancellation; + try + { + await operation(cancellation.Token); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + SetStatus("Update operation canceled."); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or + InvalidOperationException or ArgumentException) + { + logger.LogError("Update host operation failed.", ex); + SetStatus("Update operation failed: {0}", ex.Message); + } + finally + { + _cancellation = null; + _running = false; + NotifyAvailability(); + } + } + + private void ReplaceBootstrap(UpdateServerOptions? options) + { + var bootstrap = host.CreateBootstrap(options, Language); + _bootstrap?.Dispose(); + _bootstrap = bootstrap; + _bootstrap.AddListenerValidate += (_, args) => + { + if (!ReferenceEquals(_bootstrap, bootstrap)) return; + _forced = args.PackageInfo.IsForced; + NotifyAvailability(); + SetTargetVersion(args.PackageInfo.Version, "Not checked"); + SetReleaseNotes(args.PackageInfo.Description, "The server did not provide release notes."); + }; + _bootstrap.AddListenerDownloadProgressChanged += (_, args) => + { + if (!ReferenceEquals(_bootstrap, bootstrap)) return; + Progress = args.ProgressPercentage; + ProgressText = $"{args.ProgressPercentage:F1}% {args.DownloadSpeedBytesPerSecond / 1024:F1} KB/s"; + }; + _bootstrap.AddListenerInstallationConfirmed += (_, args) => + logger.LogInformation($"Installation confirmed: {args.Result.CurrentVersion}."); + } + + private async Task LaunchPendingAsync(CancellationToken ct) + { + var pending = _pending; + if (_bootstrap is null || pending is not { IsReadyToInstall: true }) return; + var result = await _bootstrap.LaunchInstallerAsync(pending.PackageInfo!, pending.FilePath!, ct); + var reconciled = await RefreshInstallationAsync(CancellationToken.None); + if (result.Success) + { + _pending = null; + _waitingForPermission = false; + if (reconciled) SetStatus("System installer opened. Confirm installation; the app will check the result when reopened."); + } + else if (reconciled && result.FailureReason == UpdateFailureReason.InstallPermissionDenied) + { + _waitingForPermission = true; + SetStatus("Allow installation from unknown sources, then return to retry."); + host.RequestInstallPermission(Language); + } + else if (reconciled) + { + _waitingForPermission = false; + SetFailureStatus(result); + } + } + + private async Task RefreshInstallationAsync(CancellationToken ct) + { + CurrentVersion = host.GetCurrentVersion(Language); + var result = await _bootstrap!.CheckInstallationAsync(CurrentVersion, ct); + if (!result.Success) + { + var message = UpdateViewText.Format(Language, "Update failed ({0}): {1}", + result.FailureReason, result.Exception?.Message ?? result.Message); + SetInstallationStatus("Update error: {0}. If the record is corrupt, explicitly reset the update record.", message); + SetStatus("Update error: {0}. If the record is corrupt, explicitly reset the update record.", message); + } + else if (result.IsInstalled) + SetInstallationStatus("Update confirmed: target {0}, current {1}.", result.Record!.TargetVersion, CurrentVersion); + else if (result.HasPendingInstallation) + SetInstallationStatus("Update not confirmed: target {0}, current {1}. You can check and retry.", result.Record!.TargetVersion, CurrentVersion); + else + SetInstallationStatus("No update record."); + return result.Success; + } + + private UpdateServerOptions ReadOptions() + { + if (!Uri.TryCreate(RequestUrl, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + throw new ArgumentException(Text("Enter a valid HTTP or HTTPS verification URL.")); + if (!int.TryParse(Platform, out var platform) || platform <= 0) + throw new ArgumentException(Text("Platform must be a positive integer configured on the server.")); + if (string.IsNullOrWhiteSpace(ProductId)) + throw new ArgumentException(Text("Enter a ProductId.")); + return new UpdateServerOptions + { + RequestUrl = uri.AbsoluteUri, + AppKey = AppKey.Trim(), + AppType = 1, + Platform = platform, + ProductId = ProductId.Trim() + }; + } + + private void SetFailureStatus(UpdateOperationResult result) => + SetStatus(result.State == UpdateState.Canceled + ? "Update operation canceled." + : "Update failed ({0}): {1}", result.FailureReason, result.Exception?.Message ?? result.Message); + + private string Text(string key) => UpdateViewText.Get(Language, key); + + private void SetStatus(string key, params object?[] arguments) + { + _statusKey = key; + _statusArguments = arguments; + PropertyChanged?.Invoke(this, new(nameof(Status))); + } + + private void SetInstallationStatus(string key, params object?[] arguments) + { + _installationStatusKey = key; + _installationStatusArguments = arguments; + PropertyChanged?.Invoke(this, new(nameof(InstallationStatus))); + } + + private void SetTargetVersion(string? value, string fallbackKey) + { + _targetVersion = value; + _targetVersionFallback = fallbackKey; + PropertyChanged?.Invoke(this, new(nameof(TargetVersion))); + } + + private void SetReleaseNotes(string? value, string fallbackKey) + { + _releaseNotes = value; + _releaseNotesFallback = fallbackKey; + PropertyChanged?.Invoke(this, new(nameof(ReleaseNotes))); + } + + private void NotifyLanguage() + { + PropertyChanged?.Invoke(this, new(nameof(Language))); + PropertyChanged?.Invoke(this, new(nameof(LanguageIndex))); + PropertyChanged?.Invoke(this, new(nameof(LanguageLabel))); + PropertyChanged?.Invoke(this, new(nameof(Title))); + PropertyChanged?.Invoke(this, new(nameof(Description))); + PropertyChanged?.Invoke(this, new(nameof(CurrentVersionLabel))); + PropertyChanged?.Invoke(this, new(nameof(TargetVersionLabel))); + PropertyChanged?.Invoke(this, new(nameof(SettingsTitle))); + PropertyChanged?.Invoke(this, new(nameof(VerificationEndpointLabel))); + PropertyChanged?.Invoke(this, new(nameof(AppKeyPlaceholder))); + PropertyChanged?.Invoke(this, new(nameof(PlatformPlaceholder))); + PropertyChanged?.Invoke(this, new(nameof(ProductIdPlaceholder))); + PropertyChanged?.Invoke(this, new(nameof(ReleaseNotesLabel))); + PropertyChanged?.Invoke(this, new(nameof(StartButtonText))); + PropertyChanged?.Invoke(this, new(nameof(CancelButtonText))); + PropertyChanged?.Invoke(this, new(nameof(ResetButtonText))); + PropertyChanged?.Invoke(this, new(nameof(TargetVersion))); + PropertyChanged?.Invoke(this, new(nameof(ReleaseNotes))); + PropertyChanged?.Invoke(this, new(nameof(Status))); + PropertyChanged?.Invoke(this, new(nameof(InstallationStatus))); + } + + private void NotifyAvailability() + { + PropertyChanged?.Invoke(this, new(nameof(CanStart))); + PropertyChanged?.Invoke(this, new(nameof(CanCancel))); + PropertyChanged?.Invoke(this, new(nameof(CanChangeLanguage))); + } + + private void Set(ref T field, T value, [CallerMemberName] string? name = null) + { + if (EqualityComparer.Default.Equals(field, value)) return; + field = value; + PropertyChanged?.Invoke(this, new(name)); + } +} diff --git a/samples/GeneralUpdate.Avalonia.Android.Sample/Views/MainView.axaml b/samples/GeneralUpdate.Avalonia.Android.Sample/Views/MainView.axaml index 0f2623d..6e511ac 100644 --- a/samples/GeneralUpdate.Avalonia.Android.Sample/Views/MainView.axaml +++ b/samples/GeneralUpdate.Avalonia.Android.Sample/Views/MainView.axaml @@ -1,12 +1,27 @@ - - + + + + + + + + + + @@ -15,72 +30,77 @@ Background="#14007ACC"> - + - + + Text="{Binding TargetVersion}" /> - + + - + + Text="{Binding AppKey, Mode=TwoWay}" + PlaceholderText="{Binding AppKeyPlaceholder}" /> + Text="{Binding Platform, Mode=TwoWay}" + PlaceholderText="{Binding PlatformPlaceholder}" /> + Text="{Binding ProductId, Mode=TwoWay}" + PlaceholderText="{Binding ProductIdPlaceholder}" /> - @@ -89,16 +109,20 @@ Margin="0,8,0,20">