@otakit/capacitor-updater 2.3.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -5
- package/android/build.gradle +16 -0
- package/android/src/main/java/com/otakit/updater/BundleCrypto.java +38 -17
- package/android/src/main/java/com/otakit/updater/BundleInfo.java +10 -0
- package/android/src/main/java/com/otakit/updater/BundleStore.java +134 -86
- package/android/src/main/java/com/otakit/updater/CheckFailure.java +53 -0
- package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +46 -41
- package/android/src/main/java/com/otakit/updater/DeviceEventClient.java +125 -26
- package/android/src/main/java/com/otakit/updater/DocumentReadyBridge.java +71 -0
- package/android/src/main/java/com/otakit/updater/DownloadRetry.java +144 -0
- package/android/src/main/java/com/otakit/updater/EventOutbox.java +174 -0
- package/android/src/main/java/com/otakit/updater/FileDownloader.java +80 -0
- package/android/src/main/java/com/otakit/updater/ForegroundDeadline.java +84 -0
- package/android/src/main/java/com/otakit/updater/HashUtils.java +26 -0
- package/android/src/main/java/com/otakit/updater/ManifestClient.java +12 -9
- package/android/src/main/java/com/otakit/updater/ManifestKeyConfig.java +47 -0
- package/android/src/main/java/com/otakit/updater/ManifestVerifier.java +22 -4
- package/android/src/main/java/com/otakit/updater/SDKVersion.java +9 -0
- package/android/src/main/java/com/otakit/updater/UpdateOwner.java +27 -0
- package/android/src/main/java/com/otakit/updater/UpdaterCoordinator.java +154 -59
- package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +284 -209
- package/android/src/main/java/com/otakit/updater/WebViewActivation.java +26 -0
- package/dist/esm/definitions.d.ts +3 -3
- package/dist/esm/definitions.d.ts.map +1 -1
- package/ios/Sources/UpdaterPlugin/BundleCrypto.swift +35 -3
- package/ios/Sources/UpdaterPlugin/BundleInfo.swift +13 -0
- package/ios/Sources/UpdaterPlugin/BundleStore.swift +103 -77
- package/ios/Sources/UpdaterPlugin/CheckFailure.swift +41 -0
- package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +29 -17
- package/ios/Sources/UpdaterPlugin/DeviceEventClient.swift +12 -10
- package/ios/Sources/UpdaterPlugin/DocumentReadyBridge.swift +43 -0
- package/ios/Sources/UpdaterPlugin/DownloadRetry.swift +51 -0
- package/ios/Sources/UpdaterPlugin/Downloader.swift +97 -39
- package/ios/Sources/UpdaterPlugin/EventOutbox.swift +183 -0
- package/ios/Sources/UpdaterPlugin/ForegroundDeadline.swift +94 -0
- package/ios/Sources/UpdaterPlugin/HashUtils.swift +16 -0
- package/ios/Sources/UpdaterPlugin/ManifestClient.swift +2 -4
- package/ios/Sources/UpdaterPlugin/ManifestKeyConfig.swift +20 -0
- package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +7 -1
- package/ios/Sources/UpdaterPlugin/SDKVersion.swift +4 -0
- package/ios/Sources/UpdaterPlugin/UpdateOwner.swift +12 -0
- package/ios/Sources/UpdaterPlugin/UpdaterCoordinator.swift +146 -110
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +206 -202
- package/ios/Tests/UpdaterPluginTests/BundleCryptoTests.swift +80 -0
- package/ios/Tests/UpdaterPluginTests/BundlePersistenceTests.swift +102 -0
- package/ios/Tests/UpdaterPluginTests/CheckFailureTests.swift +55 -0
- package/ios/Tests/UpdaterPluginTests/DeltaCacheIntegrityTests.swift +69 -0
- package/ios/Tests/UpdaterPluginTests/DocumentReadyBridgeTests.swift +102 -0
- package/ios/Tests/UpdaterPluginTests/DownloadIntegrityTests.swift +40 -0
- package/ios/Tests/UpdaterPluginTests/DownloadRetryTests.swift +189 -0
- package/ios/Tests/UpdaterPluginTests/EventDeliveryTests.swift +56 -0
- package/ios/Tests/UpdaterPluginTests/EventOutboxTests.swift +86 -0
- package/ios/Tests/UpdaterPluginTests/ForegroundDeadlineTests.swift +144 -0
- package/ios/Tests/UpdaterPluginTests/ManifestKeyConfigTests.swift +48 -0
- package/ios/Tests/UpdaterPluginTests/UpdateOwnerTests.swift +34 -0
- package/ios/Tests/UpdaterPluginTests/UpdaterCoordinatorTests.swift +394 -0
- package/package.json +7 -3
|
@@ -6,7 +6,6 @@ import java.io.File;
|
|
|
6
6
|
import java.io.FileInputStream;
|
|
7
7
|
import java.io.FileOutputStream;
|
|
8
8
|
import java.io.InputStream;
|
|
9
|
-
import java.net.HttpURLConnection;
|
|
10
9
|
import java.net.URL;
|
|
11
10
|
import java.nio.charset.StandardCharsets;
|
|
12
11
|
import java.security.MessageDigest;
|
|
@@ -37,9 +36,22 @@ final class DeltaAssembler {
|
|
|
37
36
|
private final File cacheDirectory;
|
|
38
37
|
private final boolean allowInsecureUrls;
|
|
39
38
|
|
|
39
|
+
interface Fetcher {
|
|
40
|
+
File download(URL url, Context context) throws Exception;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private final Fetcher fetcher;
|
|
44
|
+
|
|
40
45
|
DeltaAssembler(File cacheDirectory, boolean allowInsecureUrls) {
|
|
46
|
+
this(cacheDirectory, allowInsecureUrls, (url, context) ->
|
|
47
|
+
FileDownloader.download(url, context.getCacheDir(), allowInsecureUrls)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
DeltaAssembler(File cacheDirectory, boolean allowInsecureUrls, Fetcher fetcher) {
|
|
41
52
|
this.cacheDirectory = cacheDirectory;
|
|
42
53
|
this.allowInsecureUrls = allowInsecureUrls;
|
|
54
|
+
this.fetcher = fetcher;
|
|
43
55
|
}
|
|
44
56
|
|
|
45
57
|
// ── Canonical file list ─────────────────────────────────────────────
|
|
@@ -158,56 +170,42 @@ final class DeltaAssembler {
|
|
|
158
170
|
private void ensureCached(ManifestClient.ManifestFileEntry entry, Context context)
|
|
159
171
|
throws Exception {
|
|
160
172
|
File cached = cachePath(entry.sha256);
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
}
|
|
173
|
+
if (validCached(entry)) return;
|
|
174
|
+
discardDamagedCache(cached);
|
|
164
175
|
|
|
165
176
|
URL url = new URL(entry.url);
|
|
166
177
|
ManifestClient.requireHTTPS(url, allowInsecureUrls);
|
|
167
178
|
|
|
168
|
-
File temporary =
|
|
179
|
+
File temporary = fetcher.download(url, context);
|
|
169
180
|
try {
|
|
170
|
-
|
|
171
|
-
try {
|
|
172
|
-
connection.setRequestMethod("GET");
|
|
173
|
-
connection.setConnectTimeout(15_000);
|
|
174
|
-
connection.setReadTimeout(60_000);
|
|
175
|
-
|
|
176
|
-
int status = connection.getResponseCode();
|
|
177
|
-
if (status < 200 || status >= 300) {
|
|
178
|
-
throw new IllegalStateException("File download failed with HTTP " + status);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
try (
|
|
182
|
-
InputStream input = connection.getInputStream();
|
|
183
|
-
FileOutputStream output = new FileOutputStream(temporary)
|
|
184
|
-
) {
|
|
185
|
-
byte[] buffer = new byte[8192];
|
|
186
|
-
int read;
|
|
187
|
-
while ((read = input.read(buffer)) > 0) {
|
|
188
|
-
output.write(buffer, 0, read);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
} finally {
|
|
192
|
-
connection.disconnect();
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (!HashUtils.verify(temporary, entry.sha256)) {
|
|
196
|
-
throw new IllegalStateException("Downloaded file hash mismatch: " + entry.path);
|
|
197
|
-
}
|
|
181
|
+
HashUtils.verifyDownload(temporary, entry.sha256, entry.size, "file");
|
|
198
182
|
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
atomicCopyIntoCache(temporary, cached);
|
|
204
|
-
}
|
|
183
|
+
if (validCached(entry)) return;
|
|
184
|
+
discardDamagedCache(cached);
|
|
185
|
+
atomicCopyIntoCache(temporary, cached);
|
|
186
|
+
HashUtils.verifyDownload(cached, entry.sha256, entry.size, "cached file");
|
|
205
187
|
} finally {
|
|
206
188
|
//noinspection ResultOfMethodCallIgnored
|
|
207
189
|
temporary.delete();
|
|
208
190
|
}
|
|
209
191
|
}
|
|
210
192
|
|
|
193
|
+
private boolean validCached(ManifestClient.ManifestFileEntry entry) {
|
|
194
|
+
File file = cachePath(entry.sha256);
|
|
195
|
+
if (!file.isFile() || (entry.size >= 0 && file.length() != entry.size)) return false;
|
|
196
|
+
try {
|
|
197
|
+
return HashUtils.verify(file, entry.sha256);
|
|
198
|
+
} catch (Exception unreadable) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private static void discardDamagedCache(File file) throws Exception {
|
|
204
|
+
if (file.exists() && !file.delete()) throw new java.io.IOException(
|
|
205
|
+
"Cannot remove damaged delta cache entry"
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
211
209
|
// ── Assembly ────────────────────────────────────────────────────────
|
|
212
210
|
|
|
213
211
|
/**
|
|
@@ -238,6 +236,8 @@ final class DeltaAssembler {
|
|
|
238
236
|
throw new IllegalStateException("Cannot create parent: " + parent.getAbsolutePath());
|
|
239
237
|
}
|
|
240
238
|
copyFile(cachePath(entry.sha256), target);
|
|
239
|
+
// Recheck the installed copy if the cache changed while assembly was in progress.
|
|
240
|
+
HashUtils.verifyDownload(target, entry.sha256, entry.size, "assembled file");
|
|
241
241
|
}
|
|
242
242
|
}
|
|
243
243
|
|
|
@@ -372,8 +372,13 @@ final class DeltaAssembler {
|
|
|
372
372
|
|
|
373
373
|
private void atomicCopyIntoCache(File source, File destination) throws Exception {
|
|
374
374
|
File staging = new File(cacheDirectory, ".tmp-" + java.util.UUID.randomUUID());
|
|
375
|
-
|
|
376
|
-
|
|
375
|
+
try {
|
|
376
|
+
copyFile(source, staging);
|
|
377
|
+
renameIntoCache(staging, destination);
|
|
378
|
+
} finally {
|
|
379
|
+
//noinspection ResultOfMethodCallIgnored
|
|
380
|
+
staging.delete();
|
|
381
|
+
}
|
|
377
382
|
}
|
|
378
383
|
|
|
379
384
|
private static void renameIntoCache(File staging, File destination) throws Exception {
|
|
@@ -9,16 +9,96 @@ import java.util.Date;
|
|
|
9
9
|
import java.util.Locale;
|
|
10
10
|
import java.util.TimeZone;
|
|
11
11
|
import java.util.UUID;
|
|
12
|
-
import java.util.concurrent.ExecutorService;
|
|
13
12
|
import java.util.concurrent.Executors;
|
|
13
|
+
import java.util.concurrent.ScheduledExecutorService;
|
|
14
|
+
import java.util.concurrent.ScheduledFuture;
|
|
15
|
+
import java.util.concurrent.TimeUnit;
|
|
14
16
|
import org.json.JSONObject;
|
|
15
17
|
|
|
16
18
|
final class DeviceEventClient {
|
|
17
19
|
|
|
18
|
-
private static final
|
|
20
|
+
private static final ScheduledExecutorService executor =
|
|
21
|
+
Executors.newSingleThreadScheduledExecutor();
|
|
22
|
+
private static EventOutbox outbox;
|
|
23
|
+
private static ScheduledFuture<?> scheduled;
|
|
24
|
+
private static boolean sending;
|
|
19
25
|
|
|
20
26
|
private DeviceEventClient() {}
|
|
21
27
|
|
|
28
|
+
static synchronized boolean hasPendingEvents() {
|
|
29
|
+
return outbox != null && outbox.waitMilliseconds(System.currentTimeMillis()) != null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static synchronized void resume(java.io.File directory) {
|
|
33
|
+
try {
|
|
34
|
+
if (outbox == null) outbox = new EventOutbox(directory);
|
|
35
|
+
schedule();
|
|
36
|
+
} catch (Exception error) {
|
|
37
|
+
android.util.Log.w("OtaKit", "Cannot open device event outbox");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private static synchronized void schedule() {
|
|
42
|
+
if (sending || outbox == null) return;
|
|
43
|
+
if (scheduled != null) scheduled.cancel(false);
|
|
44
|
+
Long delay = outbox.waitMilliseconds(System.currentTimeMillis());
|
|
45
|
+
scheduled =
|
|
46
|
+
delay == null
|
|
47
|
+
? null
|
|
48
|
+
: executor.schedule(DeviceEventClient::drain, delay, TimeUnit.MILLISECONDS);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private static void drain() {
|
|
52
|
+
synchronized (DeviceEventClient.class) {
|
|
53
|
+
if (sending) return;
|
|
54
|
+
sending = true;
|
|
55
|
+
if (scheduled != null) scheduled.cancel(false);
|
|
56
|
+
scheduled = null;
|
|
57
|
+
}
|
|
58
|
+
boolean storageFailed = false;
|
|
59
|
+
try {
|
|
60
|
+
EventOutbox.Entry entry = outbox.ready(System.currentTimeMillis());
|
|
61
|
+
if (entry == null) return;
|
|
62
|
+
int status = 0;
|
|
63
|
+
String retryAfter = null;
|
|
64
|
+
HttpURLConnection connection = null;
|
|
65
|
+
try {
|
|
66
|
+
connection = (HttpURLConnection) new URL(entry.url).openConnection();
|
|
67
|
+
connection.setRequestMethod("POST");
|
|
68
|
+
connection.setInstanceFollowRedirects(false);
|
|
69
|
+
connection.setRequestProperty("X-App-Id", entry.appId);
|
|
70
|
+
connection.setRequestProperty("Content-Type", "application/json");
|
|
71
|
+
connection.setConnectTimeout(10_000);
|
|
72
|
+
connection.setReadTimeout(10_000);
|
|
73
|
+
connection.setDoOutput(true);
|
|
74
|
+
try (OutputStream output = connection.getOutputStream()) {
|
|
75
|
+
output.write(entry.body.getBytes(StandardCharsets.UTF_8));
|
|
76
|
+
}
|
|
77
|
+
status = connection.getResponseCode();
|
|
78
|
+
retryAfter = connection.getHeaderField("Retry-After");
|
|
79
|
+
} catch (Exception ignored) {
|
|
80
|
+
// Retain the original ID and body after a lost response or offline failure.
|
|
81
|
+
} finally {
|
|
82
|
+
if (connection != null) connection.disconnect();
|
|
83
|
+
}
|
|
84
|
+
outbox.complete(entry.id, status, retryAfter, System.currentTimeMillis(), Math.random());
|
|
85
|
+
} catch (Exception error) {
|
|
86
|
+
storageFailed = true;
|
|
87
|
+
android.util.Log.w("OtaKit", "Cannot persist device event delivery state");
|
|
88
|
+
} finally {
|
|
89
|
+
synchronized (DeviceEventClient.class) {
|
|
90
|
+
sending = false;
|
|
91
|
+
// A broken disk must not create a zero-delay retry loop.
|
|
92
|
+
if (storageFailed) scheduled = executor.schedule(
|
|
93
|
+
DeviceEventClient::drain,
|
|
94
|
+
60,
|
|
95
|
+
TimeUnit.SECONDS
|
|
96
|
+
);
|
|
97
|
+
else schedule();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
22
102
|
static void send(
|
|
23
103
|
String ingestUrl,
|
|
24
104
|
String appId,
|
|
@@ -31,8 +111,39 @@ final class DeviceEventClient {
|
|
|
31
111
|
String nativeBuild,
|
|
32
112
|
String detail
|
|
33
113
|
) {
|
|
34
|
-
|
|
35
|
-
|
|
114
|
+
send(
|
|
115
|
+
ingestUrl,
|
|
116
|
+
appId,
|
|
117
|
+
platform,
|
|
118
|
+
action,
|
|
119
|
+
bundleVersion,
|
|
120
|
+
channel,
|
|
121
|
+
runtimeVersion,
|
|
122
|
+
releaseId,
|
|
123
|
+
nativeBuild,
|
|
124
|
+
detail,
|
|
125
|
+
null,
|
|
126
|
+
null,
|
|
127
|
+
"unknown"
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
static void send(
|
|
132
|
+
String ingestUrl,
|
|
133
|
+
String appId,
|
|
134
|
+
String platform,
|
|
135
|
+
String action,
|
|
136
|
+
String bundleVersion,
|
|
137
|
+
String channel,
|
|
138
|
+
String runtimeVersion,
|
|
139
|
+
String releaseId,
|
|
140
|
+
String nativeBuild,
|
|
141
|
+
String detail,
|
|
142
|
+
String attemptId,
|
|
143
|
+
String phase,
|
|
144
|
+
String lifecycle
|
|
145
|
+
) {
|
|
146
|
+
synchronized (DeviceEventClient.class) {
|
|
36
147
|
try {
|
|
37
148
|
String base = ingestUrl.replaceAll("/+$", "");
|
|
38
149
|
URL url = new URL(base + "/events");
|
|
@@ -51,35 +162,23 @@ final class DeviceEventClient {
|
|
|
51
162
|
}
|
|
52
163
|
payload.put("releaseId", releaseId);
|
|
53
164
|
payload.put("nativeBuild", nativeBuild);
|
|
165
|
+
payload.put("nativeSdkVersion", SDKVersion.VALUE);
|
|
166
|
+
payload.put("attemptId", attemptId);
|
|
167
|
+
payload.put("phase", phase);
|
|
168
|
+
payload.put("lifecycle", lifecycle);
|
|
54
169
|
if (detail != null) {
|
|
55
170
|
String truncated = detail.length() > 500 ? detail.substring(0, 500) : detail;
|
|
56
171
|
payload.put("detail", truncated);
|
|
57
172
|
}
|
|
58
173
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
connection.setRequestMethod("POST");
|
|
63
|
-
connection.setRequestProperty("X-App-Id", appId);
|
|
64
|
-
connection.setRequestProperty("Content-Type", "application/json");
|
|
65
|
-
connection.setConnectTimeout(10_000);
|
|
66
|
-
connection.setReadTimeout(10_000);
|
|
67
|
-
connection.setDoOutput(true);
|
|
68
|
-
|
|
69
|
-
try (OutputStream output = connection.getOutputStream()) {
|
|
70
|
-
output.write(body);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Device events are best-effort and should never block the update flow.
|
|
74
|
-
connection.getResponseCode();
|
|
174
|
+
if (outbox == null) throw new java.io.IOException("Outbox unavailable");
|
|
175
|
+
outbox.enqueue(url.toString(), appId, payload.toString(), System.currentTimeMillis());
|
|
176
|
+
schedule();
|
|
75
177
|
} catch (Exception ignored) {
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
if (connection != null) {
|
|
79
|
-
connection.disconnect();
|
|
80
|
-
}
|
|
178
|
+
// Persistence failure must not fail or roll back the update itself.
|
|
179
|
+
android.util.Log.w("OtaKit", "Cannot persist device event");
|
|
81
180
|
}
|
|
82
|
-
}
|
|
181
|
+
}
|
|
83
182
|
}
|
|
84
183
|
|
|
85
184
|
private static String iso8601Now() {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
package com.otakit.updater;
|
|
2
|
+
|
|
3
|
+
import android.net.Uri;
|
|
4
|
+
import android.webkit.WebView;
|
|
5
|
+
import androidx.webkit.ScriptHandler;
|
|
6
|
+
import androidx.webkit.WebViewCompat;
|
|
7
|
+
import androidx.webkit.WebViewFeature;
|
|
8
|
+
import java.util.Collections;
|
|
9
|
+
import org.json.JSONObject;
|
|
10
|
+
|
|
11
|
+
/** Captures the activation in the document's bridge before application JavaScript runs. */
|
|
12
|
+
final class DocumentReadyBridge {
|
|
13
|
+
|
|
14
|
+
private ScriptHandler script;
|
|
15
|
+
|
|
16
|
+
static void requireSupported() {
|
|
17
|
+
if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
|
|
18
|
+
throw new IllegalStateException(
|
|
19
|
+
"Update Android System WebView before applying OtaKit updates: document-start scripts are unavailable"
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private static String origin(String appUrl) {
|
|
25
|
+
return Uri.parse(appUrl).buildUpon().path(null).fragment(null).clearQuery().build().toString();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static void preflight(WebView webView, String appUrl) {
|
|
29
|
+
requireSupported();
|
|
30
|
+
// Validate this WebView and origin before publishing a trial in persistent state.
|
|
31
|
+
WebViewCompat.addDocumentStartJavaScript(
|
|
32
|
+
webView,
|
|
33
|
+
"",
|
|
34
|
+
Collections.singleton(origin(appUrl))
|
|
35
|
+
).remove();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static String source(String activationId) {
|
|
39
|
+
return String.format(
|
|
40
|
+
java.util.Locale.ROOT,
|
|
41
|
+
"""
|
|
42
|
+
(() => {
|
|
43
|
+
if (window !== window.top) return;
|
|
44
|
+
const cap = window.Capacitor;
|
|
45
|
+
if (!cap || typeof cap.toNative !== 'function') return;
|
|
46
|
+
const original = cap.toNative;
|
|
47
|
+
const activationId = %s;
|
|
48
|
+
cap.toNative = function(plugin, method, options, callback) {
|
|
49
|
+
if (plugin === 'OtaKit' && method === 'notifyAppReady') {
|
|
50
|
+
options = Object.assign({}, options, { _otakitActivationId: activationId });
|
|
51
|
+
}
|
|
52
|
+
return original.call(this, plugin, method, options, callback);
|
|
53
|
+
};
|
|
54
|
+
})();
|
|
55
|
+
""",
|
|
56
|
+
JSONObject.quote(activationId)
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
void install(WebView webView, String appUrl, String activationId) {
|
|
61
|
+
requireSupported();
|
|
62
|
+
// Register before removing the old script so registration failure preserves it.
|
|
63
|
+
ScriptHandler replacement = WebViewCompat.addDocumentStartJavaScript(
|
|
64
|
+
webView,
|
|
65
|
+
source(activationId),
|
|
66
|
+
Collections.singleton(origin(appUrl))
|
|
67
|
+
);
|
|
68
|
+
if (script != null) script.remove();
|
|
69
|
+
script = replacement;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
package com.otakit.updater;
|
|
2
|
+
|
|
3
|
+
import java.io.IOException;
|
|
4
|
+
import java.text.ParsePosition;
|
|
5
|
+
import java.text.SimpleDateFormat;
|
|
6
|
+
import java.util.Date;
|
|
7
|
+
import java.util.Locale;
|
|
8
|
+
import java.util.TimeZone;
|
|
9
|
+
import java.util.concurrent.Callable;
|
|
10
|
+
import java.util.function.DoubleSupplier;
|
|
11
|
+
import java.util.function.LongSupplier;
|
|
12
|
+
import javax.net.ssl.SSLHandshakeException;
|
|
13
|
+
import javax.net.ssl.SSLPeerUnverifiedException;
|
|
14
|
+
|
|
15
|
+
/** Bounded retries for GET transport failures. Integrity and disk failures stay terminal. */
|
|
16
|
+
final class DownloadRetry {
|
|
17
|
+
|
|
18
|
+
static boolean isExpiredUrlFailure(Exception error) {
|
|
19
|
+
if (!(error instanceof HttpFailure)) return false;
|
|
20
|
+
int status = ((HttpFailure) error).status;
|
|
21
|
+
return status == 403 || status == 410;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Sleeper {
|
|
25
|
+
void sleep(long milliseconds) throws InterruptedException;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static final class HttpFailure extends IOException {
|
|
29
|
+
|
|
30
|
+
final int status;
|
|
31
|
+
final String retryAfter;
|
|
32
|
+
|
|
33
|
+
HttpFailure(int status, String retryAfter) {
|
|
34
|
+
super("Download failed with HTTP " + status);
|
|
35
|
+
this.status = status;
|
|
36
|
+
this.retryAfter = retryAfter;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static final class NetworkFailure extends IOException {
|
|
41
|
+
|
|
42
|
+
NetworkFailure(IOException cause) {
|
|
43
|
+
super("Download connection failed (" + cause.getClass().getSimpleName() + ")", cause);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@Override
|
|
47
|
+
public String getMessage() {
|
|
48
|
+
// Only locally constructed EOF diagnostics are safe to include; arbitrary network messages may contain URLs.
|
|
49
|
+
String detail = getCause() instanceof java.io.EOFException ? getCause().getMessage() : null;
|
|
50
|
+
return (
|
|
51
|
+
super.getMessage() +
|
|
52
|
+
(detail != null && detail.startsWith("body length mismatch;") ? "; " + detail : "")
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private final Sleeper sleeper;
|
|
58
|
+
private final DoubleSupplier random;
|
|
59
|
+
private final LongSupplier now;
|
|
60
|
+
|
|
61
|
+
DownloadRetry() {
|
|
62
|
+
this(Thread::sleep, Math::random, System::currentTimeMillis);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
DownloadRetry(Sleeper sleeper, DoubleSupplier random, LongSupplier now) {
|
|
66
|
+
this.sleeper = sleeper;
|
|
67
|
+
this.random = random;
|
|
68
|
+
this.now = now;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
<T> T run(Callable<T> operation) throws Exception {
|
|
72
|
+
for (int attempt = 1; ; attempt++) {
|
|
73
|
+
if (
|
|
74
|
+
Thread.currentThread().isInterrupted()
|
|
75
|
+
) throw new java.util.concurrent.CancellationException("Download cancelled");
|
|
76
|
+
try {
|
|
77
|
+
return operation.call();
|
|
78
|
+
} catch (Exception failure) {
|
|
79
|
+
Long delay = delay(failure, attempt);
|
|
80
|
+
if (delay == null) throw failure;
|
|
81
|
+
try {
|
|
82
|
+
sleeper.sleep(delay);
|
|
83
|
+
} catch (InterruptedException interrupted) {
|
|
84
|
+
Thread.currentThread().interrupt();
|
|
85
|
+
throw new java.util.concurrent.CancellationException("Download cancelled");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
Long delay(Exception failure, int attempt) {
|
|
92
|
+
if (attempt >= 3 || attempt < 1 || Thread.currentThread().isInterrupted()) return null;
|
|
93
|
+
Long retryAfterMs = null;
|
|
94
|
+
if (failure instanceof HttpFailure) {
|
|
95
|
+
HttpFailure http = (HttpFailure) failure;
|
|
96
|
+
if (
|
|
97
|
+
!(http.status == 408 ||
|
|
98
|
+
http.status == 425 ||
|
|
99
|
+
http.status == 429 ||
|
|
100
|
+
http.status == 500 ||
|
|
101
|
+
http.status == 502 ||
|
|
102
|
+
http.status == 503 ||
|
|
103
|
+
http.status == 504)
|
|
104
|
+
) return null;
|
|
105
|
+
retryAfterMs = retryAfterMilliseconds(http.retryAfter, now.getAsLong());
|
|
106
|
+
// Do not sleep indefinitely or retry earlier than a long server-requested delay.
|
|
107
|
+
if (retryAfterMs != null && retryAfterMs > 30_000) return null;
|
|
108
|
+
} else if (!(failure instanceof NetworkFailure)) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
long backoff = (long) (1000L * (1L << (attempt - 1)) * (0.5 + random.getAsDouble()));
|
|
112
|
+
return retryAfterMs == null ? backoff : Math.max(backoff, retryAfterMs);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
static Long retryAfterMilliseconds(String value, long now) {
|
|
116
|
+
if (value == null) return null;
|
|
117
|
+
String trimmed = value.trim();
|
|
118
|
+
if (trimmed.matches("[0-9]+")) {
|
|
119
|
+
try {
|
|
120
|
+
return Math.multiplyExact(Long.parseLong(trimmed), 1000L);
|
|
121
|
+
} catch (ArithmeticException | NumberFormatException error) {
|
|
122
|
+
return Long.MAX_VALUE;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
SimpleDateFormat format = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US);
|
|
126
|
+
format.setTimeZone(TimeZone.getTimeZone("GMT"));
|
|
127
|
+
format.setLenient(false);
|
|
128
|
+
ParsePosition position = new ParsePosition(0);
|
|
129
|
+
Date date = format.parse(trimmed, position);
|
|
130
|
+
return date != null && position.getIndex() == trimmed.length()
|
|
131
|
+
? Math.max(0, date.getTime() - now)
|
|
132
|
+
: null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
static <T> T network(Callable<T> operation) throws Exception {
|
|
136
|
+
try {
|
|
137
|
+
return operation.call();
|
|
138
|
+
} catch (SSLHandshakeException | SSLPeerUnverifiedException failure) {
|
|
139
|
+
throw failure;
|
|
140
|
+
} catch (IOException failure) {
|
|
141
|
+
throw new NetworkFailure(failure);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|