@capgo/capacitor-updater 8.51.0 → 8.51.2

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.
@@ -39,7 +39,9 @@ import java.security.SecureRandom;
39
39
  import java.util.ArrayList;
40
40
  import java.util.Date;
41
41
  import java.util.HashMap;
42
+ import java.util.HashSet;
42
43
  import java.util.Iterator;
44
+ import java.util.LinkedHashSet;
43
45
  import java.util.List;
44
46
  import java.util.Map;
45
47
  import java.util.Objects;
@@ -72,14 +74,20 @@ public class CapgoUpdater {
72
74
  private static final String FALLBACK_VERSION = "pastVersion";
73
75
  private static final String NEXT_VERSION = "nextVersion";
74
76
  private static final String PREVIEW_FALLBACK_VERSION = "previewFallbackVersion";
77
+ private static final String PENDING_DELETE_IDS = "pendingDeleteIds";
75
78
  private static final String bundleDirectory = "versions";
76
79
  private static final String TEMP_UNZIP_PREFIX = "capgo_unzip_";
80
+ private static final long DELETE_PACE_MS = 75L;
81
+ private final Object deleteLock = new Object();
77
82
  private static final String CAPACITOR_CONFIG_ASSET = "capacitor.config.json";
78
83
  private static final String BACKGROUND_RUNNER_CONFIG_KEY = "BackgroundRunner";
79
84
  private static final String BACKGROUND_RUNNER_WORKER_CLASS = "io.ionic.backgroundrunner.plugin.RunnerWorker";
80
85
 
81
86
  public static final String TAG = "Capacitor-updater";
82
87
  public SharedPreferences.Editor editor;
88
+
89
+ /** Optional gate run before any download touches disk (e.g. wait for launch cleanup). */
90
+ public Runnable downloadGate = null;
83
91
  public SharedPreferences prefs;
84
92
 
85
93
  public File documentsDir;
@@ -136,7 +144,7 @@ public class CapgoUpdater {
136
144
 
137
145
  private final FilenameFilter filter = (f, name) -> {
138
146
  // ignore directories generated by mac os x
139
- return (!name.startsWith("__MACOSX") && !name.startsWith(".") && !name.startsWith(".DS_Store"));
147
+ return !name.startsWith("__MACOSX") && !name.startsWith(".") && !name.startsWith(".DS_Store");
140
148
  };
141
149
 
142
150
  private boolean isProd() {
@@ -927,6 +935,11 @@ public class CapgoUpdater {
927
935
 
928
936
  try {
929
937
  this.deleteDirectory(entry, threadToCheck);
938
+ if (entry.exists()) {
939
+ logger.error("Orphan bundle directory still present after delete");
940
+ logger.debug("Bundle ID: " + id);
941
+ continue;
942
+ }
930
943
  this.removeBundleInfo(id);
931
944
  logger.info("Deleted orphan bundle directory");
932
945
  logger.debug("Bundle ID: " + id);
@@ -938,6 +951,43 @@ public class CapgoUpdater {
938
951
  }
939
952
  }
940
953
 
954
+ public Set<String> allowedBundleIdsForCleanup() {
955
+ final Set<String> allowedIds = new HashSet<>();
956
+ for (final BundleInfo info : this.list(true)) {
957
+ if (info == null || info.getId() == null || info.getId().isEmpty()) {
958
+ continue;
959
+ }
960
+ // DELETED tombstones must not protect leftover folders.
961
+ // DELETING stays protected so drainPendingDeletes owns the removal.
962
+ if (info.isDeleted()) {
963
+ continue;
964
+ }
965
+ allowedIds.add(info.getId());
966
+ }
967
+ final String currentId = this.getCurrentBundleId();
968
+ if (currentId != null && !currentId.isEmpty()) {
969
+ allowedIds.add(currentId);
970
+ }
971
+ final BundleInfo fallback = this.getFallbackBundle();
972
+ if (fallback != null && fallback.getId() != null && !fallback.getId().isEmpty() && !fallback.isDeleting()) {
973
+ allowedIds.add(fallback.getId());
974
+ }
975
+ final BundleInfo next = this.getNextBundle();
976
+ if (next != null && next.getId() != null && !next.getId().isEmpty() && !next.isDeleting()) {
977
+ allowedIds.add(next.getId());
978
+ }
979
+ final BundleInfo previewFallback = this.getPreviewFallbackBundle();
980
+ if (
981
+ previewFallback != null &&
982
+ previewFallback.getId() != null &&
983
+ !previewFallback.getId().isEmpty() &&
984
+ !previewFallback.isDeleting()
985
+ ) {
986
+ allowedIds.add(previewFallback.getId());
987
+ }
988
+ return allowedIds;
989
+ }
990
+
941
991
  public void cleanupOrphanedTempFolders(final Thread threadToCheck) {
942
992
  if (this.documentsDir == null) {
943
993
  logger.warn("Documents directory is null, skipping temp folder cleanup");
@@ -1221,6 +1271,27 @@ public class CapgoUpdater {
1221
1271
  );
1222
1272
  }
1223
1273
 
1274
+ private void runDownloadGate() throws IOException {
1275
+ if (this.downloadGate == null) {
1276
+ return;
1277
+ }
1278
+ try {
1279
+ this.downloadGate.run();
1280
+ } catch (final RuntimeException e) {
1281
+ throw new IOException(e.getMessage() == null ? "Download gate failed" : e.getMessage(), e);
1282
+ }
1283
+ }
1284
+
1285
+ private boolean runDownloadGateQuiet() {
1286
+ try {
1287
+ this.runDownloadGate();
1288
+ return true;
1289
+ } catch (final IOException e) {
1290
+ logger.error("Download blocked by cleanup gate: " + e.getMessage());
1291
+ return false;
1292
+ }
1293
+ }
1294
+
1224
1295
  public void downloadBackground(
1225
1296
  final String url,
1226
1297
  final String version,
@@ -1239,6 +1310,9 @@ public class CapgoUpdater {
1239
1310
  final JSONArray manifest,
1240
1311
  final boolean setNext
1241
1312
  ) {
1313
+ if (!this.runDownloadGateQuiet()) {
1314
+ return;
1315
+ }
1242
1316
  final String id = this.randomString();
1243
1317
 
1244
1318
  // Check if version is already downloading, but allow retry if previous download failed
@@ -1263,9 +1337,10 @@ public class CapgoUpdater {
1263
1337
  }
1264
1338
 
1265
1339
  public BundleInfo download(final String url, final String version, final String sessionKey, final String checksum) throws IOException {
1340
+ this.runDownloadGate();
1266
1341
  // Check for existing bundle with same version and clean up if in error state
1267
1342
  BundleInfo existingBundle = this.getBundleInfoByName(version);
1268
- if (existingBundle != null && (existingBundle.isErrorStatus() || existingBundle.isDeleted())) {
1343
+ if (existingBundle != null && (existingBundle.isErrorStatus() || existingBundle.isDeleted() || existingBundle.isDeleting())) {
1269
1344
  logger.info("Found existing failed bundle for version " + version + ", deleting before retry");
1270
1345
  this.delete(existingBundle.getId(), true);
1271
1346
  }
@@ -1311,13 +1386,14 @@ public class CapgoUpdater {
1311
1386
  final String checksum,
1312
1387
  final JSONArray manifest
1313
1388
  ) throws IOException {
1389
+ this.runDownloadGate();
1314
1390
  if (manifest == null) {
1315
1391
  return download(url, version, sessionKey, checksum);
1316
1392
  }
1317
1393
 
1318
1394
  // Check for existing bundle with same version and clean up if in error state
1319
1395
  BundleInfo existingBundle = this.getBundleInfoByName(version);
1320
- if (existingBundle != null && (existingBundle.isErrorStatus() || existingBundle.isDeleted())) {
1396
+ if (existingBundle != null && (existingBundle.isErrorStatus() || existingBundle.isDeleted() || existingBundle.isDeleting())) {
1321
1397
  logger.info("Found existing failed bundle for version " + version + ", deleting before retry");
1322
1398
  this.delete(existingBundle.getId(), true);
1323
1399
  }
@@ -1384,51 +1460,93 @@ public class CapgoUpdater {
1384
1460
  }
1385
1461
 
1386
1462
  public Boolean delete(final String id, final Boolean removeInfo) throws IOException {
1387
- final BundleInfo deleted = this.getBundleInfo(id);
1388
- if (deleted.isBuiltin() || this.getCurrentBundleId().equals(id)) {
1389
- logger.error("Cannot delete current or builtin bundle");
1390
- logger.debug("Bundle ID: " + id);
1391
- return false;
1392
- }
1393
- final BundleInfo previewFallback = this.getPreviewFallbackBundle();
1394
- if (
1395
- previewFallback != null &&
1396
- !previewFallback.isDeleted() &&
1397
- !previewFallback.isErrorStatus() &&
1398
- previewFallback.getId().equals(id)
1399
- ) {
1400
- logger.error("Cannot delete the preview fallback bundle");
1401
- logger.debug("Bundle ID: " + id);
1402
- return false;
1403
- }
1404
- final BundleInfo next = this.getNextBundle();
1405
- if (next != null && !next.isDeleted() && !next.isErrorStatus() && next.getId().equals(id)) {
1406
- logger.error("Cannot delete the next bundle");
1407
- logger.debug("Bundle ID: " + id);
1408
- return false;
1409
- }
1410
- // Cancel download for this version if active
1411
- if (this.activity != null) {
1412
- DownloadWorkerManager.cancelVersionDownload(this.activity, deleted.getVersionName());
1413
- }
1414
- final File bundle = new File(this.documentsDir, bundleDirectory + "/" + id);
1415
- if (bundle.exists()) {
1416
- this.deleteDirectory(bundle);
1417
- if (!removeInfo) {
1418
- this.saveBundleInfo(id, deleted.setStatus(BundleStatus.DELETED));
1463
+ return this.delete(id, removeInfo, true);
1464
+ }
1465
+
1466
+ public Boolean delete(final String id, final Boolean removeInfo, final boolean cancelActiveDownload) throws IOException {
1467
+ synchronized (this.deleteLock) {
1468
+ final BundleInfo deleted = this.getBundleInfo(id);
1469
+ if (deleted.isBuiltin() || this.getCurrentBundleId().equals(id)) {
1470
+ logger.error("Cannot delete current or builtin bundle");
1471
+ logger.debug("Bundle ID: " + id);
1472
+ return false;
1473
+ }
1474
+ final BundleInfo previewFallback = this.getPreviewFallbackBundle();
1475
+ if (
1476
+ previewFallback != null &&
1477
+ !previewFallback.isDeleted() &&
1478
+ !previewFallback.isErrorStatus() &&
1479
+ !previewFallback.isDeleting() &&
1480
+ previewFallback.getId().equals(id)
1481
+ ) {
1482
+ logger.error("Cannot delete the preview fallback bundle");
1483
+ logger.debug("Bundle ID: " + id);
1484
+ return false;
1485
+ }
1486
+ final BundleInfo next = this.getNextBundle();
1487
+ if (next != null && !next.isDeleted() && !next.isErrorStatus() && !next.isDeleting() && next.getId().equals(id)) {
1488
+ logger.error("Cannot delete the next bundle");
1489
+ logger.debug("Bundle ID: " + id);
1490
+ return false;
1491
+ }
1492
+
1493
+ final File bundle = this.getBundleDirectory(id);
1494
+ final boolean hadRegistry = this.hasStoredBundleInfo(id);
1495
+ final boolean hadFolder = bundle.exists();
1496
+ if (!hadRegistry && !hadFolder) {
1497
+ logger.error("Cannot delete unknown bundle");
1498
+ logger.debug("Bundle ID: " + id);
1499
+ return false;
1500
+ }
1501
+
1502
+ // Persist DELETING before touching disk so kill/OOM can resume on next launch.
1503
+ if (!deleted.isDeleting()) {
1504
+ if (!this.saveBundleInfo(id, deleted.setStatus(BundleStatus.DELETING))) {
1505
+ logger.error("Failed to persist DELETING marker, aborting disk delete");
1506
+ logger.debug("Bundle ID: " + id);
1507
+ return false;
1508
+ }
1509
+ }
1510
+
1511
+ // Cancel download for this version if active
1512
+ if (cancelActiveDownload && this.activity != null) {
1513
+ DownloadWorkerManager.cancelVersionDownload(this.activity, deleted.getVersionName());
1514
+ }
1515
+
1516
+ if (bundle.exists()) {
1517
+ try {
1518
+ this.deleteDirectory(bundle);
1519
+ } catch (final IOException e) {
1520
+ logger.error("Failed to delete bundle folder, will retry later");
1521
+ logger.debug("Bundle ID: " + id + ", Error: " + e.getMessage());
1522
+ return false;
1523
+ }
1524
+ }
1525
+
1526
+ // Only drop registry after the folder is confirmed gone.
1527
+ if (bundle.exists()) {
1528
+ logger.error("Bundle folder still present after delete, will retry later");
1529
+ logger.debug("Bundle ID: " + id);
1530
+ return false;
1531
+ }
1532
+
1533
+ final boolean finalized;
1534
+ if (Boolean.FALSE.equals(removeInfo)) {
1535
+ finalized = this.saveBundleInfo(id, deleted.setStatus(BundleStatus.DELETED));
1419
1536
  } else {
1420
- this.removeBundleInfo(id);
1537
+ finalized = this.saveBundleInfo(id, null);
1538
+ }
1539
+ if (!finalized) {
1540
+ logger.error("Failed to finalize delete registry update, will retry later");
1541
+ logger.debug("Bundle ID: " + id);
1542
+ return false;
1421
1543
  }
1544
+ this.sendStats("delete", deleted.getVersionName());
1545
+ this.dequeuePendingDelete(id);
1546
+ logger.info("Bundle deleted and confirmed gone");
1547
+ logger.debug("Bundle ID: " + id);
1422
1548
  return true;
1423
1549
  }
1424
- logger.info("Bundle not found on disk");
1425
- logger.debug("Version: " + deleted.getVersionName());
1426
- // perhaps we did not find the bundle in the files, but if the user requested a delete, we delete
1427
- if (removeInfo) {
1428
- this.removeBundleInfo(id);
1429
- }
1430
- this.sendStats("delete", deleted.getVersionName());
1431
- return false;
1432
1550
  }
1433
1551
 
1434
1552
  public Boolean delete(final String id) {
@@ -1441,6 +1559,82 @@ public class CapgoUpdater {
1441
1559
  }
1442
1560
  }
1443
1561
 
1562
+ /**
1563
+ * Resume incomplete deletes one-by-one. Safe across app kill / OOM because
1564
+ * delete() marks DELETING before disk work and only clears registry after confirm.
1565
+ */
1566
+ public void drainPendingDeletes() {
1567
+ final LinkedHashSet<String> pendingIds = new LinkedHashSet<>();
1568
+ for (final BundleInfo info : this.list(true)) {
1569
+ if (info != null && info.isDeleting() && info.getId() != null && !info.getId().isEmpty()) {
1570
+ pendingIds.add(info.getId());
1571
+ }
1572
+ }
1573
+ pendingIds.addAll(this.getPendingDeleteIds());
1574
+ for (final String id : pendingIds) {
1575
+ try {
1576
+ logger.info("Resuming pending delete for bundle: " + id);
1577
+ if (Boolean.TRUE.equals(this.delete(id, true))) {
1578
+ this.dequeuePendingDelete(id);
1579
+ }
1580
+ } catch (final Exception e) {
1581
+ logger.error("Pending delete failed, will retry next launch");
1582
+ logger.debug("Bundle ID: " + id + ", Error: " + e.getMessage());
1583
+ }
1584
+ try {
1585
+ Thread.sleep(DELETE_PACE_MS);
1586
+ } catch (final InterruptedException ie) {
1587
+ Thread.currentThread().interrupt();
1588
+ return;
1589
+ }
1590
+ }
1591
+ }
1592
+
1593
+ private Set<String> getPendingDeleteIds() {
1594
+ final Set<String> ids = new LinkedHashSet<>();
1595
+ if (this.prefs == null) {
1596
+ return ids;
1597
+ }
1598
+ final String raw = this.prefs.getString(PENDING_DELETE_IDS, "");
1599
+ if (raw == null || raw.isEmpty()) {
1600
+ return ids;
1601
+ }
1602
+ for (final String part : raw.split(",")) {
1603
+ if (part != null && !part.isEmpty()) {
1604
+ ids.add(part);
1605
+ }
1606
+ }
1607
+ return ids;
1608
+ }
1609
+
1610
+ private void enqueuePendingDelete(final String id) {
1611
+ if (id == null || id.isEmpty() || this.editor == null || this.prefs == null) {
1612
+ return;
1613
+ }
1614
+ final Set<String> ids = this.getPendingDeleteIds();
1615
+ if (!ids.add(id)) {
1616
+ return;
1617
+ }
1618
+ this.editor.putString(PENDING_DELETE_IDS, String.join(",", ids));
1619
+ this.editor.commit();
1620
+ }
1621
+
1622
+ private void dequeuePendingDelete(final String id) {
1623
+ if (id == null || id.isEmpty() || this.editor == null || this.prefs == null) {
1624
+ return;
1625
+ }
1626
+ final Set<String> ids = this.getPendingDeleteIds();
1627
+ if (!ids.remove(id)) {
1628
+ return;
1629
+ }
1630
+ if (ids.isEmpty()) {
1631
+ this.editor.remove(PENDING_DELETE_IDS);
1632
+ } else {
1633
+ this.editor.putString(PENDING_DELETE_IDS, String.join(",", ids));
1634
+ }
1635
+ this.editor.commit();
1636
+ }
1637
+
1444
1638
  private File getBundleDirectory(final String id) {
1445
1639
  return new File(this.documentsDir, bundleDirectory + "/" + id);
1446
1640
  }
@@ -1448,7 +1642,13 @@ public class CapgoUpdater {
1448
1642
  private boolean bundleExists(final String id) {
1449
1643
  final File bundle = this.getBundleDirectory(id);
1450
1644
  final BundleInfo bundleInfo = this.getBundleInfo(id);
1451
- return (bundle.isDirectory() && bundle.exists() && new File(bundle.getPath(), "/index.html").exists() && !bundleInfo.isDeleted());
1645
+ return (
1646
+ bundle.isDirectory() &&
1647
+ bundle.exists() &&
1648
+ new File(bundle.getPath(), "/index.html").exists() &&
1649
+ !bundleInfo.isDeleted() &&
1650
+ !bundleInfo.isDeleting()
1651
+ );
1452
1652
  }
1453
1653
 
1454
1654
  static final class ResetState {
@@ -1627,21 +1827,51 @@ public class CapgoUpdater {
1627
1827
  // Only attempt to delete when the fallback is a different bundle than the
1628
1828
  // currently loaded one. Otherwise we spam logs with "Cannot delete <id>"
1629
1829
  // because delete() protects the current bundle from removal.
1630
- if (
1631
- autoDeletePrevious &&
1830
+ final String previousFallbackId = fallback.getId();
1831
+ final String previousFallbackVersion = fallback.getVersionName();
1832
+ final BundleInfo nextBundle = this.getNextBundle();
1833
+ final boolean previousIsNext =
1834
+ nextBundle != null &&
1835
+ previousFallbackId != null &&
1836
+ previousFallbackId.equals(nextBundle.getId()) &&
1837
+ !nextBundle.isDeleted() &&
1838
+ !nextBundle.isErrorStatus() &&
1839
+ !nextBundle.isDeleting();
1840
+ final boolean shouldDeletePrevious =
1841
+ Boolean.TRUE.equals(autoDeletePrevious) &&
1632
1842
  !fallback.isBuiltin() &&
1633
- fallback.getId() != null &&
1634
- !fallback.getId().equals(bundle.getId()) &&
1635
- !fallbackIsPreviewFallback
1636
- ) {
1637
- final Boolean res = this.delete(fallback.getId());
1638
- if (res) {
1639
- logger.info("Deleted previous bundle: " + fallback.getVersionName());
1640
- } else {
1641
- logger.debug("Skip deleting previous bundle (same as current or protected): " + fallback.getId());
1843
+ previousFallbackId != null &&
1844
+ !previousFallbackId.equals(bundle.getId()) &&
1845
+ !fallbackIsPreviewFallback &&
1846
+ !previousIsNext;
1847
+ if (shouldDeletePrevious) {
1848
+ // Cancel any in-flight download for the old version before the async boundary
1849
+ // so a later download of the same version name is not cancelled mid-flight.
1850
+ if (this.activity != null) {
1851
+ DownloadWorkerManager.cancelVersionDownload(this.activity, previousFallbackVersion);
1852
+ }
1853
+ // Mark durable intent before fallback switch so a kill mid-flight still retries.
1854
+ if (!this.saveBundleInfo(previousFallbackId, fallback.setStatus(BundleStatus.DELETING))) {
1855
+ logger.error("Failed to persist DELETING for previous bundle; queueing durable retry");
1856
+ logger.debug("Bundle ID: " + previousFallbackId);
1857
+ this.enqueuePendingDelete(previousFallbackId);
1642
1858
  }
1643
1859
  }
1644
1860
  this.setFallbackBundle(bundle);
1861
+ if (shouldDeletePrevious) {
1862
+ new Thread(() -> {
1863
+ try {
1864
+ final Boolean res = this.delete(previousFallbackId, true, false);
1865
+ if (Boolean.TRUE.equals(res)) {
1866
+ logger.info("Deleted previous bundle: " + previousFallbackVersion);
1867
+ } else {
1868
+ logger.debug("Previous bundle delete incomplete, will retry: " + previousFallbackId);
1869
+ }
1870
+ } catch (final IOException e) {
1871
+ logger.error("Failed to delete previous bundle: " + previousFallbackId + " " + e.getMessage());
1872
+ }
1873
+ }, "CapgoUpdater-autoDeletePrevious").start();
1874
+ }
1645
1875
  }
1646
1876
 
1647
1877
  public void setError(final BundleInfo bundle) {
@@ -1964,7 +2194,6 @@ public class CapgoUpdater {
1964
2194
  logger.info("Public channel requested, channel override removed");
1965
2195
  callback.callback(res);
1966
2196
  } else {
1967
- // Success - persist defaultChannel
1968
2197
  this.defaultChannel = channel;
1969
2198
  editor.putString(defaultChannelKey, channel);
1970
2199
  editor.apply();
@@ -2484,10 +2713,10 @@ public class CapgoUpdater {
2484
2713
  this.saveBundleInfo(id, null);
2485
2714
  }
2486
2715
 
2487
- public void saveBundleInfo(final String id, final BundleInfo info) {
2716
+ public boolean saveBundleInfo(final String id, final BundleInfo info) {
2488
2717
  if (id == null || (info != null && (info.isBuiltin() || info.isUnknown()))) {
2489
2718
  logger.debug("Not saving info for bundle: [" + id + "] " + info);
2490
- return;
2719
+ return false;
2491
2720
  }
2492
2721
 
2493
2722
  if (info == null) {
@@ -2499,7 +2728,7 @@ public class CapgoUpdater {
2499
2728
  logger.debug("Storing info for bundle [" + id + "] " + update.getClass().getName() + " -> " + jsonString);
2500
2729
  this.editor.putString(id + INFO_SUFFIX, jsonString);
2501
2730
  }
2502
- this.editor.commit();
2731
+ return this.editor.commit();
2503
2732
  }
2504
2733
 
2505
2734
  private void setBundleStatus(final String id, final BundleStatus status) {
@@ -40,7 +40,7 @@ public class DelayCondition {
40
40
  public boolean equals(Object o) {
41
41
  if (this == o) return true;
42
42
  if (!(o instanceof DelayCondition that)) return false;
43
- return (getKind() == that.getKind() && Objects.equals(getValue(), that.getValue()));
43
+ return getKind() == that.getKind() && Objects.equals(getValue(), that.getValue());
44
44
  }
45
45
 
46
46
  @Override
@@ -51,6 +51,6 @@ public class DelayCondition {
51
51
  @NonNull
52
52
  @Override
53
53
  public String toString() {
54
- return ("DelayCondition{" + "kind=" + kind + ", value='" + value + '\'' + '}');
54
+ return "DelayCondition{" + "kind=" + kind + ", value='" + value + '\'' + '}';
55
55
  }
56
56
  }
@@ -22,7 +22,7 @@ public class InternalUtils {
22
22
  public static String getPackageName(PackageManager pm, String packageName) {
23
23
  try {
24
24
  PackageInfo pInfo = getPackageInfoInternal(pm, packageName);
25
- return (pInfo != null) ? pInfo.packageName : null;
25
+ return pInfo != null ? pInfo.packageName : null;
26
26
  } catch (PackageManager.NameNotFoundException e) {
27
27
  // Exception is handled internally, and null is returned to indicate the package name could not be retrieved
28
28
  return null;
@@ -273,7 +273,7 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
273
273
  String name = preview.optString("name", "");
274
274
  JSObject bundle = preview.getJSObject("bundle");
275
275
  String version = bundle == null ? "" : bundle.optString("version", "");
276
- String label = !name.isEmpty() ? name : (!version.isEmpty() ? version : preview.optString("id", "Preview"));
276
+ String label = !name.isEmpty() ? name : !version.isEmpty() ? version : preview.optString("id", "Preview");
277
277
  if (preview.optBoolean("isActive", false)) {
278
278
  label += " (current)";
279
279
  }
package/dist/docs.json CHANGED
@@ -846,7 +846,7 @@
846
846
  "text": "4.7.0"
847
847
  }
848
848
  ],
849
- "docs": "Assign this device to a specific update channel at runtime.\n\nChannels allow you to distribute different bundle versions to different groups of users\n(e.g., \"production\", \"beta\", \"staging\"). This method switches the device to a new channel.\n\n**Device Override UI:** `setChannel()` validates the channel with the backend, then stores the\nselected channel locally on the device. It does not create or update a backend Device Override,\nso the device will not appear as overridden in the Capgo dashboard. Only assignments created\nfrom the dashboard or the Public API are shown in the Device Override UI.\n\n**Requirements:**\n- The target channel must allow self-assignment (configured in your Capgo dashboard or backend)\n- The backend may accept or reject the request based on channel settings\n\n**When to use:**\n- After the app is ready and the user has interacted (e.g., opted into beta program)\n- To implement in-app channel switching (beta toggle, tester access, etc.)\n- For user-driven channel changes\n\n**When NOT to use:**\n- At app boot/initialization - use {@link PluginsConfig.CapacitorUpdater.defaultChannel} config instead\n- Before user interaction\n\n**Important: Listen for the `channelPrivate` event**\n\nWhen a user attempts to set a channel that doesn't allow device self-assignment, the method will\nthrow an error AND fire a {@link addListener}('channelPrivate') event. You should listen to this event\nto provide appropriate feedback to users:\n\n```typescript\nCapacitorUpdater.addListener('channelPrivate', (data) => {\n console.warn(`Cannot access channel \"${data.channel}\": ${data.message}`);\n // Show user-friendly message\n});\n```\n\nThis sends a request to the Capgo backend to validate the specified channel, then stores the\nchannel locally on the device.",
849
+ "docs": "Assign this device to a specific update channel at runtime.\n\nChannels allow you to distribute different bundle versions to different groups of users\n(e.g., \"production\", \"beta\", \"staging\"). This method switches the device to a new channel.\n\n**Device Override UI:** `setChannel()` validates the channel with the backend, then stores the\nselected channel locally on the device for future app restarts. It does not create or update\na backend Device Override, so the device will not appear as overridden in the Capgo dashboard.\nOnly assignments created from the dashboard or the Public API are shown in the Device Override UI.\n\n**Requirements:**\n- The target channel must allow self-assignment (configured in your Capgo dashboard or backend)\n- The backend may accept or reject the request based on channel settings\n\n**When to use:**\n- After the app is ready and the user has interacted (e.g., opted into beta program)\n- To implement in-app channel switching (beta toggle, tester access, etc.)\n- For user-driven channel changes\n\n**When NOT to use:**\n- At app boot/initialization - use {@link PluginsConfig.CapacitorUpdater.defaultChannel} config instead\n- Before user interaction\n\n**Important: Listen for the `channelPrivate` event**\n\nWhen a user attempts to set a channel that doesn't allow device self-assignment, the method will\nthrow an error AND fire a {@link addListener}('channelPrivate') event. You should listen to this event\nto provide appropriate feedback to users:\n\n```typescript\nCapacitorUpdater.addListener('channelPrivate', (data) => {\n console.warn(`Cannot access channel \"${data.channel}\": ${data.message}`);\n // Show user-friendly message\n});\n```\n\nThis sends a request to the Capgo backend to validate the specified channel, then stores the\nchannel locally on the device for future app restarts.",
850
850
  "complexTypes": [
851
851
  "ChannelRes",
852
852
  "SetChannelOptions"
@@ -911,7 +911,7 @@
911
911
  "text": "4.8.0"
912
912
  }
913
913
  ],
914
- "docs": "Get the current channel assigned to this device.\n\nReturns information about:\n- `channel`: The currently assigned channel name (if any)\n- `allowSet`: Whether the channel allows self-assignment\n- `status`: Operation status\n- `error`/`message`: Additional information (if applicable)\n\nUse this to:\n- Display current channel to users (e.g., \"You're on the Beta channel\")\n- Check if a device is on a specific channel before showing features\n- Verify channel assignment after calling {@link setChannel}\n\nOn native platforms, a successful response also refreshes the locally persisted\ndefault channel used by update checks.",
914
+ "docs": "Get the current channel assigned to this device.\n\nReturns information about:\n- `channel`: The currently assigned channel name (if any)\n- `allowSet`: Whether the channel allows self-assignment\n- `status`: Operation status\n- `error`/`message`: Additional information (if applicable)\n\nUse this to:\n- Display current channel to users (e.g., \"You're on the Beta channel\")\n- Check if a device is on a specific channel before showing features\n- Verify channel assignment after calling {@link setChannel}\n\nOn native platforms, a successful response also refreshes the default channel used by update checks.\nThis refresh is persisted across app restarts.",
915
915
  "complexTypes": [
916
916
  "GetChannelRes"
917
917
  ],
@@ -4468,6 +4468,14 @@
4468
4468
  {
4469
4469
  "text": "'downloading'",
4470
4470
  "complexTypes": []
4471
+ },
4472
+ {
4473
+ "text": "'deleted'",
4474
+ "complexTypes": []
4475
+ },
4476
+ {
4477
+ "text": "'deleting'",
4478
+ "complexTypes": []
4471
4479
  }
4472
4480
  ]
4473
4481
  },
@@ -5035,6 +5043,22 @@
5035
5043
  "complexTypes": [],
5036
5044
  "type": "boolean | undefined"
5037
5045
  },
5046
+ {
5047
+ "name": "persistDefaultChannelOnReinstall",
5048
+ "tags": [
5049
+ {
5050
+ "text": "true",
5051
+ "name": "default"
5052
+ },
5053
+ {
5054
+ "text": "8.51.0",
5055
+ "name": "since"
5056
+ }
5057
+ ],
5058
+ "docs": "Keep the default channel stored by {@link CapacitorUpdaterPlugin.setChannel} or refreshed by\n{@link CapacitorUpdaterPlugin.getChannel} when app data is restored into a new app install.\n\n`setChannel()` and a successful `getChannel()` still persist the selected channel across app\nrestarts. When this option is `false`, native startup clears that persisted channel when it\ndetects app data restored into a new installation. Native build cleanup clears the persisted\nchannel only when `persistDefaultChannelOnReinstall` is `false`, `resetWhenUpdate` is `true`,\nand the native build version has changed.\n\nOnly available for Android and iOS.",
5059
+ "complexTypes": [],
5060
+ "type": "boolean | undefined"
5061
+ },
5038
5062
  {
5039
5063
  "name": "defaultChannel",
5040
5064
  "tags": [
@@ -311,6 +311,22 @@ declare module '@capacitor/cli' {
311
311
  * @since 7.34.0
312
312
  */
313
313
  allowSetDefaultChannel?: boolean;
314
+ /**
315
+ * Keep the default channel stored by {@link CapacitorUpdaterPlugin.setChannel} or refreshed by
316
+ * {@link CapacitorUpdaterPlugin.getChannel} when app data is restored into a new app install.
317
+ *
318
+ * `setChannel()` and a successful `getChannel()` still persist the selected channel across app
319
+ * restarts. When this option is `false`, native startup clears that persisted channel when it
320
+ * detects app data restored into a new installation. Native build cleanup clears the persisted
321
+ * channel only when `persistDefaultChannelOnReinstall` is `false`, `resetWhenUpdate` is `true`,
322
+ * and the native build version has changed.
323
+ *
324
+ * Only available for Android and iOS.
325
+ *
326
+ * @default true
327
+ * @since 8.51.0
328
+ */
329
+ persistDefaultChannelOnReinstall?: boolean;
314
330
  /**
315
331
  * Set the default channel for the app in the config. Case sensitive.
316
332
  * This will setting will override the default channel set in the cloud, but will still respect overrides made in the cloud.
@@ -947,9 +963,9 @@ export interface CapacitorUpdaterPlugin {
947
963
  * (e.g., "production", "beta", "staging"). This method switches the device to a new channel.
948
964
  *
949
965
  * **Device Override UI:** `setChannel()` validates the channel with the backend, then stores the
950
- * selected channel locally on the device. It does not create or update a backend Device Override,
951
- * so the device will not appear as overridden in the Capgo dashboard. Only assignments created
952
- * from the dashboard or the Public API are shown in the Device Override UI.
966
+ * selected channel locally on the device for future app restarts. It does not create or update
967
+ * a backend Device Override, so the device will not appear as overridden in the Capgo dashboard.
968
+ * Only assignments created from the dashboard or the Public API are shown in the Device Override UI.
953
969
  *
954
970
  * **Requirements:**
955
971
  * - The target channel must allow self-assignment (configured in your Capgo dashboard or backend)
@@ -978,7 +994,7 @@ export interface CapacitorUpdaterPlugin {
978
994
  * ```
979
995
  *
980
996
  * This sends a request to the Capgo backend to validate the specified channel, then stores the
981
- * channel locally on the device.
997
+ * channel locally on the device for future app restarts.
982
998
  *
983
999
  * @param options The {@link SetChannelOptions} containing the channel name and optional auto-update trigger.
984
1000
  * @returns {Promise<ChannelRes>} Channel operation result with status and optional error/message.
@@ -1019,8 +1035,8 @@ export interface CapacitorUpdaterPlugin {
1019
1035
  * - Check if a device is on a specific channel before showing features
1020
1036
  * - Verify channel assignment after calling {@link setChannel}
1021
1037
  *
1022
- * On native platforms, a successful response also refreshes the locally persisted
1023
- * default channel used by update checks.
1038
+ * On native platforms, a successful response also refreshes the default channel used by update checks.
1039
+ * This refresh is persisted across app restarts.
1024
1040
  *
1025
1041
  * @returns {Promise<GetChannelRes>} The current channel information.
1026
1042
  * @throws {Error} If the operation fails.
@@ -1604,7 +1620,7 @@ export interface CapacitorUpdaterPlugin {
1604
1620
  * success: The bundle has been downloaded and is ready to be **SET** as the next bundle.
1605
1621
  * error: The bundle has failed to download.
1606
1622
  */
1607
- export type BundleStatus = 'success' | 'error' | 'pending' | 'downloading';
1623
+ export type BundleStatus = 'success' | 'error' | 'pending' | 'downloading' | 'deleted' | 'deleting';
1608
1624
  export type DelayUntilNext = 'background' | 'kill' | 'nativeVersion' | 'date';
1609
1625
  /**
1610
1626
  * Classification for update-check responses that do not provide a downloadable bundle.