Skip to content

Commit c3d8751

Browse files
committed
feat!: query the update server from ValidateAsync
ValidateAsync now takes only the installed version and discovers the package itself, so callers no longer build an UpdatePackageInfo by hand. - add UpdateServerOptions and wire it into AndroidUpdateOptions.UpdateServer - add HttpUpdatePackageClient: POSTs the GeneralUpdate /Upgrade/Verification protocol by default, or GETs a single UpdatePackageInfo document when UseJsonEndpoint is true; picks the newest non-frozen full APK and rejects invalid metadata - report server, transport and metadata problems through Success=false, FailureReason and AddListenerUpdateFailed, return Canceled on user cancellation, and succeed with UpdateFound=false when no package exists - forward updateServer/httpClient/httpOptions through CreateDefault so the check shares the download transport (timeout, proxy, TLS, auth) - remove the ValidateAsync(UpdatePackageInfo, currentVersion, ct) overload BREAKING CHANGE: ValidateAsync(UpdatePackageInfo packageInfo, string currentVersion, CancellationToken) is replaced by ValidateAsync(string currentVersion, CancellationToken); pass UpdateCheckResult.PackageInfo to DownloadAndVerifyAsync and LaunchInstallerAsync. Also fixes HttpResumableApkDownloader holding its write stream until the end of the method: PhysicalFileStorage opens files with FileShare.None, so MoveFile threw IOException on Windows and every download failed with FileIoError. The stream is now flushed and closed before the temporary file is renamed, which also stops .part/.part.json from being left behind. Tests cover the new package client and the end-to-end flow (validate -> download -> verify -> installer handoff) using the real downloader, real file system writes and real SHA-256. The READMEs document the server protocol and the Android prerequisites.
1 parent 36dd32b commit c3d8751

18 files changed

Lines changed: 1555 additions & 124 deletions

README-EN.md

Lines changed: 94 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,20 +65,23 @@ var cacheDirPath = Android.App.Application.Context.CacheDir?.AbsolutePath
6565
var options = new AndroidUpdateOptions
6666
{
6767
DownloadDirectoryPath = Path.Combine(cacheDirPath, "update"),
68-
FileProviderAuthority = "com.example.app.generalupdate.fileprovider"
68+
FileProviderAuthority = "com.example.app.generalupdate.fileprovider",
69+
70+
// ValidateAsync queries this server internally; callers only pass the installed version
71+
UpdateServer = new UpdateServerOptions
72+
{
73+
RequestUrl = "https://example.com/Upgrade/Verification",
74+
AppKey = "your-app-key",
75+
AppType = 1,
76+
Platform = androidPlatformId,
77+
ProductId = "your-product-id"
78+
}
6979
};
7080

7181
using var bootstrap = GeneralUpdateBootstrap.CreateDefault(options);
72-
var packageInfo = new UpdatePackageInfo
73-
{
74-
Version = "2.3.0",
75-
DownloadUrl = "https://example.com/app-release.apk",
76-
Sha256 = "REPLACE_WITH_ACTUAL_SHA256_HASH",
77-
FileName = "app-release.apk"
78-
};
7982

80-
var check = await bootstrap.ValidateAsync(packageInfo, "2.2.1", CancellationToken.None);
81-
if (check.UpdateFound)
83+
var check = await bootstrap.ValidateAsync("2.2.1", CancellationToken.None);
84+
if (check.Success && check.UpdateFound && check.PackageInfo is { } packageInfo)
8285
{
8386
var prepared = await bootstrap.DownloadAndVerifyAsync(packageInfo, CancellationToken.None);
8487
if (prepared.Success && prepared.FilePath is not null)
@@ -88,6 +91,87 @@ if (check.UpdateFound)
8891
}
8992
```
9093

94+
### Server-Driven Version Validation
95+
96+
`ValidateAsync(currentVersion, cancellationToken)` only needs the version installed on the device: the component queries
97+
the server configured through `AndroidUpdateOptions.UpdateServer`, picks the newest full APK, compares it with
98+
`currentVersion`, and exposes the discovered package through `UpdateCheckResult.PackageInfo` for download and installation.
99+
100+
The default protocol is GeneralUpdate's sample server `POST /Upgrade/Verification`: the request body carries
101+
`version/appKey/appType/platform/productId` and the response is `{"code":200,"body":[...]}`. The client maps
102+
`version/url/hash/size/name/updateLog/releaseDate/isForcibly/authScheme/authToken` and selects the newest non-frozen full APK
103+
(`packageType` 2, 0 or omitted; `format` `apk`/`.apk`, or a `.apk` URL path when omitted). ZIP, patch and driver packages are
104+
never handed to the Android installer; an empty `body` or no matching package means "no update".
105+
**Verify the deployed URL, response shape and Android platform id — do not assume a fixed id**; when the protocol differs,
106+
have the server expose the standard JSON endpoint below.
107+
108+
Static JSON server: set `UpdateServer.UseJsonEndpoint = true` and point `RequestUrl` at the JSON address; the component
109+
then GETs a single `UpdatePackageInfo` (property names are case-insensitive), for example:
110+
111+
```json
112+
{
113+
"version": "2.3.0",
114+
"downloadUrl": "https://example.com/app-release.apk",
115+
"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
116+
"description": "Release notes",
117+
"isForced": false
118+
}
119+
```
120+
121+
- `sha256` must be the APK's real 64-character hexadecimal SHA-256 (never MD5); `fileSize` may be omitted or 0 when unknown.
122+
- HTTP 204 or a JSON `null` body means "no package"; transport, protocol and metadata errors are reported through
123+
`UpdateCheckResult.Success = false`, `FailureReason` and `AddListenerUpdateFailed`, and never invoke the pre-check callback.
124+
- Cancellation during the request returns `UpdateState.Canceled`; cancelling while waiting on the operation gate throws
125+
`OperationCanceledException`.
126+
- Validation and downloads share the `httpOptions` passed to `CreateDefault` (`RequestTimeout`, proxy, TLS, `AuthProvider`).
127+
Without `httpOptions` the supplied `httpClient` is reused and its lifetime stays with the host.
128+
- Calling `ValidateAsync` without `UpdateServer` fails with `UpdateFailureReason.InvalidMetadata`.
129+
- Only query trusted servers and use HTTPS in production.
130+
131+
Once a newer version is found, the `AddListenerUpdatePrecheck` callback receives the discovered package metadata and returns
132+
`true` to skip or `false` to continue (forced updates bypass it), matching `GeneralUpdate.Core`.
133+
134+
## Android Prerequisites
135+
136+
The library ships no UI and does not request permissions on your behalf. A full update only completes when the host app
137+
configures all four items below:
138+
139+
1. **Install permission (Android 8.0+)** — declare it in `AndroidManifest.xml`:
140+
141+
```xml
142+
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
143+
```
144+
145+
When the user has not granted it, `LaunchInstallerAsync` returns `FailureReason = InstallPermissionDenied`. Send the
146+
user to the "install unknown apps" screen and retry afterwards:
147+
148+
```csharp
149+
var context = Android.App.Application.Context;
150+
context.StartActivity(new Android.Content.Intent(
151+
Android.Provider.Settings.ActionManageUnknownAppSources,
152+
Android.Net.Uri.Parse("package:" + context.PackageName))
153+
.AddFlags(Android.Content.ActivityFlags.NewTask));
154+
```
155+
156+
2. **FileProvider** — the `android:authorities` in `AndroidManifest.xml` must match
157+
`AndroidUpdateOptions.FileProviderAuthority` exactly, and `generalupdate_file_paths.xml` must cover
158+
`DownloadDirectoryPath` (defaults to `<CacheDir>/update`). A mismatch returns `InstallLaunchFailed`.
159+
160+
3. **Current Activity**`CreateDefault` uses `NullAndroidActivityProvider` by default, in which case the installer is
161+
launched through `Application.Context` + `FLAG_ACTIVITY_NEW_TASK`. Passing an `IAndroidActivityProvider` that returns
162+
the current `Activity` is more robust:
163+
164+
```csharp
165+
using var bootstrap = GeneralUpdateBootstrap.CreateDefault(options, activityProvider: myActivityProvider);
166+
```
167+
168+
4. **Server**`AndroidUpdateOptions.UpdateServer` must be configured (or use the static JSON endpoint through
169+
`UseJsonEndpoint`), and `sha256` must be a 64-character hexadecimal SHA-256.
170+
171+
`LaunchInstallerAsync` returning `Success = true` only means the installer intent was launched; it **does not** mean the user
172+
finished installing. The process is killed on completion, so compare the installed version with the server again on the next
173+
launch to confirm the update actually took effect.
174+
91175
## Directory Structure
92176

93177
```text

README.md

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,20 +65,23 @@ var cacheDirPath = Android.App.Application.Context.CacheDir?.AbsolutePath
6565
var options = new AndroidUpdateOptions
6666
{
6767
DownloadDirectoryPath = Path.Combine(cacheDirPath, "update"),
68-
FileProviderAuthority = "com.example.app.generalupdate.fileprovider"
68+
FileProviderAuthority = "com.example.app.generalupdate.fileprovider",
69+
70+
// ValidateAsync 据此在组件内部请求服务端,调用方只需要提供当前版本
71+
UpdateServer = new UpdateServerOptions
72+
{
73+
RequestUrl = "https://example.com/Upgrade/Verification",
74+
AppKey = "your-app-key",
75+
AppType = 1,
76+
Platform = androidPlatformId,
77+
ProductId = "your-product-id"
78+
}
6979
};
7080

7181
using var bootstrap = GeneralUpdateBootstrap.CreateDefault(options);
72-
var packageInfo = new UpdatePackageInfo
73-
{
74-
Version = "2.3.0",
75-
DownloadUrl = "https://example.com/app-release.apk",
76-
Sha256 = "REPLACE_WITH_ACTUAL_SHA256_HASH",
77-
FileName = "app-release.apk"
78-
};
7982

80-
var check = await bootstrap.ValidateAsync(packageInfo, "2.2.1", CancellationToken.None);
81-
if (check.UpdateFound)
83+
var check = await bootstrap.ValidateAsync("2.2.1", CancellationToken.None);
84+
if (check.Success && check.UpdateFound && check.PackageInfo is { } packageInfo)
8285
{
8386
var prepared = await bootstrap.DownloadAndVerifyAsync(packageInfo, CancellationToken.None);
8487
if (prepared.Success && prepared.FilePath is not null)
@@ -88,6 +91,83 @@ if (check.UpdateFound)
8891
}
8992
```
9093

94+
### 服务端版本校验
95+
96+
`ValidateAsync(currentVersion, cancellationToken)` 只需要当前应用的版本号:组件按
97+
`AndroidUpdateOptions.UpdateServer` 的配置请求服务端,选出最新的完整 APK,与 `currentVersion`
98+
比较,并把发现的包信息放在结果的 `PackageInfo` 中,供下载与安装继续使用。
99+
100+
默认使用 GeneralUpdate 示例服务端的 `POST /Upgrade/Verification` 协议:请求体发送
101+
`version/appKey/appType/platform/productId`,响应为 `{"code":200,"body":[...]}`;客户端映射
102+
`version/url/hash/size/name/updateLog/releaseDate/isForcibly/authScheme/authToken`,并按版本选择最新的
103+
非冻结完整 APK(`packageType` 为 2、0 或省略;`format``apk`/`.apk`,省略时 URL 路径需以 `.apk` 结尾)。
104+
ZIP、差分包、驱动包不会交给 Android 安装器;`body` 为空数组或没有符合条件的包时视为“无更新”。
105+
**请核对实际部署的地址、响应格式与 Android 平台编号,不要假定固定编号**;协议不同时,可让服务端提供下面的标准 JSON 端点。
106+
107+
静态 JSON 服务端:设置 `UpdateServer.UseJsonEndpoint = true` 并把 `RequestUrl` 指向 JSON 地址,
108+
组件自动 GET 一个 `UpdatePackageInfo`(字段名不区分大小写),例如:
109+
110+
```json
111+
{
112+
"version": "2.3.0",
113+
"downloadUrl": "https://example.com/app-release.apk",
114+
"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
115+
"description": "更新说明",
116+
"isForced": false
117+
}
118+
```
119+
120+
- `sha256` 必须是 APK 实际的 64 位十六进制 SHA-256,不能使用 MD5;`fileSize` 可省略或为 0(表示未知),已知时单位为字节。
121+
- HTTP 204 或 GET JSON `null` 表示无包;请求、协议与元数据错误通过 `UpdateCheckResult.Success = false`
122+
`FailureReason``AddListenerUpdateFailed` 上报,并且不会触发 pre-check。
123+
- 请求期间取消返回 `UpdateState.Canceled`;等待操作锁时取消会抛出 `OperationCanceledException`
124+
- 查询与下载共用 `CreateDefault``httpOptions``RequestTimeout`、代理、TLS 与 `AuthProvider`);
125+
未提供 `httpOptions` 时复用传入的 `httpClient`,其生命周期仍由宿主管理。
126+
- 未配置 `UpdateServer` 时调用 `ValidateAsync` 会以 `UpdateFailureReason.InvalidMetadata` 失败。
127+
- 仅应查询可信服务器,生产环境请使用 HTTPS。
128+
129+
发现新版本后,`AddListenerUpdatePrecheck` 回调会拿到最新包信息,返回 `true` 跳过、`false` 继续
130+
(强制更新不调用该回调),语义与 `GeneralUpdate.Core` 一致。
131+
132+
## Android 接入前提
133+
134+
库不提供 UI,也不代替宿主申请权限。要真正走完一次更新,宿主必须配置好下面四项,缺一项就会停在半路:
135+
136+
1. **安装权限(Android 8.0+)**:在 `AndroidManifest.xml` 中声明
137+
138+
```xml
139+
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
140+
```
141+
142+
用户可能仍未授予,此时 `LaunchInstallerAsync` 返回 `FailureReason = InstallPermissionDenied`
143+
用下面的 intent 引导用户开启“允许安装未知应用”,授权后重试即可:
144+
145+
```csharp
146+
var context = Android.App.Application.Context;
147+
context.StartActivity(new Android.Content.Intent(
148+
Android.Provider.Settings.ActionManageUnknownAppSources,
149+
Android.Net.Uri.Parse("package:" + context.PackageName))
150+
.AddFlags(Android.Content.ActivityFlags.NewTask));
151+
```
152+
153+
2. **FileProvider**`AndroidManifest.xml``android:authorities` 必须与
154+
`AndroidUpdateOptions.FileProviderAuthority` 完全一致,且 `generalupdate_file_paths.xml` 要覆盖
155+
`DownloadDirectoryPath`(默认是 `<CacheDir>/update`)。不一致时返回 `InstallLaunchFailed`
156+
157+
3. **当前 Activity**`CreateDefault` 默认使用 `NullAndroidActivityProvider`,此时安装器通过
158+
`Application.Context` + `FLAG_ACTIVITY_NEW_TASK` 拉起。传入实现 `IAndroidActivityProvider`
159+
provider(返回当前 `Activity`)更稳妥:
160+
161+
```csharp
162+
using var bootstrap = GeneralUpdateBootstrap.CreateDefault(options, activityProvider: myActivityProvider);
163+
```
164+
165+
4. **服务端**:必须配置 `AndroidUpdateOptions.UpdateServer`(或改用 `UseJsonEndpoint` 的静态 JSON),
166+
`sha256` 为 64 位十六进制 SHA-256。
167+
168+
`LaunchInstallerAsync` 返回 `Success = true` 只表示安装器已拉起,**不代表用户已完成安装**:安装完成后进程会被
169+
系统结束,下次启动时请自行比较本机版本与服务端版本,以确认这次更新是否真正生效。
170+
91171
## 目录结构
92172

93173
```text

src/GeneralUpdate.Avalonia.Android/Abstractions/IAndroidBootstrap.cs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,32 @@ public interface IAndroidBootstrap : IDisposable
3232
/// <exception cref="ArgumentNullException">Thrown when <paramref name="func"/> is null.</exception>
3333
IAndroidBootstrap AddListenerUpdatePrecheck(Func<UpdateInfoEventArgs, bool> func);
3434

35+
/// <summary>
36+
/// Queries the configured update server for the newest package, compares it with
37+
/// <paramref name="currentVersion"/> and runs the pre-check callback.
38+
/// <para>
39+
/// Package metadata is discovered internally from <see cref="AndroidUpdateOptions.UpdateServer"/>, so callers
40+
/// only supply the version currently installed on the device. When an update is found, the returned
41+
/// <see cref="UpdateCheckResult.PackageInfo"/> can be passed to <see cref="DownloadAndVerifyAsync"/> and
42+
/// <see cref="LaunchInstallerAsync"/>.
43+
/// </para>
44+
/// <para>
45+
/// Like <c>GeneralUpdate.Core</c>, the pre-check callback registered with
46+
/// <see cref="AddListenerUpdatePrecheck"/> decides whether to continue: returning <c>true</c> skips the update
47+
/// (the result carries <see cref="UpdateCheckResult.UpdateFound"/> set to <c>false</c> and state
48+
/// <see cref="UpdateState.Completed"/>), returning <c>false</c> keeps it. Forced updates bypass the callback.
49+
/// </para>
50+
/// <para>
51+
/// Server, protocol, metadata and HTTP failures are reported through
52+
/// <see cref="UpdateCheckResult.Success"/>, <see cref="UpdateOperationResult.FailureReason"/> and
53+
/// <see cref="AddListenerUpdateFailed"/>; when the server reports no package the call succeeds with
54+
/// <see cref="UpdateCheckResult.UpdateFound"/> set to <c>false</c>.
55+
/// </para>
56+
/// </summary>
57+
/// <param name="currentVersion">The application version currently installed on the device.</param>
58+
/// <param name="cancellationToken">Cancels the server request and the validation.</param>
59+
/// <returns>The validation outcome, including the discovered package metadata when an update is available.</returns>
3560
Task<UpdateCheckResult> ValidateAsync(
36-
UpdatePackageInfo packageInfo,
3761
string currentVersion,
3862
CancellationToken cancellationToken = default);
3963

src/GeneralUpdate.Avalonia.Android/GeneralUpdateBootstrap.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ public static IAndroidBootstrap CreateDefault(
6363
installer,
6464
usedStorage,
6565
eventDispatcher,
66-
usedLogger);
66+
usedLogger,
67+
options.UpdateServer,
68+
httpClient,
69+
httpOptions);
6770
}
6871
}

src/GeneralUpdate.Avalonia.Android/Models/AndroidUpdateOptions.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ namespace GeneralUpdate.Avalonia.Android.Models;
22

33
public sealed record AndroidUpdateOptions
44
{
5+
/// <summary>
6+
/// Update server queried by <see cref="Abstractions.IAndroidBootstrap.ValidateAsync"/>.
7+
/// When null, <see cref="Abstractions.IAndroidBootstrap.ValidateAsync"/> reports
8+
/// <see cref="Enums.UpdateFailureReason.InvalidMetadata"/> because no package can be discovered.
9+
/// </summary>
10+
public UpdateServerOptions? UpdateServer { get; init; }
11+
512
public string DownloadDirectoryPath { get; init; } = string.Empty;
613
public string TemporaryFileExtension { get; init; } = ".part";
714
public string SidecarExtension { get; init; } = ".json";

src/GeneralUpdate.Avalonia.Android/Models/HttpDownloadOptions.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
namespace GeneralUpdate.Avalonia.Android.Models;
55

66
/// <summary>
7-
/// Configures HTTP transport behavior for update downloads:
7+
/// Configures HTTP transport behavior for update verification and downloads:
88
/// SSL/TLS certificate validation, timeouts, proxy, retry, and authentication.
99
/// <para>
1010
/// When provided to <see cref="GeneralUpdateBootstrap.CreateDefault"/>,
@@ -23,7 +23,7 @@ public sealed record HttpDownloadOptions
2323
public ISslValidationPolicy? SslValidationPolicy { get; init; }
2424

2525
/// <summary>
26-
/// Timeout for individual HTTP requests (HEAD probes, etc.).
26+
/// Timeout for update server verification requests and download HEAD probes.
2727
/// Default is 30 seconds.
2828
/// </summary>
2929
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(30);
@@ -61,8 +61,8 @@ public sealed record HttpDownloadOptions
6161
public TimeSpan RetryBaseDelay { get; init; } = TimeSpan.FromSeconds(1);
6262

6363
/// <summary>
64-
/// Global authentication provider applied to all download requests.
65-
/// Per-package authentication on <see cref="UpdatePackageInfo"/> takes precedence.
64+
/// Global authentication provider applied to update server verification and download requests.
65+
/// Per-package authentication on <see cref="UpdatePackageInfo"/> takes precedence for downloads.
6666
/// </summary>
6767
public IHttpAuthProvider? AuthProvider { get; init; }
6868

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
namespace GeneralUpdate.Avalonia.Android.Models;
2+
3+
/// <summary>
4+
/// Request body for the GeneralUpdate <c>/Upgrade/Verification</c> reference protocol.
5+
/// <see cref="Platform"/> must match the identifier configured by the server.
6+
/// </summary>
7+
internal sealed record UpdatePackageRequest
8+
{
9+
public required string Version { get; init; }
10+
public required string AppKey { get; init; }
11+
public int AppType { get; init; } = 1;
12+
public required int Platform { get; init; }
13+
public required string ProductId { get; init; }
14+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
namespace GeneralUpdate.Avalonia.Android.Models;
2+
3+
/// <summary>
4+
/// Configures the update server that <see cref="Abstractions.IAndroidBootstrap.ValidateAsync"/> queries
5+
/// internally, so callers only pass the version currently installed.
6+
/// <para>
7+
/// By default the client POSTs the GeneralUpdate verification request
8+
/// (<c>version</c>, <c>appKey</c>, <c>appType</c>, <c>platform</c>, <c>productId</c>) to
9+
/// <see cref="RequestUrl"/> and picks the newest non-frozen full APK from the
10+
/// <c>{"code":200,"body":[...]}</c> response.
11+
/// </para>
12+
/// <para>
13+
/// Set <see cref="UseJsonEndpoint"/> to <c>true</c> when <see cref="RequestUrl"/> instead returns a
14+
/// single <see cref="UpdatePackageInfo"/> JSON document over GET.
15+
/// </para>
16+
/// </summary>
17+
public sealed record UpdateServerOptions
18+
{
19+
/// <summary>
20+
/// Absolute HTTP(S) URL queried by <see cref="Abstractions.IAndroidBootstrap.ValidateAsync"/>.
21+
/// For the GeneralUpdate reference protocol this is the verification endpoint
22+
/// (for example <c>https://example.com/Upgrade/Verification</c>).
23+
/// </summary>
24+
public required string RequestUrl { get; init; }
25+
26+
/// <summary>
27+
/// Application key sent in the verification request body.
28+
/// This is a request field only; it does not enable HMAC signing
29+
/// (configure <see cref="HttpDownloadOptions.AuthProvider"/> for that).
30+
/// </summary>
31+
public string AppKey { get; init; } = string.Empty;
32+
33+
/// <summary>
34+
/// Application type sent in the verification request body. Default is 1.
35+
/// </summary>
36+
public int AppType { get; init; } = 1;
37+
38+
/// <summary>
39+
/// Platform identifier expected by the server for Android.
40+
/// Must match the identifier configured by the deployment; no fixed value is assumed.
41+
/// </summary>
42+
public int Platform { get; init; }
43+
44+
/// <summary>
45+
/// Product identifier sent in the verification request body.
46+
/// </summary>
47+
public string ProductId { get; init; } = string.Empty;
48+
49+
/// <summary>
50+
/// GET a JSON <see cref="UpdatePackageInfo"/> from <see cref="RequestUrl"/> instead of POSTing
51+
/// the GeneralUpdate verification protocol. HTTP 204 or a JSON <c>null</c> body means no update.
52+
/// </summary>
53+
public bool UseJsonEndpoint { get; init; }
54+
}

0 commit comments

Comments
 (0)