@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.
@@ -50,6 +50,7 @@ import com.google.android.play.core.install.model.InstallStatus;
50
50
  import com.google.android.play.core.install.model.UpdateAvailability;
51
51
  import io.github.g00fy2.versioncompare.Version;
52
52
  import java.io.ByteArrayOutputStream;
53
+ import java.io.File;
53
54
  import java.io.IOException;
54
55
  import java.io.InputStream;
55
56
  import java.net.HttpURLConnection;
@@ -68,6 +69,7 @@ import java.util.Set;
68
69
  import java.util.TimeZone;
69
70
  import java.util.Timer;
70
71
  import java.util.TimerTask;
72
+ import java.util.concurrent.CountDownLatch;
71
73
  import java.util.concurrent.Phaser;
72
74
  import java.util.concurrent.Semaphore;
73
75
  import java.util.concurrent.TimeUnit;
@@ -103,6 +105,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
103
105
  private static final String CHANNEL_URL_PREF_KEY = "CapacitorUpdater.channelUrl";
104
106
  private static final String DEFAULT_CHANNEL_PREF_KEY = "CapacitorUpdater.defaultChannel";
105
107
  private static final String PREVIEW_SESSION_PREF_KEY = "CapacitorUpdater.previewSession";
108
+ private static final String DEFAULT_CHANNEL_INSTALL_MARKER_PREF_KEY = "CapacitorUpdater.defaultChannelInstallMarkerCreated";
109
+ private static final String DEFAULT_CHANNEL_INSTALL_MARKER_FILE = "CapacitorUpdater.defaultChannelInstallMarker";
106
110
  private static final String PREVIEW_PREVIOUS_SHAKE_MENU_PREF_KEY = "CapacitorUpdater.previewPreviousShakeMenu";
107
111
  private static final String PREVIEW_PREVIOUS_SHAKE_CHANNEL_SELECTOR_PREF_KEY = "CapacitorUpdater.previewPreviousShakeChannelSelector";
108
112
  private static final String PREVIEW_PREVIOUS_NEXT_BUNDLE_PREF_KEY = "CapacitorUpdater.previewPreviousNextBundle";
@@ -142,7 +146,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
142
146
  static final int APPLICATION_EXIT_REASON_USER_REQUESTED = 10;
143
147
  static final int APPLICATION_EXIT_REASON_DEPENDENCY_DIED = 12;
144
148
 
145
- private final String pluginVersion = "8.51.0";
149
+ private final String pluginVersion = "8.51.2";
146
150
  private static final String DELAY_CONDITION_PREFERENCES = "";
147
151
 
148
152
  private SharedPreferences.Editor editor;
@@ -151,6 +155,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
151
155
  protected CapgoUpdater implementation;
152
156
  private Boolean persistCustomId = false;
153
157
  private Boolean persistModifyUrl = false;
158
+ private Boolean persistDefaultChannelOnReinstall = true;
154
159
 
155
160
  private Integer appReadyTimeout = 10000;
156
161
  private Integer periodCheckDelay = 0;
@@ -215,6 +220,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
215
220
 
216
221
  private volatile Thread backgroundDownloadTask;
217
222
  private volatile Thread appReadyCheck;
223
+ // When true, sendReadyToJs should wait for notifyAppReady before hiding splash.
224
+ private volatile boolean pendingNotifyAppReadyWait = false;
225
+ private volatile int pendingNotifyAppReadyPhase = -1;
218
226
  private volatile long downloadStartTimeMs = 0;
219
227
  private static final long DOWNLOAD_TIMEOUT_MS = 600000; // 10 minute timeout
220
228
 
@@ -227,8 +235,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
227
235
 
228
236
  // Lock to ensure cleanup completes before downloads start
229
237
  private final Object cleanupLock = new Object();
238
+ private volatile CountDownLatch cleanupLatch = new CountDownLatch(0);
230
239
  private volatile boolean cleanupComplete = false;
231
240
  private volatile Thread cleanupThread = null;
241
+ private volatile boolean defaultChannelCleanupMustRetry = false;
232
242
 
233
243
  private int lastNotifiedStatPercent = 0;
234
244
 
@@ -751,6 +761,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
751
761
 
752
762
  this.persistCustomId = this.getConfig().getBoolean("persistCustomId", false);
753
763
  this.persistModifyUrl = this.getConfig().getBoolean("persistModifyUrl", false);
764
+ this.persistDefaultChannelOnReinstall = this.getConfig().getBoolean("persistDefaultChannelOnReinstall", true);
754
765
  this.allowSetDefaultChannel = this.getConfig().getBoolean("allowSetDefaultChannel", true);
755
766
  this.implementation.setPublicKey(this.getConfig().getString("publicKey", ""));
756
767
  // Log public key prefix if encryption is enabled
@@ -777,6 +788,35 @@ public class CapacitorUpdaterPlugin extends Plugin {
777
788
  }
778
789
  }
779
790
 
791
+ final boolean resetWhenUpdate = this.getConfig().getBoolean("resetWhenUpdate", true);
792
+ final boolean nativeBuildVersionChanged = this.hasNativeBuildVersionChanged();
793
+ final boolean defaultChannelPersistenceDisabled = !Boolean.TRUE.equals(this.persistDefaultChannelOnReinstall);
794
+ final boolean restoredReinstall = defaultChannelPersistenceDisabled && this.isRestoredReinstall();
795
+ boolean installMarkerCanBePrepared = true;
796
+ if (
797
+ shouldClearPersistedDefaultChannel(
798
+ Boolean.TRUE.equals(this.persistDefaultChannelOnReinstall),
799
+ resetWhenUpdate,
800
+ nativeBuildVersionChanged,
801
+ restoredReinstall
802
+ )
803
+ ) {
804
+ installMarkerCanBePrepared = clearPersistedDefaultChannel(this.editor);
805
+ if (installMarkerCanBePrepared) {
806
+ logger.info("Cleared persisted defaultChannel because reinstall persistence is disabled");
807
+ } else {
808
+ logger.warn("Cannot durably clear persisted defaultChannel");
809
+ this.defaultChannelCleanupMustRetry = true;
810
+ if (!invalidateDefaultChannelInstallMarker(this.defaultChannelInstallMarker())) {
811
+ logger.warn("Cannot invalidate default channel install marker for cleanup retry");
812
+ }
813
+ }
814
+ }
815
+ if (defaultChannelPersistenceDisabled && installMarkerCanBePrepared) {
816
+ this.prepareDefaultChannelInstallMarker();
817
+ }
818
+
819
+ final String configDefaultChannel = this.getConfig().getString("defaultChannel", "");
780
820
  // Load defaultChannel: first try from persistent storage (set via setChannel), then fall back to config
781
821
  if (this.prefs.contains(DEFAULT_CHANNEL_PREF_KEY)) {
782
822
  final String storedDefaultChannel = this.prefs.getString(DEFAULT_CHANNEL_PREF_KEY, "");
@@ -784,10 +824,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
784
824
  this.implementation.defaultChannel = storedDefaultChannel;
785
825
  logger.info("Loaded persisted defaultChannel from setChannel()");
786
826
  } else {
787
- this.implementation.defaultChannel = this.getConfig().getString("defaultChannel", "");
827
+ this.implementation.defaultChannel = configDefaultChannel;
788
828
  }
789
829
  } else {
790
- this.implementation.defaultChannel = this.getConfig().getString("defaultChannel", "");
830
+ this.implementation.defaultChannel = configDefaultChannel;
791
831
  }
792
832
 
793
833
  this.periodCheckDelay = normalizedPeriodCheckDelayMs(this.getConfig().getInt("periodCheckDelay", 0));
@@ -856,11 +896,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
856
896
  ? this.prefs.getBoolean(PREVIEW_PREVIOUS_SHAKE_CHANNEL_SELECTOR_PREF_KEY, false)
857
897
  : this.shakeChannelSelectorEnabled;
858
898
  }
859
- boolean resetWhenUpdate = this.getConfig().getBoolean("resetWhenUpdate", true);
860
899
 
861
900
  // Check if app was recently installed/updated BEFORE cleanupObsoleteVersions updates LatestVersionNative
862
901
  this.wasRecentlyInstalledOrUpdated = this.checkIfRecentlyInstalledOrUpdated();
863
- final boolean nativeBuildVersionChanged = this.hasNativeBuildVersionChanged();
864
902
 
865
903
  this.implementation.autoReset(this.currentBuildVersion, resetWhenUpdate);
866
904
  if (nativeBuildVersionChanged) {
@@ -871,11 +909,14 @@ public class CapacitorUpdaterPlugin extends Plugin {
871
909
  this.reportPreviousAppExitReasons();
872
910
  this.reportPreviousWebViewRenderProcessGone();
873
911
  this.installWebViewStatsReporter();
874
- if (resetWhenUpdate) {
875
- this.cleanupObsoleteVersions();
876
- } else {
912
+ // Downloads (including shake-menu / CapgoUpdater entry points) wait on this gate.
913
+ this.implementation.downloadGate = this::waitForCleanupIfNeeded;
914
+ // Always run async cleanup: delete obsolete bundles on native update (when enabled)
915
+ // and sweep orphan folders every launch. Must not block app startup.
916
+ if (!resetWhenUpdate) {
877
917
  this.persistCurrentNativeBuildVersion();
878
918
  }
919
+ this.cleanupObsoleteVersions(resetWhenUpdate);
879
920
 
880
921
  // Check for 'kill' delay condition on app launch
881
922
  // This handles cases where the app was killed by the system (onDestroy is not reliable)
@@ -909,6 +950,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
909
950
  } else {
910
951
  logger.info("Using activity lifecycle callbacks for foreground/background detection (Android <14)");
911
952
  }
953
+
954
+ // Expect notifyAppReady before the first appReady/splash hide (same idea as iOS).
955
+ this.armPendingNotifyAppReadyWait();
912
956
  }
913
957
 
914
958
  private boolean semaphoreWait(final int phase, Number waitTime) {
@@ -951,6 +995,32 @@ public class CapacitorUpdaterPlugin extends Plugin {
951
995
  semaphoreReady.arriveAndDeregister();
952
996
  }
953
997
 
998
+ private void armPendingNotifyAppReadyWait() {
999
+ this.clearPendingNotifyAppReadyWait();
1000
+ this.pendingNotifyAppReadyPhase = this.semaphoreUp();
1001
+ this.pendingNotifyAppReadyWait = true;
1002
+ }
1003
+
1004
+ private void clearPendingNotifyAppReadyWait() {
1005
+ if (!this.pendingNotifyAppReadyWait) {
1006
+ return;
1007
+ }
1008
+ final int phase = this.pendingNotifyAppReadyPhase;
1009
+ this.pendingNotifyAppReadyWait = false;
1010
+ this.pendingNotifyAppReadyPhase = -1;
1011
+ this.cleanupTimedOutSemaphoreWait(phase);
1012
+ }
1013
+
1014
+ private boolean consumePendingNotifyAppReadyWait(final int[] phaseOut) {
1015
+ if (!this.pendingNotifyAppReadyWait) {
1016
+ return false;
1017
+ }
1018
+ phaseOut[0] = this.pendingNotifyAppReadyPhase;
1019
+ this.pendingNotifyAppReadyWait = false;
1020
+ this.pendingNotifyAppReadyPhase = -1;
1021
+ return true;
1022
+ }
1023
+
954
1024
  protected long getMinimumPendingBundleAppReadyTimeoutMs() {
955
1025
  return PENDING_BUNDLE_APP_READY_MIN_TIMEOUT_MS;
956
1026
  }
@@ -989,19 +1059,40 @@ public class CapacitorUpdaterPlugin extends Plugin {
989
1059
 
990
1060
  private void sendReadyToJs(final BundleInfo current, final String msg, final boolean isDirectUpdate) {
991
1061
  logger.info("sendReadyToJs: " + msg);
992
- final JSObject ret = new JSObject();
993
- ret.put("bundle", InternalUtils.mapToJSObject(current.toJSONMap()));
994
- ret.put("status", msg);
1062
+ final int[] pendingPhase = new int[] { -1 };
1063
+ final boolean shouldWait = this.consumePendingNotifyAppReadyWait(pendingPhase);
995
1064
 
996
- // No need to wait for semaphore anymore since _reload() has already waited
997
- this.notifyListeners("appReady", ret, true);
1065
+ final Runnable emitReady = () -> {
1066
+ final JSObject ret = new JSObject();
1067
+ ret.put("bundle", InternalUtils.mapToJSObject(current.toJSONMap()));
1068
+ ret.put("status", msg);
998
1069
 
999
- // Auto hide splashscreen if enabled
1000
- // We show it on background when conditions are met, so we should hide it on foreground regardless of update outcome
1001
- if (this.autoSplashscreen) {
1002
- this.hideSplashscreen();
1070
+ this.notifyListeners("appReady", ret, true);
1071
+
1072
+ // Auto hide splashscreen if enabled
1073
+ // We show it on background when conditions are met, so we should hide it on foreground regardless of update outcome
1074
+ if (this.autoSplashscreen) {
1075
+ this.hideSplashscreen();
1076
+ }
1077
+ this.hidePreviewTransitionLoader("app-ready");
1078
+ };
1079
+
1080
+ if (!shouldWait) {
1081
+ emitReady.run();
1082
+ return;
1083
+ }
1084
+
1085
+ // Never block the UI thread waiting for notifyAppReady (JS needs it).
1086
+ if (Looper.myLooper() == Looper.getMainLooper()) {
1087
+ startNewThread(() -> {
1088
+ this.semaphoreWait(pendingPhase[0], this.appReadyTimeout);
1089
+ emitReady.run();
1090
+ });
1091
+ return;
1003
1092
  }
1004
- this.hidePreviewTransitionLoader("app-ready");
1093
+
1094
+ this.semaphoreWait(pendingPhase[0], this.appReadyTimeout);
1095
+ emitReady.run();
1005
1096
  }
1006
1097
 
1007
1098
  private void hideSplashscreen() {
@@ -1305,6 +1396,74 @@ public class CapacitorUpdaterPlugin extends Plugin {
1305
1396
  return false;
1306
1397
  }
1307
1398
 
1399
+ static boolean shouldClearPersistedDefaultChannel(
1400
+ final boolean persistDefaultChannelOnReinstall,
1401
+ final boolean resetWhenUpdate,
1402
+ final boolean nativeBuildVersionChanged,
1403
+ final boolean restoredReinstall
1404
+ ) {
1405
+ return !persistDefaultChannelOnReinstall && (restoredReinstall || (resetWhenUpdate && nativeBuildVersionChanged));
1406
+ }
1407
+
1408
+ static boolean clearPersistedDefaultChannel(final SharedPreferences.Editor editor) {
1409
+ editor.remove(DEFAULT_CHANNEL_PREF_KEY);
1410
+ editor.remove(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_PREF_KEY);
1411
+ editor.remove(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY);
1412
+ return editor.commit();
1413
+ }
1414
+
1415
+ private File defaultChannelInstallMarker() {
1416
+ return new File(this.getContext().getNoBackupFilesDir(), DEFAULT_CHANNEL_INSTALL_MARKER_FILE);
1417
+ }
1418
+
1419
+ static boolean invalidateDefaultChannelInstallMarker(final File marker) {
1420
+ return !marker.exists() || marker.delete();
1421
+ }
1422
+
1423
+ private boolean isRestoredReinstall() {
1424
+ return isRestoredReinstall(
1425
+ this.defaultChannelInstallMarker(),
1426
+ this.prefs.getBoolean(DEFAULT_CHANNEL_INSTALL_MARKER_PREF_KEY, false)
1427
+ );
1428
+ }
1429
+
1430
+ static boolean isRestoredReinstall(final File marker, final boolean markerWasCreated) {
1431
+ return markerWasCreated && !marker.exists();
1432
+ }
1433
+
1434
+ private void prepareDefaultChannelInstallMarker() {
1435
+ prepareDefaultChannelInstallMarker(
1436
+ this.defaultChannelInstallMarker(),
1437
+ this.prefs.getBoolean(DEFAULT_CHANNEL_INSTALL_MARKER_PREF_KEY, false),
1438
+ this.editor,
1439
+ this.logger
1440
+ );
1441
+ }
1442
+
1443
+ static void prepareDefaultChannelInstallMarker(
1444
+ final File marker,
1445
+ final boolean markerWasCreated,
1446
+ final SharedPreferences.Editor editor,
1447
+ final Logger logger
1448
+ ) {
1449
+ if (!marker.exists()) {
1450
+ try {
1451
+ if (!marker.createNewFile() && !marker.exists()) {
1452
+ throw new IOException("Marker file was not created");
1453
+ }
1454
+ } catch (final IOException e) {
1455
+ logger.warn("Cannot create default channel install marker: " + e.getMessage());
1456
+ editor.remove(DEFAULT_CHANNEL_INSTALL_MARKER_PREF_KEY);
1457
+ editor.commit();
1458
+ return;
1459
+ }
1460
+ }
1461
+ if (!markerWasCreated) {
1462
+ editor.putBoolean(DEFAULT_CHANNEL_INSTALL_MARKER_PREF_KEY, true);
1463
+ editor.apply();
1464
+ }
1465
+ }
1466
+
1308
1467
  private boolean hasNativeBuildVersionChanged() {
1309
1468
  final String lastKnownVersion = this.getStoredNativeBuildVersion();
1310
1469
  return !lastKnownVersion.isEmpty() && !lastKnownVersion.equals(this.currentBuildVersion);
@@ -2222,67 +2381,57 @@ public class CapacitorUpdaterPlugin extends Plugin {
2222
2381
  return false;
2223
2382
  }
2224
2383
 
2225
- private void cleanupObsoleteVersions() {
2384
+ private void cleanupObsoleteVersions(final boolean resetWhenUpdate) {
2385
+ // Latch created before start so waiters never race past an unstarted cleanup thread.
2386
+ final CountDownLatch latch = new CountDownLatch(1);
2387
+ this.cleanupComplete = false;
2388
+ this.cleanupLatch = latch;
2226
2389
  cleanupThread = startNewThread(() -> {
2227
- synchronized (cleanupLock) {
2228
- try {
2229
- final String previous = this.getStoredNativeBuildVersion();
2230
- if (!"".equals(previous) && !Objects.equals(this.currentBuildVersion, previous)) {
2231
- logger.info("New native build version detected: " + this.currentBuildVersion);
2232
- this.implementation.reset(true);
2233
- final List<BundleInfo> installed = this.implementation.list(false);
2234
- for (final BundleInfo bundle : installed) {
2235
- // Check if thread was interrupted (cancelled)
2236
- if (Thread.currentThread().isInterrupted()) {
2237
- logger.warn("Cleanup was cancelled, stopping");
2238
- return;
2239
- }
2240
- try {
2241
- logger.info("Deleting obsolete bundle: " + bundle.getId());
2242
- this.implementation.delete(bundle.getId());
2243
- } catch (final Exception e) {
2244
- logger.error("Failed to delete: " + bundle.getId() + " " + e.getMessage());
2245
- }
2246
- }
2247
- final List<BundleInfo> storedBundles = this.implementation.list(true);
2248
- final Set<String> allowedIds = new HashSet<>();
2249
- for (final BundleInfo info : storedBundles) {
2250
- if (info != null && info.getId() != null && !info.getId().isEmpty()) {
2251
- allowedIds.add(info.getId());
2390
+ try {
2391
+ synchronized (cleanupLock) {
2392
+ try {
2393
+ final String previous = this.getStoredNativeBuildVersion();
2394
+ final boolean nativeVersionChanged = !"".equals(previous) && !Objects.equals(this.currentBuildVersion, previous);
2395
+ if (resetWhenUpdate && nativeVersionChanged) {
2396
+ logger.info("New native build version detected: " + this.currentBuildVersion);
2397
+ this.implementation.reset(true);
2398
+ final List<BundleInfo> installed = this.implementation.list(false);
2399
+ for (final BundleInfo bundle : installed) {
2400
+ try {
2401
+ logger.info("Deleting obsolete bundle: " + bundle.getId());
2402
+ this.implementation.delete(bundle.getId());
2403
+ } catch (final Exception e) {
2404
+ logger.error("Failed to delete: " + bundle.getId() + " " + e.getMessage());
2405
+ }
2406
+ try {
2407
+ Thread.sleep(75L);
2408
+ } catch (final InterruptedException ie) {
2409
+ Thread.currentThread().interrupt();
2410
+ return;
2411
+ }
2252
2412
  }
2413
+ this.implementation.cleanupDeltaCache();
2253
2414
  }
2254
- this.implementation.cleanupDownloadDirectories(allowedIds, Thread.currentThread());
2255
- this.implementation.cleanupOrphanedTempFolders(Thread.currentThread());
2256
2415
 
2257
- // Check again before the expensive delta cache cleanup
2258
- if (Thread.currentThread().isInterrupted()) {
2259
- logger.warn("Cleanup was cancelled before delta cache cleanup");
2260
- return;
2261
- }
2262
- this.implementation.cleanupDeltaCache(Thread.currentThread());
2263
- }
2264
- this.editor.putString("LatestNativeBuildVersion", this.currentBuildVersion);
2265
- this.editor.apply();
2266
- } catch (Exception e) {
2267
- logger.error("Error during cleanupObsoleteVersions: " + e.getMessage());
2268
- } finally {
2269
- cleanupComplete = true;
2270
- logger.info("Cleanup complete");
2271
- }
2272
- }
2273
- });
2416
+ // Resume any DELETING leftovers from prior kills, one-by-one.
2417
+ this.implementation.drainPendingDeletes();
2274
2418
 
2275
- // Start a timeout watchdog thread to cancel cleanup if it takes too long
2276
- final long timeout = this.appReadyTimeout / 2;
2277
- startNewThread(() -> {
2278
- try {
2279
- Thread.sleep(timeout);
2280
- if (cleanupThread != null && cleanupThread.isAlive() && !cleanupComplete) {
2281
- logger.warn("Cleanup timeout exceeded (" + timeout + "ms), interrupting cleanup thread");
2282
- cleanupThread.interrupt();
2419
+ // Always sweep orphan folders so incomplete prior cleanups (or failed deletes)
2420
+ // cannot leave hundreds of MB behind across launches.
2421
+ final Set<String> allowedIds = this.implementation.allowedBundleIdsForCleanup();
2422
+ this.implementation.cleanupDownloadDirectories(allowedIds);
2423
+ this.implementation.cleanupOrphanedTempFolders(null);
2424
+
2425
+ this.persistCurrentNativeBuildVersion();
2426
+ } catch (Exception e) {
2427
+ logger.error("Error during cleanupObsoleteVersions: " + e.getMessage());
2428
+ } finally {
2429
+ cleanupComplete = true;
2430
+ logger.info("Cleanup complete");
2431
+ }
2283
2432
  }
2284
- } catch (InterruptedException e) {
2285
- // Watchdog thread was interrupted, that's fine
2433
+ } finally {
2434
+ latch.countDown();
2286
2435
  }
2287
2436
  });
2288
2437
  }
@@ -2296,6 +2445,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
2296
2445
  }
2297
2446
 
2298
2447
  void persistCurrentNativeBuildVersion() {
2448
+ if (this.defaultChannelCleanupMustRetry) {
2449
+ logger.warn("Keeping the previous native build version so default channel cleanup retries");
2450
+ return;
2451
+ }
2299
2452
  this.editor.putString("LatestNativeBuildVersion", this.currentBuildVersion);
2300
2453
  this.editor.apply();
2301
2454
  }
@@ -2306,11 +2459,14 @@ public class CapacitorUpdaterPlugin extends Plugin {
2306
2459
  }
2307
2460
 
2308
2461
  logger.info("Waiting for cleanup to complete before starting download...");
2309
-
2310
- // Wait for cleanup to complete - blocks until lock is released
2311
- synchronized (cleanupLock) {
2312
- logger.info("Cleanup finished, proceeding with download");
2462
+ try {
2463
+ this.cleanupLatch.await();
2464
+ } catch (final InterruptedException e) {
2465
+ Thread.currentThread().interrupt();
2466
+ logger.warn("Interrupted while waiting for cleanup");
2467
+ throw new IllegalStateException("Interrupted while waiting for cleanup");
2313
2468
  }
2469
+ logger.info("Cleanup finished, proceeding with download");
2314
2470
  }
2315
2471
 
2316
2472
  public void notifyDownload(final String id, final int percent) {
@@ -2648,6 +2804,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
2648
2804
  final String checksum,
2649
2805
  final JSONArray manifest
2650
2806
  ) throws IOException {
2807
+ // Manual/preview downloads must wait too — launch orphan sweep can delete their temps.
2808
+ waitForCleanupIfNeeded();
2651
2809
  if (manifest != null) {
2652
2810
  return this.implementation.downloadManifest(url, version, sessionKey, checksum, manifest);
2653
2811
  }
@@ -2821,6 +2979,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
2821
2979
  }
2822
2980
 
2823
2981
  protected boolean _reload() {
2982
+ // Drop any launch pending wait; this reload owns notifyAppReady synchronization.
2983
+ this.clearPendingNotifyAppReadyWait();
2824
2984
  final int phase = this.semaphoreUp();
2825
2985
  this.applyCurrentBundleToBridge();
2826
2986
 
@@ -4961,15 +5121,21 @@ public class CapacitorUpdaterPlugin extends Plugin {
4961
5121
  this.implementation.setError(current);
4962
5122
  this.performReset(true, false, true);
4963
5123
  if (CapacitorUpdaterPlugin.this.autoDeleteFailed && !current.isBuiltin()) {
4964
- logger.info("Deleting failing bundle: " + current.getVersionName());
4965
- try {
4966
- final Boolean res = this.implementation.delete(current.getId(), false);
4967
- if (res) {
4968
- logger.info("Failed bundle deleted: " + current.getVersionName());
5124
+ final String failedId = current.getId();
5125
+ final String failedVersion = current.getVersionName();
5126
+ logger.info("Deleting failing bundle: " + failedVersion);
5127
+ // Mark before async work so kill/OOM still resumes via drainPendingDeletes.
5128
+ CapacitorUpdaterPlugin.this.implementation.saveBundleInfo(failedId, current.setStatus(BundleStatus.DELETING));
5129
+ startNewThread(() -> {
5130
+ try {
5131
+ final Boolean res = CapacitorUpdaterPlugin.this.implementation.delete(failedId, false, false);
5132
+ if (Boolean.TRUE.equals(res)) {
5133
+ logger.info("Failed bundle deleted: " + failedVersion);
5134
+ }
5135
+ } catch (final IOException e) {
5136
+ logger.error("Failed to delete failed bundle: " + failedVersion + " " + e.getMessage());
4969
5137
  }
4970
- } catch (final IOException e) {
4971
- logger.error("Failed to delete failed bundle: " + current.getVersionName() + " " + e.getMessage());
4972
- }
5138
+ });
4973
5139
  }
4974
5140
  } else {
4975
5141
  logger.info("notifyAppReady was called. This is fine: " + current.getId());