@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
@@ -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
  }
@@ -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 {
@@ -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 {
@@ -984,20 +1035,54 @@ public class UpdaterPlugin extends Plugin {
984
1035
  }
985
1036
 
986
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);
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;
1081
+ }
997
1082
 
998
1083
  cancelTrialTimeout();
999
- if (preparation.trialBundleId != null) {
1000
- scheduleTrialTimeout(preparation.trialBundleId);
1084
+ if (preparation.trial != null) {
1085
+ scheduleTrialTimeout(preparation.trial);
1001
1086
  }
1002
1087
  return true;
1003
1088
  }
@@ -1008,33 +1093,33 @@ public class UpdaterPlugin extends Plugin {
1008
1093
  }
1009
1094
  }
1010
1095
 
1011
- private void scheduleTrialTimeout(String bundleId) {
1012
- cancelTrialTimeout();
1013
- trialTimeoutRunnable = () -> {
1014
- if (coordinator.isCurrentTrialBundle(bundleId)) {
1015
- rollbackCurrentBundle("notify_timeout");
1016
- }
1017
- };
1018
- mainHandler.postDelayed(trialTimeoutRunnable, appReadyTimeoutMs);
1096
+ private void scheduleTrialTimeout(UpdaterCoordinator.Trial trial) {
1097
+ trialDeadline.start(appReadyTimeoutMs, () -> rollbackCurrentBundle(trial, "notify_timeout"));
1019
1098
  }
1020
1099
 
1021
1100
  private void cancelTrialTimeout() {
1022
- if (trialTimeoutRunnable != null) {
1023
- mainHandler.removeCallbacks(trialTimeoutRunnable);
1024
- trialTimeoutRunnable = null;
1025
- }
1101
+ trialDeadline.cancel();
1026
1102
  }
1027
1103
 
1028
- private void rollbackCurrentBundle(String reason) {
1029
- cancelTrialTimeout();
1030
- UpdaterCoordinator.RollbackPreparation preparation = coordinator.prepareRollback(
1031
- reason,
1032
- this::isBundleUsable
1033
- );
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
+ }
1034
1118
  if (!preparation.didRollback) {
1035
1119
  return;
1036
1120
  }
1037
- coordinator.cleanupBundles(preparation.cleanupBundleIds);
1121
+ cancelTrialTimeout();
1122
+ cleanupInBackground(preparation.cleanupBundleIds);
1038
1123
  if (preparation.eventPayload != null) {
1039
1124
  sendDeviceEvent(preparation.eventPayload);
1040
1125
  emitEvent(
@@ -1156,6 +1241,9 @@ public class UpdaterPlugin extends Plugin {
1156
1241
  */
1157
1242
  private BundleInfo assembleAndStage(ManifestClient.LatestManifest manifest, String targetChannel)
1158
1243
  throws Exception {
1244
+ ensureOwnerActive();
1245
+ String installationId = BundleStore.newInstallationId();
1246
+ String phase = "admission";
1159
1247
  // Same conservative disk-space guard as the zip path.
1160
1248
  if (manifest.size > 0) {
1161
1249
  long requiredSpace = (long) (manifest.size * 2.5);
@@ -1166,7 +1254,9 @@ public class UpdaterPlugin extends Plugin {
1166
1254
  manifest.runtimeVersion,
1167
1255
  targetChannel,
1168
1256
  manifest.releaseId,
1169
- "insufficient_disk_space"
1257
+ "insufficient_disk_space",
1258
+ installationId,
1259
+ phase
1170
1260
  );
1171
1261
  emitEvent(
1172
1262
  "downloadFailed",
@@ -1188,6 +1278,7 @@ public class UpdaterPlugin extends Plugin {
1188
1278
  );
1189
1279
 
1190
1280
  try {
1281
+ phase = "delta";
1191
1282
  if (manifest.files == null || manifest.files.isEmpty()) {
1192
1283
  throw new IllegalStateException("Delta manifest is missing its file list");
1193
1284
  }
@@ -1200,10 +1291,11 @@ public class UpdaterPlugin extends Plugin {
1200
1291
  assembler.seedFromBuiltinIfNeeded(getContext(), BUILTIN_ASSET_PATH, store.getNativeBuild());
1201
1292
  assembler.assemble(manifest.files, assembleDirectory, getContext());
1202
1293
 
1203
- String bundleId = buildBundleId(manifest.version, manifest.releaseId, manifest.sha256);
1294
+ phase = "install";
1295
+ String bundleId = installationId;
1204
1296
  File destination = coordinator.bundleDirectory(bundleId);
1205
1297
  if (destination.exists()) {
1206
- deleteRecursively(destination);
1298
+ throw new IllegalStateException("Bundle installation directory already exists");
1207
1299
  }
1208
1300
  moveDirectory(assembleDirectory, destination);
1209
1301
 
@@ -1233,7 +1325,8 @@ public class UpdaterPlugin extends Plugin {
1233
1325
  targetChannel,
1234
1326
  manifest.releaseId
1235
1327
  );
1236
- java.util.List<String> cleanupBundleIds = coordinator.stageDownloadedBundle(info);
1328
+ phase = "stage";
1329
+ java.util.List<String> cleanupBundleIds = stageOwnedBundle(info);
1237
1330
  coordinator.cleanupBundles(cleanupBundleIds);
1238
1331
 
1239
1332
  pruneDeltaCache(assembler);
@@ -1244,20 +1337,25 @@ public class UpdaterPlugin extends Plugin {
1244
1337
  manifest.runtimeVersion,
1245
1338
  targetChannel,
1246
1339
  manifest.releaseId,
1247
- null
1340
+ null,
1341
+ installationId,
1342
+ phase
1248
1343
  );
1249
1344
  JSObject stagedData = new JSObject();
1250
1345
  stagedData.put("bundle", info.toJSObject());
1251
1346
  emitEvent("updateStaged", stagedData);
1252
1347
  return info;
1253
1348
  } catch (Exception e) {
1349
+ if (e instanceof java.util.concurrent.CancellationException) throw e;
1254
1350
  sendDeviceEvent(
1255
1351
  "download_error",
1256
1352
  manifest.version,
1257
1353
  manifest.runtimeVersion,
1258
1354
  targetChannel,
1259
1355
  manifest.releaseId,
1260
- e.getMessage()
1356
+ e.getMessage(),
1357
+ installationId,
1358
+ phase
1261
1359
  );
1262
1360
  emitEvent(
1263
1361
  "downloadFailed",
@@ -1271,6 +1369,9 @@ public class UpdaterPlugin extends Plugin {
1271
1369
  );
1272
1370
  throw e;
1273
1371
  } finally {
1372
+ if (installationId != null) coordinator.cleanupBundles(
1373
+ java.util.Collections.singletonList(installationId)
1374
+ );
1274
1375
  if (assembleDirectory.exists()) {
1275
1376
  try {
1276
1377
  deleteRecursively(assembleDirectory);
@@ -1374,36 +1475,6 @@ public class UpdaterPlugin extends Plugin {
1374
1475
  }
1375
1476
  }
1376
1477
 
1377
- private String buildBundleId(String version, String releaseId, String sha256) throws Exception {
1378
- String trimmed = version == null ? "" : version.trim();
1379
- String normalized = trimmed.replaceAll("[^A-Za-z0-9._-]", "-");
1380
- normalized = normalized.replaceAll("-{2,}", "-");
1381
- normalized = normalized.replaceAll("^[\\-.]+|[\\-.]+$", "");
1382
- if (normalized.isEmpty()) {
1383
- normalized = "bundle";
1384
- }
1385
- if (normalized.length() > 64) {
1386
- normalized = normalized.substring(0, 64);
1387
- }
1388
-
1389
- String identitySource = trimToNull(releaseId);
1390
- if (identitySource == null) {
1391
- identitySource = trimToNull(sha256);
1392
- }
1393
- if (identitySource == null) {
1394
- identitySource = trimmed;
1395
- }
1396
-
1397
- MessageDigest digest = MessageDigest.getInstance("SHA-256");
1398
- byte[] hash = digest.digest(identitySource.getBytes(StandardCharsets.UTF_8));
1399
- StringBuilder suffix = new StringBuilder();
1400
- for (int i = 0; i < 6; i++) {
1401
- suffix.append(String.format("%02x", hash[i]));
1402
- }
1403
-
1404
- return normalized + "-" + suffix;
1405
- }
1406
-
1407
1478
  private String resolveIngestUrl(String configured, String env) {
1408
1479
  String configuredValue = trimToNull(configured);
1409
1480
  if (configuredValue != null) {
@@ -1525,6 +1596,19 @@ public class UpdaterPlugin extends Plugin {
1525
1596
  String channel,
1526
1597
  String releaseId,
1527
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
1528
1612
  ) {
1529
1613
  sendDeviceEvent(
1530
1614
  new UpdaterCoordinator.DeviceEventPayload(
@@ -1533,7 +1617,9 @@ public class UpdaterPlugin extends Plugin {
1533
1617
  runtimeVersion,
1534
1618
  channel,
1535
1619
  releaseId,
1536
- detail
1620
+ detail,
1621
+ attemptId,
1622
+ phase
1537
1623
  )
1538
1624
  );
1539
1625
  }
@@ -1543,12 +1629,12 @@ public class UpdaterPlugin extends Plugin {
1543
1629
  return;
1544
1630
  }
1545
1631
  String normalizedBundleVersion = trimToNull(payload.bundleVersion);
1546
- if (normalizedBundleVersion == null) {
1632
+ if (normalizedBundleVersion == null && !"check_error".equals(payload.action)) {
1547
1633
  android.util.Log.w("OtaKit", "Skipping device event without bundleVersion");
1548
1634
  return;
1549
1635
  }
1550
1636
  String normalizedReleaseId = trimToNull(payload.releaseId);
1551
- if (normalizedReleaseId == null) {
1637
+ if (normalizedReleaseId == null && !"check_error".equals(payload.action)) {
1552
1638
  android.util.Log.w("OtaKit", "Skipping device event without releaseId");
1553
1639
  return;
1554
1640
  }
@@ -1567,7 +1653,10 @@ public class UpdaterPlugin extends Plugin {
1567
1653
  trimToNull(payload.runtimeVersion),
1568
1654
  normalizedReleaseId,
1569
1655
  nativeBuild,
1570
- payload.detail
1656
+ payload.detail,
1657
+ payload.attemptId,
1658
+ payload.phase,
1659
+ lifecycleState
1571
1660
  );
1572
1661
  }
1573
1662