@capgo/capacitor-updater 8.45.3 → 8.45.9

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.
package/Package.swift CHANGED
@@ -11,7 +11,7 @@ let package = Package(
11
11
  ],
12
12
  dependencies: [
13
13
  .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0"),
14
- .package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.11.1")),
14
+ .package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.11.2")),
15
15
  .package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.20"),
16
16
  .package(url: "https://github.com/mrackwitz/Version.git", exact: "0.8.0"),
17
17
  .package(url: "https://github.com/attaswift/BigInt.git", from: "5.7.0")
package/README.md CHANGED
@@ -2063,9 +2063,10 @@ If you don't use backend, you need to provide the URL and version of the bundle.
2063
2063
 
2064
2064
  ##### ResetOptions
2065
2065
 
2066
- | Prop | Type |
2067
- | ---------------------- | -------------------- |
2068
- | **`toLastSuccessful`** | <code>boolean</code> |
2066
+ | Prop | Type | Description | Default |
2067
+ | ---------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------ |
2068
+ | **`toLastSuccessful`** | <code>boolean</code> | Reset to the last successfully loaded bundle instead of the builtin one. | <code>false</code> |
2069
+ | **`usePendingBundle`** | <code>boolean</code> | Apply the pending bundle set via {@link next} while resetting. When `true`, the plugin will switch to the pending bundle immediately and clear the pending flag. If no pending bundle exists, the reset will fail. | <code>false</code> |
2069
2070
 
2070
2071
 
2071
2072
  ##### CurrentBundleResult
@@ -22,6 +22,7 @@ import android.view.View;
22
22
  import android.view.ViewGroup;
23
23
  import android.widget.FrameLayout;
24
24
  import android.widget.ProgressBar;
25
+ import androidx.core.content.pm.PackageInfoCompat;
25
26
  import com.getcapacitor.Bridge;
26
27
  import com.getcapacitor.CapConfig;
27
28
  import com.getcapacitor.JSArray;
@@ -87,8 +88,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
87
88
  private static final String SPLASH_SCREEN_PLUGIN_ID = "SplashScreen";
88
89
  private static final int SPLASH_SCREEN_RETRY_DELAY_MS = 100;
89
90
  private static final int SPLASH_SCREEN_MAX_RETRIES = 20;
91
+ private static final long PENDING_BUNDLE_APP_READY_MIN_TIMEOUT_MS = 30000L;
90
92
 
91
- private final String pluginVersion = "8.45.3";
93
+ private final String pluginVersion = "8.45.9";
92
94
  private static final String DELAY_CONDITION_PREFERENCES = "";
93
95
 
94
96
  private SharedPreferences.Editor editor;
@@ -133,8 +135,12 @@ public class CapacitorUpdaterPlugin extends Plugin {
133
135
  private volatile long downloadStartTimeMs = 0;
134
136
  private static final long DOWNLOAD_TIMEOUT_MS = 3600000; // 1 hour timeout
135
137
 
136
- // private static final CountDownLatch semaphoreReady = new CountDownLatch(1);
137
- private static final Phaser semaphoreReady = new Phaser(1);
138
+ private final Phaser semaphoreReady = new Phaser(0) {
139
+ @Override
140
+ protected boolean onAdvance(final int phase, final int registeredParties) {
141
+ return false;
142
+ }
143
+ };
138
144
 
139
145
  // Lock to ensure cleanup completes before downloads start
140
146
  private final Object cleanupLock = new Object();
@@ -191,7 +197,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
191
197
  }
192
198
 
193
199
  private String getVersionCode(final PackageInfo packageInfo) {
194
- return Long.toString(packageInfo.getLongVersionCode());
200
+ return Long.toString(PackageInfoCompat.getLongVersionCode(packageInfo));
195
201
  }
196
202
 
197
203
  private void notifyBreakingEvents(final String version) {
@@ -283,13 +289,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
283
289
 
284
290
  @Override
285
291
  public void directUpdateFinish(final BundleInfo latest) {
286
- if (activity != null) {
287
- activity.runOnUiThread(() -> {
288
- CapacitorUpdaterPlugin.this.directUpdateFinish(latest);
289
- });
290
- } else {
291
- logger.warn("directUpdateFinish: Activity is null, skipping notification");
292
- }
292
+ CapacitorUpdaterPlugin.this.scheduleDirectUpdateFinish(latest);
293
293
  }
294
294
 
295
295
  @Override
@@ -520,30 +520,69 @@ public class CapacitorUpdaterPlugin extends Plugin {
520
520
  }
521
521
  }
522
522
 
523
- private void semaphoreWait(Number waitTime) {
523
+ private boolean semaphoreWait(final int phase, Number waitTime) {
524
524
  try {
525
- semaphoreReady.awaitAdvanceInterruptibly(semaphoreReady.getPhase(), waitTime.longValue(), TimeUnit.SECONDS);
525
+ semaphoreReady.awaitAdvanceInterruptibly(phase, waitTime.longValue(), TimeUnit.MILLISECONDS);
526
526
  logger.info("semaphoreReady count " + semaphoreReady.getPhase());
527
+ return true;
527
528
  } catch (InterruptedException e) {
528
529
  logger.info("semaphoreWait InterruptedException");
530
+ cleanupTimedOutSemaphoreWait(phase);
529
531
  Thread.currentThread().interrupt(); // Restore interrupted status
532
+ return false;
530
533
  } catch (TimeoutException e) {
531
534
  logger.error("Semaphore timeout: " + e.getMessage());
532
- // Don't throw runtime exception, just log and continue
535
+ cleanupTimedOutSemaphoreWait(phase);
536
+ return false;
533
537
  }
534
538
  }
535
539
 
536
- private void semaphoreUp() {
540
+ private int semaphoreUp() {
537
541
  logger.info("semaphoreUp");
538
- semaphoreReady.register();
542
+ return semaphoreReady.register();
539
543
  }
540
544
 
541
545
  private void semaphoreDown() {
546
+ if (semaphoreReady.getRegisteredParties() == 0) {
547
+ logger.info("semaphoreDown skipped, no pending app ready wait");
548
+ return;
549
+ }
542
550
  logger.info("semaphoreDown");
543
551
  logger.info("semaphoreDown count " + semaphoreReady.getPhase());
544
552
  semaphoreReady.arriveAndDeregister();
545
553
  }
546
554
 
555
+ private void cleanupTimedOutSemaphoreWait(final int phase) {
556
+ if (semaphoreReady.getPhase() != phase || semaphoreReady.getRegisteredParties() == 0) {
557
+ return;
558
+ }
559
+ logger.info("Cleaning up stale app ready wait for phase " + phase);
560
+ semaphoreReady.arriveAndDeregister();
561
+ }
562
+
563
+ protected long getMinimumPendingBundleAppReadyTimeoutMs() {
564
+ return PENDING_BUNDLE_APP_READY_MIN_TIMEOUT_MS;
565
+ }
566
+
567
+ private long resolveAppReadyCheckTimeoutMs() {
568
+ long configuredTimeoutMs = this.appReadyTimeout.longValue();
569
+ try {
570
+ if (this.implementation == null) {
571
+ return configuredTimeoutMs;
572
+ }
573
+
574
+ final BundleInfo current = this.implementation.getCurrentBundle();
575
+ if (current == null || BundleStatus.SUCCESS == current.getStatus()) {
576
+ return configuredTimeoutMs;
577
+ }
578
+
579
+ return Math.max(configuredTimeoutMs, this.getMinimumPendingBundleAppReadyTimeoutMs());
580
+ } catch (final Exception e) {
581
+ logger.warn("Falling back to configured appReadyTimeout: " + e.getMessage());
582
+ return configuredTimeoutMs;
583
+ }
584
+ }
585
+
547
586
  private void sendReadyToJs(final BundleInfo current, final String msg) {
548
587
  sendReadyToJs(current, msg, false);
549
588
  }
@@ -863,6 +902,22 @@ public class CapacitorUpdaterPlugin extends Plugin {
863
902
  this.endBackGroundTaskWithNotif("test", current.getVersionName(), current, false, plannedDirectUpdate);
864
903
  }
865
904
 
905
+ void scheduleDirectUpdateFinish(final BundleInfo latest) {
906
+ startNewThread(() -> {
907
+ try {
908
+ Activity currentActivity = this.getActivity();
909
+ if (currentActivity != null) {
910
+ this.implementation.activity = currentActivity;
911
+ } else {
912
+ logger.warn("directUpdateFinish: Activity is null, proceeding without refreshing the activity reference");
913
+ }
914
+ this.directUpdateFinish(latest);
915
+ } catch (final Exception e) {
916
+ logger.error("directUpdateFinish failed: " + e.getMessage());
917
+ }
918
+ });
919
+ }
920
+
866
921
  private void directUpdateFinish(final BundleInfo latest) {
867
922
  if ("onLaunch".equals(this.directUpdateMode)) {
868
923
  this.onLaunchDirectUpdateUsed = true;
@@ -1333,12 +1388,12 @@ public class CapacitorUpdaterPlugin extends Plugin {
1333
1388
  this.bridge.getWebView().post(() -> this.bridge.getWebView().evaluateJavascript(script, null));
1334
1389
  }
1335
1390
 
1336
- protected boolean _reload() {
1391
+ private void applyCurrentBundleToBridge() {
1337
1392
  final String path = this.implementation.getCurrentBundlePath();
1393
+ final boolean usingBuiltin = this.implementation.isUsingBuiltin();
1338
1394
  if (this.keepUrlPathAfterReload) {
1339
1395
  this.syncKeepUrlPathFlag(true);
1340
1396
  }
1341
- this.semaphoreUp();
1342
1397
  logger.info("Reloading: " + path);
1343
1398
 
1344
1399
  AtomicReference<URL> url = new AtomicReference<>();
@@ -1383,7 +1438,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
1383
1438
  }
1384
1439
 
1385
1440
  if (url.get() != null) {
1386
- if (this.implementation.isUsingBuiltin()) {
1441
+ if (usingBuiltin) {
1387
1442
  this.bridge.getLocalServer().hostAssets(path);
1388
1443
  } else {
1389
1444
  this.bridge.getLocalServer().hostFiles(path);
@@ -1403,14 +1458,14 @@ public class CapacitorUpdaterPlugin extends Plugin {
1403
1458
  } catch (MalformedURLException e) {
1404
1459
  logger.error("Cannot get finalUrl from capacitor bridge " + e.getMessage());
1405
1460
 
1406
- if (this.implementation.isUsingBuiltin()) {
1461
+ if (usingBuiltin) {
1407
1462
  this.bridge.setServerAssetPath(path);
1408
1463
  } else {
1409
1464
  this.bridge.setServerBasePath(path);
1410
1465
  }
1411
1466
  }
1412
1467
  } else {
1413
- if (this.implementation.isUsingBuiltin()) {
1468
+ if (usingBuiltin) {
1414
1469
  this.bridge.setServerAssetPath(path);
1415
1470
  } else {
1416
1471
  this.bridge.setServerBasePath(path);
@@ -1426,24 +1481,63 @@ public class CapacitorUpdaterPlugin extends Plugin {
1426
1481
  });
1427
1482
  }
1428
1483
  }
1484
+ }
1429
1485
 
1430
- this.checkAppReady();
1431
- this.notifyListeners("appReloaded", new JSObject());
1432
-
1433
- // Wait for the reload to complete (until notifyAppReady is called)
1486
+ protected void restoreLiveBundleStateAfterFailedReload() {
1434
1487
  try {
1435
- this.semaphoreWait(this.appReadyTimeout);
1436
- } catch (Exception e) {
1437
- logger.error("Error waiting for app ready: " + e.getMessage());
1438
- return false;
1488
+ this.applyCurrentBundleToBridge();
1489
+ } catch (final Exception e) {
1490
+ logger.warn("Failed to restore live bundle after rejected reload: " + e.getMessage());
1439
1491
  }
1492
+ }
1440
1493
 
1441
- return true;
1494
+ protected boolean _reload() {
1495
+ final int phase = this.semaphoreUp();
1496
+ this.applyCurrentBundleToBridge();
1497
+
1498
+ final long waitTimeMs = this.resolveAppReadyCheckTimeoutMs();
1499
+ this.checkAppReady(waitTimeMs);
1500
+ this.notifyListeners("appReloaded", new JSObject());
1501
+
1502
+ // Wait for the reload to complete (until notifyAppReady is called)
1503
+ return this.semaphoreWait(phase, waitTimeMs);
1442
1504
  }
1443
1505
 
1444
1506
  @PluginMethod
1445
1507
  public void reload(final PluginCall call) {
1446
1508
  try {
1509
+ final BundleInfo current = this.implementation.getCurrentBundle();
1510
+ final BundleInfo next = this.implementation.getNextBundle();
1511
+
1512
+ if (next != null && !next.isErrorStatus() && !next.getId().equals(current.getId())) {
1513
+ final CapgoUpdater.ResetState previousState = this.implementation.captureResetState();
1514
+ final String previousBundleName = this.implementation.getCurrentBundle().getVersionName();
1515
+ logger.info("Applying pending bundle before reload: " + next.getVersionName());
1516
+ final boolean didApplyPendingBundle;
1517
+ if (next.isBuiltin()) {
1518
+ this.implementation.prepareResetStateForTransition();
1519
+ didApplyPendingBundle = true;
1520
+ } else {
1521
+ didApplyPendingBundle = this.implementation.stagePendingReload(next);
1522
+ }
1523
+ if (didApplyPendingBundle && this._reload()) {
1524
+ if (next.isBuiltin()) {
1525
+ this.implementation.finalizeResetTransition(previousBundleName, false);
1526
+ } else {
1527
+ this.implementation.finalizePendingReload(next, previousBundleName);
1528
+ }
1529
+ this.notifyBundleSet(next);
1530
+ this.implementation.setNextBundle(null);
1531
+ call.resolve();
1532
+ return;
1533
+ }
1534
+ this.implementation.restoreResetState(previousState);
1535
+ this.restoreLiveBundleStateAfterFailedReload();
1536
+ logger.error("Reload failed after applying pending bundle: " + next.getVersionName());
1537
+ call.reject("Reload failed after applying pending bundle: " + next.getVersionName());
1538
+ return;
1539
+ }
1540
+
1447
1541
  if (this._reload()) {
1448
1542
  call.resolve();
1449
1543
  } else {
@@ -1605,28 +1699,83 @@ public class CapacitorUpdaterPlugin extends Plugin {
1605
1699
  );
1606
1700
  }
1607
1701
 
1608
- private boolean _reset(final Boolean toLastSuccessful) {
1702
+ private boolean _reset(final Boolean toLastSuccessful, final Boolean usePendingBundle) {
1703
+ return this.performReset(toLastSuccessful, usePendingBundle, false);
1704
+ }
1705
+
1706
+ private boolean performReset(final Boolean toLastSuccessful, final Boolean usePendingBundle, final boolean internal) {
1609
1707
  final BundleInfo fallback = this.implementation.getFallbackBundle();
1610
- this.implementation.reset();
1708
+ final BundleInfo pending = this.implementation.getNextBundle();
1709
+ final CapgoUpdater.ResetState previousState = this.implementation.captureResetState();
1710
+ final String previousBundleName = this.implementation.getCurrentBundle().getVersionName();
1611
1711
 
1612
- if (toLastSuccessful && !fallback.isBuiltin()) {
1613
- logger.info("Resetting to: " + fallback);
1614
- if (this.implementation.set(fallback) && this._reload()) {
1615
- this.notifyBundleSet(fallback);
1712
+ if (Boolean.TRUE.equals(usePendingBundle)) {
1713
+ if (pending == null || pending.isErrorStatus()) {
1714
+ logger.error("No pending bundle available to reset to");
1715
+ return false;
1716
+ }
1717
+ if (!this.implementation.canSet(pending)) {
1718
+ logger.error("Pending bundle is not installable");
1719
+ return false;
1720
+ }
1721
+ this.implementation.prepareResetStateForTransition();
1722
+ logger.info("Resetting to pending bundle: " + pending.getVersionName());
1723
+ final boolean didApplyPendingBundle;
1724
+ if (pending.isBuiltin()) {
1725
+ didApplyPendingBundle = true;
1726
+ } else {
1727
+ didApplyPendingBundle = this.implementation.set(pending);
1728
+ }
1729
+ if (didApplyPendingBundle && this._reload()) {
1730
+ this.implementation.finalizeResetTransition(previousBundleName, internal);
1731
+ this.notifyBundleSet(pending);
1732
+ this.implementation.setNextBundle(null);
1616
1733
  return true;
1617
1734
  }
1735
+ this.implementation.restoreResetState(previousState);
1736
+ this.restoreLiveBundleStateAfterFailedReload();
1618
1737
  return false;
1619
1738
  }
1620
1739
 
1740
+ if (Boolean.TRUE.equals(toLastSuccessful) && !fallback.isBuiltin()) {
1741
+ if (this.implementation.canSet(fallback)) {
1742
+ this.implementation.prepareResetStateForTransition();
1743
+ logger.info("Resetting to: " + fallback);
1744
+ if (this.implementation.set(fallback) && this._reload()) {
1745
+ this.implementation.finalizeResetTransition(previousBundleName, internal);
1746
+ this.notifyBundleSet(fallback);
1747
+ return true;
1748
+ }
1749
+ if (!internal) {
1750
+ this.implementation.restoreResetState(previousState);
1751
+ this.restoreLiveBundleStateAfterFailedReload();
1752
+ return false;
1753
+ }
1754
+ logger.warn("Fallback reload failed during internal reset, resetting to native instead");
1755
+ } else {
1756
+ logger.warn("Fallback bundle is not installable, resetting to native instead");
1757
+ }
1758
+ }
1759
+
1760
+ this.implementation.prepareResetStateForTransition();
1621
1761
  logger.info("Resetting to native.");
1622
- return this._reload();
1762
+ if (this._reload()) {
1763
+ this.implementation.finalizeResetTransition(previousBundleName, internal);
1764
+ return true;
1765
+ }
1766
+ if (!internal) {
1767
+ this.implementation.restoreResetState(previousState);
1768
+ this.restoreLiveBundleStateAfterFailedReload();
1769
+ }
1770
+ return false;
1623
1771
  }
1624
1772
 
1625
1773
  @PluginMethod
1626
1774
  public void reset(final PluginCall call) {
1627
1775
  try {
1628
1776
  final Boolean toLastSuccessful = call.getBoolean("toLastSuccessful", false);
1629
- if (this._reset(toLastSuccessful)) {
1777
+ final Boolean usePendingBundle = call.getBoolean("usePendingBundle", false);
1778
+ if (this._reset(toLastSuccessful, usePendingBundle)) {
1630
1779
  call.resolve();
1631
1780
  return;
1632
1781
  }
@@ -1828,11 +1977,15 @@ public class CapacitorUpdaterPlugin extends Plugin {
1828
1977
  }
1829
1978
 
1830
1979
  private void checkAppReady() {
1980
+ this.checkAppReady(this.resolveAppReadyCheckTimeoutMs());
1981
+ }
1982
+
1983
+ private void checkAppReady(final long waitTimeMs) {
1831
1984
  try {
1832
1985
  if (this.appReadyCheck != null) {
1833
1986
  this.appReadyCheck.interrupt();
1834
1987
  }
1835
- this.appReadyCheck = startNewThread(new DeferredNotifyAppReadyCheck());
1988
+ this.appReadyCheck = startNewThread(new DeferredNotifyAppReadyCheck(waitTimeMs));
1836
1989
  } catch (final Exception e) {
1837
1990
  logger.error("Failed to start " + DeferredNotifyAppReadyCheck.class.getName() + " " + e.getMessage());
1838
1991
  }
@@ -1990,7 +2143,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
1990
2143
  );
1991
2144
  if (directUpdateAllowedNow) {
1992
2145
  logger.info("Direct update to builtin version");
1993
- this._reset(false);
2146
+ this._reset(false, false);
1994
2147
  CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
1995
2148
  "Updated to builtin version",
1996
2149
  latestVersionName,
@@ -2224,16 +2377,18 @@ public class CapacitorUpdaterPlugin extends Plugin {
2224
2377
  if (next != null && !next.isErrorStatus() && !next.getId().equals(current.getId())) {
2225
2378
  // There is a next bundle waiting for activation
2226
2379
  logger.debug("Next bundle is: " + next.getVersionName());
2227
- if (this.implementation.set(next) && this._reload()) {
2228
- logger.info("Updated to bundle: " + next.getVersionName());
2229
- this.notifyBundleSet(next);
2230
- this.implementation.setNextBundle(null);
2231
- } else {
2232
- logger.error("Update to bundle: " + next.getVersionName() + " Failed!");
2233
- }
2380
+ startNewThread(() -> {
2381
+ if (this.implementation.set(next) && this._reload()) {
2382
+ logger.info("Updated to bundle: " + next.getVersionName());
2383
+ this.notifyBundleSet(next);
2384
+ this.implementation.setNextBundle(null);
2385
+ } else {
2386
+ logger.error("Update to bundle: " + next.getVersionName() + " Failed!");
2387
+ }
2388
+ });
2234
2389
  }
2235
2390
  } catch (final Exception e) {
2236
- logger.error("Error during onActivityStopped " + e.getMessage());
2391
+ logger.error("Error during installNext " + e);
2237
2392
  }
2238
2393
  }
2239
2394
 
@@ -2256,7 +2411,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2256
2411
  this.notifyListeners("updateFailed", ret);
2257
2412
  this.implementation.sendStats("update_fail", current.getVersionName());
2258
2413
  this.implementation.setError(current);
2259
- this._reset(true);
2414
+ this.performReset(true, false, true);
2260
2415
  if (CapacitorUpdaterPlugin.this.autoDeleteFailed && !current.isBuiltin()) {
2261
2416
  logger.info("Deleting failing bundle: " + current.getVersionName());
2262
2417
  try {
@@ -2275,11 +2430,17 @@ public class CapacitorUpdaterPlugin extends Plugin {
2275
2430
 
2276
2431
  private class DeferredNotifyAppReadyCheck implements Runnable {
2277
2432
 
2433
+ private final long waitTimeMs;
2434
+
2435
+ DeferredNotifyAppReadyCheck(final long waitTimeMs) {
2436
+ this.waitTimeMs = waitTimeMs;
2437
+ }
2438
+
2278
2439
  @Override
2279
2440
  public void run() {
2280
2441
  try {
2281
- logger.info("Wait for " + CapacitorUpdaterPlugin.this.appReadyTimeout + "ms, then check for notifyAppReady");
2282
- Thread.sleep(CapacitorUpdaterPlugin.this.appReadyTimeout);
2442
+ logger.info("Wait for " + this.waitTimeMs + "ms, then check for notifyAppReady");
2443
+ Thread.sleep(this.waitTimeMs);
2283
2444
  CapacitorUpdaterPlugin.this.checkRevert();
2284
2445
  CapacitorUpdaterPlugin.this.appReadyCheck = null;
2285
2446
  } catch (final InterruptedException e) {
@@ -991,6 +991,64 @@ public class CapgoUpdater {
991
991
  return (bundle.isDirectory() && bundle.exists() && new File(bundle.getPath(), "/index.html").exists() && !bundleInfo.isDeleted());
992
992
  }
993
993
 
994
+ static final class ResetState {
995
+
996
+ final String currentBundlePath;
997
+ final String fallbackBundleId;
998
+ final String nextBundleId;
999
+
1000
+ ResetState(final String currentBundlePath, final String fallbackBundleId, final String nextBundleId) {
1001
+ this.currentBundlePath = currentBundlePath;
1002
+ this.fallbackBundleId = fallbackBundleId;
1003
+ this.nextBundleId = nextBundleId;
1004
+ }
1005
+ }
1006
+
1007
+ ResetState captureResetState() {
1008
+ return new ResetState(
1009
+ this.getCurrentBundlePath(),
1010
+ this.prefs.getString(FALLBACK_VERSION, BundleInfo.ID_BUILTIN),
1011
+ this.prefs.getString(NEXT_VERSION, null)
1012
+ );
1013
+ }
1014
+
1015
+ void restoreResetState(final ResetState state) {
1016
+ final String currentBundlePath = state.currentBundlePath == null || state.currentBundlePath.trim().isEmpty()
1017
+ ? "public"
1018
+ : state.currentBundlePath;
1019
+ final String fallbackBundleId = state.fallbackBundleId == null || state.fallbackBundleId.isEmpty()
1020
+ ? BundleInfo.ID_BUILTIN
1021
+ : state.fallbackBundleId;
1022
+
1023
+ this.editor.putString(this.CAP_SERVER_PATH, currentBundlePath);
1024
+ this.editor.putString(FALLBACK_VERSION, fallbackBundleId);
1025
+ if (state.nextBundleId == null || state.nextBundleId.isEmpty()) {
1026
+ this.editor.remove(NEXT_VERSION);
1027
+ } else {
1028
+ this.editor.putString(NEXT_VERSION, state.nextBundleId);
1029
+ }
1030
+ this.editor.commit();
1031
+ }
1032
+
1033
+ void prepareResetStateForTransition() {
1034
+ this.setCurrentBundle(new File("public"));
1035
+ this.setFallbackBundle(null);
1036
+ this.setNextBundle(null);
1037
+ }
1038
+
1039
+ void finalizeResetTransition(final String previousBundleName, final boolean internal) {
1040
+ if (this.activity != null) {
1041
+ DownloadWorkerManager.cancelAllDownloads(this.activity);
1042
+ }
1043
+ if (!internal) {
1044
+ this.sendStats("reset", this.getCurrentBundle().getVersionName(), previousBundleName);
1045
+ }
1046
+ }
1047
+
1048
+ boolean canSet(final BundleInfo bundle) {
1049
+ return bundle != null && (bundle.isBuiltin() || this.bundleExists(bundle.getId()));
1050
+ }
1051
+
994
1052
  public Boolean set(final BundleInfo bundle) {
995
1053
  return this.set(bundle.getId());
996
1054
  }
@@ -1015,6 +1073,21 @@ public class CapgoUpdater {
1015
1073
  return false;
1016
1074
  }
1017
1075
 
1076
+ boolean stagePendingReload(final BundleInfo bundle) {
1077
+ if (bundle == null || bundle.isBuiltin() || !this.bundleExists(bundle.getId())) {
1078
+ return false;
1079
+ }
1080
+ this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
1081
+ return true;
1082
+ }
1083
+
1084
+ void finalizePendingReload(final BundleInfo bundle, final String previousBundleName) {
1085
+ if (bundle == null || bundle.isBuiltin()) {
1086
+ return;
1087
+ }
1088
+ this.sendStats("set", bundle.getVersionName(), previousBundleName);
1089
+ }
1090
+
1018
1091
  public void autoReset() {
1019
1092
  final BundleInfo currentBundle = this.getCurrentBundle();
1020
1093
  if (!currentBundle.isBuiltin() && !this.bundleExists(currentBundle.getId())) {
@@ -1058,17 +1131,9 @@ public class CapgoUpdater {
1058
1131
 
1059
1132
  public void reset(final boolean internal) {
1060
1133
  logger.debug("reset: " + internal);
1061
- var currentBundleName = this.getCurrentBundle().getVersionName();
1062
- this.setCurrentBundle(new File("public"));
1063
- this.setFallbackBundle(null);
1064
- this.setNextBundle(null);
1065
- // Cancel any ongoing downloads
1066
- if (this.activity != null) {
1067
- DownloadWorkerManager.cancelAllDownloads(this.activity);
1068
- }
1069
- if (!internal) {
1070
- this.sendStats("reset", this.getCurrentBundle().getVersionName(), currentBundleName);
1071
- }
1134
+ final String currentBundleName = this.getCurrentBundle().getVersionName();
1135
+ this.prepareResetStateForTransition();
1136
+ this.finalizeResetTransition(currentBundleName, internal);
1072
1137
  }
1073
1138
 
1074
1139
  private JSONObject createInfoObject() throws JSONException {
@@ -110,7 +110,23 @@ public class DownloadService extends Worker {
110
110
  }
111
111
 
112
112
  private static String sanitizeUserAgentValue(String value) {
113
- return value == null || value.isEmpty() ? "unknown" : value;
113
+ if (value == null || value.isEmpty()) {
114
+ return "unknown";
115
+ }
116
+
117
+ StringBuilder sanitized = new StringBuilder();
118
+ value
119
+ .codePoints()
120
+ .forEach((cp) -> {
121
+ boolean isVisibleAscii = cp >= 0x20 && cp <= 0x7E;
122
+ boolean isIso88591 = cp >= 0xA0 && cp <= 0xFF;
123
+ if (isVisibleAscii || isIso88591) {
124
+ sanitized.appendCodePoint(cp);
125
+ }
126
+ });
127
+
128
+ String result = sanitized.toString().trim();
129
+ return result.isEmpty() ? "unknown" : result;
114
130
  }
115
131
 
116
132
  // Method to update User-Agent values
package/dist/docs.json CHANGED
@@ -343,7 +343,7 @@
343
343
  },
344
344
  {
345
345
  "name": "link",
346
- "text": "ResetOptions} to control reset behavior. If `toLastSuccessful` is `false` (or omitted), resets to builtin. If `true`, resets to last successful bundle."
346
+ "text": "ResetOptions} to control reset behavior.\nIf `toLastSuccessful` is `false` (or omitted), resets to builtin.\nIf `true`, resets to last successful bundle.\nIf `usePendingBundle` is `true`, applies the pending bundle set via {@link next} and clears it."
347
347
  },
348
348
  {
349
349
  "name": "returns",
@@ -1872,10 +1872,27 @@
1872
1872
  "properties": [
1873
1873
  {
1874
1874
  "name": "toLastSuccessful",
1875
- "tags": [],
1876
- "docs": "",
1875
+ "tags": [
1876
+ {
1877
+ "text": "false",
1878
+ "name": "default"
1879
+ }
1880
+ ],
1881
+ "docs": "Reset to the last successfully loaded bundle instead of the builtin one.",
1877
1882
  "complexTypes": [],
1878
- "type": "boolean"
1883
+ "type": "boolean | undefined"
1884
+ },
1885
+ {
1886
+ "name": "usePendingBundle",
1887
+ "tags": [
1888
+ {
1889
+ "text": "false",
1890
+ "name": "default"
1891
+ }
1892
+ ],
1893
+ "docs": "Apply the pending bundle set via {@link next} while resetting.\n\nWhen `true`, the plugin will switch to the pending bundle immediately and clear the pending flag.\nIf no pending bundle exists, the reset will fail.",
1894
+ "complexTypes": [],
1895
+ "type": "boolean | undefined"
1879
1896
  }
1880
1897
  ]
1881
1898
  },
@@ -568,7 +568,10 @@ export interface CapacitorUpdaterPlugin {
568
568
  * - Testing rollback functionality
569
569
  * - Providing users a "reset to factory" option
570
570
  *
571
- * @param options {@link ResetOptions} to control reset behavior. If `toLastSuccessful` is `false` (or omitted), resets to builtin. If `true`, resets to last successful bundle.
571
+ * @param options {@link ResetOptions} to control reset behavior.
572
+ * If `toLastSuccessful` is `false` (or omitted), resets to builtin.
573
+ * If `true`, resets to last successful bundle.
574
+ * If `usePendingBundle` is `true`, applies the pending bundle set via {@link next} and clears it.
572
575
  * @returns {Promise<void>} A promise that may never resolve because the app will be reloaded.
573
576
  * @throws {Error} If the reset operation fails.
574
577
  */
@@ -1680,7 +1683,19 @@ export interface BundleListResult {
1680
1683
  bundles: BundleInfo[];
1681
1684
  }
1682
1685
  export interface ResetOptions {
1683
- toLastSuccessful: boolean;
1686
+ /**
1687
+ * Reset to the last successfully loaded bundle instead of the builtin one.
1688
+ * @default false
1689
+ */
1690
+ toLastSuccessful?: boolean;
1691
+ /**
1692
+ * Apply the pending bundle set via {@link next} while resetting.
1693
+ *
1694
+ * When `true`, the plugin will switch to the pending bundle immediately and clear the pending flag.
1695
+ * If no pending bundle exists, the reset will fail.
1696
+ * @default false
1697
+ */
1698
+ usePendingBundle?: boolean;
1684
1699
  }
1685
1700
  export interface ListOptions {
1686
1701
  /**