@capgo/capacitor-updater 8.50.2 → 8.51.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -68,6 +68,7 @@ import java.util.Set;
68
68
  import java.util.TimeZone;
69
69
  import java.util.Timer;
70
70
  import java.util.TimerTask;
71
+ import java.util.concurrent.CountDownLatch;
71
72
  import java.util.concurrent.Phaser;
72
73
  import java.util.concurrent.Semaphore;
73
74
  import java.util.concurrent.TimeUnit;
@@ -142,7 +143,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
142
143
  static final int APPLICATION_EXIT_REASON_USER_REQUESTED = 10;
143
144
  static final int APPLICATION_EXIT_REASON_DEPENDENCY_DIED = 12;
144
145
 
145
- private final String pluginVersion = "8.50.2";
146
+ private final String pluginVersion = "8.51.1";
146
147
  private static final String DELAY_CONDITION_PREFERENCES = "";
147
148
 
148
149
  private SharedPreferences.Editor editor;
@@ -215,6 +216,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
215
216
 
216
217
  private volatile Thread backgroundDownloadTask;
217
218
  private volatile Thread appReadyCheck;
219
+ // When true, sendReadyToJs should wait for notifyAppReady before hiding splash.
220
+ private volatile boolean pendingNotifyAppReadyWait = false;
221
+ private volatile int pendingNotifyAppReadyPhase = -1;
218
222
  private volatile long downloadStartTimeMs = 0;
219
223
  private static final long DOWNLOAD_TIMEOUT_MS = 600000; // 10 minute timeout
220
224
 
@@ -227,6 +231,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
227
231
 
228
232
  // Lock to ensure cleanup completes before downloads start
229
233
  private final Object cleanupLock = new Object();
234
+ private volatile CountDownLatch cleanupLatch = new CountDownLatch(0);
230
235
  private volatile boolean cleanupComplete = false;
231
236
  private volatile Thread cleanupThread = null;
232
237
 
@@ -236,6 +241,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
236
241
 
237
242
  private ShakeMenu shakeMenu;
238
243
  private final Handler mainHandler = new Handler(Looper.getMainLooper());
244
+ private final long launchStartedAtMs = System.currentTimeMillis();
245
+ private volatile long webViewPageStartedAtMs = 0;
246
+ private volatile boolean launchStartReported = false;
247
+ private volatile boolean launchReadyReported = false;
239
248
  private FrameLayout splashscreenLoaderOverlay;
240
249
  private Runnable splashscreenTimeoutRunnable;
241
250
  private FrameLayout previewTransitionLoaderOverlay;
@@ -807,6 +816,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
807
816
  }
808
817
  logger.info("init for device " + this.implementation.deviceID);
809
818
  logger.info("version native " + this.currentVersionNative.getOriginalString());
819
+ this.reportAppLaunchStart();
810
820
  this.autoDeleteFailed = this.getConfig().getBoolean("autoDeleteFailed", true);
811
821
  this.autoDeletePrevious = this.getConfig().getBoolean("autoDeletePrevious", true);
812
822
  this.updateUrl = this.getConfig().getString("updateUrl", updateUrlDefault);
@@ -866,11 +876,14 @@ public class CapacitorUpdaterPlugin extends Plugin {
866
876
  this.reportPreviousAppExitReasons();
867
877
  this.reportPreviousWebViewRenderProcessGone();
868
878
  this.installWebViewStatsReporter();
869
- if (resetWhenUpdate) {
870
- this.cleanupObsoleteVersions();
871
- } else {
879
+ // Downloads (including shake-menu / CapgoUpdater entry points) wait on this gate.
880
+ this.implementation.downloadGate = this::waitForCleanupIfNeeded;
881
+ // Always run async cleanup: delete obsolete bundles on native update (when enabled)
882
+ // and sweep orphan folders every launch. Must not block app startup.
883
+ if (!resetWhenUpdate) {
872
884
  this.persistCurrentNativeBuildVersion();
873
885
  }
886
+ this.cleanupObsoleteVersions(resetWhenUpdate);
874
887
 
875
888
  // Check for 'kill' delay condition on app launch
876
889
  // This handles cases where the app was killed by the system (onDestroy is not reliable)
@@ -904,6 +917,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
904
917
  } else {
905
918
  logger.info("Using activity lifecycle callbacks for foreground/background detection (Android <14)");
906
919
  }
920
+
921
+ // Expect notifyAppReady before the first appReady/splash hide (same idea as iOS).
922
+ this.armPendingNotifyAppReadyWait();
907
923
  }
908
924
 
909
925
  private boolean semaphoreWait(final int phase, Number waitTime) {
@@ -946,6 +962,32 @@ public class CapacitorUpdaterPlugin extends Plugin {
946
962
  semaphoreReady.arriveAndDeregister();
947
963
  }
948
964
 
965
+ private void armPendingNotifyAppReadyWait() {
966
+ this.clearPendingNotifyAppReadyWait();
967
+ this.pendingNotifyAppReadyPhase = this.semaphoreUp();
968
+ this.pendingNotifyAppReadyWait = true;
969
+ }
970
+
971
+ private void clearPendingNotifyAppReadyWait() {
972
+ if (!this.pendingNotifyAppReadyWait) {
973
+ return;
974
+ }
975
+ final int phase = this.pendingNotifyAppReadyPhase;
976
+ this.pendingNotifyAppReadyWait = false;
977
+ this.pendingNotifyAppReadyPhase = -1;
978
+ this.cleanupTimedOutSemaphoreWait(phase);
979
+ }
980
+
981
+ private boolean consumePendingNotifyAppReadyWait(final int[] phaseOut) {
982
+ if (!this.pendingNotifyAppReadyWait) {
983
+ return false;
984
+ }
985
+ phaseOut[0] = this.pendingNotifyAppReadyPhase;
986
+ this.pendingNotifyAppReadyWait = false;
987
+ this.pendingNotifyAppReadyPhase = -1;
988
+ return true;
989
+ }
990
+
949
991
  protected long getMinimumPendingBundleAppReadyTimeoutMs() {
950
992
  return PENDING_BUNDLE_APP_READY_MIN_TIMEOUT_MS;
951
993
  }
@@ -984,19 +1026,40 @@ public class CapacitorUpdaterPlugin extends Plugin {
984
1026
 
985
1027
  private void sendReadyToJs(final BundleInfo current, final String msg, final boolean isDirectUpdate) {
986
1028
  logger.info("sendReadyToJs: " + msg);
987
- final JSObject ret = new JSObject();
988
- ret.put("bundle", InternalUtils.mapToJSObject(current.toJSONMap()));
989
- ret.put("status", msg);
1029
+ final int[] pendingPhase = new int[] { -1 };
1030
+ final boolean shouldWait = this.consumePendingNotifyAppReadyWait(pendingPhase);
990
1031
 
991
- // No need to wait for semaphore anymore since _reload() has already waited
992
- this.notifyListeners("appReady", ret, true);
1032
+ final Runnable emitReady = () -> {
1033
+ final JSObject ret = new JSObject();
1034
+ ret.put("bundle", InternalUtils.mapToJSObject(current.toJSONMap()));
1035
+ ret.put("status", msg);
993
1036
 
994
- // Auto hide splashscreen if enabled
995
- // We show it on background when conditions are met, so we should hide it on foreground regardless of update outcome
996
- if (this.autoSplashscreen) {
997
- this.hideSplashscreen();
1037
+ this.notifyListeners("appReady", ret, true);
1038
+
1039
+ // Auto hide splashscreen if enabled
1040
+ // We show it on background when conditions are met, so we should hide it on foreground regardless of update outcome
1041
+ if (this.autoSplashscreen) {
1042
+ this.hideSplashscreen();
1043
+ }
1044
+ this.hidePreviewTransitionLoader("app-ready");
1045
+ };
1046
+
1047
+ if (!shouldWait) {
1048
+ emitReady.run();
1049
+ return;
998
1050
  }
999
- this.hidePreviewTransitionLoader("app-ready");
1051
+
1052
+ // Never block the UI thread waiting for notifyAppReady (JS needs it).
1053
+ if (Looper.myLooper() == Looper.getMainLooper()) {
1054
+ startNewThread(() -> {
1055
+ this.semaphoreWait(pendingPhase[0], this.appReadyTimeout);
1056
+ emitReady.run();
1057
+ });
1058
+ return;
1059
+ }
1060
+
1061
+ this.semaphoreWait(pendingPhase[0], this.appReadyTimeout);
1062
+ emitReady.run();
1000
1063
  }
1001
1064
 
1002
1065
  private void hideSplashscreen() {
@@ -1486,26 +1549,39 @@ public class CapacitorUpdaterPlugin extends Plugin {
1486
1549
  this.webViewStatsListener = new WebViewListener() {
1487
1550
  @Override
1488
1551
  public void onPageStarted(final android.webkit.WebView view) {
1552
+ CapacitorUpdaterPlugin.this.webViewPageStartedAtMs = System.currentTimeMillis();
1489
1553
  CapacitorUpdaterPlugin.this.evaluateWebViewStatsReporterScript(view, script);
1490
1554
  }
1491
1555
 
1492
1556
  @Override
1493
1557
  public void onPageLoaded(final android.webkit.WebView view) {
1558
+ CapacitorUpdaterPlugin.this.reportWebViewPageLoaded(view);
1494
1559
  CapacitorUpdaterPlugin.this.evaluateWebViewStatsReporterScript(view, script);
1495
1560
  }
1496
-
1497
- @Override
1498
- public boolean onRenderProcessGone(final android.webkit.WebView view, final RenderProcessGoneDetail detail) {
1499
- final Map<String, String> metadata = CapacitorUpdaterPlugin.this.buildWebViewRenderProcessGoneMetadata(detail);
1500
- CapacitorUpdaterPlugin.this.persistPendingWebViewRenderProcessGone(metadata);
1501
- return false;
1502
- }
1503
1561
  };
1504
1562
 
1505
1563
  this.bridge.addWebViewListener(this.webViewStatsListener);
1564
+ // Keep RenderProcessGoneDetail off the Plugin class method table and off this
1565
+ // listener so Android < 8 (API 26) does not crash during plugin reflection.
1566
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1567
+ this.installWebViewRenderProcessGoneReporter();
1568
+ }
1506
1569
  this.evaluateWebViewStatsReporterScript(webView, script);
1507
1570
  }
1508
1571
 
1572
+ private void installWebViewRenderProcessGoneReporter() {
1573
+ this.bridge.addWebViewListener(
1574
+ new WebViewListener() {
1575
+ @Override
1576
+ public boolean onRenderProcessGone(final android.webkit.WebView view, final RenderProcessGoneDetail detail) {
1577
+ final Map<String, String> metadata = CapacitorUpdaterPlugin.this.buildWebViewRenderProcessGoneMetadata(detail);
1578
+ CapacitorUpdaterPlugin.this.persistPendingWebViewRenderProcessGone(metadata);
1579
+ return false;
1580
+ }
1581
+ }
1582
+ );
1583
+ }
1584
+
1509
1585
  private void installDocumentStartWebViewStatsReporter(final android.webkit.WebView webView, final String script) {
1510
1586
  try {
1511
1587
  final Class<?> webViewFeature = Class.forName("androidx.webkit.WebViewFeature");
@@ -1545,16 +1621,84 @@ public class CapacitorUpdaterPlugin extends Plugin {
1545
1621
  });
1546
1622
  }
1547
1623
 
1548
- private Map<String, String> buildWebViewRenderProcessGoneMetadata(final RenderProcessGoneDetail detail) {
1624
+ private void reportAppLaunchStart() {
1625
+ if (
1626
+ this.implementation == null ||
1627
+ this.implementation.statsUrl == null ||
1628
+ this.implementation.statsUrl.isEmpty() ||
1629
+ this.launchStartReported
1630
+ ) {
1631
+ return;
1632
+ }
1633
+
1634
+ this.launchStartReported = true;
1635
+ final BundleInfo current = this.implementation.getCurrentBundle();
1636
+ final Map<String, String> metadata = new HashMap<>();
1637
+ metadata.put("launch_started_at", Long.toString(this.launchStartedAtMs));
1638
+ metadata.put("source", "plugin_load");
1639
+ this.implementation.sendStats("app_launch_start", current == null ? "" : current.getVersionName(), "", metadata);
1640
+ }
1641
+
1642
+ private void reportAppLaunchReady(final BundleInfo bundle) {
1643
+ if (
1644
+ this.implementation == null ||
1645
+ this.implementation.statsUrl == null ||
1646
+ this.implementation.statsUrl.isEmpty() ||
1647
+ this.launchReadyReported
1648
+ ) {
1649
+ return;
1650
+ }
1651
+
1652
+ this.launchReadyReported = true;
1653
+ final Map<String, String> metadata = new HashMap<>();
1654
+ metadata.put("duration_ms", Long.toString(Math.max(0, System.currentTimeMillis() - this.launchStartedAtMs)));
1655
+ metadata.put("launch_started_at", Long.toString(this.launchStartedAtMs));
1656
+ metadata.put("source", "notify_app_ready");
1657
+ this.implementation.sendStats("app_launch_ready", bundle == null ? "" : bundle.getVersionName(), "", metadata);
1658
+ }
1659
+
1660
+ private void reportAppLaunchTimeout(final BundleInfo bundle) {
1661
+ if (this.implementation == null || this.implementation.statsUrl == null || this.implementation.statsUrl.isEmpty()) {
1662
+ return;
1663
+ }
1664
+
1665
+ final Map<String, String> metadata = new HashMap<>();
1666
+ metadata.put("duration_ms", Long.toString(Math.max(0, System.currentTimeMillis() - this.launchStartedAtMs)));
1667
+ metadata.put("launch_started_at", Long.toString(this.launchStartedAtMs));
1668
+ metadata.put("timeout_ms", Long.toString(this.appReadyTimeout));
1669
+ metadata.put("source", "app_ready_timeout");
1670
+ this.implementation.sendStats("app_launch_timeout", bundle == null ? "" : bundle.getVersionName(), "", metadata);
1671
+ }
1672
+
1673
+ private void reportWebViewPageLoaded(final android.webkit.WebView view) {
1674
+ if (this.implementation == null || this.implementation.statsUrl == null || this.implementation.statsUrl.isEmpty()) {
1675
+ return;
1676
+ }
1677
+
1678
+ final Map<String, String> metadata = new HashMap<>();
1679
+ metadata.put("source", "android_webview_listener");
1680
+ final long pageStartedAt = this.webViewPageStartedAtMs;
1681
+ if (pageStartedAt > 0) {
1682
+ metadata.put("duration_ms", Long.toString(Math.max(0, System.currentTimeMillis() - pageStartedAt)));
1683
+ metadata.put("page_started_at", Long.toString(pageStartedAt));
1684
+ }
1685
+ if (view != null && view.getUrl() != null && !view.getUrl().isEmpty()) {
1686
+ metadata.put("href", truncateStatsMetadataValue(sanitizeStatsMetadataUrl(view.getUrl()), 512));
1687
+ }
1688
+ this.reportWebViewStats("webview_page_loaded", metadata);
1689
+ }
1690
+
1691
+ private Map<String, String> buildWebViewRenderProcessGoneMetadata(final Object detailObj) {
1549
1692
  final Map<String, String> metadata = new HashMap<>();
1550
1693
  metadata.put("error_type", "render_process_gone");
1551
1694
  metadata.put("source", "android_on_render_process_gone");
1552
1695
  metadata.put("timestamp", Long.toString(System.currentTimeMillis()));
1553
- if (detail != null) {
1696
+ // Parameter typed as Object so Android < 8 ART does not resolve
1697
+ // RenderProcessGoneDetail while reflecting CapacitorUpdaterPlugin methods.
1698
+ if (detailObj != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1699
+ final RenderProcessGoneDetail detail = (RenderProcessGoneDetail) detailObj;
1554
1700
  metadata.put("did_crash", Boolean.toString(detail.didCrash()));
1555
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1556
- metadata.put("renderer_priority_at_exit", Integer.toString(detail.rendererPriorityAtExit()));
1557
- }
1701
+ metadata.put("renderer_priority_at_exit", Integer.toString(detail.rendererPriorityAtExit()));
1558
1702
  }
1559
1703
  return metadata;
1560
1704
  }
@@ -1652,6 +1796,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
1652
1796
  return "webview_render_process_gone";
1653
1797
  case "web_content_process_terminated":
1654
1798
  return "webview_content_process_terminated";
1799
+ case "webview_dom_content_loaded":
1800
+ return "webview_dom_content_loaded";
1801
+ case "webview_page_loaded":
1802
+ return "webview_page_loaded";
1655
1803
  case "javascript_error":
1656
1804
  default:
1657
1805
  return "webview_javascript_error";
@@ -1670,6 +1818,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
1670
1818
  putStatsMetadataValue(metadata, "href", sanitizeStatsMetadataUrl(data.optString("href", "")), 512);
1671
1819
  putStatsMetadataValue(metadata, "user_agent", data.optString("user_agent", ""), 256);
1672
1820
  putStatsMetadataValue(metadata, "session_id", data.optString("session_id", ""), 128);
1821
+ putStatsMetadataValue(metadata, "duration_ms", data.optString("duration_ms", ""), 32);
1822
+ putStatsMetadataValue(metadata, "page_started_at", data.optString("page_started_at", ""), 64);
1673
1823
  putStatsMetadataValue(metadata, "previous_session_id", data.optString("previous_session_id", ""), 128);
1674
1824
  putStatsMetadataValue(metadata, "previous_href", sanitizeStatsMetadataUrl(data.optString("previous_href", "")), 512);
1675
1825
  putStatsMetadataValue(metadata, "previous_started_at", data.optString("previous_started_at", ""), 64);
@@ -1802,12 +1952,14 @@ public class CapacitorUpdaterPlugin extends Plugin {
1802
1952
  "if(previous&&previous.active){send({type:'webview_unclean_restart',message:'WebView restarted without a clean page unload',previous_session_id:s(previous.id),previous_href:s(previous.href),previous_started_at:s(previous.started_at),previous_updated_at:s(previous.updated_at)});}" +
1803
1953
  "writeSession(true);" +
1804
1954
  "setInterval(function(){writeSession(true);},15000);" +
1955
+ "function pageDuration(){var started=Number(window.__capgoWebViewSessionStartedAt||Date.now());return String(Math.max(0,Date.now()-started));}" +
1805
1956
  "function markClean(){writeSession(false);}" +
1806
1957
  "window.addEventListener('pagehide',markClean,true);" +
1807
1958
  "window.addEventListener('beforeunload',markClean,true);" +
1808
1959
  "window.addEventListener('error',function(event){var target=event&&event.target;if(target&&target!==window&&(target.src||target.href)){send({type:'resource_error',message:'Resource failed to load',source:s(target.src||target.href),tag_name:s(target.tagName)});return;}send({type:'javascript_error',message:s((event&&event.message)||(event&&event.error)),source:s(event&&event.filename),line:s(event&&event.lineno),column:s(event&&event.colno),stack:stack(event&&event.error)});},true);" +
1809
1960
  "window.addEventListener('unhandledrejection',function(event){var reason=event&&event.reason;send({type:'unhandled_rejection',message:s(reason),stack:stack(reason)});},true);" +
1810
1961
  "document.addEventListener('securitypolicyviolation',function(event){send({type:'security_policy_violation',message:s(event&&event.violatedDirective),source:s(event&&event.blockedURI)});},true);" +
1962
+ "document.addEventListener('DOMContentLoaded',function(){send({type:'webview_dom_content_loaded',message:'WebView DOM content loaded',duration_ms:pageDuration(),page_started_at:String(window.__capgoWebViewSessionStartedAt)});},true);" +
1811
1963
  "document.addEventListener('deviceready',scheduleFlush,false);" +
1812
1964
  "setTimeout(scheduleFlush,0);" +
1813
1965
  "})();"
@@ -2128,67 +2280,58 @@ public class CapacitorUpdaterPlugin extends Plugin {
2128
2280
  return false;
2129
2281
  }
2130
2282
 
2131
- private void cleanupObsoleteVersions() {
2283
+ private void cleanupObsoleteVersions(final boolean resetWhenUpdate) {
2284
+ // Latch created before start so waiters never race past an unstarted cleanup thread.
2285
+ final CountDownLatch latch = new CountDownLatch(1);
2286
+ this.cleanupComplete = false;
2287
+ this.cleanupLatch = latch;
2132
2288
  cleanupThread = startNewThread(() -> {
2133
- synchronized (cleanupLock) {
2134
- try {
2135
- final String previous = this.getStoredNativeBuildVersion();
2136
- if (!"".equals(previous) && !Objects.equals(this.currentBuildVersion, previous)) {
2137
- logger.info("New native build version detected: " + this.currentBuildVersion);
2138
- this.implementation.reset(true);
2139
- final List<BundleInfo> installed = this.implementation.list(false);
2140
- for (final BundleInfo bundle : installed) {
2141
- // Check if thread was interrupted (cancelled)
2142
- if (Thread.currentThread().isInterrupted()) {
2143
- logger.warn("Cleanup was cancelled, stopping");
2144
- return;
2145
- }
2146
- try {
2147
- logger.info("Deleting obsolete bundle: " + bundle.getId());
2148
- this.implementation.delete(bundle.getId());
2149
- } catch (final Exception e) {
2150
- logger.error("Failed to delete: " + bundle.getId() + " " + e.getMessage());
2151
- }
2152
- }
2153
- final List<BundleInfo> storedBundles = this.implementation.list(true);
2154
- final Set<String> allowedIds = new HashSet<>();
2155
- for (final BundleInfo info : storedBundles) {
2156
- if (info != null && info.getId() != null && !info.getId().isEmpty()) {
2157
- allowedIds.add(info.getId());
2289
+ try {
2290
+ synchronized (cleanupLock) {
2291
+ try {
2292
+ final String previous = this.getStoredNativeBuildVersion();
2293
+ final boolean nativeVersionChanged = !"".equals(previous) && !Objects.equals(this.currentBuildVersion, previous);
2294
+ if (resetWhenUpdate && nativeVersionChanged) {
2295
+ logger.info("New native build version detected: " + this.currentBuildVersion);
2296
+ this.implementation.reset(true);
2297
+ final List<BundleInfo> installed = this.implementation.list(false);
2298
+ for (final BundleInfo bundle : installed) {
2299
+ try {
2300
+ logger.info("Deleting obsolete bundle: " + bundle.getId());
2301
+ this.implementation.delete(bundle.getId());
2302
+ } catch (final Exception e) {
2303
+ logger.error("Failed to delete: " + bundle.getId() + " " + e.getMessage());
2304
+ }
2305
+ try {
2306
+ Thread.sleep(75L);
2307
+ } catch (final InterruptedException ie) {
2308
+ Thread.currentThread().interrupt();
2309
+ return;
2310
+ }
2158
2311
  }
2312
+ this.implementation.cleanupDeltaCache();
2159
2313
  }
2160
- this.implementation.cleanupDownloadDirectories(allowedIds, Thread.currentThread());
2161
- this.implementation.cleanupOrphanedTempFolders(Thread.currentThread());
2162
2314
 
2163
- // Check again before the expensive delta cache cleanup
2164
- if (Thread.currentThread().isInterrupted()) {
2165
- logger.warn("Cleanup was cancelled before delta cache cleanup");
2166
- return;
2167
- }
2168
- this.implementation.cleanupDeltaCache(Thread.currentThread());
2169
- }
2170
- this.editor.putString("LatestNativeBuildVersion", this.currentBuildVersion);
2171
- this.editor.apply();
2172
- } catch (Exception e) {
2173
- logger.error("Error during cleanupObsoleteVersions: " + e.getMessage());
2174
- } finally {
2175
- cleanupComplete = true;
2176
- logger.info("Cleanup complete");
2177
- }
2178
- }
2179
- });
2315
+ // Resume any DELETING leftovers from prior kills, one-by-one.
2316
+ this.implementation.drainPendingDeletes();
2180
2317
 
2181
- // Start a timeout watchdog thread to cancel cleanup if it takes too long
2182
- final long timeout = this.appReadyTimeout / 2;
2183
- startNewThread(() -> {
2184
- try {
2185
- Thread.sleep(timeout);
2186
- if (cleanupThread != null && cleanupThread.isAlive() && !cleanupComplete) {
2187
- logger.warn("Cleanup timeout exceeded (" + timeout + "ms), interrupting cleanup thread");
2188
- cleanupThread.interrupt();
2318
+ // Always sweep orphan folders so incomplete prior cleanups (or failed deletes)
2319
+ // cannot leave hundreds of MB behind across launches.
2320
+ final Set<String> allowedIds = this.implementation.allowedBundleIdsForCleanup();
2321
+ this.implementation.cleanupDownloadDirectories(allowedIds);
2322
+ this.implementation.cleanupOrphanedTempFolders(null);
2323
+
2324
+ this.editor.putString("LatestNativeBuildVersion", this.currentBuildVersion);
2325
+ this.editor.apply();
2326
+ } catch (Exception e) {
2327
+ logger.error("Error during cleanupObsoleteVersions: " + e.getMessage());
2328
+ } finally {
2329
+ cleanupComplete = true;
2330
+ logger.info("Cleanup complete");
2331
+ }
2189
2332
  }
2190
- } catch (InterruptedException e) {
2191
- // Watchdog thread was interrupted, that's fine
2333
+ } finally {
2334
+ latch.countDown();
2192
2335
  }
2193
2336
  });
2194
2337
  }
@@ -2212,11 +2355,14 @@ public class CapacitorUpdaterPlugin extends Plugin {
2212
2355
  }
2213
2356
 
2214
2357
  logger.info("Waiting for cleanup to complete before starting download...");
2215
-
2216
- // Wait for cleanup to complete - blocks until lock is released
2217
- synchronized (cleanupLock) {
2218
- logger.info("Cleanup finished, proceeding with download");
2358
+ try {
2359
+ this.cleanupLatch.await();
2360
+ } catch (final InterruptedException e) {
2361
+ Thread.currentThread().interrupt();
2362
+ logger.warn("Interrupted while waiting for cleanup");
2363
+ throw new IllegalStateException("Interrupted while waiting for cleanup");
2219
2364
  }
2365
+ logger.info("Cleanup finished, proceeding with download");
2220
2366
  }
2221
2367
 
2222
2368
  public void notifyDownload(final String id, final int percent) {
@@ -2554,6 +2700,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
2554
2700
  final String checksum,
2555
2701
  final JSONArray manifest
2556
2702
  ) throws IOException {
2703
+ // Manual/preview downloads must wait too — launch orphan sweep can delete their temps.
2704
+ waitForCleanupIfNeeded();
2557
2705
  if (manifest != null) {
2558
2706
  return this.implementation.downloadManifest(url, version, sessionKey, checksum, manifest);
2559
2707
  }
@@ -2727,6 +2875,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
2727
2875
  }
2728
2876
 
2729
2877
  protected boolean _reload() {
2878
+ // Drop any launch pending wait; this reload owns notifyAppReady synchronization.
2879
+ this.clearPendingNotifyAppReadyWait();
2730
2880
  final int phase = this.semaphoreUp();
2731
2881
  this.applyCurrentBundleToBridge();
2732
2882
 
@@ -4187,6 +4337,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
4187
4337
  try {
4188
4338
  final BundleInfo bundle = this.implementation.getCurrentBundle();
4189
4339
  this.implementation.setSuccess(bundle, this.autoDeletePrevious);
4340
+ this.reportAppLaunchReady(bundle);
4190
4341
  logger.info("Current bundle loaded successfully. ['notifyAppReady()' was called] " + bundle);
4191
4342
  logger.info("semaphoreReady countDown");
4192
4343
  this.semaphoreDown();
@@ -4861,19 +5012,26 @@ public class CapacitorUpdaterPlugin extends Plugin {
4861
5012
  ret.put("bundle", InternalUtils.mapToJSObject(current.toJSONMap()));
4862
5013
  this.persistLastFailedBundle(current);
4863
5014
  this.notifyListeners("updateFailed", ret);
5015
+ this.reportAppLaunchTimeout(current);
4864
5016
  this.implementation.sendStats("update_fail", current.getVersionName());
4865
5017
  this.implementation.setError(current);
4866
5018
  this.performReset(true, false, true);
4867
5019
  if (CapacitorUpdaterPlugin.this.autoDeleteFailed && !current.isBuiltin()) {
4868
- logger.info("Deleting failing bundle: " + current.getVersionName());
4869
- try {
4870
- final Boolean res = this.implementation.delete(current.getId(), false);
4871
- if (res) {
4872
- logger.info("Failed bundle deleted: " + current.getVersionName());
5020
+ final String failedId = current.getId();
5021
+ final String failedVersion = current.getVersionName();
5022
+ logger.info("Deleting failing bundle: " + failedVersion);
5023
+ // Mark before async work so kill/OOM still resumes via drainPendingDeletes.
5024
+ CapacitorUpdaterPlugin.this.implementation.saveBundleInfo(failedId, current.setStatus(BundleStatus.DELETING));
5025
+ startNewThread(() -> {
5026
+ try {
5027
+ final Boolean res = CapacitorUpdaterPlugin.this.implementation.delete(failedId, false, false);
5028
+ if (Boolean.TRUE.equals(res)) {
5029
+ logger.info("Failed bundle deleted: " + failedVersion);
5030
+ }
5031
+ } catch (final IOException e) {
5032
+ logger.error("Failed to delete failed bundle: " + failedVersion + " " + e.getMessage());
4873
5033
  }
4874
- } catch (final IOException e) {
4875
- logger.error("Failed to delete failed bundle: " + current.getVersionName() + " " + e.getMessage());
4876
- }
5034
+ });
4877
5035
  }
4878
5036
  } else {
4879
5037
  logger.info("notifyAppReady was called. This is fine: " + current.getId());