@otakit/capacitor-updater 2.1.2 → 2.3.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.
@@ -68,18 +68,24 @@ public class UpdaterPlugin extends Plugin {
68
68
 
69
69
  final String kind;
70
70
  final BundleInfo bundle;
71
+ final boolean forceImmediate;
71
72
 
72
- private DownloadResolution(String kind, BundleInfo bundle) {
73
+ private DownloadResolution(String kind, BundleInfo bundle, boolean forceImmediate) {
73
74
  this.kind = kind;
74
75
  this.bundle = bundle;
76
+ this.forceImmediate = forceImmediate;
75
77
  }
76
78
 
77
79
  static DownloadResolution noUpdate() {
78
- return new DownloadResolution("no_update", null);
80
+ return new DownloadResolution("no_update", null, false);
79
81
  }
80
82
 
81
- static DownloadResolution staged(BundleInfo bundle) {
82
- return new DownloadResolution("staged", bundle);
83
+ static DownloadResolution staged(BundleInfo bundle, boolean forceImmediate) {
84
+ return new DownloadResolution("staged", bundle, forceImmediate);
85
+ }
86
+
87
+ boolean isForcedStaged() {
88
+ return "staged".equals(kind) && forceImmediate;
83
89
  }
84
90
  }
85
91
 
@@ -107,6 +113,7 @@ public class UpdaterPlugin extends Plugin {
107
113
  private String channel;
108
114
  private String runtimeVersion;
109
115
  private java.util.List<ManifestVerifier.KeyEntry> manifestKeys = new java.util.ArrayList<>();
116
+ private final java.util.Map<String, byte[]> bundleKeys = new java.util.HashMap<>();
110
117
  private long checkIntervalMs = 600_000;
111
118
  private boolean coldStartInProgress = false;
112
119
  private static final String DEFAULT_INGEST_URL = "https://ingest.otakit.app/v1";
@@ -115,6 +122,8 @@ public class UpdaterPlugin extends Plugin {
115
122
  private static final String KEY_LAST_CHECK_TIMESTAMP = "last_check_timestamp";
116
123
  private static final String DEFAULT_RUNTIME_KEY = "__default__";
117
124
  private static final String BUILTIN_ASSET_PATH = "public";
125
+ private static final java.util.regex.Pattern CHANNEL_NAME_PATTERN =
126
+ java.util.regex.Pattern.compile("^[A-Za-z0-9._-]{1,64}$");
118
127
  private UpdaterCoordinator.StartupPreparation pendingStartupPreparation;
119
128
 
120
129
  @Override
@@ -187,6 +196,38 @@ public class UpdaterPlugin extends Plugin {
187
196
  manifestKeys.addAll(HostedManifestKeys.createDefaultKeys());
188
197
  }
189
198
 
199
+ try {
200
+ org.json.JSONArray rawBundleKeys = getConfig().getConfigJSON().optJSONArray("bundleKeys");
201
+ if (rawBundleKeys != null && rawBundleKeys.length() > 0) {
202
+ for (int i = 0; i < rawBundleKeys.length(); i++) {
203
+ org.json.JSONObject entry = rawBundleKeys.optJSONObject(i);
204
+ if (entry == null) {
205
+ continue;
206
+ }
207
+ String kid = entry.optString("kid", null);
208
+ String keyBase64 = entry.optString("key", null);
209
+ if (kid != null && keyBase64 != null) {
210
+ byte[] keyBytes = android.util.Base64.decode(keyBase64, android.util.Base64.DEFAULT);
211
+ if (keyBytes.length == 32) {
212
+ bundleKeys.put(kid, keyBytes);
213
+ }
214
+ }
215
+ }
216
+ if (bundleKeys.isEmpty()) {
217
+ android.util.Log.e(
218
+ "OtaKit",
219
+ "bundleKeys configured but all entries are invalid. Encrypted bundles cannot be decrypted."
220
+ );
221
+ }
222
+ }
223
+ } catch (Exception e) {
224
+ android.util.Log.e(
225
+ "OtaKit",
226
+ "Failed to parse bundleKeys. Encrypted bundles cannot be decrypted.",
227
+ e
228
+ );
229
+ }
230
+
190
231
  this.appReadyTimeoutMs = Math.max(1000, getConfig().getInt("appReadyTimeout", 10_000));
191
232
  this.checkIntervalMs = getConfig().getInt("checkInterval", 600_000);
192
233
 
@@ -290,14 +331,20 @@ public class UpdaterPlugin extends Plugin {
290
331
  return;
291
332
  }
292
333
  executeAutomaticUpdate("runtime apply-staged fallback", () -> {
293
- downloadLatest(false, null);
334
+ DownloadResolution result = downloadLatest(false, null);
294
335
  resolveCurrentRuntimeKey();
336
+ if (result.isForcedStaged()) {
337
+ requireApplyStaged(true);
338
+ }
295
339
  });
296
340
  return;
297
341
  case SHADOW:
298
342
  executeAutomaticUpdate("runtime shadow", () -> {
299
- downloadLatest(false, null);
343
+ DownloadResolution result = downloadLatest(false, null);
300
344
  resolveCurrentRuntimeKey();
345
+ if (result.isForcedStaged()) {
346
+ requireApplyStaged(true);
347
+ }
301
348
  });
302
349
  return;
303
350
  case IMMEDIATE:
@@ -327,10 +374,20 @@ public class UpdaterPlugin extends Plugin {
327
374
  android.util.Log.w("OtaKit", "launch apply-staged failed", e);
328
375
  return;
329
376
  }
330
- executeAutomaticUpdate("launch apply-staged fallback", () -> downloadLatest(false, null));
377
+ executeAutomaticUpdate("launch apply-staged fallback", () -> {
378
+ DownloadResolution result = downloadLatest(false, null);
379
+ if (result.isForcedStaged()) {
380
+ requireApplyStaged(true);
381
+ }
382
+ });
331
383
  return;
332
384
  case SHADOW:
333
- executeAutomaticUpdate("launch shadow", () -> downloadLatest(false, null));
385
+ executeAutomaticUpdate("launch shadow", () -> {
386
+ DownloadResolution result = downloadLatest(false, null);
387
+ if (result.isForcedStaged()) {
388
+ requireApplyStaged(true);
389
+ }
390
+ });
334
391
  return;
335
392
  case IMMEDIATE:
336
393
  executeAutomaticUpdate("launch immediate", () -> {
@@ -352,11 +409,19 @@ public class UpdaterPlugin extends Plugin {
352
409
  if (applyStaged(true)) {
353
410
  return;
354
411
  }
355
- downloadLatest(true, null);
412
+ DownloadResolution result = downloadLatest(true, null);
413
+ if (result.isForcedStaged()) {
414
+ requireApplyStaged(true);
415
+ }
356
416
  });
357
417
  return;
358
418
  case SHADOW:
359
- executeAutomaticUpdate("resume shadow", () -> downloadLatest(true, null));
419
+ executeAutomaticUpdate("resume shadow", () -> {
420
+ DownloadResolution result = downloadLatest(true, null);
421
+ if (result.isForcedStaged()) {
422
+ requireApplyStaged(true);
423
+ }
424
+ });
360
425
  return;
361
426
  case IMMEDIATE:
362
427
  executeAutomaticUpdate("resume immediate", () -> {
@@ -512,6 +577,11 @@ public class UpdaterPlugin extends Plugin {
512
577
  coordinator.cleanupBundles(preparation.cleanupBundleIds);
513
578
  if (preparation.eventPayload != null) {
514
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);
515
585
  }
516
586
  call.resolve();
517
587
  }
@@ -526,6 +596,63 @@ public class UpdaterPlugin extends Plugin {
526
596
  call.resolve(failed.toJSObject());
527
597
  }
528
598
 
599
+ @PluginMethod
600
+ public void setChannel(PluginCall call) {
601
+ Object raw = call.getData().opt("channel");
602
+ if (raw == null || raw == org.json.JSONObject.NULL) {
603
+ store.setOverrideChannel(null);
604
+ call.resolve();
605
+ return;
606
+ }
607
+ if (!(raw instanceof String)) {
608
+ call.reject("channel must be a string or null");
609
+ return;
610
+ }
611
+ String name = (String) raw;
612
+ if (!isValidChannelName(name)) {
613
+ call.reject(
614
+ "Invalid channel name '" +
615
+ name +
616
+ "': use 1-64 letters, numbers, '.', '_' or '-' (reserved names: base, default)"
617
+ );
618
+ return;
619
+ }
620
+ store.setOverrideChannel(name);
621
+ call.resolve();
622
+ }
623
+
624
+ @PluginMethod
625
+ public void getChannel(PluginCall call) {
626
+ JSObject result = new JSObject();
627
+ String override = store.getOverrideChannel();
628
+ if (override != null) {
629
+ result.put("channel", override);
630
+ result.put("source", "override");
631
+ call.resolve(result);
632
+ return;
633
+ }
634
+ result.put("channel", channel != null ? channel : org.json.JSONObject.NULL);
635
+ result.put("source", "config");
636
+ call.resolve(result);
637
+ }
638
+
639
+ /**
640
+ * Mirrors the server's isValidChannelName (console/lib/validation.ts):
641
+ * charset regex plus reserved names. The channel is interpolated into the
642
+ * manifest CDN path, so anything outside this charset (or a ".." sequence)
643
+ * is rejected before it is persisted or used.
644
+ */
645
+ private boolean isValidChannelName(String name) {
646
+ if (!CHANNEL_NAME_PATTERN.matcher(name).matches()) {
647
+ return false;
648
+ }
649
+ if (name.contains("..") || ".".equals(name)) {
650
+ return false;
651
+ }
652
+ String lower = name.toLowerCase(java.util.Locale.ROOT);
653
+ return !"base".equals(lower) && !"default".equals(lower);
654
+ }
655
+
529
656
  private ManifestClient.LatestManifest fetchLatest(String channel) throws Exception {
530
657
  if (appId == null || appId.trim().isEmpty()) {
531
658
  throw new IllegalStateException("Missing appId in plugin config");
@@ -558,6 +685,10 @@ public class UpdaterPlugin extends Plugin {
558
685
 
559
686
  CheckResolution resolution = classifyLatestManifest(latest, targetChannel);
560
687
 
688
+ if ("update_available".equals(resolution.kind) && resolution.latest != null) {
689
+ emitEvent("updateAvailable", manifestToJSObject(resolution.latest));
690
+ }
691
+
561
692
  if (respectInterval) {
562
693
  recordCheckTimestamp();
563
694
  }
@@ -572,10 +703,13 @@ public class UpdaterPlugin extends Plugin {
572
703
  case "no_update":
573
704
  return DownloadResolution.noUpdate();
574
705
  case "already_staged":
575
- return DownloadResolution.staged(result.bundle);
706
+ return DownloadResolution.staged(result.bundle, result.latest.forceImmediate);
576
707
  case "update_available":
577
708
  try {
578
- return DownloadResolution.staged(downloadLatestManifest(result.latest, targetChannel));
709
+ return DownloadResolution.staged(
710
+ downloadLatestManifest(result.latest, targetChannel),
711
+ result.latest.forceImmediate
712
+ );
579
713
  } catch (Exception e) {
580
714
  if (!isExpiredURLError(e)) {
581
715
  throw e;
@@ -591,10 +725,14 @@ public class UpdaterPlugin extends Plugin {
591
725
  case "no_update":
592
726
  return DownloadResolution.noUpdate();
593
727
  case "already_staged":
594
- return DownloadResolution.staged(refreshedResolution.bundle);
728
+ return DownloadResolution.staged(
729
+ refreshedResolution.bundle,
730
+ refreshedResolution.latest.forceImmediate
731
+ );
595
732
  case "update_available":
596
733
  return DownloadResolution.staged(
597
- downloadLatestManifest(refreshedResolution.latest, targetChannel)
734
+ downloadLatestManifest(refreshedResolution.latest, targetChannel),
735
+ refreshedResolution.latest.forceImmediate
598
736
  );
599
737
  default:
600
738
  throw new IllegalStateException(
@@ -665,11 +803,15 @@ public class UpdaterPlugin extends Plugin {
665
803
  int expectedSize,
666
804
  String runtimeVersion,
667
805
  String channel,
668
- String releaseId
806
+ String releaseId,
807
+ ManifestClient.ManifestEncryption encryption
669
808
  ) throws Exception {
670
809
  // Check disk space before downloading
671
810
  if (expectedSize > 0) {
672
- long requiredSpace = (long) (expectedSize * 2.5); // zip + extracted + buffer
811
+ // zip + extracted + buffer; encrypted bundles keep an extra decrypted
812
+ // zip copy on disk between decrypt and extract.
813
+ double multiplier = encryption != null ? 3.5 : 2.5;
814
+ long requiredSpace = (long) (expectedSize * multiplier);
673
815
  long availableSpace = getFreeDiskSpace();
674
816
  if (availableSpace < requiredSpace) {
675
817
  sendDeviceEvent(
@@ -680,19 +822,50 @@ public class UpdaterPlugin extends Plugin {
680
822
  releaseId,
681
823
  "insufficient_disk_space"
682
824
  );
825
+ emitEvent(
826
+ "downloadFailed",
827
+ failureEventData(version, runtimeVersion, channel, releaseId, "insufficient_disk_space")
828
+ );
683
829
  throw new IllegalStateException("Insufficient disk space");
684
830
  }
685
831
  }
686
832
 
687
833
  File downloadedZip = null;
834
+ File decryptedZip = null;
688
835
  File extractedDirectory = null;
689
836
 
690
837
  try {
691
838
  downloadedZip = downloadZip(url);
839
+ // The manifest sha256 covers the downloaded object as-is — the
840
+ // ciphertext when the bundle is encrypted.
692
841
  if (!HashUtils.verify(downloadedZip, expectedSha256)) {
693
842
  throw new IllegalStateException("Downloaded bundle hash mismatch");
694
843
  }
695
844
 
845
+ File zipToExtract = downloadedZip;
846
+ if (encryption != null) {
847
+ byte[] kek = bundleKeys.get(encryption.kid);
848
+ if (kek == null) {
849
+ throw new IllegalStateException("no matching bundle key (kid " + encryption.kid + ")");
850
+ }
851
+ byte[] dek = BundleCrypto.unwrapDek(
852
+ kek,
853
+ android.util.Base64.decode(encryption.wrapNonce, android.util.Base64.DEFAULT),
854
+ android.util.Base64.decode(encryption.wrappedDek, android.util.Base64.DEFAULT)
855
+ );
856
+ decryptedZip = new File(
857
+ getContext().getCacheDir(),
858
+ "otakit-decrypted-" + System.currentTimeMillis() + ".zip"
859
+ );
860
+ BundleCrypto.decryptFile(
861
+ dek,
862
+ android.util.Base64.decode(encryption.nonce, android.util.Base64.DEFAULT),
863
+ downloadedZip,
864
+ decryptedZip
865
+ );
866
+ zipToExtract = decryptedZip;
867
+ }
868
+
696
869
  extractedDirectory = new File(
697
870
  getContext().getCacheDir(),
698
871
  "otakit-extract-" + System.currentTimeMillis()
@@ -701,7 +874,7 @@ public class UpdaterPlugin extends Plugin {
701
874
  throw new IllegalStateException("Cannot create temporary extraction directory");
702
875
  }
703
876
 
704
- zipUtils.extractSecurely(downloadedZip, extractedDirectory);
877
+ zipUtils.extractSecurely(zipToExtract, extractedDirectory);
705
878
  File bundleRoot = resolveBundleRoot(extractedDirectory);
706
879
 
707
880
  String bundleId = buildBundleId(version, releaseId, expectedSha256);
@@ -726,6 +899,9 @@ public class UpdaterPlugin extends Plugin {
726
899
  coordinator.cleanupBundles(cleanupBundleIds);
727
900
 
728
901
  sendDeviceEvent("downloaded", version, runtimeVersion, channel, releaseId, null);
902
+ JSObject stagedData = new JSObject();
903
+ stagedData.put("bundle", info.toJSObject());
904
+ emitEvent("updateStaged", stagedData);
729
905
  return info;
730
906
  } catch (Exception e) {
731
907
  sendDeviceEvent(
@@ -736,12 +912,20 @@ public class UpdaterPlugin extends Plugin {
736
912
  releaseId,
737
913
  e.getMessage()
738
914
  );
915
+ emitEvent(
916
+ "downloadFailed",
917
+ failureEventData(version, runtimeVersion, channel, releaseId, failureReason(e))
918
+ );
739
919
  throw e;
740
920
  } finally {
741
921
  if (downloadedZip != null && downloadedZip.exists()) {
742
922
  //noinspection ResultOfMethodCallIgnored
743
923
  downloadedZip.delete();
744
924
  }
925
+ if (decryptedZip != null && decryptedZip.exists()) {
926
+ //noinspection ResultOfMethodCallIgnored
927
+ decryptedZip.delete();
928
+ }
745
929
  if (extractedDirectory != null && extractedDirectory.exists()) {
746
930
  try {
747
931
  deleteRecursively(extractedDirectory);
@@ -856,6 +1040,18 @@ public class UpdaterPlugin extends Plugin {
856
1040
  coordinator.cleanupBundles(preparation.cleanupBundleIds);
857
1041
  if (preparation.eventPayload != null) {
858
1042
  sendDeviceEvent(preparation.eventPayload);
1043
+ emitEvent(
1044
+ "rollback",
1045
+ failureEventData(
1046
+ preparation.eventPayload.bundleVersion != null
1047
+ ? preparation.eventPayload.bundleVersion
1048
+ : "",
1049
+ preparation.eventPayload.runtimeVersion,
1050
+ preparation.eventPayload.channel,
1051
+ preparation.eventPayload.releaseId,
1052
+ reason
1053
+ )
1054
+ );
859
1055
  }
860
1056
 
861
1057
  try {
@@ -928,13 +1124,17 @@ public class UpdaterPlugin extends Plugin {
928
1124
  private JSObject manifestToJSObject(ManifestClient.LatestManifest latest) {
929
1125
  JSObject object = new JSObject();
930
1126
  object.put("version", latest.version);
931
- object.put("url", latest.url);
1127
+ if (latest.url != null) {
1128
+ object.put("url", latest.url);
1129
+ }
932
1130
  object.put("sha256", latest.sha256);
933
1131
  object.put("size", latest.size);
1132
+ object.put("strategy", latest.strategy);
934
1133
  if (latest.runtimeVersion != null) {
935
1134
  object.put("runtimeVersion", latest.runtimeVersion);
936
1135
  }
937
1136
  object.put("releaseId", latest.releaseId);
1137
+ object.put("forceImmediate", latest.forceImmediate);
938
1138
  return object;
939
1139
  }
940
1140
 
@@ -942,6 +1142,14 @@ public class UpdaterPlugin extends Plugin {
942
1142
  ManifestClient.LatestManifest latest,
943
1143
  String targetChannel
944
1144
  ) throws Exception {
1145
+ if ("deltas".equals(latest.strategy)) {
1146
+ return assembleAndStage(latest, targetChannel);
1147
+ }
1148
+
1149
+ if (latest.url == null) {
1150
+ throw new IllegalStateException("Invalid download URL from manifest");
1151
+ }
1152
+
945
1153
  return downloadAndStage(
946
1154
  new URL(latest.url),
947
1155
  latest.version,
@@ -949,10 +1157,179 @@ public class UpdaterPlugin extends Plugin {
949
1157
  latest.size,
950
1158
  latest.runtimeVersion,
951
1159
  targetChannel,
952
- latest.releaseId
1160
+ latest.releaseId,
1161
+ latest.encryption
953
1162
  );
954
1163
  }
955
1164
 
1165
+ private static final String BUNDLE_FILE_LIST_NAME = "otakit_files.json";
1166
+
1167
+ /**
1168
+ * Deltas strategy: fill content-cache misses and assemble the bundle from
1169
+ * the cache, then hand it to the same staging path the zip flow uses.
1170
+ */
1171
+ private BundleInfo assembleAndStage(ManifestClient.LatestManifest manifest, String targetChannel)
1172
+ throws Exception {
1173
+ // Same conservative disk-space guard as the zip path.
1174
+ if (manifest.size > 0) {
1175
+ long requiredSpace = (long) (manifest.size * 2.5);
1176
+ if (getFreeDiskSpace() < requiredSpace) {
1177
+ sendDeviceEvent(
1178
+ "download_error",
1179
+ manifest.version,
1180
+ manifest.runtimeVersion,
1181
+ targetChannel,
1182
+ manifest.releaseId,
1183
+ "insufficient_disk_space"
1184
+ );
1185
+ emitEvent(
1186
+ "downloadFailed",
1187
+ failureEventData(
1188
+ manifest.version,
1189
+ manifest.runtimeVersion,
1190
+ targetChannel,
1191
+ manifest.releaseId,
1192
+ "insufficient_disk_space"
1193
+ )
1194
+ );
1195
+ throw new IllegalStateException("Insufficient disk space");
1196
+ }
1197
+ }
1198
+
1199
+ File assembleDirectory = new File(
1200
+ getContext().getCacheDir(),
1201
+ "otakit-assemble-" + System.currentTimeMillis()
1202
+ );
1203
+
1204
+ try {
1205
+ if (manifest.files == null || manifest.files.isEmpty()) {
1206
+ throw new IllegalStateException("Delta manifest is missing its file list");
1207
+ }
1208
+
1209
+ DeltaAssembler assembler = new DeltaAssembler(
1210
+ store.getFilesCacheDirectory(),
1211
+ allowInsecureUrls
1212
+ );
1213
+ assembler.validate(manifest.files, manifest.sha256);
1214
+ assembler.seedFromBuiltinIfNeeded(getContext(), BUILTIN_ASSET_PATH, store.getNativeBuild());
1215
+ assembler.assemble(manifest.files, assembleDirectory, getContext());
1216
+
1217
+ String bundleId = buildBundleId(manifest.version, manifest.releaseId, manifest.sha256);
1218
+ File destination = coordinator.bundleDirectory(bundleId);
1219
+ if (destination.exists()) {
1220
+ deleteRecursively(destination);
1221
+ }
1222
+ moveDirectory(assembleDirectory, destination);
1223
+
1224
+ // Record this bundle's content hashes for cache pruning.
1225
+ try {
1226
+ org.json.JSONArray hashes = new org.json.JSONArray();
1227
+ for (ManifestClient.ManifestFileEntry entry : manifest.files) {
1228
+ hashes.put(entry.sha256.toLowerCase());
1229
+ }
1230
+ try (
1231
+ FileOutputStream output = new FileOutputStream(
1232
+ new File(destination, BUNDLE_FILE_LIST_NAME)
1233
+ )
1234
+ ) {
1235
+ output.write(hashes.toString().getBytes(StandardCharsets.UTF_8));
1236
+ }
1237
+ } catch (Exception ignored) {}
1238
+
1239
+ BundleInfo info = new BundleInfo(
1240
+ bundleId,
1241
+ manifest.version,
1242
+ manifest.runtimeVersion,
1243
+ BundleStatus.PENDING,
1244
+ System.currentTimeMillis(),
1245
+ manifest.sha256,
1246
+ destination.getAbsolutePath(),
1247
+ targetChannel,
1248
+ manifest.releaseId
1249
+ );
1250
+ java.util.List<String> cleanupBundleIds = coordinator.stageDownloadedBundle(info);
1251
+ coordinator.cleanupBundles(cleanupBundleIds);
1252
+
1253
+ pruneDeltaCache(assembler);
1254
+
1255
+ sendDeviceEvent(
1256
+ "downloaded",
1257
+ manifest.version,
1258
+ manifest.runtimeVersion,
1259
+ targetChannel,
1260
+ manifest.releaseId,
1261
+ null
1262
+ );
1263
+ JSObject stagedData = new JSObject();
1264
+ stagedData.put("bundle", info.toJSObject());
1265
+ emitEvent("updateStaged", stagedData);
1266
+ return info;
1267
+ } catch (Exception e) {
1268
+ sendDeviceEvent(
1269
+ "download_error",
1270
+ manifest.version,
1271
+ manifest.runtimeVersion,
1272
+ targetChannel,
1273
+ manifest.releaseId,
1274
+ e.getMessage()
1275
+ );
1276
+ emitEvent(
1277
+ "downloadFailed",
1278
+ failureEventData(
1279
+ manifest.version,
1280
+ manifest.runtimeVersion,
1281
+ targetChannel,
1282
+ manifest.releaseId,
1283
+ failureReason(e)
1284
+ )
1285
+ );
1286
+ throw e;
1287
+ } finally {
1288
+ if (assembleDirectory.exists()) {
1289
+ try {
1290
+ deleteRecursively(assembleDirectory);
1291
+ } catch (Exception ignored) {}
1292
+ }
1293
+ }
1294
+ }
1295
+
1296
+ /** Keep only cache entries referenced by live bundles (plus the builtin seed). */
1297
+ private void pruneDeltaCache(DeltaAssembler assembler) {
1298
+ java.util.Set<String> referenced = new java.util.HashSet<>();
1299
+ String[] liveBundleIds = new String[] {
1300
+ store.getCurrentBundleId(),
1301
+ store.getFallbackBundleId(),
1302
+ store.getStagedBundleId(),
1303
+ };
1304
+ for (String bundleId : liveBundleIds) {
1305
+ if (bundleId == null) {
1306
+ continue;
1307
+ }
1308
+ File listFile = new File(store.bundleDirectory(bundleId), BUNDLE_FILE_LIST_NAME);
1309
+ if (!listFile.exists()) {
1310
+ continue;
1311
+ }
1312
+ try (FileInputStream input = new FileInputStream(listFile)) {
1313
+ java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
1314
+ byte[] buffer = new byte[8192];
1315
+ int read;
1316
+ while ((read = input.read(buffer)) > 0) {
1317
+ out.write(buffer, 0, read);
1318
+ }
1319
+ org.json.JSONArray hashes = new org.json.JSONArray(
1320
+ new String(out.toByteArray(), StandardCharsets.UTF_8)
1321
+ );
1322
+ for (int index = 0; index < hashes.length(); index++) {
1323
+ String hash = hashes.optString(index, null);
1324
+ if (hash != null) {
1325
+ referenced.add(hash.toLowerCase());
1326
+ }
1327
+ }
1328
+ } catch (Exception ignored) {}
1329
+ }
1330
+ assembler.pruneCache(referenced);
1331
+ }
1332
+
956
1333
  private void moveDirectory(File source, File destination) throws Exception {
957
1334
  if (source.renameTo(destination)) {
958
1335
  return;
@@ -1092,7 +1469,67 @@ public class UpdaterPlugin extends Plugin {
1092
1469
 
1093
1470
  private String resolveTargetChannel(String channel) {
1094
1471
  String resolved = trimToNull(channel);
1095
- return resolved != null ? resolved : this.channel;
1472
+ if (resolved != null) {
1473
+ return resolved;
1474
+ }
1475
+ String override = store.getOverrideChannel();
1476
+ if (override != null) {
1477
+ return override;
1478
+ }
1479
+ return this.channel;
1480
+ }
1481
+
1482
+ /**
1483
+ * Emit a JS lifecycle event. notifyListeners marshals to the bridge
1484
+ * safely from any thread, so no manual dispatch is needed.
1485
+ */
1486
+ private void emitEvent(String name, JSObject data) {
1487
+ notifyListeners(name, data);
1488
+ }
1489
+
1490
+ private JSObject failureEventData(
1491
+ String version,
1492
+ String runtimeVersion,
1493
+ String channel,
1494
+ String releaseId,
1495
+ String reason
1496
+ ) {
1497
+ JSObject data = new JSObject();
1498
+ data.put("version", version);
1499
+ data.put("reason", reason);
1500
+ if (trimToNull(runtimeVersion) != null) {
1501
+ data.put("runtimeVersion", runtimeVersion.trim());
1502
+ }
1503
+ if (trimToNull(channel) != null) {
1504
+ data.put("channel", channel.trim());
1505
+ }
1506
+ if (trimToNull(releaseId) != null) {
1507
+ data.put("releaseId", releaseId.trim());
1508
+ }
1509
+ return data;
1510
+ }
1511
+
1512
+ /** Map a native error to a stable event reason string. */
1513
+ private String failureReason(Exception e) {
1514
+ String message = e.getMessage() != null ? e.getMessage().toLowerCase() : "";
1515
+ if (message.contains("hash mismatch")) {
1516
+ return "hash_mismatch";
1517
+ }
1518
+ if (message.contains("disk space")) {
1519
+ return "insufficient_disk_space";
1520
+ }
1521
+ if (message.contains("index.html")) {
1522
+ return "invalid_bundle";
1523
+ }
1524
+ // ZipUtils throws SecurityException for guard violations and prefixes
1525
+ // its messages with "Zip ". Don't match ".zip" anywhere in the message:
1526
+ // network failures often carry the temp file path (…/otakit-….zip).
1527
+ if (
1528
+ e instanceof SecurityException || message.startsWith("zip ") || message.contains("extract")
1529
+ ) {
1530
+ return "extract_failed";
1531
+ }
1532
+ return "download_failed";
1096
1533
  }
1097
1534
 
1098
1535
  private void sendDeviceEvent(