@capgo/cordova-updater 8.1.4 → 8.2.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 (36) hide show
  1. package/dist/esm/definitions.d.ts +116 -86
  2. package/dist/esm/definitions.js +2 -2
  3. package/dist/esm/definitions.js.map +1 -1
  4. package/dist/esm/exec.js +11 -1
  5. package/dist/esm/exec.js.map +1 -1
  6. package/dist/esm/history.js +7 -7
  7. package/dist/esm/history.js.map +1 -1
  8. package/dist/plugin.cjs.js +13 -3
  9. package/dist/plugin.cjs.js.map +1 -1
  10. package/dist/plugin.js +13 -3
  11. package/dist/plugin.js.map +1 -1
  12. package/package.json +5 -3
  13. package/plugin.xml +9 -9
  14. package/src/android/CordovaUpdaterPlugin.java +173 -5
  15. package/src/android/app/capgo/cordova/updater/CapgoUpdater.java +313 -55
  16. package/src/android/app/capgo/cordova/updater/CryptoCipher.java +51 -18
  17. package/src/android/app/capgo/cordova/updater/DelayUpdateUtils.java +0 -1
  18. package/src/android/app/capgo/cordova/updater/DownloadService.java +402 -89
  19. package/src/android/app/capgo/cordova/updater/Version.java +312 -0
  20. package/src/android/capgo-cordova-updater.gradle +0 -2
  21. package/src/android/test/app/capgo/cordova/updater/BackgroundDownloadSettlementTest.java +29 -0
  22. package/src/android/test/app/capgo/cordova/updater/CapacitorUpdaterUnitTest.java.skip +2 -12
  23. package/src/android/test/app/capgo/cordova/updater/DelayUpdateUtilsTest.java +0 -1
  24. package/src/android/test/app/capgo/cordova/updater/RsaContractTest.java +137 -0
  25. package/src/android/test/app/capgo/cordova/updater/SecurityHardeningTest.java +197 -0
  26. package/src/android/test/app/capgo/cordova/updater/SessionKeyRequiredTest.java +344 -0
  27. package/src/ios/CapgoSemanticVersion.swift +181 -0
  28. package/src/ios/CapgoUpdater.swift +427 -114
  29. package/src/ios/CordovaUpdaterPlugin.swift +171 -40
  30. package/src/ios/CryptoCipher.swift +41 -38
  31. package/src/ios/DelayUpdateUtils.swift +19 -45
  32. package/src/ios/Logger.swift +81 -5
  33. package/src/ios/RedirectPolicyDelegate.swift +53 -0
  34. package/src/ios/WebViewStatsReporter.swift +5 -0
  35. package/src/ios/ZipArchiveReader.swift +263 -0
  36. package/src/ios/ZipCentralDirectory.swift +283 -0
@@ -23,8 +23,6 @@ import androidx.work.OneTimeWorkRequest;
23
23
  import androidx.work.PeriodicWorkRequest;
24
24
  import androidx.work.WorkInfo;
25
25
  import androidx.work.WorkManager;
26
- import com.google.common.util.concurrent.Futures;
27
- import com.google.common.util.concurrent.ListenableFuture;
28
26
  import java.io.BufferedInputStream;
29
27
  import java.io.BufferedReader;
30
28
  import java.io.File;
@@ -152,6 +150,9 @@ public class CapgoUpdater {
152
150
  }
153
151
 
154
152
  private final Map<String, CompletableFuture<BundleInfo>> downloadFutures = new ConcurrentHashMap<>();
153
+ // Versions whose WorkManager download is observed by this process. WorkManager persists work across a
154
+ // process kill, but the observer that finishes the download does not survive it.
155
+ private final Set<String> observedDownloadVersions = ConcurrentHashMap.newKeySet();
155
156
  private final ExecutorService io = Executors.newSingleThreadExecutor();
156
157
 
157
158
  public CapgoUpdater(Logger logger) {
@@ -257,8 +258,53 @@ public class CapgoUpdater {
257
258
 
258
259
  void directUpdateFinish(final BundleInfo latest) {}
259
260
 
261
+ /** Launch downloads have no waiter. The plugin emits appReady from here when WorkManager settles. */
262
+ void backgroundDownloadSettled(final BundleInfo bundle, final String status, final long settlementToken) {}
263
+
264
+ private void settleBackgroundDownload(final BundleInfo bundle, final String status, final long settlementToken) {
265
+ this.backgroundDownloadSettled(bundle, status, settlementToken);
266
+ }
267
+
260
268
  void notifyListeners(final String id, final Map<String, Object> res) {}
261
269
 
270
+ static boolean shouldNotifyLaunchDownloadReady(
271
+ final boolean awaitedByCaller,
272
+ final boolean success,
273
+ final boolean directInstall,
274
+ final boolean previewSession
275
+ ) {
276
+ if (awaitedByCaller) {
277
+ return false;
278
+ }
279
+ if (!success) {
280
+ return true;
281
+ }
282
+ return !directInstall && !previewSession;
283
+ }
284
+
285
+ static boolean shouldRestartOrphanedDownload(final boolean observedByThisProcess) {
286
+ return !observedByThisProcess;
287
+ }
288
+
289
+ static boolean shouldReleaseLaunchWhileRetrying(
290
+ final boolean awaitedByCaller,
291
+ final boolean setNext,
292
+ final boolean directUpdate,
293
+ final boolean previewSession
294
+ ) {
295
+ return !awaitedByCaller && setNext && directUpdate && !previewSession;
296
+ }
297
+
298
+ static String launchDownloadReadyStatus(final boolean success, final boolean setNext) {
299
+ if (!success) {
300
+ return "Error downloading file";
301
+ }
302
+ if (setNext) {
303
+ return "update downloaded, will install next background";
304
+ }
305
+ return "update downloaded, autoUpdate onlyDownload";
306
+ }
307
+
262
308
  public String randomString() {
263
309
  final StringBuilder sb = new StringBuilder(10);
264
310
  for (int i = 0; i < 10; i++) sb.append(AB.charAt(rnd.nextInt(AB.length())));
@@ -287,6 +333,31 @@ public class CapgoUpdater {
287
333
  this.cachedKeyId = CryptoCipher.calcKeyId(publicKey);
288
334
  }
289
335
 
336
+ private void requireSessionKeyForEncryptedUpdate(final String sessionKey) throws IOException {
337
+ if (!this.publicKey.isEmpty() && !CryptoCipher.isValidSessionKey(sessionKey)) {
338
+ logger.error("Public key present but no valid session key provided");
339
+ this.sendStats("session_key_required");
340
+ throw new IOException("Session key required when public key is present");
341
+ }
342
+ }
343
+
344
+ private void requireBundleChecksum(final String checksum) throws IOException {
345
+ if (checksum == null || checksum.isEmpty()) {
346
+ logger.error("No checksum provided");
347
+ this.sendStats("checksum_required");
348
+ throw new IOException("Checksum required");
349
+ }
350
+ }
351
+
352
+ static boolean containsPathTraversalSegment(final String relativePath) {
353
+ for (final String segment : relativePath.split("/")) {
354
+ if ("..".equals(segment)) {
355
+ return true;
356
+ }
357
+ }
358
+ return false;
359
+ }
360
+
290
361
  static File resolvePathInsideDirectory(final File baseDirectory, final String relativePath) throws IOException {
291
362
  if (relativePath == null || relativePath.isEmpty()) {
292
363
  throw new IOException("Invalid empty path");
@@ -294,6 +365,9 @@ public class CapgoUpdater {
294
365
  if (relativePath.contains("\\") || relativePath.indexOf('\0') >= 0) {
295
366
  throw new IOException("Invalid path separator");
296
367
  }
368
+ if (containsPathTraversalSegment(relativePath)) {
369
+ throw new IOException("Path traversal segments are not allowed");
370
+ }
297
371
  if (new File(relativePath).isAbsolute()) {
298
372
  throw new IOException("Absolute paths are not allowed");
299
373
  }
@@ -304,13 +378,18 @@ public class CapgoUpdater {
304
378
  final String targetPath = canonicalTarget.getPath();
305
379
  final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
306
380
 
307
- if (!targetPath.equals(basePath) && !targetPath.startsWith(normalizedBasePath)) {
381
+ // Require a strict child of the base. Equality would accept "." and wipe/write the root.
382
+ if (!targetPath.startsWith(normalizedBasePath)) {
308
383
  throw new IOException("Path escapes base directory: " + relativePath);
309
384
  }
310
385
 
311
386
  return canonicalTarget;
312
387
  }
313
388
 
389
+ static File resolveBundleDirectory(final File documentsDir, final String bundleId) throws IOException {
390
+ return resolvePathInsideDirectory(new File(documentsDir, bundleDirectory), bundleId);
391
+ }
392
+
314
393
  public String getKeyId() {
315
394
  return this.cachedKeyId;
316
395
  }
@@ -378,11 +457,11 @@ public class CapgoUpdater {
378
457
  }
379
458
  }
380
459
 
381
- private void flattenAssets(final File sourceFile, final String dest) throws IOException {
460
+ private void flattenAssets(final File sourceFile, final File destinationFile) throws IOException {
382
461
  if (!sourceFile.exists()) {
383
462
  throw new FileNotFoundException("Source file not found: " + sourceFile.getPath());
384
463
  }
385
- final File destinationFile = new File(this.documentsDir, dest);
464
+ assertPathInsideBundleRoot(destinationFile);
386
465
  Objects.requireNonNull(destinationFile.getParentFile()).mkdirs();
387
466
  final String[] entries = sourceFile.list(this.filter);
388
467
  if (entries == null || entries.length == 0) {
@@ -411,7 +490,13 @@ public class CapgoUpdater {
411
490
  return;
412
491
  }
413
492
 
414
- final File bundleDir = this.getBundleDirectory(id);
493
+ final File bundleDir;
494
+ try {
495
+ bundleDir = this.getBundleDirectory(id);
496
+ } catch (IOException e) {
497
+ logger.debug("Skip delta cache population: invalid bundle id");
498
+ return;
499
+ }
415
500
  if (!bundleDir.exists()) {
416
501
  logger.debug("Skip delta cache population: bundle dir missing");
417
502
  return;
@@ -499,7 +584,10 @@ public class CapgoUpdater {
499
584
  if (fileHash.isEmpty()) {
500
585
  return "";
501
586
  }
502
- if (this.publicKey != null && !this.publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
587
+ if (this.publicKey != null && !this.publicKey.isEmpty()) {
588
+ if (!CryptoCipher.isValidSessionKey(sessionKey)) {
589
+ return "";
590
+ }
503
591
  try {
504
592
  fileHash = CryptoCipher.decryptChecksum(fileHash, this.publicKey);
505
593
  } catch (Exception e) {
@@ -663,12 +751,36 @@ public class CapgoUpdater {
663
751
  }
664
752
  }
665
753
 
666
- private void observeWorkProgress(Context context, String id, boolean setNext) {
754
+ private void notifyLaunchDownloadReady(
755
+ final boolean awaitedByCaller,
756
+ final boolean success,
757
+ final boolean directInstall,
758
+ final boolean previewSession,
759
+ final boolean setNext,
760
+ final BundleInfo bundle,
761
+ final long settlementToken
762
+ ) {
763
+ if (!shouldNotifyLaunchDownloadReady(awaitedByCaller, success, directInstall, previewSession)) {
764
+ return;
765
+ }
766
+ final BundleInfo readyBundle = bundle != null ? bundle : this.getCurrentBundle();
767
+ this.backgroundDownloadSettled(readyBundle, launchDownloadReadyStatus(success, setNext), settlementToken);
768
+ }
769
+
770
+ private void observeWorkProgress(
771
+ Context context,
772
+ String id,
773
+ String observedVersion,
774
+ boolean setNext,
775
+ final long settlementToken
776
+ ) {
667
777
  if (!(context instanceof LifecycleOwner)) {
668
778
  logger.error("Context is not a LifecycleOwner, cannot observe work progress");
669
779
  return;
670
780
  }
671
781
 
782
+ final AtomicBoolean terminalHandled = new AtomicBoolean(false);
783
+ final AtomicBoolean launchReleasedWhileRetrying = new AtomicBoolean(false);
672
784
  activity.runOnUiThread(() -> {
673
785
  WorkManager.getInstance(context)
674
786
  .getWorkInfosByTagLiveData(id)
@@ -679,11 +791,40 @@ public class CapgoUpdater {
679
791
  Data progress = workInfo.getProgress();
680
792
 
681
793
  switch (workInfo.getState()) {
794
+ case ENQUEUED:
795
+ // A run attempt already happened, so WorkManager scheduled a retry (network lost midway,
796
+ // 5xx, ...). Retries back off without limit, so a direct install must not keep the launch
797
+ // (and its splashscreen) blocked until the network comes back.
798
+ if (
799
+ workInfo.getRunAttemptCount() > 0 &&
800
+ shouldReleaseLaunchWhileRetrying(
801
+ downloadFutures.containsKey(id),
802
+ setNext,
803
+ Boolean.TRUE.equals(CapgoUpdater.this.directUpdate),
804
+ CapgoUpdater.this.previewSession
805
+ ) &&
806
+ launchReleasedWhileRetrying.compareAndSet(false, true)
807
+ ) {
808
+ logger.warn("Direct update download is retrying, continuing launch on the current bundle");
809
+ // Same fallback as the autoSplashscreen timeout: a later success installs on next background.
810
+ CapgoUpdater.this.directUpdate = false;
811
+ io.execute(
812
+ () ->
813
+ backgroundDownloadSettled(
814
+ getCurrentBundle(),
815
+ launchDownloadReadyStatus(false, false),
816
+ settlementToken
817
+ )
818
+ );
819
+ }
820
+ break;
682
821
  case RUNNING:
683
822
  int percent = progress.getInt(DownloadService.PERCENT, 0);
684
823
  notifyDownload(id, percent);
685
824
  break;
686
825
  case SUCCEEDED:
826
+ if (!terminalHandled.compareAndSet(false, true)) break;
827
+ observedDownloadVersions.remove(observedVersion);
687
828
  logger.info("Download succeeded: " + workInfo.getState());
688
829
  Data outputData = workInfo.getOutputData();
689
830
  String dest = outputData.getString(DownloadService.FILEDEST);
@@ -693,6 +834,10 @@ public class CapgoUpdater {
693
834
  boolean isManifest = outputData.getBoolean(DownloadService.IS_MANIFEST, false);
694
835
 
695
836
  io.execute(() -> {
837
+ // finishDownload clears directUpdate, so read the install plan first.
838
+ final boolean directInstall =
839
+ setNext && Boolean.TRUE.equals(CapgoUpdater.this.directUpdate) && !CapgoUpdater.this.previewSession;
840
+ final boolean previewSession = CapgoUpdater.this.previewSession;
696
841
  boolean success = finishDownload(id, dest, version, sessionKey, checksum, setNext, isManifest);
697
842
  BundleInfo resultBundle;
698
843
  if (!success) {
@@ -719,14 +864,29 @@ public class CapgoUpdater {
719
864
  resultBundle = getBundleInfo(id);
720
865
  }
721
866
 
722
- // Complete the future if it exists
867
+ // Complete the future if it exists. download() waits on it.
868
+ // downloadBackground does not, so the launch check must emit appReady here.
723
869
  CompletableFuture<BundleInfo> future = downloadFutures.remove(id);
724
870
  if (future != null) {
725
871
  future.complete(resultBundle);
726
872
  }
873
+ final BundleInfo readyBundle = success && setNext ? resultBundle : null;
874
+ if (!launchReleasedWhileRetrying.get()) {
875
+ notifyLaunchDownloadReady(
876
+ future != null,
877
+ success,
878
+ directInstall,
879
+ previewSession,
880
+ setNext,
881
+ readyBundle,
882
+ settlementToken
883
+ );
884
+ }
727
885
  });
728
886
  break;
729
887
  case FAILED:
888
+ if (!terminalHandled.compareAndSet(false, true)) break;
889
+ observedDownloadVersions.remove(observedVersion);
730
890
  Data failedData = workInfo.getOutputData();
731
891
  String error = failedData.getString(DownloadService.ERROR);
732
892
  logger.error("Download failed");
@@ -761,9 +921,22 @@ public class CapgoUpdater {
761
921
  if (failedFuture != null) {
762
922
  failedFuture.complete(failedBundle);
763
923
  }
924
+ if (!launchReleasedWhileRetrying.get()) {
925
+ notifyLaunchDownloadReady(
926
+ failedFuture != null,
927
+ false,
928
+ false,
929
+ false,
930
+ false,
931
+ null,
932
+ settlementToken
933
+ );
934
+ }
764
935
  });
765
936
  break;
766
937
  case CANCELLED:
938
+ if (!terminalHandled.compareAndSet(false, true)) break;
939
+ observedDownloadVersions.remove(observedVersion);
767
940
  DataManager.getInstance().clearManifest(id);
768
941
  CompletableFuture<BundleInfo> cancelledFuture = downloadFutures.remove(id);
769
942
  if (cancelledFuture != null) {
@@ -783,13 +956,15 @@ public class CapgoUpdater {
783
956
  final String sessionKey,
784
957
  final String checksum,
785
958
  final JSONArray manifest,
786
- final boolean setNext
959
+ final boolean setNext,
960
+ final long settlementToken
787
961
  ) {
788
962
  if (this.activity == null) {
789
963
  logger.error("Activity is null, cannot observe work progress");
790
964
  return;
791
965
  }
792
- observeWorkProgress(this.activity, id, setNext);
966
+ observedDownloadVersions.add(version);
967
+ observeWorkProgress(this.activity, id, version, setNext, settlementToken);
793
968
 
794
969
  if (manifest != null) {
795
970
  DataManager.getInstance().setManifest(id, manifest);
@@ -835,31 +1010,24 @@ public class CapgoUpdater {
835
1010
  String checksum = "";
836
1011
 
837
1012
  try {
1013
+ this.requireSessionKeyForEncryptedUpdate(sessionKey);
838
1014
  this.notifyDownload(id, 71);
839
1015
  downloaded = new File(this.documentsDir, dest);
840
1016
 
841
1017
  if (!isManifest) {
842
- String checksumDecrypted = Objects.requireNonNullElse(checksumRes, "");
1018
+ String expectedChecksum = Objects.requireNonNullElse(checksumRes, "");
1019
+ this.requireBundleChecksum(expectedChecksum);
843
1020
 
844
- // If public key is present but no checksum provided, refuse installation
845
- if (!this.publicKey.isEmpty() && checksumDecrypted.isEmpty()) {
846
- logger.error("Public key present but no checksum provided");
847
- this.sendStats("checksum_required");
848
- throw new IOException("Checksum required when public key is present: " + id);
849
- }
850
-
851
- if (!sessionKey.isEmpty()) {
1021
+ if (CryptoCipher.isValidSessionKey(sessionKey)) {
852
1022
  CryptoCipher.decryptFile(downloaded, publicKey, sessionKey);
853
- checksumDecrypted = CryptoCipher.decryptChecksum(checksumRes, publicKey);
854
- checksum = CryptoCipher.calcChecksum(downloaded);
855
- } else {
856
- checksum = CryptoCipher.calcChecksum(downloaded);
1023
+ expectedChecksum = CryptoCipher.decryptChecksum(checksumRes, publicKey);
857
1024
  }
1025
+ checksum = CryptoCipher.calcChecksum(downloaded);
858
1026
  CryptoCipher.logChecksumInfo("Calculated checksum", checksum);
859
- CryptoCipher.logChecksumInfo("Expected checksum", checksumDecrypted);
860
- if ((!checksumDecrypted.isEmpty() || !this.publicKey.isEmpty()) && !checksumDecrypted.equals(checksum)) {
1027
+ CryptoCipher.logChecksumInfo("Expected checksum", expectedChecksum);
1028
+ if (!expectedChecksum.equals(checksum)) {
861
1029
  logger.error("Checksum mismatch");
862
- logger.debug("Expected: " + checksumDecrypted + ", Got: " + checksum);
1030
+ logger.debug("Expected: " + expectedChecksum + ", Got: " + checksum);
863
1031
  this.sendStats("checksum_fail");
864
1032
  throw new IOException("Checksum failed: " + id);
865
1033
  }
@@ -887,13 +1055,11 @@ public class CapgoUpdater {
887
1055
  if (!isManifest) {
888
1056
  extractedDir = this.unzip(id, downloaded, TEMP_UNZIP_PREFIX + this.randomString());
889
1057
  this.notifyDownload(id, 91);
890
- final String idName = bundleDirectory + "/" + id;
891
- this.flattenAssets(extractedDir, idName);
1058
+ this.flattenAssets(extractedDir, this.getBundleDirectory(id));
892
1059
  this.cacheBundleFilesAsync(id);
893
1060
  } else {
894
1061
  this.notifyDownload(id, 91);
895
- final String idName = bundleDirectory + "/" + id;
896
- this.flattenAssets(downloaded, idName);
1062
+ this.flattenAssets(downloaded, this.getBundleDirectory(id));
897
1063
  downloaded.delete();
898
1064
  }
899
1065
  // Remove old bundle info and set new one
@@ -1112,18 +1278,44 @@ public class CapgoUpdater {
1112
1278
  }
1113
1279
  }
1114
1280
 
1281
+ private void assertPathInsideDocumentsDir(final File target) throws IOException {
1282
+ if (this.documentsDir == null) {
1283
+ throw new IOException("Documents directory unavailable");
1284
+ }
1285
+ final File canonicalBase = this.documentsDir.getCanonicalFile();
1286
+ final File canonicalTarget = target.getCanonicalFile();
1287
+ final String basePath = canonicalBase.getPath();
1288
+ final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
1289
+ final String targetPath = canonicalTarget.getPath();
1290
+ if (!targetPath.equals(basePath) && !targetPath.startsWith(normalizedBasePath)) {
1291
+ throw new IOException("Path escapes updater storage: " + target.getPath());
1292
+ }
1293
+ }
1294
+
1295
+ private void assertPathInsideBundleRoot(final File target) throws IOException {
1296
+ final File bundleRoot = new File(this.documentsDir, bundleDirectory).getCanonicalFile();
1297
+ final File canonicalTarget = target.getCanonicalFile();
1298
+ final String basePath = bundleRoot.getPath();
1299
+ final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
1300
+ final String targetPath = canonicalTarget.getPath();
1301
+ if (!targetPath.equals(basePath) && !targetPath.startsWith(normalizedBasePath)) {
1302
+ throw new IOException("Path escapes bundle storage: " + target.getPath());
1303
+ }
1304
+ }
1305
+
1115
1306
  private void safeDelete(final File target) {
1116
1307
  if (target == null || !target.exists()) {
1117
1308
  return;
1118
1309
  }
1119
1310
  try {
1311
+ this.assertPathInsideDocumentsDir(target);
1120
1312
  if (target.isDirectory()) {
1121
1313
  this.deleteDirectory(target);
1122
1314
  } else if (!target.delete()) {
1123
1315
  logger.warn("Failed to delete file: " + target.getAbsolutePath());
1124
1316
  }
1125
1317
  } catch (IOException cleanupError) {
1126
- logger.warn("Cleanup failed for " + target.getAbsolutePath() + ": " + cleanupError.getMessage());
1318
+ logger.warn("Refusing unsafe delete for " + target.getAbsolutePath() + ": " + cleanupError.getMessage());
1127
1319
  }
1128
1320
  }
1129
1321
 
@@ -1379,24 +1571,25 @@ public class CapgoUpdater {
1379
1571
  }
1380
1572
  }
1381
1573
 
1382
- public void downloadBackground(
1383
- final String url,
1384
- final String version,
1385
- final String sessionKey,
1386
- final String checksum,
1387
- final JSONArray manifest
1388
- ) {
1389
- downloadBackground(url, version, sessionKey, checksum, manifest, true);
1390
- }
1391
-
1392
1574
  public void downloadBackground(
1393
1575
  final String url,
1394
1576
  final String version,
1395
1577
  final String sessionKey,
1396
1578
  final String checksum,
1397
1579
  final JSONArray manifest,
1398
- final boolean setNext
1580
+ final boolean setNext,
1581
+ final long settlementToken
1399
1582
  ) {
1583
+ try {
1584
+ this.requireSessionKeyForEncryptedUpdate(sessionKey);
1585
+ if (manifest == null) {
1586
+ this.requireBundleChecksum(checksum);
1587
+ }
1588
+ } catch (final IOException e) {
1589
+ logger.error("Download blocked: " + e.getMessage());
1590
+ this.settleBackgroundDownload(null, launchDownloadReadyStatus(false, setNext), settlementToken);
1591
+ return;
1592
+ }
1400
1593
  if (!this.runDownloadGateQuiet()) {
1401
1594
  return;
1402
1595
  }
@@ -1410,9 +1603,26 @@ public class CapgoUpdater {
1410
1603
  // Cancel the failed download and allow retry
1411
1604
  if (!DownloadWorkerManager.cancelVersionDownloadAndAwait(this.activity, version)) {
1412
1605
  logger.error("Failed to cancel previous download before retry");
1606
+ this.settleBackgroundDownload(
1607
+ this.getCurrentBundle(),
1608
+ launchDownloadReadyStatus(false, setNext),
1609
+ settlementToken
1610
+ );
1413
1611
  return;
1414
1612
  }
1415
1613
  logger.info("Retrying failed download for version: " + version);
1614
+ } else if (shouldRestartOrphanedDownload(observedDownloadVersions.contains(version))) {
1615
+ // Left over from a killed process: nothing would finish it or release the launch, so start over.
1616
+ if (!DownloadWorkerManager.cancelVersionDownloadAndAwait(this.activity, version)) {
1617
+ logger.error("Failed to cancel orphaned download before restarting it");
1618
+ this.settleBackgroundDownload(
1619
+ this.getCurrentBundle(),
1620
+ launchDownloadReadyStatus(false, setNext),
1621
+ settlementToken
1622
+ );
1623
+ return;
1624
+ }
1625
+ logger.info("Restarting download orphaned by a previous process for version: " + version);
1416
1626
  } else {
1417
1627
  logger.info("Version already downloading: " + version);
1418
1628
  return;
@@ -1423,10 +1633,12 @@ public class CapgoUpdater {
1423
1633
  this.notifyDownload(id, 0);
1424
1634
  this.notifyDownload(id, 5);
1425
1635
 
1426
- this.download(id, url, this.randomString(), version, sessionKey, checksum, manifest, setNext);
1636
+ this.download(id, url, this.randomString(), version, sessionKey, checksum, manifest, setNext, settlementToken);
1427
1637
  }
1428
1638
 
1429
1639
  public BundleInfo download(final String url, final String version, final String sessionKey, final String checksum) throws IOException {
1640
+ this.requireSessionKeyForEncryptedUpdate(sessionKey);
1641
+ this.requireBundleChecksum(checksum);
1430
1642
  this.runDownloadGate();
1431
1643
  // Check for existing bundle with same version and clean up if in error state
1432
1644
  BundleInfo existingBundle = this.getBundleInfoByName(version);
@@ -1448,7 +1660,7 @@ public class CapgoUpdater {
1448
1660
  downloadFutures.put(id, downloadFuture);
1449
1661
 
1450
1662
  // Start the download
1451
- this.download(id, url, dest, version, sessionKey, checksum, null, false);
1663
+ this.download(id, url, dest, version, sessionKey, checksum, null, false, 0L);
1452
1664
 
1453
1665
  // Wait for completion without timeout
1454
1666
  try {
@@ -1478,6 +1690,7 @@ public class CapgoUpdater {
1478
1690
  final String checksum,
1479
1691
  final JSONArray manifest
1480
1692
  ) throws IOException {
1693
+ this.requireSessionKeyForEncryptedUpdate(sessionKey);
1481
1694
  this.runDownloadGate();
1482
1695
  if (manifest == null) {
1483
1696
  return download(url, version, sessionKey, checksum);
@@ -1503,7 +1716,7 @@ public class CapgoUpdater {
1503
1716
  downloadFutures.put(id, downloadFuture);
1504
1717
 
1505
1718
  // Start the download
1506
- this.download(id, url, dest, version, sessionKey, checksum, manifest, false);
1719
+ this.download(id, url, dest, version, sessionKey, checksum, manifest, false, 0L);
1507
1720
 
1508
1721
  // Wait for completion without timeout
1509
1722
  try {
@@ -1559,6 +1772,14 @@ public class CapgoUpdater {
1559
1772
 
1560
1773
  public Boolean delete(final String id, final Boolean removeInfo, final boolean cancelActiveDownload) throws IOException {
1561
1774
  synchronized (this.deleteLock) {
1775
+ final File bundle;
1776
+ try {
1777
+ bundle = this.getBundleDirectory(id);
1778
+ } catch (IOException e) {
1779
+ logger.error("Cannot delete bundle with invalid id");
1780
+ logger.debug("Bundle ID: " + id + ", Error: " + e.getMessage());
1781
+ return false;
1782
+ }
1562
1783
  final BundleInfo deleted = this.getBundleInfo(id);
1563
1784
  if (deleted.isBuiltin() || this.getCurrentBundleId().equals(id)) {
1564
1785
  logger.error("Cannot delete current or builtin bundle");
@@ -1584,7 +1805,6 @@ public class CapgoUpdater {
1584
1805
  return false;
1585
1806
  }
1586
1807
 
1587
- final File bundle = this.getBundleDirectory(id);
1588
1808
  final boolean hadRegistry = this.hasStoredBundleInfo(id);
1589
1809
  final boolean hadFolder = bundle.exists();
1590
1810
  if (!hadRegistry && !hadFolder) {
@@ -1732,12 +1952,17 @@ public class CapgoUpdater {
1732
1952
  this.editor.commit();
1733
1953
  }
1734
1954
 
1735
- private File getBundleDirectory(final String id) {
1736
- return new File(this.documentsDir, bundleDirectory + "/" + id);
1955
+ private File getBundleDirectory(final String id) throws IOException {
1956
+ return resolveBundleDirectory(this.documentsDir, id);
1737
1957
  }
1738
1958
 
1739
1959
  private boolean bundleExists(final String id) {
1740
- final File bundle = this.getBundleDirectory(id);
1960
+ final File bundle;
1961
+ try {
1962
+ bundle = this.getBundleDirectory(id);
1963
+ } catch (IOException e) {
1964
+ return false;
1965
+ }
1741
1966
  final BundleInfo bundleInfo = this.getBundleInfo(id);
1742
1967
  return (
1743
1968
  bundle.isDirectory() &&
@@ -1814,7 +2039,16 @@ public class CapgoUpdater {
1814
2039
  this.reset();
1815
2040
  return true;
1816
2041
  }
1817
- final File bundle = this.getBundleDirectory(id);
2042
+ final File bundle;
2043
+ try {
2044
+ bundle = this.getBundleDirectory(id);
2045
+ } catch (IOException e) {
2046
+ logger.error("Invalid bundle id");
2047
+ logger.debug("Bundle ID: " + id + ", Error: " + e.getMessage());
2048
+ this.setBundleStatus(id, BundleStatus.ERROR);
2049
+ this.sendStats("set_fail", newBundle.getVersionName());
2050
+ return false;
2051
+ }
1818
2052
  logger.info("Setting next active bundle: " + id);
1819
2053
  if (this.bundleExists(id)) {
1820
2054
  var currentBundleName = this.getCurrentBundle().getVersionName();
@@ -1832,8 +2066,12 @@ public class CapgoUpdater {
1832
2066
  if (bundle == null || bundle.isBuiltin() || !this.bundleExists(bundle.getId())) {
1833
2067
  return false;
1834
2068
  }
1835
- this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
1836
- return true;
2069
+ try {
2070
+ this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
2071
+ return true;
2072
+ } catch (IOException e) {
2073
+ return false;
2074
+ }
1837
2075
  }
1838
2076
 
1839
2077
  boolean stagePreviewFallbackReload(final BundleInfo bundle) {
@@ -1847,8 +2085,12 @@ public class CapgoUpdater {
1847
2085
  if (!this.bundleExists(bundle.getId())) {
1848
2086
  return false;
1849
2087
  }
1850
- this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
1851
- return true;
2088
+ try {
2089
+ this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
2090
+ return true;
2091
+ } catch (IOException e) {
2092
+ return false;
2093
+ }
1852
2094
  }
1853
2095
 
1854
2096
  void finalizePendingReload(final BundleInfo bundle, final String previousBundleName) {
@@ -2409,8 +2651,18 @@ public class CapgoUpdater {
2409
2651
  final SharedPreferences.Editor editor,
2410
2652
  final String defaultChannelKey,
2411
2653
  final String configDefaultChannel,
2654
+ final boolean allowSetDefaultChannel,
2412
2655
  final Callback callback
2413
2656
  ) {
2657
+ if (!allowSetDefaultChannel) {
2658
+ logger.error("unsetChannel is disabled by allowSetDefaultChannel config");
2659
+ final Map<String, Object> retError = new HashMap<>();
2660
+ retError.put("message", "unsetChannel is disabled by configuration");
2661
+ retError.put("error", "disabled_by_config");
2662
+ callback.callback(retError);
2663
+ return;
2664
+ }
2665
+
2414
2666
  // Clear persisted defaultChannel and revert to config value
2415
2667
  editor.remove(defaultChannelKey);
2416
2668
  editor.apply();
@@ -3361,6 +3613,12 @@ public class CapgoUpdater {
3361
3613
  if (!newBundle.isBuiltin() && !this.bundleExists(next)) {
3362
3614
  return false;
3363
3615
  }
3616
+ if (next.equals(this.getCurrentBundleId()) && BundleStatus.SUCCESS == newBundle.getStatus()) {
3617
+ logger.info("Bundle " + next + " is already the current successful bundle. Skip next().");
3618
+ this.editor.remove(NEXT_VERSION);
3619
+ this.editor.commit();
3620
+ return true;
3621
+ }
3364
3622
  this.editor.putString(NEXT_VERSION, next);
3365
3623
  this.setBundleStatus(next, BundleStatus.PENDING);
3366
3624
  bundleToNotify = newBundle;