@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.
Files changed (57) 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 +284 -209
  22. package/android/src/main/java/com/otakit/updater/WebViewActivation.java +26 -0
  23. package/dist/esm/definitions.d.ts +3 -3
  24. package/dist/esm/definitions.d.ts.map +1 -1
  25. package/ios/Sources/UpdaterPlugin/BundleCrypto.swift +35 -3
  26. package/ios/Sources/UpdaterPlugin/BundleInfo.swift +13 -0
  27. package/ios/Sources/UpdaterPlugin/BundleStore.swift +103 -77
  28. package/ios/Sources/UpdaterPlugin/CheckFailure.swift +41 -0
  29. package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +29 -17
  30. package/ios/Sources/UpdaterPlugin/DeviceEventClient.swift +12 -10
  31. package/ios/Sources/UpdaterPlugin/DocumentReadyBridge.swift +43 -0
  32. package/ios/Sources/UpdaterPlugin/DownloadRetry.swift +51 -0
  33. package/ios/Sources/UpdaterPlugin/Downloader.swift +97 -39
  34. package/ios/Sources/UpdaterPlugin/EventOutbox.swift +183 -0
  35. package/ios/Sources/UpdaterPlugin/ForegroundDeadline.swift +94 -0
  36. package/ios/Sources/UpdaterPlugin/HashUtils.swift +16 -0
  37. package/ios/Sources/UpdaterPlugin/ManifestClient.swift +2 -4
  38. package/ios/Sources/UpdaterPlugin/ManifestKeyConfig.swift +20 -0
  39. package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +7 -1
  40. package/ios/Sources/UpdaterPlugin/SDKVersion.swift +4 -0
  41. package/ios/Sources/UpdaterPlugin/UpdateOwner.swift +12 -0
  42. package/ios/Sources/UpdaterPlugin/UpdaterCoordinator.swift +146 -110
  43. package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +206 -202
  44. package/ios/Tests/UpdaterPluginTests/BundleCryptoTests.swift +80 -0
  45. package/ios/Tests/UpdaterPluginTests/BundlePersistenceTests.swift +102 -0
  46. package/ios/Tests/UpdaterPluginTests/CheckFailureTests.swift +55 -0
  47. package/ios/Tests/UpdaterPluginTests/DeltaCacheIntegrityTests.swift +69 -0
  48. package/ios/Tests/UpdaterPluginTests/DocumentReadyBridgeTests.swift +102 -0
  49. package/ios/Tests/UpdaterPluginTests/DownloadIntegrityTests.swift +40 -0
  50. package/ios/Tests/UpdaterPluginTests/DownloadRetryTests.swift +189 -0
  51. package/ios/Tests/UpdaterPluginTests/EventDeliveryTests.swift +56 -0
  52. package/ios/Tests/UpdaterPluginTests/EventOutboxTests.swift +86 -0
  53. package/ios/Tests/UpdaterPluginTests/ForegroundDeadlineTests.swift +144 -0
  54. package/ios/Tests/UpdaterPluginTests/ManifestKeyConfigTests.swift +48 -0
  55. package/ios/Tests/UpdaterPluginTests/UpdateOwnerTests.swift +34 -0
  56. package/ios/Tests/UpdaterPluginTests/UpdaterCoordinatorTests.swift +394 -0
  57. package/package.json +7 -3
@@ -13,11 +13,8 @@ import com.getcapacitor.annotation.CapacitorPlugin;
13
13
  import java.io.File;
14
14
  import java.io.FileInputStream;
15
15
  import java.io.FileOutputStream;
16
- import java.io.InputStream;
17
- import java.net.HttpURLConnection;
18
16
  import java.net.URL;
19
17
  import java.nio.charset.StandardCharsets;
20
- import java.security.MessageDigest;
21
18
  import java.util.concurrent.CountDownLatch;
22
19
  import java.util.concurrent.ExecutorService;
23
20
  import java.util.concurrent.Executors;
@@ -100,9 +97,13 @@ public class UpdaterPlugin extends Plugin {
100
97
 
101
98
  private BundleStore store;
102
99
  private UpdaterCoordinator coordinator;
103
- private Runnable trialTimeoutRunnable;
100
+ private final UpdateOwner updateOwner;
101
+ private final DocumentReadyBridge documentReadyBridge = new DocumentReadyBridge();
102
+ private final ForegroundDeadline trialDeadline;
104
103
 
105
104
  private int appReadyTimeoutMs = 10_000;
105
+ private boolean storageReady;
106
+ private volatile String lifecycleState = "unknown";
106
107
  private boolean allowInsecureUrls = false;
107
108
  private Policy launchPolicy = Policy.APPLY_STAGED;
108
109
  private Policy resumePolicy = Policy.SHADOW;
@@ -126,6 +127,21 @@ public class UpdaterPlugin extends Plugin {
126
127
  java.util.regex.Pattern.compile("^[A-Za-z0-9._-]{1,64}$");
127
128
  private UpdaterCoordinator.StartupPreparation pendingStartupPreparation;
128
129
 
130
+ public UpdaterPlugin() {
131
+ this.trialDeadline = new ForegroundDeadline(mainHandler);
132
+ this.updateOwner = new UpdateOwner(() -> bridge != null && bridge.getWebView() != null);
133
+ }
134
+
135
+ UpdaterPlugin(ForegroundDeadline trialDeadline) {
136
+ this.trialDeadline = trialDeadline;
137
+ this.updateOwner = new UpdateOwner(() -> bridge != null && bridge.getWebView() != null);
138
+ }
139
+
140
+ UpdaterPlugin(ForegroundDeadline trialDeadline, UpdateOwner updateOwner) {
141
+ this.trialDeadline = trialDeadline;
142
+ this.updateOwner = updateOwner;
143
+ }
144
+
129
145
  @Override
130
146
  public void load() {
131
147
  super.load();
@@ -151,6 +167,7 @@ public class UpdaterPlugin extends Plugin {
151
167
  );
152
168
  this.cdnUrl = resolveCdnUrl(getConfig().getString("cdnUrl"), System.getenv("OTAKIT_CDN_URL"));
153
169
  this.appId = getConfig().getString("appId");
170
+ DeviceEventClient.resume(new File(getContext().getNoBackupFilesDir(), "otakit-events"));
154
171
  this.channel = trimToNull(getConfig().getString("channel"));
155
172
  this.runtimeVersion = trimToNull(getConfig().getString("runtimeVersion"));
156
173
  this.store = new BundleStore(getContext(), builtinVersion, nativeBuild, this.runtimeVersion);
@@ -160,37 +177,7 @@ public class UpdaterPlugin extends Plugin {
160
177
  this.resumePolicy = resolvePolicy(getConfig().getString("resumePolicy"), Policy.SHADOW);
161
178
  this.runtimePolicy = resolvePolicy(getConfig().getString("runtimePolicy"), Policy.IMMEDIATE);
162
179
 
163
- try {
164
- org.json.JSONArray rawKeys = getConfig().getConfigJSON().optJSONArray("manifestKeys");
165
- if (rawKeys != null && rawKeys.length() > 0) {
166
- for (int i = 0; i < rawKeys.length(); i++) {
167
- org.json.JSONObject entry = rawKeys.optJSONObject(i);
168
- if (entry == null) {
169
- continue;
170
- }
171
- String kid = entry.optString("kid", null);
172
- String keyBase64 = entry.optString("key", null);
173
- if (kid != null && keyBase64 != null) {
174
- byte[] keyBytes = android.util.Base64.decode(keyBase64, android.util.Base64.DEFAULT);
175
- manifestKeys.add(new ManifestVerifier.KeyEntry(kid, keyBytes));
176
- }
177
- }
178
- if (manifestKeys.isEmpty()) {
179
- android.util.Log.e(
180
- "OtaKit",
181
- "manifestKeys configured but all entries are invalid. Manifest verification will reject all updates."
182
- );
183
- manifestKeys.add(new ManifestVerifier.KeyEntry("_invalid_", new byte[0]));
184
- }
185
- }
186
- } catch (Exception e) {
187
- android.util.Log.e(
188
- "OtaKit",
189
- "Failed to parse manifestKeys. Manifest verification will reject all updates.",
190
- e
191
- );
192
- manifestKeys.add(new ManifestVerifier.KeyEntry("_invalid_", new byte[0]));
193
- }
180
+ manifestKeys = ManifestKeyConfig.parse(getConfig().getConfigJSON());
194
181
 
195
182
  if (manifestKeys.isEmpty() && HostedManifestKeys.matchesManagedManifestUrl(cdnUrl)) {
196
183
  manifestKeys.addAll(HostedManifestKeys.createDefaultKeys());
@@ -231,13 +218,17 @@ public class UpdaterPlugin extends Plugin {
231
218
  this.appReadyTimeoutMs = Math.max(1000, getConfig().getInt("appReadyTimeout", 10_000));
232
219
  this.checkIntervalMs = getConfig().getInt("checkInterval", 600_000);
233
220
 
234
- pruneIncompatibleBundles();
235
-
236
- UpdaterCoordinator.StartupPreparation startup = coordinator.normalizeStartupState(
237
- this::isBundleUsable
238
- );
239
- coordinator.cleanupBundles(startup.cleanupBundleIds);
240
- pendingStartupPreparation = startup;
221
+ try {
222
+ pruneIncompatibleBundles();
223
+ UpdaterCoordinator.StartupPreparation startup = coordinator.normalizeStartupState(
224
+ this::isBundleUsable
225
+ );
226
+ cleanupInBackground(startup.cleanupBundleIds);
227
+ pendingStartupPreparation = startup;
228
+ storageReady = true;
229
+ } catch (Exception error) {
230
+ android.util.Log.e("OtaKit", "Cannot persist startup recovery; keeping builtin", error);
231
+ }
241
232
  }
242
233
 
243
234
  @Override
@@ -249,6 +240,12 @@ public class UpdaterPlugin extends Plugin {
249
240
  @Override
250
241
  protected void handleOnResume() {
251
242
  super.handleOnResume();
243
+ lifecycleState = "foreground";
244
+ trialDeadline.setForeground(true);
245
+ if (bridge != null) DeviceEventClient.resume(
246
+ new File(getContext().getNoBackupFilesDir(), "otakit-events")
247
+ );
248
+ if (!storageReady) return;
252
249
  if (coldStartInProgress) {
253
250
  coldStartInProgress = false;
254
251
  return;
@@ -256,6 +253,20 @@ public class UpdaterPlugin extends Plugin {
256
253
  handleResume();
257
254
  }
258
255
 
256
+ @Override
257
+ protected void handleOnPause() {
258
+ lifecycleState = "background";
259
+ trialDeadline.setForeground(false);
260
+ super.handleOnPause();
261
+ }
262
+
263
+ @Override
264
+ protected void handleOnDestroy() {
265
+ updateOwner.close();
266
+ trialDeadline.cancel();
267
+ super.handleOnDestroy();
268
+ }
269
+
259
270
  private boolean shouldSkipCheckInterval() {
260
271
  if (checkIntervalMs <= 0) return false;
261
272
  long lastCheck = store.getPrefs().getLong(KEY_LAST_CHECK_TIMESTAMP, 0);
@@ -285,9 +296,14 @@ public class UpdaterPlugin extends Plugin {
285
296
 
286
297
  if (startup.activationPath != null && !startup.activationPath.isEmpty()) {
287
298
  try {
299
+ if (startup.trial != null) installReadinessBridge(startup.trial);
288
300
  applyServerBasePathSynchronously(startup.activationPath);
289
301
  } catch (Exception e) {
290
302
  android.util.Log.w("OtaKit", "startup activation failed", e);
303
+ if (startup.trial != null) {
304
+ rollbackCurrentBundle(startup.trial, "activation_failed");
305
+ return;
306
+ }
291
307
  }
292
308
  }
293
309
 
@@ -295,8 +311,8 @@ public class UpdaterPlugin extends Plugin {
295
311
  sendDeviceEvent(startup.eventPayload);
296
312
  }
297
313
 
298
- if (startup.trialBundleId != null) {
299
- scheduleTrialTimeout(startup.trialBundleId);
314
+ if (startup.trial != null) {
315
+ scheduleTrialTimeout(startup.trial);
300
316
  } else {
301
317
  cancelTrialTimeout();
302
318
  }
@@ -319,7 +335,7 @@ public class UpdaterPlugin extends Plugin {
319
335
  if (hasStagedBundle) {
320
336
  resolveCurrentRuntimeKey();
321
337
  try {
322
- if (!applyStaged(false)) {
338
+ if (!applyStaged()) {
323
339
  android.util.Log.w(
324
340
  "OtaKit",
325
341
  "Failed to apply a valid staged bundle during runtime handling"
@@ -334,7 +350,7 @@ public class UpdaterPlugin extends Plugin {
334
350
  DownloadResolution result = downloadLatest(false, null);
335
351
  resolveCurrentRuntimeKey();
336
352
  if (result.isForcedStaged()) {
337
- requireApplyStaged(true);
353
+ requireApplyStaged();
338
354
  }
339
355
  });
340
356
  return;
@@ -343,7 +359,7 @@ public class UpdaterPlugin extends Plugin {
343
359
  DownloadResolution result = downloadLatest(false, null);
344
360
  resolveCurrentRuntimeKey();
345
361
  if (result.isForcedStaged()) {
346
- requireApplyStaged(true);
362
+ requireApplyStaged();
347
363
  }
348
364
  });
349
365
  return;
@@ -355,7 +371,7 @@ public class UpdaterPlugin extends Plugin {
355
371
  return;
356
372
  }
357
373
  resolveCurrentRuntimeKey();
358
- requireApplyStaged(true);
374
+ requireApplyStaged();
359
375
  });
360
376
  return;
361
377
  }
@@ -367,7 +383,7 @@ public class UpdaterPlugin extends Plugin {
367
383
  return;
368
384
  case APPLY_STAGED:
369
385
  try {
370
- if (applyStaged(false)) {
386
+ if (applyStaged()) {
371
387
  return;
372
388
  }
373
389
  } catch (Exception e) {
@@ -377,7 +393,7 @@ public class UpdaterPlugin extends Plugin {
377
393
  executeAutomaticUpdate("launch apply-staged fallback", () -> {
378
394
  DownloadResolution result = downloadLatest(false, null);
379
395
  if (result.isForcedStaged()) {
380
- requireApplyStaged(true);
396
+ requireApplyStaged();
381
397
  }
382
398
  });
383
399
  return;
@@ -385,7 +401,7 @@ public class UpdaterPlugin extends Plugin {
385
401
  executeAutomaticUpdate("launch shadow", () -> {
386
402
  DownloadResolution result = downloadLatest(false, null);
387
403
  if (result.isForcedStaged()) {
388
- requireApplyStaged(true);
404
+ requireApplyStaged();
389
405
  }
390
406
  });
391
407
  return;
@@ -393,7 +409,7 @@ public class UpdaterPlugin extends Plugin {
393
409
  executeAutomaticUpdate("launch immediate", () -> {
394
410
  DownloadResolution result = downloadLatest(false, null);
395
411
  if ("staged".equals(result.kind)) {
396
- requireApplyStaged(true);
412
+ requireApplyStaged();
397
413
  }
398
414
  });
399
415
  return;
@@ -406,12 +422,12 @@ public class UpdaterPlugin extends Plugin {
406
422
  return;
407
423
  case APPLY_STAGED:
408
424
  executeAutomaticUpdate("resume apply-staged", () -> {
409
- if (applyStaged(true)) {
425
+ if (applyStaged()) {
410
426
  return;
411
427
  }
412
428
  DownloadResolution result = downloadLatest(true, null);
413
429
  if (result.isForcedStaged()) {
414
- requireApplyStaged(true);
430
+ requireApplyStaged();
415
431
  }
416
432
  });
417
433
  return;
@@ -419,7 +435,7 @@ public class UpdaterPlugin extends Plugin {
419
435
  executeAutomaticUpdate("resume shadow", () -> {
420
436
  DownloadResolution result = downloadLatest(true, null);
421
437
  if (result.isForcedStaged()) {
422
- requireApplyStaged(true);
438
+ requireApplyStaged();
423
439
  }
424
440
  });
425
441
  return;
@@ -427,7 +443,7 @@ public class UpdaterPlugin extends Plugin {
427
443
  executeAutomaticUpdate("resume immediate", () -> {
428
444
  DownloadResolution result = downloadLatest(false, null);
429
445
  if ("staged".equals(result.kind)) {
430
- requireApplyStaged(true);
446
+ requireApplyStaged();
431
447
  }
432
448
  });
433
449
  return;
@@ -441,7 +457,10 @@ public class UpdaterPlugin extends Plugin {
441
457
  }
442
458
  executor.execute(() -> {
443
459
  try {
460
+ ensureOwnerActive();
444
461
  operation.run();
462
+ } catch (java.util.concurrent.CancellationException ignored) {
463
+ // The owning bridge went away; this is not a failed update attempt.
445
464
  } catch (Exception e) {
446
465
  android.util.Log.w("OtaKit", label + " failed", e);
447
466
  } finally {
@@ -539,7 +558,7 @@ public class UpdaterPlugin extends Plugin {
539
558
  }
540
559
  executor.execute(() -> {
541
560
  try {
542
- requireApplyStaged(true);
561
+ requireApplyStaged();
543
562
  } catch (Exception e) {
544
563
  call.reject("apply failed: " + e.getMessage());
545
564
  } finally {
@@ -558,7 +577,7 @@ public class UpdaterPlugin extends Plugin {
558
577
  try {
559
578
  DownloadResolution result = downloadLatest(false, null);
560
579
  if ("staged".equals(result.kind)) {
561
- requireApplyStaged(true);
580
+ requireApplyStaged();
562
581
  return;
563
582
  }
564
583
  call.resolve();
@@ -572,18 +591,24 @@ public class UpdaterPlugin extends Plugin {
572
591
 
573
592
  @PluginMethod
574
593
  public void notifyAppReady(PluginCall call) {
575
- cancelTrialTimeout();
576
- UpdaterCoordinator.NotifyReadyPreparation preparation = coordinator.prepareNotifyAppReady();
577
- coordinator.cleanupBundles(preparation.cleanupBundleIds);
578
- if (preparation.eventPayload != null) {
579
- sendDeviceEvent(preparation.eventPayload);
580
- // eventPayload is non-null only on a genuine trial -> success transition,
581
- // so repeat notifyAppReady() calls never double-emit.
582
- JSObject appliedData = new JSObject();
583
- appliedData.put("bundle", store.getCurrentBundle().toJSObject());
584
- emitEvent("updateApplied", appliedData);
585
- }
586
- call.resolve();
594
+ mainHandler.post(() -> {
595
+ try {
596
+ UpdaterCoordinator.NotifyReadyPreparation preparation = updateOwner.run(() ->
597
+ coordinator.prepareNotifyAppReady(call.getString("_otakitActivationId"))
598
+ );
599
+ cleanupInBackground(preparation.cleanupBundleIds);
600
+ if (preparation.eventPayload != null) {
601
+ cancelTrialTimeout();
602
+ sendDeviceEvent(preparation.eventPayload);
603
+ JSObject appliedData = new JSObject();
604
+ appliedData.put("bundle", store.getCurrentBundle().toJSObject());
605
+ emitEvent("updateApplied", appliedData);
606
+ }
607
+ call.resolve();
608
+ } catch (Exception error) {
609
+ call.reject("Could not persist update readiness: " + error.getMessage(), error);
610
+ }
611
+ });
587
612
  }
588
613
 
589
614
  @PluginMethod
@@ -658,17 +683,38 @@ public class UpdaterPlugin extends Plugin {
658
683
  throw new IllegalStateException("Missing appId in plugin config");
659
684
  }
660
685
 
661
- return ManifestClient.fetchLatest(
662
- cdnUrl,
663
- appId,
664
- channel,
665
- runtimeVersion,
666
- allowInsecureUrls,
667
- manifestKeys
686
+ String checkId = "check-" + java.util.UUID.randomUUID();
687
+ return CheckFailure.observe(
688
+ () -> {
689
+ ManifestClient.LatestManifest latest = ManifestClient.fetchLatest(
690
+ cdnUrl,
691
+ appId,
692
+ channel,
693
+ runtimeVersion,
694
+ allowInsecureUrls,
695
+ manifestKeys
696
+ );
697
+ ensureOwnerActive();
698
+ return latest;
699
+ },
700
+ failure -> {
701
+ ensureOwnerActive();
702
+ sendDeviceEvent(
703
+ "check_error",
704
+ null,
705
+ runtimeVersion,
706
+ channel,
707
+ null,
708
+ failure.detail,
709
+ checkId,
710
+ failure.phase
711
+ );
712
+ }
668
713
  );
669
714
  }
670
715
 
671
716
  private CheckResolution checkLatest(boolean respectInterval, String channel) throws Exception {
717
+ ensureOwnerActive();
672
718
  String targetChannel = resolveTargetChannel(channel);
673
719
  if (respectInterval && shouldSkipCheckInterval()) {
674
720
  android.util.Log.d("OtaKit", "Skipping resume check: checkInterval has not elapsed");
@@ -750,6 +796,17 @@ public class UpdaterPlugin extends Plugin {
750
796
  String targetChannel
751
797
  ) throws Exception {
752
798
  if (!isCompatibleRuntime(latest.runtimeVersion)) {
799
+ ensureOwnerActive();
800
+ sendDeviceEvent(
801
+ "check_error",
802
+ latest.version,
803
+ runtimeVersion,
804
+ targetChannel,
805
+ latest.releaseId,
806
+ "runtime_mismatch",
807
+ "check-" + java.util.UUID.randomUUID(),
808
+ "check"
809
+ );
753
810
  throw new IllegalStateException(
754
811
  "Manifest runtimeVersion does not match the installed app runtime"
755
812
  );
@@ -785,15 +842,7 @@ public class UpdaterPlugin extends Plugin {
785
842
  }
786
843
 
787
844
  private boolean isExpiredURLError(Exception e) {
788
- String msg = e.getMessage();
789
- if (msg == null) return false;
790
- msg = msg.toLowerCase();
791
- return (
792
- msg.contains("403") ||
793
- msg.contains("410") ||
794
- msg.contains("forbidden") ||
795
- msg.contains("expired")
796
- );
845
+ return DownloadRetry.isExpiredUrlFailure(e);
797
846
  }
798
847
 
799
848
  private BundleInfo downloadAndStage(
@@ -806,6 +855,9 @@ public class UpdaterPlugin extends Plugin {
806
855
  String releaseId,
807
856
  ManifestClient.ManifestEncryption encryption
808
857
  ) throws Exception {
858
+ ensureOwnerActive();
859
+ String installationId = BundleStore.newInstallationId();
860
+ String phase = "admission";
809
861
  // Check disk space before downloading
810
862
  if (expectedSize > 0) {
811
863
  // zip + extracted + buffer; encrypted bundles keep an extra decrypted
@@ -820,7 +872,9 @@ public class UpdaterPlugin extends Plugin {
820
872
  runtimeVersion,
821
873
  channel,
822
874
  releaseId,
823
- "insufficient_disk_space"
875
+ "insufficient_disk_space",
876
+ installationId,
877
+ phase
824
878
  );
825
879
  emitEvent(
826
880
  "downloadFailed",
@@ -835,15 +889,22 @@ public class UpdaterPlugin extends Plugin {
835
889
  File extractedDirectory = null;
836
890
 
837
891
  try {
892
+ phase = "transfer";
838
893
  downloadedZip = downloadZip(url);
894
+ ensureOwnerActive();
839
895
  // The manifest sha256 covers the downloaded object as-is — the
840
896
  // ciphertext when the bundle is encrypted.
841
- if (!HashUtils.verify(downloadedZip, expectedSha256)) {
842
- throw new IllegalStateException("Downloaded bundle hash mismatch");
843
- }
897
+ phase = "integrity";
898
+ HashUtils.verifyDownload(
899
+ downloadedZip,
900
+ expectedSha256,
901
+ expectedSize > 0 ? expectedSize : -1,
902
+ "bundle"
903
+ );
844
904
 
845
905
  File zipToExtract = downloadedZip;
846
906
  if (encryption != null) {
907
+ phase = "decrypt";
847
908
  byte[] kek = bundleKeys.get(encryption.kid);
848
909
  if (kek == null) {
849
910
  throw new IllegalStateException("no matching bundle key (kid " + encryption.kid + ")");
@@ -866,6 +927,7 @@ public class UpdaterPlugin extends Plugin {
866
927
  zipToExtract = decryptedZip;
867
928
  }
868
929
 
930
+ phase = "extract";
869
931
  extractedDirectory = new File(
870
932
  getContext().getCacheDir(),
871
933
  "otakit-extract-" + System.currentTimeMillis()
@@ -877,10 +939,11 @@ public class UpdaterPlugin extends Plugin {
877
939
  zipUtils.extractSecurely(zipToExtract, extractedDirectory);
878
940
  File bundleRoot = resolveBundleRoot(extractedDirectory);
879
941
 
880
- String bundleId = buildBundleId(version, releaseId, expectedSha256);
942
+ phase = "install";
943
+ String bundleId = installationId;
881
944
  File destination = coordinator.bundleDirectory(bundleId);
882
945
  if (destination.exists()) {
883
- deleteRecursively(destination);
946
+ throw new IllegalStateException("Bundle installation directory already exists");
884
947
  }
885
948
  moveDirectory(bundleRoot, destination);
886
949
 
@@ -895,22 +958,35 @@ public class UpdaterPlugin extends Plugin {
895
958
  channel,
896
959
  releaseId
897
960
  );
898
- java.util.List<String> cleanupBundleIds = coordinator.stageDownloadedBundle(info);
961
+ phase = "stage";
962
+ java.util.List<String> cleanupBundleIds = stageOwnedBundle(info);
899
963
  coordinator.cleanupBundles(cleanupBundleIds);
900
964
 
901
- sendDeviceEvent("downloaded", version, runtimeVersion, channel, releaseId, null);
965
+ sendDeviceEvent(
966
+ "downloaded",
967
+ version,
968
+ runtimeVersion,
969
+ channel,
970
+ releaseId,
971
+ null,
972
+ installationId,
973
+ phase
974
+ );
902
975
  JSObject stagedData = new JSObject();
903
976
  stagedData.put("bundle", info.toJSObject());
904
977
  emitEvent("updateStaged", stagedData);
905
978
  return info;
906
979
  } catch (Exception e) {
980
+ if (e instanceof java.util.concurrent.CancellationException) throw e;
907
981
  sendDeviceEvent(
908
982
  "download_error",
909
983
  version,
910
984
  runtimeVersion,
911
985
  channel,
912
986
  releaseId,
913
- e.getMessage()
987
+ e.getMessage(),
988
+ installationId,
989
+ phase
914
990
  );
915
991
  emitEvent(
916
992
  "downloadFailed",
@@ -918,6 +994,9 @@ public class UpdaterPlugin extends Plugin {
918
994
  );
919
995
  throw e;
920
996
  } finally {
997
+ if (installationId != null) coordinator.cleanupBundles(
998
+ java.util.Collections.singletonList(installationId)
999
+ );
921
1000
  if (downloadedZip != null && downloadedZip.exists()) {
922
1001
  //noinspection ResultOfMethodCallIgnored
923
1002
  downloadedZip.delete();
@@ -935,35 +1014,7 @@ public class UpdaterPlugin extends Plugin {
935
1014
  }
936
1015
 
937
1016
  private File downloadZip(URL url) throws Exception {
938
- ManifestClient.requireHTTPS(url, allowInsecureUrls);
939
- HttpURLConnection connection = (HttpURLConnection) url.openConnection();
940
- try {
941
- connection.setRequestMethod("GET");
942
- connection.setConnectTimeout(15_000);
943
- connection.setReadTimeout(60_000);
944
-
945
- int status = connection.getResponseCode();
946
- if (status < 200 || status >= 300) {
947
- throw new IllegalStateException("Download failed with HTTP " + status);
948
- }
949
-
950
- File destination = File.createTempFile("otakit-", ".zip", getContext().getCacheDir());
951
-
952
- try (
953
- InputStream input = connection.getInputStream();
954
- FileOutputStream output = new FileOutputStream(destination)
955
- ) {
956
- byte[] buffer = new byte[8192];
957
- int read;
958
- while ((read = input.read(buffer)) > 0) {
959
- output.write(buffer, 0, read);
960
- }
961
- }
962
-
963
- return destination;
964
- } finally {
965
- connection.disconnect();
966
- }
1017
+ return FileDownloader.download(url, getContext().getCacheDir(), allowInsecureUrls);
967
1018
  }
968
1019
 
969
1020
  private File resolveBundleRoot(File extractedDirectory) throws Exception {
@@ -983,61 +1034,92 @@ public class UpdaterPlugin extends Plugin {
983
1034
  throw new IllegalStateException("Bundle archive does not contain index.html");
984
1035
  }
985
1036
 
986
- private boolean applyStaged(boolean reloadAfterApply) throws Exception {
1037
+ private boolean applyStaged() throws Exception {
1038
+ java.util.concurrent.atomic.AtomicBoolean applied =
1039
+ new java.util.concurrent.atomic.AtomicBoolean();
1040
+ runOnMainSynchronously(() -> applied.set(updateOwner.run(this::applyStagedOnMain)));
1041
+ return applied.get();
1042
+ }
1043
+
1044
+ private java.util.List<String> stageOwnedBundle(BundleInfo bundle) throws Exception {
1045
+ AtomicReference<java.util.List<String>> cleanup = new AtomicReference<>();
1046
+ runOnMainSynchronously(() ->
1047
+ cleanup.set(updateOwner.run(() -> coordinator.stageDownloadedBundle(bundle)))
1048
+ );
1049
+ return cleanup.get();
1050
+ }
1051
+
1052
+ private void ensureOwnerActive() throws Exception {
1053
+ runOnMainSynchronously(() -> updateOwner.run(() -> null));
1054
+ }
1055
+
1056
+ private void cleanupInBackground(java.util.List<String> ids) {
1057
+ if (!ids.isEmpty()) executor.execute(() -> coordinator.cleanupBundles(ids));
1058
+ }
1059
+
1060
+ private void installReadinessBridge(UpdaterCoordinator.Trial trial) {
1061
+ documentReadyBridge.install(bridge.getWebView(), bridge.getAppUrl(), trial.activationId);
1062
+ }
1063
+
1064
+ private boolean applyStagedOnMain() throws Exception {
1065
+ DocumentReadyBridge.preflight(bridge.getWebView(), bridge.getAppUrl());
987
1066
  UpdaterCoordinator.ApplyPreparation preparation = coordinator.prepareApplyStaged(
988
1067
  this::isCompatibleRuntime,
989
1068
  this::isBundleUsable
990
1069
  );
991
- coordinator.cleanupBundles(preparation.cleanupBundleIds);
1070
+ cleanupInBackground(preparation.cleanupBundleIds);
992
1071
  if (!preparation.didApply()) {
993
1072
  return false;
994
1073
  }
995
1074
 
996
- applyServerBasePathSynchronously(preparation.activationPath);
997
- if (reloadAfterApply) {
998
- reloadWebViewSynchronously();
1075
+ try {
1076
+ if (preparation.trial != null) installReadinessBridge(preparation.trial);
1077
+ applyServerBasePathSynchronously(preparation.activationPath);
1078
+ } catch (Exception error) {
1079
+ if (preparation.trial != null) rollbackCurrentBundle(preparation.trial, "activation_failed");
1080
+ throw error;
999
1081
  }
1000
1082
 
1001
1083
  cancelTrialTimeout();
1002
- if (preparation.trialBundleId != null) {
1003
- scheduleTrialTimeout(preparation.trialBundleId);
1084
+ if (preparation.trial != null) {
1085
+ scheduleTrialTimeout(preparation.trial);
1004
1086
  }
1005
1087
  return true;
1006
1088
  }
1007
1089
 
1008
- private void requireApplyStaged(boolean reloadAfterApply) throws Exception {
1009
- if (!applyStaged(reloadAfterApply)) {
1090
+ private void requireApplyStaged() throws Exception {
1091
+ if (!applyStaged()) {
1010
1092
  throw new IllegalStateException("Expected a staged bundle to be ready for apply");
1011
1093
  }
1012
1094
  }
1013
1095
 
1014
- private void scheduleTrialTimeout(String bundleId) {
1015
- cancelTrialTimeout();
1016
- trialTimeoutRunnable = () -> {
1017
- if (coordinator.isCurrentTrialBundle(bundleId)) {
1018
- rollbackCurrentBundle("notify_timeout", true);
1019
- }
1020
- };
1021
- mainHandler.postDelayed(trialTimeoutRunnable, appReadyTimeoutMs);
1096
+ private void scheduleTrialTimeout(UpdaterCoordinator.Trial trial) {
1097
+ trialDeadline.start(appReadyTimeoutMs, () -> rollbackCurrentBundle(trial, "notify_timeout"));
1022
1098
  }
1023
1099
 
1024
1100
  private void cancelTrialTimeout() {
1025
- if (trialTimeoutRunnable != null) {
1026
- mainHandler.removeCallbacks(trialTimeoutRunnable);
1027
- trialTimeoutRunnable = null;
1028
- }
1101
+ trialDeadline.cancel();
1029
1102
  }
1030
1103
 
1031
- private void rollbackCurrentBundle(String reason, boolean shouldReload) {
1032
- cancelTrialTimeout();
1033
- UpdaterCoordinator.RollbackPreparation preparation = coordinator.prepareRollback(
1034
- reason,
1035
- this::isBundleUsable
1036
- );
1104
+ private void rollbackCurrentBundle(UpdaterCoordinator.Trial expectedTrial, String reason) {
1105
+ UpdaterCoordinator.RollbackPreparation preparation;
1106
+ try {
1107
+ preparation = updateOwner.run(() ->
1108
+ coordinator.prepareRollback(expectedTrial, reason, this::isBundleUsable)
1109
+ );
1110
+ } catch (java.util.concurrent.CancellationException ignored) {
1111
+ cancelTrialTimeout();
1112
+ return;
1113
+ } catch (Exception error) {
1114
+ android.util.Log.e("OtaKit", "Cannot persist rollback", error);
1115
+ scheduleTrialTimeout(expectedTrial);
1116
+ return;
1117
+ }
1037
1118
  if (!preparation.didRollback) {
1038
1119
  return;
1039
1120
  }
1040
- coordinator.cleanupBundles(preparation.cleanupBundleIds);
1121
+ cancelTrialTimeout();
1122
+ cleanupInBackground(preparation.cleanupBundleIds);
1041
1123
  if (preparation.eventPayload != null) {
1042
1124
  sendDeviceEvent(preparation.eventPayload);
1043
1125
  emitEvent(
@@ -1056,9 +1138,6 @@ public class UpdaterPlugin extends Plugin {
1056
1138
 
1057
1139
  try {
1058
1140
  applyServerBasePathSynchronously(preparation.activationPath);
1059
- if (shouldReload) {
1060
- reloadWebViewSynchronously();
1061
- }
1062
1141
  } catch (Exception e) {
1063
1142
  android.util.Log.w("OtaKit", "rollback activation failed", e);
1064
1143
  }
@@ -1069,20 +1148,12 @@ public class UpdaterPlugin extends Plugin {
1069
1148
  if (bridge == null) {
1070
1149
  throw new IllegalStateException("Bridge not available for activation");
1071
1150
  }
1072
- if (path == null || path.isEmpty()) {
1073
- bridge.setServerAssetPath(BUILTIN_ASSET_PATH);
1074
- } else {
1075
- bridge.setServerBasePath(path);
1076
- }
1077
- });
1078
- }
1079
-
1080
- private void reloadWebViewSynchronously() throws Exception {
1081
- runOnMainSynchronously(() -> {
1082
- if (bridge == null || bridge.getWebView() == null) {
1083
- throw new IllegalStateException("WebView not available for reload");
1084
- }
1085
- bridge.getWebView().reload();
1151
+ WebViewActivation.activate(
1152
+ path,
1153
+ BUILTIN_ASSET_PATH,
1154
+ bridge::setServerBasePath,
1155
+ bridge::setServerAssetPath
1156
+ );
1086
1157
  });
1087
1158
  }
1088
1159
 
@@ -1170,6 +1241,9 @@ public class UpdaterPlugin extends Plugin {
1170
1241
  */
1171
1242
  private BundleInfo assembleAndStage(ManifestClient.LatestManifest manifest, String targetChannel)
1172
1243
  throws Exception {
1244
+ ensureOwnerActive();
1245
+ String installationId = BundleStore.newInstallationId();
1246
+ String phase = "admission";
1173
1247
  // Same conservative disk-space guard as the zip path.
1174
1248
  if (manifest.size > 0) {
1175
1249
  long requiredSpace = (long) (manifest.size * 2.5);
@@ -1180,7 +1254,9 @@ public class UpdaterPlugin extends Plugin {
1180
1254
  manifest.runtimeVersion,
1181
1255
  targetChannel,
1182
1256
  manifest.releaseId,
1183
- "insufficient_disk_space"
1257
+ "insufficient_disk_space",
1258
+ installationId,
1259
+ phase
1184
1260
  );
1185
1261
  emitEvent(
1186
1262
  "downloadFailed",
@@ -1202,6 +1278,7 @@ public class UpdaterPlugin extends Plugin {
1202
1278
  );
1203
1279
 
1204
1280
  try {
1281
+ phase = "delta";
1205
1282
  if (manifest.files == null || manifest.files.isEmpty()) {
1206
1283
  throw new IllegalStateException("Delta manifest is missing its file list");
1207
1284
  }
@@ -1214,10 +1291,11 @@ public class UpdaterPlugin extends Plugin {
1214
1291
  assembler.seedFromBuiltinIfNeeded(getContext(), BUILTIN_ASSET_PATH, store.getNativeBuild());
1215
1292
  assembler.assemble(manifest.files, assembleDirectory, getContext());
1216
1293
 
1217
- String bundleId = buildBundleId(manifest.version, manifest.releaseId, manifest.sha256);
1294
+ phase = "install";
1295
+ String bundleId = installationId;
1218
1296
  File destination = coordinator.bundleDirectory(bundleId);
1219
1297
  if (destination.exists()) {
1220
- deleteRecursively(destination);
1298
+ throw new IllegalStateException("Bundle installation directory already exists");
1221
1299
  }
1222
1300
  moveDirectory(assembleDirectory, destination);
1223
1301
 
@@ -1247,7 +1325,8 @@ public class UpdaterPlugin extends Plugin {
1247
1325
  targetChannel,
1248
1326
  manifest.releaseId
1249
1327
  );
1250
- java.util.List<String> cleanupBundleIds = coordinator.stageDownloadedBundle(info);
1328
+ phase = "stage";
1329
+ java.util.List<String> cleanupBundleIds = stageOwnedBundle(info);
1251
1330
  coordinator.cleanupBundles(cleanupBundleIds);
1252
1331
 
1253
1332
  pruneDeltaCache(assembler);
@@ -1258,20 +1337,25 @@ public class UpdaterPlugin extends Plugin {
1258
1337
  manifest.runtimeVersion,
1259
1338
  targetChannel,
1260
1339
  manifest.releaseId,
1261
- null
1340
+ null,
1341
+ installationId,
1342
+ phase
1262
1343
  );
1263
1344
  JSObject stagedData = new JSObject();
1264
1345
  stagedData.put("bundle", info.toJSObject());
1265
1346
  emitEvent("updateStaged", stagedData);
1266
1347
  return info;
1267
1348
  } catch (Exception e) {
1349
+ if (e instanceof java.util.concurrent.CancellationException) throw e;
1268
1350
  sendDeviceEvent(
1269
1351
  "download_error",
1270
1352
  manifest.version,
1271
1353
  manifest.runtimeVersion,
1272
1354
  targetChannel,
1273
1355
  manifest.releaseId,
1274
- e.getMessage()
1356
+ e.getMessage(),
1357
+ installationId,
1358
+ phase
1275
1359
  );
1276
1360
  emitEvent(
1277
1361
  "downloadFailed",
@@ -1285,6 +1369,9 @@ public class UpdaterPlugin extends Plugin {
1285
1369
  );
1286
1370
  throw e;
1287
1371
  } finally {
1372
+ if (installationId != null) coordinator.cleanupBundles(
1373
+ java.util.Collections.singletonList(installationId)
1374
+ );
1288
1375
  if (assembleDirectory.exists()) {
1289
1376
  try {
1290
1377
  deleteRecursively(assembleDirectory);
@@ -1388,36 +1475,6 @@ public class UpdaterPlugin extends Plugin {
1388
1475
  }
1389
1476
  }
1390
1477
 
1391
- private String buildBundleId(String version, String releaseId, String sha256) throws Exception {
1392
- String trimmed = version == null ? "" : version.trim();
1393
- String normalized = trimmed.replaceAll("[^A-Za-z0-9._-]", "-");
1394
- normalized = normalized.replaceAll("-{2,}", "-");
1395
- normalized = normalized.replaceAll("^[\\-.]+|[\\-.]+$", "");
1396
- if (normalized.isEmpty()) {
1397
- normalized = "bundle";
1398
- }
1399
- if (normalized.length() > 64) {
1400
- normalized = normalized.substring(0, 64);
1401
- }
1402
-
1403
- String identitySource = trimToNull(releaseId);
1404
- if (identitySource == null) {
1405
- identitySource = trimToNull(sha256);
1406
- }
1407
- if (identitySource == null) {
1408
- identitySource = trimmed;
1409
- }
1410
-
1411
- MessageDigest digest = MessageDigest.getInstance("SHA-256");
1412
- byte[] hash = digest.digest(identitySource.getBytes(StandardCharsets.UTF_8));
1413
- StringBuilder suffix = new StringBuilder();
1414
- for (int i = 0; i < 6; i++) {
1415
- suffix.append(String.format("%02x", hash[i]));
1416
- }
1417
-
1418
- return normalized + "-" + suffix;
1419
- }
1420
-
1421
1478
  private String resolveIngestUrl(String configured, String env) {
1422
1479
  String configuredValue = trimToNull(configured);
1423
1480
  if (configuredValue != null) {
@@ -1539,6 +1596,19 @@ public class UpdaterPlugin extends Plugin {
1539
1596
  String channel,
1540
1597
  String releaseId,
1541
1598
  String detail
1599
+ ) {
1600
+ sendDeviceEvent(action, bundleVersion, runtimeVersion, channel, releaseId, detail, null, null);
1601
+ }
1602
+
1603
+ private void sendDeviceEvent(
1604
+ String action,
1605
+ String bundleVersion,
1606
+ String runtimeVersion,
1607
+ String channel,
1608
+ String releaseId,
1609
+ String detail,
1610
+ String attemptId,
1611
+ String phase
1542
1612
  ) {
1543
1613
  sendDeviceEvent(
1544
1614
  new UpdaterCoordinator.DeviceEventPayload(
@@ -1547,7 +1617,9 @@ public class UpdaterPlugin extends Plugin {
1547
1617
  runtimeVersion,
1548
1618
  channel,
1549
1619
  releaseId,
1550
- detail
1620
+ detail,
1621
+ attemptId,
1622
+ phase
1551
1623
  )
1552
1624
  );
1553
1625
  }
@@ -1557,12 +1629,12 @@ public class UpdaterPlugin extends Plugin {
1557
1629
  return;
1558
1630
  }
1559
1631
  String normalizedBundleVersion = trimToNull(payload.bundleVersion);
1560
- if (normalizedBundleVersion == null) {
1632
+ if (normalizedBundleVersion == null && !"check_error".equals(payload.action)) {
1561
1633
  android.util.Log.w("OtaKit", "Skipping device event without bundleVersion");
1562
1634
  return;
1563
1635
  }
1564
1636
  String normalizedReleaseId = trimToNull(payload.releaseId);
1565
- if (normalizedReleaseId == null) {
1637
+ if (normalizedReleaseId == null && !"check_error".equals(payload.action)) {
1566
1638
  android.util.Log.w("OtaKit", "Skipping device event without releaseId");
1567
1639
  return;
1568
1640
  }
@@ -1581,7 +1653,10 @@ public class UpdaterPlugin extends Plugin {
1581
1653
  trimToNull(payload.runtimeVersion),
1582
1654
  normalizedReleaseId,
1583
1655
  nativeBuild,
1584
- payload.detail
1656
+ payload.detail,
1657
+ payload.attemptId,
1658
+ payload.phase,
1659
+ lifecycleState
1585
1660
  );
1586
1661
  }
1587
1662