@otakit/capacitor-updater 2.3.3 → 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.
Files changed (56) hide show
  1. package/README.md +46 -5
  2. package/android/build.gradle +16 -0
  3. package/android/src/main/java/com/otakit/updater/BundleCrypto.java +38 -17
  4. package/android/src/main/java/com/otakit/updater/BundleInfo.java +10 -0
  5. package/android/src/main/java/com/otakit/updater/BundleStore.java +134 -86
  6. package/android/src/main/java/com/otakit/updater/CheckFailure.java +53 -0
  7. package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +46 -41
  8. package/android/src/main/java/com/otakit/updater/DeviceEventClient.java +125 -26
  9. package/android/src/main/java/com/otakit/updater/DocumentReadyBridge.java +71 -0
  10. package/android/src/main/java/com/otakit/updater/DownloadRetry.java +144 -0
  11. package/android/src/main/java/com/otakit/updater/EventOutbox.java +174 -0
  12. package/android/src/main/java/com/otakit/updater/FileDownloader.java +80 -0
  13. package/android/src/main/java/com/otakit/updater/ForegroundDeadline.java +84 -0
  14. package/android/src/main/java/com/otakit/updater/HashUtils.java +26 -0
  15. package/android/src/main/java/com/otakit/updater/ManifestClient.java +12 -9
  16. package/android/src/main/java/com/otakit/updater/ManifestKeyConfig.java +47 -0
  17. package/android/src/main/java/com/otakit/updater/ManifestVerifier.java +22 -4
  18. package/android/src/main/java/com/otakit/updater/SDKVersion.java +9 -0
  19. package/android/src/main/java/com/otakit/updater/UpdateOwner.java +27 -0
  20. package/android/src/main/java/com/otakit/updater/UpdaterCoordinator.java +154 -59
  21. package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +262 -173
  22. package/dist/esm/definitions.d.ts +3 -3
  23. package/dist/esm/definitions.d.ts.map +1 -1
  24. package/ios/Sources/UpdaterPlugin/BundleCrypto.swift +35 -3
  25. package/ios/Sources/UpdaterPlugin/BundleInfo.swift +13 -0
  26. package/ios/Sources/UpdaterPlugin/BundleStore.swift +103 -77
  27. package/ios/Sources/UpdaterPlugin/CheckFailure.swift +41 -0
  28. package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +29 -17
  29. package/ios/Sources/UpdaterPlugin/DeviceEventClient.swift +12 -10
  30. package/ios/Sources/UpdaterPlugin/DocumentReadyBridge.swift +43 -0
  31. package/ios/Sources/UpdaterPlugin/DownloadRetry.swift +51 -0
  32. package/ios/Sources/UpdaterPlugin/Downloader.swift +97 -39
  33. package/ios/Sources/UpdaterPlugin/EventOutbox.swift +183 -0
  34. package/ios/Sources/UpdaterPlugin/ForegroundDeadline.swift +94 -0
  35. package/ios/Sources/UpdaterPlugin/HashUtils.swift +16 -0
  36. package/ios/Sources/UpdaterPlugin/ManifestClient.swift +2 -4
  37. package/ios/Sources/UpdaterPlugin/ManifestKeyConfig.swift +20 -0
  38. package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +7 -1
  39. package/ios/Sources/UpdaterPlugin/SDKVersion.swift +4 -0
  40. package/ios/Sources/UpdaterPlugin/UpdateOwner.swift +12 -0
  41. package/ios/Sources/UpdaterPlugin/UpdaterCoordinator.swift +146 -110
  42. package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +206 -202
  43. package/ios/Tests/UpdaterPluginTests/BundleCryptoTests.swift +80 -0
  44. package/ios/Tests/UpdaterPluginTests/BundlePersistenceTests.swift +102 -0
  45. package/ios/Tests/UpdaterPluginTests/CheckFailureTests.swift +55 -0
  46. package/ios/Tests/UpdaterPluginTests/DeltaCacheIntegrityTests.swift +69 -0
  47. package/ios/Tests/UpdaterPluginTests/DocumentReadyBridgeTests.swift +102 -0
  48. package/ios/Tests/UpdaterPluginTests/DownloadIntegrityTests.swift +40 -0
  49. package/ios/Tests/UpdaterPluginTests/DownloadRetryTests.swift +189 -0
  50. package/ios/Tests/UpdaterPluginTests/EventDeliveryTests.swift +56 -0
  51. package/ios/Tests/UpdaterPluginTests/EventOutboxTests.swift +86 -0
  52. package/ios/Tests/UpdaterPluginTests/ForegroundDeadlineTests.swift +144 -0
  53. package/ios/Tests/UpdaterPluginTests/ManifestKeyConfigTests.swift +48 -0
  54. package/ios/Tests/UpdaterPluginTests/UpdateOwnerTests.swift +34 -0
  55. package/ios/Tests/UpdaterPluginTests/UpdaterCoordinatorTests.swift +394 -0
  56. 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 (cached.exists()) {
162
- return;
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 = File.createTempFile("otakit-file-", ".tmp", context.getCacheDir());
179
+ File temporary = fetcher.download(url, context);
169
180
  try {
170
- HttpURLConnection connection = (HttpURLConnection) url.openConnection();
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 (!cached.exists()) {
200
- // Write via temp + rename so process death mid-copy can never leave
201
- // a truncated file at a content-addressed path (exists() implies
202
- // fully-written, hash-verified content).
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
- copyFile(source, staging);
376
- renameIntoCache(staging, destination);
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 ExecutorService executor = Executors.newSingleThreadExecutor();
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
- executor.execute(() -> {
35
- HttpURLConnection connection = null;
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
- byte[] body = payload.toString().getBytes(StandardCharsets.UTF_8);
60
-
61
- connection = (HttpURLConnection) url.openConnection();
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
- // Device events are best-effort, don't fail on errors
77
- } finally {
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
+ }