@otakit/capacitor-updater 2.0.1 → 2.1.1

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 (37) hide show
  1. package/{UpdatekitUpdater.podspec → OtaKitUpdater.podspec} +1 -1
  2. package/README.md +165 -130
  3. package/android/build.gradle +1 -1
  4. package/android/src/main/java/com/{updatekit → otakit}/updater/BundleInfo.java +1 -1
  5. package/android/src/main/java/com/{updatekit → otakit}/updater/BundleStatus.java +1 -1
  6. package/android/src/main/java/com/{updatekit → otakit}/updater/BundleStore.java +38 -9
  7. package/android/src/main/java/com/{updatekit → otakit}/updater/DateUtils.java +1 -1
  8. package/android/src/main/java/com/{updatekit → otakit}/updater/DeviceEventClient.java +3 -4
  9. package/android/src/main/java/com/{updatekit → otakit}/updater/HashUtils.java +1 -1
  10. package/android/src/main/java/com/{updatekit → otakit}/updater/HostedManifestKeys.java +1 -1
  11. package/android/src/main/java/com/{updatekit → otakit}/updater/ManifestClient.java +32 -15
  12. package/android/src/main/java/com/{updatekit → otakit}/updater/ManifestVerifier.java +1 -1
  13. package/android/src/main/java/com/otakit/updater/UpdaterCoordinator.java +726 -0
  14. package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +1191 -0
  15. package/android/src/main/java/com/{updatekit → otakit}/updater/ZipUtils.java +1 -1
  16. package/dist/esm/definitions.d.ts +48 -105
  17. package/dist/esm/definitions.d.ts.map +1 -1
  18. package/dist/esm/definitions.js.map +1 -1
  19. package/dist/esm/index.d.ts.map +1 -1
  20. package/dist/esm/index.js +6 -60
  21. package/dist/esm/index.js.map +1 -1
  22. package/dist/esm/web.d.ts +7 -11
  23. package/dist/esm/web.d.ts.map +1 -1
  24. package/dist/esm/web.js +8 -14
  25. package/dist/esm/web.js.map +1 -1
  26. package/dist/plugin.cjs.js +14 -74
  27. package/dist/plugin.cjs.js.map +1 -1
  28. package/dist/plugin.js +14 -74
  29. package/dist/plugin.js.map +1 -1
  30. package/ios/Sources/UpdaterPlugin/BundleStore.swift +28 -6
  31. package/ios/Sources/UpdaterPlugin/Downloader.swift +1 -1
  32. package/ios/Sources/UpdaterPlugin/ManifestClient.swift +8 -4
  33. package/ios/Sources/UpdaterPlugin/UpdaterCoordinator.swift +635 -0
  34. package/ios/Sources/UpdaterPlugin/UpdaterPlugin.m +3 -5
  35. package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +506 -551
  36. package/package.json +2 -2
  37. package/android/src/main/java/com/updatekit/updater/UpdaterPlugin.java +0 -1191
@@ -0,0 +1,1191 @@
1
+ package com.otakit.updater;
2
+
3
+ import android.content.pm.PackageInfo;
4
+ import android.content.pm.PackageManager;
5
+ import android.os.Build;
6
+ import android.os.Handler;
7
+ import android.os.Looper;
8
+ import com.getcapacitor.JSObject;
9
+ import com.getcapacitor.Plugin;
10
+ import com.getcapacitor.PluginCall;
11
+ import com.getcapacitor.PluginMethod;
12
+ import com.getcapacitor.annotation.CapacitorPlugin;
13
+ import java.io.File;
14
+ import java.io.FileInputStream;
15
+ import java.io.FileOutputStream;
16
+ import java.io.InputStream;
17
+ import java.net.HttpURLConnection;
18
+ import java.net.URL;
19
+ import java.nio.charset.StandardCharsets;
20
+ import java.security.MessageDigest;
21
+ import java.util.concurrent.CountDownLatch;
22
+ import java.util.concurrent.ExecutorService;
23
+ import java.util.concurrent.Executors;
24
+ import java.util.concurrent.atomic.AtomicReference;
25
+
26
+ @CapacitorPlugin(name = "OtaKit")
27
+ public class UpdaterPlugin extends Plugin {
28
+
29
+ private enum Policy {
30
+ OFF("off"),
31
+ SHADOW("shadow"),
32
+ APPLY_STAGED("apply-staged"),
33
+ IMMEDIATE("immediate");
34
+
35
+ final String value;
36
+
37
+ Policy(String value) {
38
+ this.value = value;
39
+ }
40
+ }
41
+
42
+ private static final class CheckResolution {
43
+
44
+ final String kind;
45
+ final ManifestClient.LatestManifest latest;
46
+ final BundleInfo bundle;
47
+
48
+ private CheckResolution(String kind, ManifestClient.LatestManifest latest, BundleInfo bundle) {
49
+ this.kind = kind;
50
+ this.latest = latest;
51
+ this.bundle = bundle;
52
+ }
53
+
54
+ static CheckResolution noUpdate() {
55
+ return new CheckResolution("no_update", null, null);
56
+ }
57
+
58
+ static CheckResolution alreadyStaged(ManifestClient.LatestManifest latest, BundleInfo bundle) {
59
+ return new CheckResolution("already_staged", latest, bundle);
60
+ }
61
+
62
+ static CheckResolution updateAvailable(ManifestClient.LatestManifest latest) {
63
+ return new CheckResolution("update_available", latest, null);
64
+ }
65
+ }
66
+
67
+ private static final class DownloadResolution {
68
+
69
+ final String kind;
70
+ final BundleInfo bundle;
71
+
72
+ private DownloadResolution(String kind, BundleInfo bundle) {
73
+ this.kind = kind;
74
+ this.bundle = bundle;
75
+ }
76
+
77
+ static DownloadResolution noUpdate() {
78
+ return new DownloadResolution("no_update", null);
79
+ }
80
+
81
+ static DownloadResolution staged(BundleInfo bundle) {
82
+ return new DownloadResolution("staged", bundle);
83
+ }
84
+ }
85
+
86
+ @FunctionalInterface
87
+ private interface ThrowingRunnable {
88
+ void run() throws Exception;
89
+ }
90
+
91
+ private final ExecutorService executor = Executors.newSingleThreadExecutor();
92
+ private final Handler mainHandler = new Handler(Looper.getMainLooper());
93
+ private final ZipUtils zipUtils = new ZipUtils();
94
+
95
+ private BundleStore store;
96
+ private UpdaterCoordinator coordinator;
97
+ private Runnable trialTimeoutRunnable;
98
+
99
+ private int appReadyTimeoutMs = 10_000;
100
+ private boolean allowInsecureUrls = false;
101
+ private Policy launchPolicy = Policy.APPLY_STAGED;
102
+ private Policy resumePolicy = Policy.SHADOW;
103
+ private Policy runtimePolicy = Policy.IMMEDIATE;
104
+ private String ingestUrl;
105
+ private String cdnUrl;
106
+ private String appId;
107
+ private String channel;
108
+ private String runtimeVersion;
109
+ private java.util.List<ManifestVerifier.KeyEntry> manifestKeys = new java.util.ArrayList<>();
110
+ private long checkIntervalMs = 600_000;
111
+ private boolean coldStartInProgress = false;
112
+ private static final String DEFAULT_INGEST_URL = "https://ingest.otakit.app/v1";
113
+ private static final String DEFAULT_CDN_URL = "https://cdn.otakit.app";
114
+ private static final String INGEST_PATH_SUFFIX = "/v1";
115
+ private static final String KEY_LAST_CHECK_TIMESTAMP = "last_check_timestamp";
116
+ private static final String DEFAULT_RUNTIME_KEY = "__default__";
117
+ private static final String BUILTIN_ASSET_PATH = "public";
118
+ private UpdaterCoordinator.StartupPreparation pendingStartupPreparation;
119
+
120
+ @Override
121
+ public void load() {
122
+ super.load();
123
+
124
+ String builtinVersion = "0.0.0";
125
+ String nativeBuild = "1";
126
+ try {
127
+ PackageInfo info = getContext()
128
+ .getPackageManager()
129
+ .getPackageInfo(getContext().getPackageName(), 0);
130
+ builtinVersion = info.versionName != null ? info.versionName : builtinVersion;
131
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
132
+ nativeBuild = String.valueOf(info.getLongVersionCode());
133
+ } else {
134
+ //noinspection deprecation
135
+ nativeBuild = String.valueOf(info.versionCode);
136
+ }
137
+ } catch (PackageManager.NameNotFoundException ignored) {}
138
+
139
+ this.ingestUrl = resolveIngestUrl(
140
+ getConfig().getString("ingestUrl"),
141
+ System.getenv("OTAKIT_INGEST_URL")
142
+ );
143
+ this.cdnUrl = resolveCdnUrl(getConfig().getString("cdnUrl"), System.getenv("OTAKIT_CDN_URL"));
144
+ this.appId = getConfig().getString("appId");
145
+ this.channel = trimToNull(getConfig().getString("channel"));
146
+ this.runtimeVersion = trimToNull(getConfig().getString("runtimeVersion"));
147
+ this.store = new BundleStore(getContext(), builtinVersion, nativeBuild, this.runtimeVersion);
148
+ this.coordinator = new UpdaterCoordinator(this.store);
149
+ this.allowInsecureUrls = getConfig().getBoolean("allowInsecureUrls", false);
150
+ this.launchPolicy = resolvePolicy(getConfig().getString("launchPolicy"), Policy.APPLY_STAGED);
151
+ this.resumePolicy = resolvePolicy(getConfig().getString("resumePolicy"), Policy.SHADOW);
152
+ this.runtimePolicy = resolvePolicy(getConfig().getString("runtimePolicy"), Policy.IMMEDIATE);
153
+
154
+ try {
155
+ org.json.JSONArray rawKeys = getConfig().getConfigJSON().optJSONArray("manifestKeys");
156
+ if (rawKeys != null && rawKeys.length() > 0) {
157
+ for (int i = 0; i < rawKeys.length(); i++) {
158
+ org.json.JSONObject entry = rawKeys.optJSONObject(i);
159
+ if (entry == null) {
160
+ continue;
161
+ }
162
+ String kid = entry.optString("kid", null);
163
+ String keyBase64 = entry.optString("key", null);
164
+ if (kid != null && keyBase64 != null) {
165
+ byte[] keyBytes = android.util.Base64.decode(keyBase64, android.util.Base64.DEFAULT);
166
+ manifestKeys.add(new ManifestVerifier.KeyEntry(kid, keyBytes));
167
+ }
168
+ }
169
+ if (manifestKeys.isEmpty()) {
170
+ android.util.Log.e(
171
+ "OtaKit",
172
+ "manifestKeys configured but all entries are invalid. Manifest verification will reject all updates."
173
+ );
174
+ manifestKeys.add(new ManifestVerifier.KeyEntry("_invalid_", new byte[0]));
175
+ }
176
+ }
177
+ } catch (Exception e) {
178
+ android.util.Log.e(
179
+ "OtaKit",
180
+ "Failed to parse manifestKeys. Manifest verification will reject all updates.",
181
+ e
182
+ );
183
+ manifestKeys.add(new ManifestVerifier.KeyEntry("_invalid_", new byte[0]));
184
+ }
185
+
186
+ if (manifestKeys.isEmpty() && HostedManifestKeys.matchesManagedManifestUrl(cdnUrl)) {
187
+ manifestKeys.addAll(HostedManifestKeys.createDefaultKeys());
188
+ }
189
+
190
+ this.appReadyTimeoutMs = Math.max(1000, getConfig().getInt("appReadyTimeout", 10_000));
191
+ this.checkIntervalMs = getConfig().getInt("checkInterval", 600_000);
192
+
193
+ pruneIncompatibleBundles();
194
+
195
+ UpdaterCoordinator.StartupPreparation startup = coordinator.normalizeStartupState(
196
+ this::isBundleUsable
197
+ );
198
+ coordinator.cleanupBundles(startup.cleanupBundleIds);
199
+ pendingStartupPreparation = startup;
200
+ }
201
+
202
+ @Override
203
+ protected void handleOnStart() {
204
+ super.handleOnStart();
205
+ consumePendingStartupPreparation();
206
+ }
207
+
208
+ @Override
209
+ protected void handleOnResume() {
210
+ super.handleOnResume();
211
+ if (coldStartInProgress) {
212
+ coldStartInProgress = false;
213
+ return;
214
+ }
215
+ handleResume();
216
+ }
217
+
218
+ private boolean shouldSkipCheckInterval() {
219
+ if (checkIntervalMs <= 0) return false;
220
+ long lastCheck = store.getPrefs().getLong(KEY_LAST_CHECK_TIMESTAMP, 0);
221
+ if (lastCheck <= 0) return false;
222
+ long elapsed = System.currentTimeMillis() - lastCheck;
223
+ return elapsed < checkIntervalMs;
224
+ }
225
+
226
+ private void recordCheckTimestamp() {
227
+ store.getPrefs().edit().putLong(KEY_LAST_CHECK_TIMESTAMP, System.currentTimeMillis()).apply();
228
+ }
229
+
230
+ private void dispatchColdStart() {
231
+ if (isRuntimeUnresolved()) {
232
+ handleRuntime();
233
+ } else {
234
+ handleLaunch();
235
+ }
236
+ }
237
+
238
+ private void consumePendingStartupPreparation() {
239
+ UpdaterCoordinator.StartupPreparation startup = pendingStartupPreparation;
240
+ if (startup == null) {
241
+ return;
242
+ }
243
+ pendingStartupPreparation = null;
244
+
245
+ if (startup.activationPath != null && !startup.activationPath.isEmpty()) {
246
+ try {
247
+ applyServerBasePathSynchronously(startup.activationPath);
248
+ } catch (Exception e) {
249
+ android.util.Log.w("OtaKit", "startup activation failed", e);
250
+ }
251
+ }
252
+
253
+ if (startup.eventPayload != null) {
254
+ sendDeviceEvent(startup.eventPayload);
255
+ }
256
+
257
+ if (startup.trialBundleId != null) {
258
+ scheduleTrialTimeout(startup.trialBundleId);
259
+ } else {
260
+ cancelTrialTimeout();
261
+ }
262
+
263
+ coldStartInProgress = true;
264
+ dispatchColdStart();
265
+ }
266
+
267
+ private void handleRuntime() {
268
+ switch (runtimePolicy) {
269
+ case OFF:
270
+ resolveCurrentRuntimeKey();
271
+ return;
272
+ case APPLY_STAGED:
273
+ boolean hasStagedBundle =
274
+ coordinator.snapshotState(
275
+ bundle -> isCompatibleRuntime(bundle) && isBundleUsable(bundle)
276
+ ).staged !=
277
+ null;
278
+ if (hasStagedBundle) {
279
+ resolveCurrentRuntimeKey();
280
+ try {
281
+ if (!applyStaged(false)) {
282
+ android.util.Log.w(
283
+ "OtaKit",
284
+ "Failed to apply a valid staged bundle during runtime handling"
285
+ );
286
+ }
287
+ } catch (Exception e) {
288
+ android.util.Log.w("OtaKit", "runtime apply-staged failed", e);
289
+ }
290
+ return;
291
+ }
292
+ executeAutomaticUpdate("runtime apply-staged fallback", () -> {
293
+ downloadLatest(false, null);
294
+ resolveCurrentRuntimeKey();
295
+ });
296
+ return;
297
+ case SHADOW:
298
+ executeAutomaticUpdate("runtime shadow", () -> {
299
+ downloadLatest(false, null);
300
+ resolveCurrentRuntimeKey();
301
+ });
302
+ return;
303
+ case IMMEDIATE:
304
+ executeAutomaticUpdate("runtime immediate", () -> {
305
+ DownloadResolution result = downloadLatest(false, null);
306
+ if ("no_update".equals(result.kind)) {
307
+ resolveCurrentRuntimeKey();
308
+ return;
309
+ }
310
+ resolveCurrentRuntimeKey();
311
+ requireApplyStaged(true);
312
+ });
313
+ return;
314
+ }
315
+ }
316
+
317
+ private void handleLaunch() {
318
+ switch (launchPolicy) {
319
+ case OFF:
320
+ return;
321
+ case APPLY_STAGED:
322
+ try {
323
+ if (applyStaged(false)) {
324
+ return;
325
+ }
326
+ } catch (Exception e) {
327
+ android.util.Log.w("OtaKit", "launch apply-staged failed", e);
328
+ return;
329
+ }
330
+ executeAutomaticUpdate("launch apply-staged fallback", () -> downloadLatest(false, null));
331
+ return;
332
+ case SHADOW:
333
+ executeAutomaticUpdate("launch shadow", () -> downloadLatest(false, null));
334
+ return;
335
+ case IMMEDIATE:
336
+ executeAutomaticUpdate("launch immediate", () -> {
337
+ DownloadResolution result = downloadLatest(false, null);
338
+ if ("staged".equals(result.kind)) {
339
+ requireApplyStaged(true);
340
+ }
341
+ });
342
+ return;
343
+ }
344
+ }
345
+
346
+ private void handleResume() {
347
+ switch (resumePolicy) {
348
+ case OFF:
349
+ return;
350
+ case APPLY_STAGED:
351
+ executeAutomaticUpdate("resume apply-staged", () -> {
352
+ if (applyStaged(true)) {
353
+ return;
354
+ }
355
+ downloadLatest(true, null);
356
+ });
357
+ return;
358
+ case SHADOW:
359
+ executeAutomaticUpdate("resume shadow", () -> downloadLatest(true, null));
360
+ return;
361
+ case IMMEDIATE:
362
+ executeAutomaticUpdate("resume immediate", () -> {
363
+ DownloadResolution result = downloadLatest(false, null);
364
+ if ("staged".equals(result.kind)) {
365
+ requireApplyStaged(true);
366
+ }
367
+ });
368
+ return;
369
+ }
370
+ }
371
+
372
+ private void executeAutomaticUpdate(String label, ThrowingRunnable operation) {
373
+ if (!coordinator.tryBeginOperation()) {
374
+ android.util.Log.d("OtaKit", "Skipping " + label + ": update already in progress");
375
+ return;
376
+ }
377
+ executor.execute(() -> {
378
+ try {
379
+ operation.run();
380
+ } catch (Exception e) {
381
+ android.util.Log.w("OtaKit", label + " failed", e);
382
+ } finally {
383
+ coordinator.endOperation();
384
+ }
385
+ });
386
+ }
387
+
388
+ private String currentRuntimeKey() {
389
+ return runtimeVersion != null ? runtimeVersion : DEFAULT_RUNTIME_KEY;
390
+ }
391
+
392
+ private boolean isRuntimeUnresolved() {
393
+ return coordinator.isRuntimeUnresolved(currentRuntimeKey());
394
+ }
395
+
396
+ private void resolveCurrentRuntimeKey() {
397
+ coordinator.resolveRuntimeKey(currentRuntimeKey());
398
+ }
399
+
400
+ private Policy resolvePolicy(String configured, Policy defaultPolicy) {
401
+ String raw = configured == null ? "" : configured.trim().toLowerCase();
402
+ if (raw.isEmpty()) {
403
+ return defaultPolicy;
404
+ }
405
+ if (Policy.OFF.value.equals(raw)) {
406
+ return Policy.OFF;
407
+ }
408
+ if (Policy.SHADOW.value.equals(raw)) {
409
+ return Policy.SHADOW;
410
+ }
411
+ if (Policy.APPLY_STAGED.value.equals(raw)) {
412
+ return Policy.APPLY_STAGED;
413
+ }
414
+ if (Policy.IMMEDIATE.value.equals(raw)) {
415
+ return Policy.IMMEDIATE;
416
+ }
417
+ android.util.Log.w(
418
+ "OtaKit",
419
+ "Unknown policy '" + raw + "', defaulting to '" + defaultPolicy.value + "'"
420
+ );
421
+ return defaultPolicy;
422
+ }
423
+
424
+ @PluginMethod
425
+ public void getState(PluginCall call) {
426
+ UpdaterCoordinator.StateSnapshot snapshot = coordinator.snapshotState(this::isBundleUsable);
427
+ JSObject result = new JSObject();
428
+ result.put("current", snapshot.current.toJSObject());
429
+ result.put("fallback", snapshot.fallback.toJSObject());
430
+ result.put("builtinVersion", snapshot.builtinVersion);
431
+ result.put("staged", snapshot.staged != null ? snapshot.staged.toJSObject() : null);
432
+ call.resolve(result);
433
+ }
434
+
435
+ @PluginMethod
436
+ public void check(PluginCall call) {
437
+ if (!coordinator.tryBeginOperation()) {
438
+ call.reject("Another update operation is already in progress");
439
+ return;
440
+ }
441
+ executor.execute(() -> {
442
+ try {
443
+ call.resolve(checkResolutionToJSObject(checkLatest(false, null)));
444
+ } catch (Exception e) {
445
+ call.reject("check failed: " + e.getMessage());
446
+ } finally {
447
+ coordinator.endOperation();
448
+ }
449
+ });
450
+ }
451
+
452
+ @PluginMethod
453
+ public void download(PluginCall call) {
454
+ if (!coordinator.tryBeginOperation()) {
455
+ call.reject("Another update operation is already in progress");
456
+ return;
457
+ }
458
+ executor.execute(() -> {
459
+ try {
460
+ call.resolve(downloadResolutionToJSObject(downloadLatest(false, null)));
461
+ } catch (Exception e) {
462
+ call.reject("download failed: " + e.getMessage());
463
+ } finally {
464
+ coordinator.endOperation();
465
+ }
466
+ });
467
+ }
468
+
469
+ @PluginMethod
470
+ public void apply(PluginCall call) {
471
+ if (!coordinator.tryBeginOperation()) {
472
+ call.reject("Another update operation is already in progress");
473
+ return;
474
+ }
475
+ executor.execute(() -> {
476
+ try {
477
+ requireApplyStaged(true);
478
+ } catch (Exception e) {
479
+ call.reject("apply failed: " + e.getMessage());
480
+ } finally {
481
+ coordinator.endOperation();
482
+ }
483
+ });
484
+ }
485
+
486
+ @PluginMethod
487
+ public void update(PluginCall call) {
488
+ if (!coordinator.tryBeginOperation()) {
489
+ call.reject("Another update operation is already in progress");
490
+ return;
491
+ }
492
+ executor.execute(() -> {
493
+ try {
494
+ DownloadResolution result = downloadLatest(false, null);
495
+ if ("staged".equals(result.kind)) {
496
+ requireApplyStaged(true);
497
+ return;
498
+ }
499
+ call.resolve();
500
+ } catch (Exception e) {
501
+ call.reject("update failed: " + e.getMessage());
502
+ } finally {
503
+ coordinator.endOperation();
504
+ }
505
+ });
506
+ }
507
+
508
+ @PluginMethod
509
+ public void notifyAppReady(PluginCall call) {
510
+ cancelTrialTimeout();
511
+ UpdaterCoordinator.NotifyReadyPreparation preparation = coordinator.prepareNotifyAppReady();
512
+ coordinator.cleanupBundles(preparation.cleanupBundleIds);
513
+ if (preparation.eventPayload != null) {
514
+ sendDeviceEvent(preparation.eventPayload);
515
+ }
516
+ call.resolve();
517
+ }
518
+
519
+ @PluginMethod
520
+ public void getLastFailure(PluginCall call) {
521
+ BundleInfo failed = coordinator.lastFailure();
522
+ if (failed == null) {
523
+ call.resolve((JSObject) null);
524
+ return;
525
+ }
526
+ call.resolve(failed.toJSObject());
527
+ }
528
+
529
+ private ManifestClient.LatestManifest fetchLatest(String channel) throws Exception {
530
+ if (appId == null || appId.trim().isEmpty()) {
531
+ throw new IllegalStateException("Missing appId in plugin config");
532
+ }
533
+
534
+ return ManifestClient.fetchLatest(
535
+ cdnUrl,
536
+ appId,
537
+ channel,
538
+ runtimeVersion,
539
+ allowInsecureUrls,
540
+ manifestKeys
541
+ );
542
+ }
543
+
544
+ private CheckResolution checkLatest(boolean respectInterval, String channel) throws Exception {
545
+ String targetChannel = resolveTargetChannel(channel);
546
+ if (respectInterval && shouldSkipCheckInterval()) {
547
+ android.util.Log.d("OtaKit", "Skipping resume check: checkInterval has not elapsed");
548
+ return CheckResolution.noUpdate();
549
+ }
550
+
551
+ ManifestClient.LatestManifest latest = fetchLatest(targetChannel);
552
+ if (latest == null) {
553
+ if (respectInterval) {
554
+ recordCheckTimestamp();
555
+ }
556
+ return CheckResolution.noUpdate();
557
+ }
558
+
559
+ CheckResolution resolution = classifyLatestManifest(latest, targetChannel);
560
+
561
+ if (respectInterval) {
562
+ recordCheckTimestamp();
563
+ }
564
+ return resolution;
565
+ }
566
+
567
+ private DownloadResolution downloadLatest(boolean respectInterval, String channel)
568
+ throws Exception {
569
+ String targetChannel = resolveTargetChannel(channel);
570
+ CheckResolution result = checkLatest(respectInterval, channel);
571
+ switch (result.kind) {
572
+ case "no_update":
573
+ return DownloadResolution.noUpdate();
574
+ case "already_staged":
575
+ return DownloadResolution.staged(result.bundle);
576
+ case "update_available":
577
+ try {
578
+ return DownloadResolution.staged(downloadLatestManifest(result.latest, targetChannel));
579
+ } catch (Exception e) {
580
+ if (!isExpiredURLError(e)) {
581
+ throw e;
582
+ }
583
+
584
+ ManifestClient.LatestManifest refreshed = fetchLatest(targetChannel);
585
+ if (refreshed == null) {
586
+ return DownloadResolution.noUpdate();
587
+ }
588
+
589
+ CheckResolution refreshedResolution = classifyLatestManifest(refreshed, targetChannel);
590
+ switch (refreshedResolution.kind) {
591
+ case "no_update":
592
+ return DownloadResolution.noUpdate();
593
+ case "already_staged":
594
+ return DownloadResolution.staged(refreshedResolution.bundle);
595
+ case "update_available":
596
+ return DownloadResolution.staged(
597
+ downloadLatestManifest(refreshedResolution.latest, targetChannel)
598
+ );
599
+ default:
600
+ throw new IllegalStateException(
601
+ "Unknown refreshed check result: " + refreshedResolution.kind
602
+ );
603
+ }
604
+ }
605
+ default:
606
+ throw new IllegalStateException("Unknown check result: " + result.kind);
607
+ }
608
+ }
609
+
610
+ private CheckResolution classifyLatestManifest(
611
+ ManifestClient.LatestManifest latest,
612
+ String targetChannel
613
+ ) throws Exception {
614
+ if (!isCompatibleRuntime(latest.runtimeVersion)) {
615
+ throw new IllegalStateException(
616
+ "Manifest runtimeVersion does not match the installed app runtime"
617
+ );
618
+ }
619
+
620
+ UpdaterCoordinator.LatestManifestClassification classification =
621
+ coordinator.classifyLatestManifest(latest, targetChannel, this::isBundleUsable);
622
+ if ("no_update".equals(classification.kind)) {
623
+ return CheckResolution.noUpdate();
624
+ }
625
+ if ("already_staged".equals(classification.kind)) {
626
+ return CheckResolution.alreadyStaged(latest, classification.bundle);
627
+ }
628
+ return CheckResolution.updateAvailable(latest);
629
+ }
630
+
631
+ private JSObject checkResolutionToJSObject(CheckResolution result) {
632
+ JSObject object = new JSObject();
633
+ object.put("kind", result.kind);
634
+ if (result.latest != null) {
635
+ object.put("latest", manifestToJSObject(result.latest));
636
+ }
637
+ return object;
638
+ }
639
+
640
+ private JSObject downloadResolutionToJSObject(DownloadResolution result) {
641
+ JSObject object = new JSObject();
642
+ object.put("kind", result.kind);
643
+ if (result.bundle != null) {
644
+ object.put("bundle", result.bundle.toJSObject());
645
+ }
646
+ return object;
647
+ }
648
+
649
+ private boolean isExpiredURLError(Exception e) {
650
+ String msg = e.getMessage();
651
+ if (msg == null) return false;
652
+ msg = msg.toLowerCase();
653
+ return (
654
+ msg.contains("403") ||
655
+ msg.contains("410") ||
656
+ msg.contains("forbidden") ||
657
+ msg.contains("expired")
658
+ );
659
+ }
660
+
661
+ private BundleInfo downloadAndStage(
662
+ URL url,
663
+ String version,
664
+ String expectedSha256,
665
+ int expectedSize,
666
+ String runtimeVersion,
667
+ String channel,
668
+ String releaseId
669
+ ) throws Exception {
670
+ // Check disk space before downloading
671
+ if (expectedSize > 0) {
672
+ long requiredSpace = (long) (expectedSize * 2.5); // zip + extracted + buffer
673
+ long availableSpace = getFreeDiskSpace();
674
+ if (availableSpace < requiredSpace) {
675
+ sendDeviceEvent(
676
+ "download_error",
677
+ version,
678
+ runtimeVersion,
679
+ channel,
680
+ releaseId,
681
+ "insufficient_disk_space"
682
+ );
683
+ throw new IllegalStateException("Insufficient disk space");
684
+ }
685
+ }
686
+
687
+ File downloadedZip = null;
688
+ File extractedDirectory = null;
689
+
690
+ try {
691
+ downloadedZip = downloadZip(url);
692
+ if (!HashUtils.verify(downloadedZip, expectedSha256)) {
693
+ throw new IllegalStateException("Downloaded bundle hash mismatch");
694
+ }
695
+
696
+ extractedDirectory = new File(
697
+ getContext().getCacheDir(),
698
+ "otakit-extract-" + System.currentTimeMillis()
699
+ );
700
+ if (!extractedDirectory.exists() && !extractedDirectory.mkdirs()) {
701
+ throw new IllegalStateException("Cannot create temporary extraction directory");
702
+ }
703
+
704
+ zipUtils.extractSecurely(downloadedZip, extractedDirectory);
705
+ File bundleRoot = resolveBundleRoot(extractedDirectory);
706
+
707
+ String bundleId = buildBundleId(version, releaseId, expectedSha256);
708
+ File destination = coordinator.bundleDirectory(bundleId);
709
+ if (destination.exists()) {
710
+ deleteRecursively(destination);
711
+ }
712
+ moveDirectory(bundleRoot, destination);
713
+
714
+ BundleInfo info = new BundleInfo(
715
+ bundleId,
716
+ version,
717
+ runtimeVersion,
718
+ BundleStatus.PENDING,
719
+ System.currentTimeMillis(),
720
+ expectedSha256,
721
+ destination.getAbsolutePath(),
722
+ channel,
723
+ releaseId
724
+ );
725
+ java.util.List<String> cleanupBundleIds = coordinator.stageDownloadedBundle(info);
726
+ coordinator.cleanupBundles(cleanupBundleIds);
727
+
728
+ sendDeviceEvent("downloaded", version, runtimeVersion, channel, releaseId, null);
729
+ return info;
730
+ } catch (Exception e) {
731
+ sendDeviceEvent(
732
+ "download_error",
733
+ version,
734
+ runtimeVersion,
735
+ channel,
736
+ releaseId,
737
+ e.getMessage()
738
+ );
739
+ throw e;
740
+ } finally {
741
+ if (downloadedZip != null && downloadedZip.exists()) {
742
+ //noinspection ResultOfMethodCallIgnored
743
+ downloadedZip.delete();
744
+ }
745
+ if (extractedDirectory != null && extractedDirectory.exists()) {
746
+ try {
747
+ deleteRecursively(extractedDirectory);
748
+ } catch (Exception ignored) {}
749
+ }
750
+ }
751
+ }
752
+
753
+ private File downloadZip(URL url) throws Exception {
754
+ ManifestClient.requireHTTPS(url, allowInsecureUrls);
755
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
756
+ try {
757
+ connection.setRequestMethod("GET");
758
+ connection.setConnectTimeout(15_000);
759
+ connection.setReadTimeout(60_000);
760
+
761
+ int status = connection.getResponseCode();
762
+ if (status < 200 || status >= 300) {
763
+ throw new IllegalStateException("Download failed with HTTP " + status);
764
+ }
765
+
766
+ File destination = File.createTempFile("otakit-", ".zip", getContext().getCacheDir());
767
+
768
+ try (
769
+ InputStream input = connection.getInputStream();
770
+ FileOutputStream output = new FileOutputStream(destination)
771
+ ) {
772
+ byte[] buffer = new byte[8192];
773
+ int read;
774
+ while ((read = input.read(buffer)) > 0) {
775
+ output.write(buffer, 0, read);
776
+ }
777
+ }
778
+
779
+ return destination;
780
+ } finally {
781
+ connection.disconnect();
782
+ }
783
+ }
784
+
785
+ private File resolveBundleRoot(File extractedDirectory) throws Exception {
786
+ File rootIndex = new File(extractedDirectory, "index.html");
787
+ if (rootIndex.exists()) {
788
+ return extractedDirectory;
789
+ }
790
+
791
+ File[] children = extractedDirectory.listFiles();
792
+ if (children != null && children.length == 1 && children[0].isDirectory()) {
793
+ File nestedIndex = new File(children[0], "index.html");
794
+ if (nestedIndex.exists()) {
795
+ return children[0];
796
+ }
797
+ }
798
+
799
+ throw new IllegalStateException("Bundle archive does not contain index.html");
800
+ }
801
+
802
+ private boolean applyStaged(boolean reloadAfterApply) throws Exception {
803
+ UpdaterCoordinator.ApplyPreparation preparation = coordinator.prepareApplyStaged(
804
+ this::isCompatibleRuntime,
805
+ this::isBundleUsable
806
+ );
807
+ coordinator.cleanupBundles(preparation.cleanupBundleIds);
808
+ if (!preparation.didApply()) {
809
+ return false;
810
+ }
811
+
812
+ applyServerBasePathSynchronously(preparation.activationPath);
813
+ if (reloadAfterApply) {
814
+ reloadWebViewSynchronously();
815
+ }
816
+
817
+ cancelTrialTimeout();
818
+ if (preparation.trialBundleId != null) {
819
+ scheduleTrialTimeout(preparation.trialBundleId);
820
+ }
821
+ return true;
822
+ }
823
+
824
+ private void requireApplyStaged(boolean reloadAfterApply) throws Exception {
825
+ if (!applyStaged(reloadAfterApply)) {
826
+ throw new IllegalStateException("Expected a staged bundle to be ready for apply");
827
+ }
828
+ }
829
+
830
+ private void scheduleTrialTimeout(String bundleId) {
831
+ cancelTrialTimeout();
832
+ trialTimeoutRunnable = () -> {
833
+ if (coordinator.isCurrentTrialBundle(bundleId)) {
834
+ rollbackCurrentBundle("notify_timeout", true);
835
+ }
836
+ };
837
+ mainHandler.postDelayed(trialTimeoutRunnable, appReadyTimeoutMs);
838
+ }
839
+
840
+ private void cancelTrialTimeout() {
841
+ if (trialTimeoutRunnable != null) {
842
+ mainHandler.removeCallbacks(trialTimeoutRunnable);
843
+ trialTimeoutRunnable = null;
844
+ }
845
+ }
846
+
847
+ private void rollbackCurrentBundle(String reason, boolean shouldReload) {
848
+ cancelTrialTimeout();
849
+ UpdaterCoordinator.RollbackPreparation preparation = coordinator.prepareRollback(
850
+ reason,
851
+ this::isBundleUsable
852
+ );
853
+ if (!preparation.didRollback) {
854
+ return;
855
+ }
856
+ coordinator.cleanupBundles(preparation.cleanupBundleIds);
857
+ if (preparation.eventPayload != null) {
858
+ sendDeviceEvent(preparation.eventPayload);
859
+ }
860
+
861
+ try {
862
+ applyServerBasePathSynchronously(preparation.activationPath);
863
+ if (shouldReload) {
864
+ reloadWebViewSynchronously();
865
+ }
866
+ } catch (Exception e) {
867
+ android.util.Log.w("OtaKit", "rollback activation failed", e);
868
+ }
869
+ }
870
+
871
+ private void applyServerBasePathSynchronously(String path) throws Exception {
872
+ runOnMainSynchronously(() -> {
873
+ if (bridge == null) {
874
+ throw new IllegalStateException("Bridge not available for activation");
875
+ }
876
+ if (path == null || path.isEmpty()) {
877
+ bridge.setServerAssetPath(BUILTIN_ASSET_PATH);
878
+ } else {
879
+ bridge.setServerBasePath(path);
880
+ }
881
+ });
882
+ }
883
+
884
+ private void reloadWebViewSynchronously() throws Exception {
885
+ runOnMainSynchronously(() -> {
886
+ if (bridge == null || bridge.getWebView() == null) {
887
+ throw new IllegalStateException("WebView not available for reload");
888
+ }
889
+ bridge.getWebView().reload();
890
+ });
891
+ }
892
+
893
+ private void runOnMainSynchronously(ThrowingRunnable work) throws Exception {
894
+ if (Looper.myLooper() == Looper.getMainLooper()) {
895
+ work.run();
896
+ return;
897
+ }
898
+
899
+ CountDownLatch latch = new CountDownLatch(1);
900
+ AtomicReference<Throwable> failure = new AtomicReference<>();
901
+ mainHandler.post(() -> {
902
+ try {
903
+ work.run();
904
+ } catch (Throwable error) {
905
+ failure.set(error);
906
+ } finally {
907
+ latch.countDown();
908
+ }
909
+ });
910
+
911
+ try {
912
+ latch.await();
913
+ } catch (InterruptedException e) {
914
+ Thread.currentThread().interrupt();
915
+ throw new IllegalStateException("Interrupted while waiting for main thread activation", e);
916
+ }
917
+
918
+ Throwable error = failure.get();
919
+ if (error == null) {
920
+ return;
921
+ }
922
+ if (error instanceof Exception) {
923
+ throw (Exception) error;
924
+ }
925
+ throw new RuntimeException(error);
926
+ }
927
+
928
+ private JSObject manifestToJSObject(ManifestClient.LatestManifest latest) {
929
+ JSObject object = new JSObject();
930
+ object.put("version", latest.version);
931
+ object.put("url", latest.url);
932
+ object.put("sha256", latest.sha256);
933
+ object.put("size", latest.size);
934
+ if (latest.runtimeVersion != null) {
935
+ object.put("runtimeVersion", latest.runtimeVersion);
936
+ }
937
+ object.put("releaseId", latest.releaseId);
938
+ return object;
939
+ }
940
+
941
+ private BundleInfo downloadLatestManifest(
942
+ ManifestClient.LatestManifest latest,
943
+ String targetChannel
944
+ ) throws Exception {
945
+ return downloadAndStage(
946
+ new URL(latest.url),
947
+ latest.version,
948
+ latest.sha256,
949
+ latest.size,
950
+ latest.runtimeVersion,
951
+ targetChannel,
952
+ latest.releaseId
953
+ );
954
+ }
955
+
956
+ private void moveDirectory(File source, File destination) throws Exception {
957
+ if (source.renameTo(destination)) {
958
+ return;
959
+ }
960
+ copyRecursively(source, destination);
961
+ deleteRecursively(source);
962
+ }
963
+
964
+ private void copyRecursively(File source, File destination) throws Exception {
965
+ if (source.isDirectory()) {
966
+ if (!destination.exists() && !destination.mkdirs()) {
967
+ throw new IllegalStateException(
968
+ "Cannot create directory: " + destination.getAbsolutePath()
969
+ );
970
+ }
971
+ File[] children = source.listFiles();
972
+ if (children != null) {
973
+ for (File child : children) {
974
+ copyRecursively(child, new File(destination, child.getName()));
975
+ }
976
+ }
977
+ return;
978
+ }
979
+
980
+ File parent = destination.getParentFile();
981
+ if (parent != null && !parent.exists() && !parent.mkdirs()) {
982
+ throw new IllegalStateException("Cannot create parent: " + parent.getAbsolutePath());
983
+ }
984
+
985
+ try (
986
+ FileInputStream input = new FileInputStream(source);
987
+ FileOutputStream output = new FileOutputStream(destination)
988
+ ) {
989
+ byte[] buffer = new byte[8192];
990
+ int read;
991
+ while ((read = input.read(buffer)) > 0) {
992
+ output.write(buffer, 0, read);
993
+ }
994
+ }
995
+ }
996
+
997
+ private void deleteRecursively(File target) throws Exception {
998
+ if (!target.exists()) {
999
+ return;
1000
+ }
1001
+ if (target.isDirectory()) {
1002
+ File[] children = target.listFiles();
1003
+ if (children != null) {
1004
+ for (File child : children) {
1005
+ deleteRecursively(child);
1006
+ }
1007
+ }
1008
+ }
1009
+ if (!target.delete()) {
1010
+ throw new IllegalStateException("Failed to delete: " + target.getAbsolutePath());
1011
+ }
1012
+ }
1013
+
1014
+ private String buildBundleId(String version, String releaseId, String sha256) throws Exception {
1015
+ String trimmed = version == null ? "" : version.trim();
1016
+ String normalized = trimmed.replaceAll("[^A-Za-z0-9._-]", "-");
1017
+ normalized = normalized.replaceAll("-{2,}", "-");
1018
+ normalized = normalized.replaceAll("^[\\-.]+|[\\-.]+$", "");
1019
+ if (normalized.isEmpty()) {
1020
+ normalized = "bundle";
1021
+ }
1022
+ if (normalized.length() > 64) {
1023
+ normalized = normalized.substring(0, 64);
1024
+ }
1025
+
1026
+ String identitySource = trimToNull(releaseId);
1027
+ if (identitySource == null) {
1028
+ identitySource = trimToNull(sha256);
1029
+ }
1030
+ if (identitySource == null) {
1031
+ identitySource = trimmed;
1032
+ }
1033
+
1034
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
1035
+ byte[] hash = digest.digest(identitySource.getBytes(StandardCharsets.UTF_8));
1036
+ StringBuilder suffix = new StringBuilder();
1037
+ for (int i = 0; i < 6; i++) {
1038
+ suffix.append(String.format("%02x", hash[i]));
1039
+ }
1040
+
1041
+ return normalized + "-" + suffix;
1042
+ }
1043
+
1044
+ private String resolveIngestUrl(String configured, String env) {
1045
+ String configuredValue = trimToNull(configured);
1046
+ if (configuredValue != null) {
1047
+ return normalizeIngestUrl(configuredValue);
1048
+ }
1049
+
1050
+ String envValue = trimToNull(env);
1051
+ if (envValue != null) {
1052
+ return normalizeIngestUrl(envValue);
1053
+ }
1054
+
1055
+ return DEFAULT_INGEST_URL;
1056
+ }
1057
+
1058
+ private String resolveCdnUrl(String configured, String env) {
1059
+ String configuredValue = trimToNull(configured);
1060
+ if (configuredValue != null) {
1061
+ return normalizeCdnUrl(configuredValue);
1062
+ }
1063
+
1064
+ String envValue = trimToNull(env);
1065
+ if (envValue != null) {
1066
+ return normalizeCdnUrl(envValue);
1067
+ }
1068
+
1069
+ return DEFAULT_CDN_URL;
1070
+ }
1071
+
1072
+ private String normalizeIngestUrl(String raw) {
1073
+ String trimmed = raw.trim().replaceAll("/+$", "");
1074
+ if (trimmed.toLowerCase(java.util.Locale.ROOT).endsWith(INGEST_PATH_SUFFIX)) {
1075
+ return trimmed;
1076
+ }
1077
+ return trimmed + INGEST_PATH_SUFFIX;
1078
+ }
1079
+
1080
+ private String normalizeCdnUrl(String raw) {
1081
+ return raw.trim().replaceAll("/+$", "");
1082
+ }
1083
+
1084
+ private String trimToNull(String value) {
1085
+ if (value == null) {
1086
+ return null;
1087
+ }
1088
+
1089
+ String trimmed = value.trim();
1090
+ return trimmed.isEmpty() ? null : trimmed;
1091
+ }
1092
+
1093
+ private String resolveTargetChannel(String channel) {
1094
+ String resolved = trimToNull(channel);
1095
+ return resolved != null ? resolved : this.channel;
1096
+ }
1097
+
1098
+ private void sendDeviceEvent(
1099
+ String action,
1100
+ String bundleVersion,
1101
+ String runtimeVersion,
1102
+ String channel,
1103
+ String releaseId,
1104
+ String detail
1105
+ ) {
1106
+ sendDeviceEvent(
1107
+ new UpdaterCoordinator.DeviceEventPayload(
1108
+ action,
1109
+ bundleVersion,
1110
+ runtimeVersion,
1111
+ channel,
1112
+ releaseId,
1113
+ detail
1114
+ )
1115
+ );
1116
+ }
1117
+
1118
+ private void sendDeviceEvent(UpdaterCoordinator.DeviceEventPayload payload) {
1119
+ if (appId == null) {
1120
+ return;
1121
+ }
1122
+ String normalizedBundleVersion = trimToNull(payload.bundleVersion);
1123
+ if (normalizedBundleVersion == null) {
1124
+ android.util.Log.w("OtaKit", "Skipping device event without bundleVersion");
1125
+ return;
1126
+ }
1127
+ String normalizedReleaseId = trimToNull(payload.releaseId);
1128
+ if (normalizedReleaseId == null) {
1129
+ android.util.Log.w("OtaKit", "Skipping device event without releaseId");
1130
+ return;
1131
+ }
1132
+ String nativeBuild = trimToNull(coordinator.getNativeBuild());
1133
+ if (nativeBuild == null) {
1134
+ android.util.Log.w("OtaKit", "Skipping device event without nativeBuild");
1135
+ return;
1136
+ }
1137
+ DeviceEventClient.send(
1138
+ ingestUrl,
1139
+ appId,
1140
+ "android",
1141
+ payload.action,
1142
+ normalizedBundleVersion,
1143
+ payload.channel,
1144
+ trimToNull(payload.runtimeVersion),
1145
+ normalizedReleaseId,
1146
+ nativeBuild,
1147
+ payload.detail
1148
+ );
1149
+ }
1150
+
1151
+ private void pruneIncompatibleBundles() {
1152
+ coordinator.cleanupBundles(coordinator.pruneIncompatibleBundles(this::isCompatibleRuntime));
1153
+ }
1154
+
1155
+ private boolean isCompatibleRuntime(String bundleRuntimeVersion) {
1156
+ return java.util.Objects.equals(trimToNull(bundleRuntimeVersion), runtimeVersion);
1157
+ }
1158
+
1159
+ private boolean isCompatibleRuntime(BundleInfo bundle) {
1160
+ return isCompatibleRuntime(bundle.runtimeVersion);
1161
+ }
1162
+
1163
+ private boolean isBundleUsable(BundleInfo bundle) {
1164
+ return bundle != null && (bundle.isBuiltin() || isBundlePathUsable(bundle.path));
1165
+ }
1166
+
1167
+ private boolean isBundlePathUsable(String path) {
1168
+ String normalizedPath = trimToNull(path);
1169
+ if (normalizedPath == null) {
1170
+ return false;
1171
+ }
1172
+
1173
+ File directory = new File(normalizedPath);
1174
+ if (!directory.exists() || !directory.isDirectory()) {
1175
+ return false;
1176
+ }
1177
+
1178
+ return new File(directory, "index.html").exists();
1179
+ }
1180
+
1181
+ private long getFreeDiskSpace() {
1182
+ try {
1183
+ android.os.StatFs statFs = new android.os.StatFs(
1184
+ getContext().getFilesDir().getAbsolutePath()
1185
+ );
1186
+ return statFs.getAvailableBlocksLong() * statFs.getBlockSizeLong();
1187
+ } catch (Exception e) {
1188
+ return Long.MAX_VALUE; // If we can't determine, allow download
1189
+ }
1190
+ }
1191
+ }