@capgo/capacitor-updater 5.50.1 → 5.51.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/CapgoCapacitorUpdater.podspec +1 -1
  2. package/Package.swift +3 -2
  3. package/README.md +53 -48
  4. package/android/build.gradle +1 -0
  5. package/android/src/main/java/ee/forgr/capacitor_updater/AppLifecycleObserver.java +29 -2
  6. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +7 -3
  7. package/android/src/main/java/ee/forgr/capacitor_updater/BundleStatus.java +1 -0
  8. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +476 -151
  9. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1354 -412
  10. package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +102 -31
  11. package/android/src/main/java/ee/forgr/capacitor_updater/DataManager.java +23 -7
  12. package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +2 -2
  13. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +540 -224
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +103 -3
  15. package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +1 -1
  16. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +131 -145
  17. package/dist/docs.json +32 -8
  18. package/dist/esm/definitions.d.ts +41 -17
  19. package/dist/esm/definitions.js.map +1 -1
  20. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +124 -0
  21. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +9 -1
  22. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +3 -0
  23. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +787 -92
  24. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1014 -268
  25. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +49 -31
  26. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +44 -20
  27. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +28 -0
  28. package/package.json +12 -7
@@ -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 = "5.50.1";
149
+ private final String pluginVersion = "5.51.15";
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
 
@@ -236,6 +246,12 @@ public class CapacitorUpdaterPlugin extends Plugin {
236
246
 
237
247
  private ShakeMenu shakeMenu;
238
248
  private final Handler mainHandler = new Handler(Looper.getMainLooper());
249
+ private final long launchStartedAtMs = System.currentTimeMillis();
250
+ private volatile long webViewPageStartedAtMs = 0;
251
+ private volatile boolean launchStartReported = false;
252
+ private volatile boolean launchReadyReported = false;
253
+ private volatile boolean launchTimeoutReported = false;
254
+ private final Object launchReportLock = new Object();
239
255
  private FrameLayout splashscreenLoaderOverlay;
240
256
  private Runnable splashscreenTimeoutRunnable;
241
257
  private FrameLayout previewTransitionLoaderOverlay;
@@ -268,6 +284,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
268
284
  // App lifecycle observer using ProcessLifecycleOwner for reliable foreground/background detection
269
285
  private AppLifecycleObserver appLifecycleObserver;
270
286
 
287
+ private boolean isProcessLifecycleObserverActive() {
288
+ return this.appLifecycleObserver != null && this.appLifecycleObserver.isRegistered();
289
+ }
290
+
271
291
  // Play Store In-App Updates
272
292
  private AppUpdateManager appUpdateManager;
273
293
  private AppUpdateInfo cachedAppUpdateInfo;
@@ -747,6 +767,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
747
767
 
748
768
  this.persistCustomId = this.getConfig().getBoolean("persistCustomId", false);
749
769
  this.persistModifyUrl = this.getConfig().getBoolean("persistModifyUrl", false);
770
+ this.persistDefaultChannelOnReinstall = this.getConfig().getBoolean("persistDefaultChannelOnReinstall", true);
750
771
  this.allowSetDefaultChannel = this.getConfig().getBoolean("allowSetDefaultChannel", true);
751
772
  this.implementation.setPublicKey(this.getConfig().getString("publicKey", ""));
752
773
  // Log public key prefix if encryption is enabled
@@ -773,6 +794,35 @@ public class CapacitorUpdaterPlugin extends Plugin {
773
794
  }
774
795
  }
775
796
 
797
+ final boolean resetWhenUpdate = this.getConfig().getBoolean("resetWhenUpdate", true);
798
+ final boolean nativeBuildVersionChanged = this.hasNativeBuildVersionChanged();
799
+ final boolean defaultChannelPersistenceDisabled = !Boolean.TRUE.equals(this.persistDefaultChannelOnReinstall);
800
+ final boolean restoredReinstall = defaultChannelPersistenceDisabled && this.isRestoredReinstall();
801
+ boolean installMarkerCanBePrepared = true;
802
+ if (
803
+ shouldClearPersistedDefaultChannel(
804
+ Boolean.TRUE.equals(this.persistDefaultChannelOnReinstall),
805
+ resetWhenUpdate,
806
+ nativeBuildVersionChanged,
807
+ restoredReinstall
808
+ )
809
+ ) {
810
+ installMarkerCanBePrepared = clearPersistedDefaultChannel(this.editor);
811
+ if (installMarkerCanBePrepared) {
812
+ logger.info("Cleared persisted defaultChannel because reinstall persistence is disabled");
813
+ } else {
814
+ logger.warn("Cannot durably clear persisted defaultChannel");
815
+ this.defaultChannelCleanupMustRetry = true;
816
+ if (!invalidateDefaultChannelInstallMarker(this.defaultChannelInstallMarker())) {
817
+ logger.warn("Cannot invalidate default channel install marker for cleanup retry");
818
+ }
819
+ }
820
+ }
821
+ if (defaultChannelPersistenceDisabled && installMarkerCanBePrepared) {
822
+ this.prepareDefaultChannelInstallMarker();
823
+ }
824
+
825
+ final String configDefaultChannel = this.getConfig().getString("defaultChannel", "");
776
826
  // Load defaultChannel: first try from persistent storage (set via setChannel), then fall back to config
777
827
  if (this.prefs.contains(DEFAULT_CHANNEL_PREF_KEY)) {
778
828
  final String storedDefaultChannel = this.prefs.getString(DEFAULT_CHANNEL_PREF_KEY, "");
@@ -780,20 +830,22 @@ public class CapacitorUpdaterPlugin extends Plugin {
780
830
  this.implementation.defaultChannel = storedDefaultChannel;
781
831
  logger.info("Loaded persisted defaultChannel from setChannel()");
782
832
  } else {
783
- this.implementation.defaultChannel = this.getConfig().getString("defaultChannel", "");
833
+ this.implementation.defaultChannel = configDefaultChannel;
784
834
  }
785
835
  } else {
786
- this.implementation.defaultChannel = this.getConfig().getString("defaultChannel", "");
836
+ this.implementation.defaultChannel = configDefaultChannel;
787
837
  }
788
838
 
789
839
  this.periodCheckDelay = normalizedPeriodCheckDelayMs(this.getConfig().getInt("periodCheckDelay", 0));
790
840
 
791
841
  this.implementation.documentsDir = this.getContext().getFilesDir();
842
+ this.implementation.noBackupDir = this.getContext().getNoBackupFilesDir();
792
843
  this.implementation.prefs = this.prefs;
793
844
  this.implementation.editor = this.editor;
794
845
  this.implementation.versionOs = Build.VERSION.RELEASE;
795
846
  // Use DeviceIdHelper to get or create device ID that persists across reinstalls
796
847
  this.implementation.deviceID = DeviceIdHelper.getOrCreateDeviceId(this.getContext(), this.prefs);
848
+ this.implementation.restorePendingStats();
797
849
 
798
850
  // Update User-Agent for shared OkHttpClient with OS version
799
851
  DownloadService.updateUserAgent(this.implementation.appId, this.pluginVersion, this.implementation.versionOs);
@@ -807,6 +859,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
807
859
  }
808
860
  logger.info("init for device " + this.implementation.deviceID);
809
861
  logger.info("version native " + this.currentVersionNative.getOriginalString());
862
+ this.reportAppLaunchStart();
810
863
  this.autoDeleteFailed = this.getConfig().getBoolean("autoDeleteFailed", true);
811
864
  this.autoDeletePrevious = this.getConfig().getBoolean("autoDeletePrevious", true);
812
865
  this.updateUrl = this.getConfig().getString("updateUrl", updateUrlDefault);
@@ -828,7 +881,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
828
881
  this.autoSplashscreenLoader = this.getConfig().getBoolean("autoSplashscreenLoader", false);
829
882
  int splashscreenTimeoutValue = this.getConfig().getInt("autoSplashscreenTimeout", 10000);
830
883
  this.autoSplashscreenTimeout = Math.max(0, splashscreenTimeoutValue);
831
- this.implementation.timeout = this.getConfig().getInt("responseTimeout", 20) * 1000;
884
+ int responseTimeoutSeconds = this.getConfig().getInt("responseTimeout", 20);
885
+ long responseTimeoutMillis = responseTimeoutSeconds > 0 ? (long) responseTimeoutSeconds * 1000L : 20_000L;
886
+ this.implementation.timeout = (int) Math.min(Integer.MAX_VALUE, responseTimeoutMillis);
887
+ DownloadService.applyHttpTimeouts(this.implementation.timeout);
832
888
  this.shakeMenuEnabled = this.getConfig().getBoolean("shakeMenu", false);
833
889
  this.shakeChannelSelectorEnabled = this.getConfig().getBoolean("allowShakeChannelSelector", false);
834
890
  this.shakeMenuGesture = normalizedShakeMenuGesture(this.getConfig().getString("shakeMenuGesture", SHAKE_MENU_GESTURE_SHAKE));
@@ -851,11 +907,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
851
907
  ? this.prefs.getBoolean(PREVIEW_PREVIOUS_SHAKE_CHANNEL_SELECTOR_PREF_KEY, false)
852
908
  : this.shakeChannelSelectorEnabled;
853
909
  }
854
- boolean resetWhenUpdate = this.getConfig().getBoolean("resetWhenUpdate", true);
855
910
 
856
911
  // Check if app was recently installed/updated BEFORE cleanupObsoleteVersions updates LatestVersionNative
857
912
  this.wasRecentlyInstalledOrUpdated = this.checkIfRecentlyInstalledOrUpdated();
858
- final boolean nativeBuildVersionChanged = this.hasNativeBuildVersionChanged();
859
913
 
860
914
  this.implementation.autoReset(this.currentBuildVersion, resetWhenUpdate);
861
915
  if (nativeBuildVersionChanged) {
@@ -866,11 +920,14 @@ public class CapacitorUpdaterPlugin extends Plugin {
866
920
  this.reportPreviousAppExitReasons();
867
921
  this.reportPreviousWebViewRenderProcessGone();
868
922
  this.installWebViewStatsReporter();
869
- if (resetWhenUpdate) {
870
- this.cleanupObsoleteVersions();
871
- } else {
923
+ // Downloads (including shake-menu / CapgoUpdater entry points) wait on this gate.
924
+ this.implementation.downloadGate = this::waitForCleanupIfNeeded;
925
+ // Always run async cleanup: delete obsolete bundles on native update (when enabled)
926
+ // and sweep orphan folders every launch. Must not block app startup.
927
+ if (!resetWhenUpdate) {
872
928
  this.persistCurrentNativeBuildVersion();
873
929
  }
930
+ this.cleanupObsoleteVersions(resetWhenUpdate);
874
931
 
875
932
  // Check for 'kill' delay condition on app launch
876
933
  // This handles cases where the app was killed by the system (onDestroy is not reliable)
@@ -899,11 +956,18 @@ public class CapacitorUpdaterPlugin extends Plugin {
899
956
  },
900
957
  logger
901
958
  );
902
- this.appLifecycleObserver.register();
903
- logger.info("Using ProcessLifecycleOwner for foreground/background detection (Android 14+)");
959
+ this.appLifecycleObserver.register(this.getContext());
960
+ if (!this.appLifecycleObserver.isRegistered()) {
961
+ logger.warn("ProcessLifecycleOwner unavailable; using activity lifecycle callbacks");
962
+ } else {
963
+ logger.info("Using ProcessLifecycleOwner for foreground/background detection (Android 14+)");
964
+ }
904
965
  } else {
905
966
  logger.info("Using activity lifecycle callbacks for foreground/background detection (Android <14)");
906
967
  }
968
+
969
+ // Expect notifyAppReady before the first appReady/splash hide (same idea as iOS).
970
+ this.armPendingNotifyAppReadyWait();
907
971
  }
908
972
 
909
973
  private boolean semaphoreWait(final int phase, Number waitTime) {
@@ -946,6 +1010,32 @@ public class CapacitorUpdaterPlugin extends Plugin {
946
1010
  semaphoreReady.arriveAndDeregister();
947
1011
  }
948
1012
 
1013
+ private void armPendingNotifyAppReadyWait() {
1014
+ this.clearPendingNotifyAppReadyWait();
1015
+ this.pendingNotifyAppReadyPhase = this.semaphoreUp();
1016
+ this.pendingNotifyAppReadyWait = true;
1017
+ }
1018
+
1019
+ private void clearPendingNotifyAppReadyWait() {
1020
+ if (!this.pendingNotifyAppReadyWait) {
1021
+ return;
1022
+ }
1023
+ final int phase = this.pendingNotifyAppReadyPhase;
1024
+ this.pendingNotifyAppReadyWait = false;
1025
+ this.pendingNotifyAppReadyPhase = -1;
1026
+ this.cleanupTimedOutSemaphoreWait(phase);
1027
+ }
1028
+
1029
+ private boolean consumePendingNotifyAppReadyWait(final int[] phaseOut) {
1030
+ if (!this.pendingNotifyAppReadyWait) {
1031
+ return false;
1032
+ }
1033
+ phaseOut[0] = this.pendingNotifyAppReadyPhase;
1034
+ this.pendingNotifyAppReadyWait = false;
1035
+ this.pendingNotifyAppReadyPhase = -1;
1036
+ return true;
1037
+ }
1038
+
949
1039
  protected long getMinimumPendingBundleAppReadyTimeoutMs() {
950
1040
  return PENDING_BUNDLE_APP_READY_MIN_TIMEOUT_MS;
951
1041
  }
@@ -984,19 +1074,40 @@ public class CapacitorUpdaterPlugin extends Plugin {
984
1074
 
985
1075
  private void sendReadyToJs(final BundleInfo current, final String msg, final boolean isDirectUpdate) {
986
1076
  logger.info("sendReadyToJs: " + msg);
987
- final JSObject ret = new JSObject();
988
- ret.put("bundle", InternalUtils.mapToJSObject(current.toJSONMap()));
989
- ret.put("status", msg);
1077
+ final int[] pendingPhase = new int[] { -1 };
1078
+ final boolean shouldWait = this.consumePendingNotifyAppReadyWait(pendingPhase);
990
1079
 
991
- // No need to wait for semaphore anymore since _reload() has already waited
992
- this.notifyListeners("appReady", ret, true);
1080
+ final Runnable emitReady = () -> {
1081
+ final JSObject ret = new JSObject();
1082
+ ret.put("bundle", InternalUtils.mapToJSObject(current.toJSONMap()));
1083
+ ret.put("status", msg);
993
1084
 
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();
1085
+ this.notifyListeners("appReady", ret, true);
1086
+
1087
+ // Auto hide splashscreen if enabled
1088
+ // We show it on background when conditions are met, so we should hide it on foreground regardless of update outcome
1089
+ if (this.autoSplashscreen) {
1090
+ this.hideSplashscreen();
1091
+ }
1092
+ this.hidePreviewTransitionLoader("app-ready");
1093
+ };
1094
+
1095
+ if (!shouldWait) {
1096
+ emitReady.run();
1097
+ return;
1098
+ }
1099
+
1100
+ // Never block the UI thread waiting for notifyAppReady (JS needs it).
1101
+ if (Looper.myLooper() == Looper.getMainLooper()) {
1102
+ startNewThread(() -> {
1103
+ this.semaphoreWait(pendingPhase[0], this.appReadyTimeout);
1104
+ emitReady.run();
1105
+ });
1106
+ return;
998
1107
  }
999
- this.hidePreviewTransitionLoader("app-ready");
1108
+
1109
+ this.semaphoreWait(pendingPhase[0], this.appReadyTimeout);
1110
+ emitReady.run();
1000
1111
  }
1001
1112
 
1002
1113
  private void hideSplashscreen() {
@@ -1300,6 +1411,74 @@ public class CapacitorUpdaterPlugin extends Plugin {
1300
1411
  return false;
1301
1412
  }
1302
1413
 
1414
+ static boolean shouldClearPersistedDefaultChannel(
1415
+ final boolean persistDefaultChannelOnReinstall,
1416
+ final boolean resetWhenUpdate,
1417
+ final boolean nativeBuildVersionChanged,
1418
+ final boolean restoredReinstall
1419
+ ) {
1420
+ return !persistDefaultChannelOnReinstall && (restoredReinstall || (resetWhenUpdate && nativeBuildVersionChanged));
1421
+ }
1422
+
1423
+ static boolean clearPersistedDefaultChannel(final SharedPreferences.Editor editor) {
1424
+ editor.remove(DEFAULT_CHANNEL_PREF_KEY);
1425
+ editor.remove(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_PREF_KEY);
1426
+ editor.remove(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY);
1427
+ return editor.commit();
1428
+ }
1429
+
1430
+ private File defaultChannelInstallMarker() {
1431
+ return new File(this.getContext().getNoBackupFilesDir(), DEFAULT_CHANNEL_INSTALL_MARKER_FILE);
1432
+ }
1433
+
1434
+ static boolean invalidateDefaultChannelInstallMarker(final File marker) {
1435
+ return !marker.exists() || marker.delete();
1436
+ }
1437
+
1438
+ private boolean isRestoredReinstall() {
1439
+ return isRestoredReinstall(
1440
+ this.defaultChannelInstallMarker(),
1441
+ this.prefs.getBoolean(DEFAULT_CHANNEL_INSTALL_MARKER_PREF_KEY, false)
1442
+ );
1443
+ }
1444
+
1445
+ static boolean isRestoredReinstall(final File marker, final boolean markerWasCreated) {
1446
+ return markerWasCreated && !marker.exists();
1447
+ }
1448
+
1449
+ private void prepareDefaultChannelInstallMarker() {
1450
+ prepareDefaultChannelInstallMarker(
1451
+ this.defaultChannelInstallMarker(),
1452
+ this.prefs.getBoolean(DEFAULT_CHANNEL_INSTALL_MARKER_PREF_KEY, false),
1453
+ this.editor,
1454
+ this.logger
1455
+ );
1456
+ }
1457
+
1458
+ static void prepareDefaultChannelInstallMarker(
1459
+ final File marker,
1460
+ final boolean markerWasCreated,
1461
+ final SharedPreferences.Editor editor,
1462
+ final Logger logger
1463
+ ) {
1464
+ if (!marker.exists()) {
1465
+ try {
1466
+ if (!marker.createNewFile() && !marker.exists()) {
1467
+ throw new IOException("Marker file was not created");
1468
+ }
1469
+ } catch (final IOException e) {
1470
+ logger.warn("Cannot create default channel install marker: " + e.getMessage());
1471
+ editor.remove(DEFAULT_CHANNEL_INSTALL_MARKER_PREF_KEY);
1472
+ editor.commit();
1473
+ return;
1474
+ }
1475
+ }
1476
+ if (!markerWasCreated) {
1477
+ editor.putBoolean(DEFAULT_CHANNEL_INSTALL_MARKER_PREF_KEY, true);
1478
+ editor.apply();
1479
+ }
1480
+ }
1481
+
1303
1482
  private boolean hasNativeBuildVersionChanged() {
1304
1483
  final String lastKnownVersion = this.getStoredNativeBuildVersion();
1305
1484
  return !lastKnownVersion.isEmpty() && !lastKnownVersion.equals(this.currentBuildVersion);
@@ -1486,26 +1665,39 @@ public class CapacitorUpdaterPlugin extends Plugin {
1486
1665
  this.webViewStatsListener = new WebViewListener() {
1487
1666
  @Override
1488
1667
  public void onPageStarted(final android.webkit.WebView view) {
1668
+ CapacitorUpdaterPlugin.this.webViewPageStartedAtMs = System.currentTimeMillis();
1489
1669
  CapacitorUpdaterPlugin.this.evaluateWebViewStatsReporterScript(view, script);
1490
1670
  }
1491
1671
 
1492
1672
  @Override
1493
1673
  public void onPageLoaded(final android.webkit.WebView view) {
1674
+ CapacitorUpdaterPlugin.this.reportWebViewPageLoaded(view);
1494
1675
  CapacitorUpdaterPlugin.this.evaluateWebViewStatsReporterScript(view, script);
1495
1676
  }
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
1677
  };
1504
1678
 
1505
1679
  this.bridge.addWebViewListener(this.webViewStatsListener);
1680
+ // Keep RenderProcessGoneDetail off the Plugin class method table and off this
1681
+ // listener so Android < 8 (API 26) does not crash during plugin reflection.
1682
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1683
+ this.installWebViewRenderProcessGoneReporter();
1684
+ }
1506
1685
  this.evaluateWebViewStatsReporterScript(webView, script);
1507
1686
  }
1508
1687
 
1688
+ private void installWebViewRenderProcessGoneReporter() {
1689
+ this.bridge.addWebViewListener(
1690
+ new WebViewListener() {
1691
+ @Override
1692
+ public boolean onRenderProcessGone(final android.webkit.WebView view, final RenderProcessGoneDetail detail) {
1693
+ final Map<String, String> metadata = CapacitorUpdaterPlugin.this.buildWebViewRenderProcessGoneMetadata(detail);
1694
+ CapacitorUpdaterPlugin.this.persistPendingWebViewRenderProcessGone(metadata);
1695
+ return false;
1696
+ }
1697
+ }
1698
+ );
1699
+ }
1700
+
1509
1701
  private void installDocumentStartWebViewStatsReporter(final android.webkit.WebView webView, final String script) {
1510
1702
  try {
1511
1703
  final Class<?> webViewFeature = Class.forName("androidx.webkit.WebViewFeature");
@@ -1545,16 +1737,94 @@ public class CapacitorUpdaterPlugin extends Plugin {
1545
1737
  });
1546
1738
  }
1547
1739
 
1548
- private Map<String, String> buildWebViewRenderProcessGoneMetadata(final RenderProcessGoneDetail detail) {
1740
+ private void reportAppLaunchStart() {
1741
+ if (
1742
+ this.implementation == null ||
1743
+ this.implementation.statsUrl == null ||
1744
+ this.implementation.statsUrl.isEmpty() ||
1745
+ this.launchStartReported
1746
+ ) {
1747
+ return;
1748
+ }
1749
+
1750
+ this.launchStartReported = true;
1751
+ final BundleInfo current = this.implementation.getCurrentBundle();
1752
+ final Map<String, String> metadata = new HashMap<>();
1753
+ metadata.put("launch_started_at", Long.toString(this.launchStartedAtMs));
1754
+ metadata.put("source", "plugin_load");
1755
+ this.implementation.sendStats("app_launch_start", current == null ? "" : current.getVersionName(), "", metadata);
1756
+ }
1757
+
1758
+ private void reportAppLaunchReady(final BundleInfo bundle) {
1759
+ synchronized (this.launchReportLock) {
1760
+ if (
1761
+ this.implementation == null ||
1762
+ this.implementation.statsUrl == null ||
1763
+ this.implementation.statsUrl.isEmpty() ||
1764
+ this.launchReadyReported ||
1765
+ this.launchTimeoutReported
1766
+ ) {
1767
+ return;
1768
+ }
1769
+ this.launchReadyReported = true;
1770
+ }
1771
+ final Map<String, String> metadata = new HashMap<>();
1772
+ metadata.put("duration_ms", Long.toString(Math.max(0, System.currentTimeMillis() - this.launchStartedAtMs)));
1773
+ metadata.put("launch_started_at", Long.toString(this.launchStartedAtMs));
1774
+ metadata.put("source", "notify_app_ready");
1775
+ this.implementation.sendStats("app_launch_ready", bundle == null ? "" : bundle.getVersionName(), "", metadata);
1776
+ }
1777
+
1778
+ private void reportAppLaunchTimeout(final BundleInfo bundle) {
1779
+ synchronized (this.launchReportLock) {
1780
+ if (
1781
+ this.implementation == null ||
1782
+ this.implementation.statsUrl == null ||
1783
+ this.implementation.statsUrl.isEmpty() ||
1784
+ this.launchReadyReported ||
1785
+ this.launchTimeoutReported
1786
+ ) {
1787
+ return;
1788
+ }
1789
+ this.launchTimeoutReported = true;
1790
+ }
1791
+ final Map<String, String> metadata = new HashMap<>();
1792
+ metadata.put("duration_ms", Long.toString(Math.max(0, System.currentTimeMillis() - this.launchStartedAtMs)));
1793
+ metadata.put("launch_started_at", Long.toString(this.launchStartedAtMs));
1794
+ metadata.put("timeout_ms", Long.toString(this.resolveAppReadyCheckTimeoutMs()));
1795
+ metadata.put("source", "app_ready_timeout");
1796
+ this.implementation.sendStats("app_launch_timeout", bundle == null ? "" : bundle.getVersionName(), "", metadata);
1797
+ }
1798
+
1799
+ private void reportWebViewPageLoaded(final android.webkit.WebView view) {
1800
+ if (this.implementation == null || this.implementation.statsUrl == null || this.implementation.statsUrl.isEmpty()) {
1801
+ return;
1802
+ }
1803
+
1804
+ final Map<String, String> metadata = new HashMap<>();
1805
+ metadata.put("source", "android_webview_listener");
1806
+ final long pageStartedAt = this.webViewPageStartedAtMs;
1807
+ if (pageStartedAt > 0) {
1808
+ metadata.put("duration_ms", Long.toString(Math.max(0, System.currentTimeMillis() - pageStartedAt)));
1809
+ metadata.put("page_started_at", Long.toString(pageStartedAt));
1810
+ }
1811
+ if (view != null && view.getUrl() != null && !view.getUrl().isEmpty()) {
1812
+ metadata.put("href", truncateStatsMetadataValue(sanitizeStatsMetadataUrl(view.getUrl()), 512));
1813
+ }
1814
+ this.reportWebViewStats("webview_page_loaded", metadata);
1815
+ }
1816
+
1817
+ private Map<String, String> buildWebViewRenderProcessGoneMetadata(final Object detailObj) {
1549
1818
  final Map<String, String> metadata = new HashMap<>();
1550
1819
  metadata.put("error_type", "render_process_gone");
1551
1820
  metadata.put("source", "android_on_render_process_gone");
1552
1821
  metadata.put("timestamp", Long.toString(System.currentTimeMillis()));
1553
- if (detail != null) {
1822
+ // Parameter typed as Object so Android < 8 ART does not resolve
1823
+ // RenderProcessGoneDetail while reflecting CapacitorUpdaterPlugin methods.
1824
+ if (detailObj != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1825
+ final RenderProcessGoneDetail detail = (RenderProcessGoneDetail) detailObj;
1554
1826
  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
- }
1827
+ metadata.put("renderer_priority_at_exit", Integer.toString(detail.rendererPriorityAtExit()));
1558
1828
  }
1559
1829
  return metadata;
1560
1830
  }
@@ -1652,6 +1922,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
1652
1922
  return "webview_render_process_gone";
1653
1923
  case "web_content_process_terminated":
1654
1924
  return "webview_content_process_terminated";
1925
+ case "webview_dom_content_loaded":
1926
+ return "webview_dom_content_loaded";
1927
+ case "webview_page_loaded":
1928
+ return "webview_page_loaded";
1655
1929
  case "javascript_error":
1656
1930
  default:
1657
1931
  return "webview_javascript_error";
@@ -1670,6 +1944,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
1670
1944
  putStatsMetadataValue(metadata, "href", sanitizeStatsMetadataUrl(data.optString("href", "")), 512);
1671
1945
  putStatsMetadataValue(metadata, "user_agent", data.optString("user_agent", ""), 256);
1672
1946
  putStatsMetadataValue(metadata, "session_id", data.optString("session_id", ""), 128);
1947
+ putStatsMetadataValue(metadata, "duration_ms", data.optString("duration_ms", ""), 32);
1948
+ putStatsMetadataValue(metadata, "page_started_at", data.optString("page_started_at", ""), 64);
1673
1949
  putStatsMetadataValue(metadata, "previous_session_id", data.optString("previous_session_id", ""), 128);
1674
1950
  putStatsMetadataValue(metadata, "previous_href", sanitizeStatsMetadataUrl(data.optString("previous_href", "")), 512);
1675
1951
  putStatsMetadataValue(metadata, "previous_started_at", data.optString("previous_started_at", ""), 64);
@@ -1802,12 +2078,14 @@ public class CapacitorUpdaterPlugin extends Plugin {
1802
2078
  "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
2079
  "writeSession(true);" +
1804
2080
  "setInterval(function(){writeSession(true);},15000);" +
2081
+ "function pageDuration(){var started=Number(window.__capgoWebViewSessionStartedAt||Date.now());return String(Math.max(0,Date.now()-started));}" +
1805
2082
  "function markClean(){writeSession(false);}" +
1806
2083
  "window.addEventListener('pagehide',markClean,true);" +
1807
2084
  "window.addEventListener('beforeunload',markClean,true);" +
1808
2085
  "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
2086
  "window.addEventListener('unhandledrejection',function(event){var reason=event&&event.reason;send({type:'unhandled_rejection',message:s(reason),stack:stack(reason)});},true);" +
1810
2087
  "document.addEventListener('securitypolicyviolation',function(event){send({type:'security_policy_violation',message:s(event&&event.violatedDirective),source:s(event&&event.blockedURI)});},true);" +
2088
+ "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
2089
  "document.addEventListener('deviceready',scheduleFlush,false);" +
1812
2090
  "setTimeout(scheduleFlush,0);" +
1813
2091
  "})();"
@@ -1860,10 +2138,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
1860
2138
  );
1861
2139
  }
1862
2140
  } else {
1863
- final boolean enabled =
1864
- configuredMode != null
1865
- ? "true".equals(configuredMode)
1866
- : Boolean.TRUE.equals(this.getConfig().getBoolean("autoUpdate", true));
2141
+ final boolean enabled = configuredMode != null
2142
+ ? "true".equals(configuredMode)
2143
+ : Boolean.TRUE.equals(this.getConfig().getBoolean("autoUpdate", true));
1867
2144
  this.autoUpdateMode = enabled
1868
2145
  ? autoUpdateModeForLegacyDirectUpdateMode(this.resolveLegacyDirectUpdateModeFromConfig())
1869
2146
  : AUTO_UPDATE_MODE_OFF;
@@ -2128,67 +2405,57 @@ public class CapacitorUpdaterPlugin extends Plugin {
2128
2405
  return false;
2129
2406
  }
2130
2407
 
2131
- private void cleanupObsoleteVersions() {
2408
+ private void cleanupObsoleteVersions(final boolean resetWhenUpdate) {
2409
+ // Latch created before start so waiters never race past an unstarted cleanup thread.
2410
+ final CountDownLatch latch = new CountDownLatch(1);
2411
+ this.cleanupComplete = false;
2412
+ this.cleanupLatch = latch;
2132
2413
  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());
2414
+ try {
2415
+ synchronized (cleanupLock) {
2416
+ try {
2417
+ final String previous = this.getStoredNativeBuildVersion();
2418
+ final boolean nativeVersionChanged = !"".equals(previous) && !Objects.equals(this.currentBuildVersion, previous);
2419
+ if (resetWhenUpdate && nativeVersionChanged) {
2420
+ logger.info("New native build version detected: " + this.currentBuildVersion);
2421
+ this.implementation.reset(true);
2422
+ final List<BundleInfo> installed = this.implementation.list(false);
2423
+ for (final BundleInfo bundle : installed) {
2424
+ try {
2425
+ logger.info("Deleting obsolete bundle: " + bundle.getId());
2426
+ this.implementation.delete(bundle.getId());
2427
+ } catch (final Exception e) {
2428
+ logger.error("Failed to delete: " + bundle.getId() + " " + e.getMessage());
2429
+ }
2430
+ try {
2431
+ Thread.sleep(75L);
2432
+ } catch (final InterruptedException ie) {
2433
+ Thread.currentThread().interrupt();
2434
+ return;
2435
+ }
2158
2436
  }
2437
+ this.implementation.cleanupDeltaCache();
2159
2438
  }
2160
- this.implementation.cleanupDownloadDirectories(allowedIds, Thread.currentThread());
2161
- this.implementation.cleanupOrphanedTempFolders(Thread.currentThread());
2162
2439
 
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
- });
2440
+ // Resume any DELETING leftovers from prior kills, one-by-one.
2441
+ this.implementation.drainPendingDeletes();
2180
2442
 
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();
2443
+ // Always sweep orphan folders so incomplete prior cleanups (or failed deletes)
2444
+ // cannot leave hundreds of MB behind across launches.
2445
+ final Set<String> allowedIds = this.implementation.allowedBundleIdsForCleanup();
2446
+ this.implementation.cleanupDownloadDirectories(allowedIds);
2447
+ this.implementation.cleanupOrphanedTempFolders(null);
2448
+
2449
+ this.persistCurrentNativeBuildVersion();
2450
+ } catch (Exception e) {
2451
+ logger.error("Error during cleanupObsoleteVersions: " + e.getMessage());
2452
+ } finally {
2453
+ cleanupComplete = true;
2454
+ logger.info("Cleanup complete");
2455
+ }
2189
2456
  }
2190
- } catch (InterruptedException e) {
2191
- // Watchdog thread was interrupted, that's fine
2457
+ } finally {
2458
+ latch.countDown();
2192
2459
  }
2193
2460
  });
2194
2461
  }
@@ -2202,6 +2469,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
2202
2469
  }
2203
2470
 
2204
2471
  void persistCurrentNativeBuildVersion() {
2472
+ if (this.defaultChannelCleanupMustRetry) {
2473
+ logger.warn("Keeping the previous native build version so default channel cleanup retries");
2474
+ return;
2475
+ }
2205
2476
  this.editor.putString("LatestNativeBuildVersion", this.currentBuildVersion);
2206
2477
  this.editor.apply();
2207
2478
  }
@@ -2212,11 +2483,14 @@ public class CapacitorUpdaterPlugin extends Plugin {
2212
2483
  }
2213
2484
 
2214
2485
  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");
2486
+ try {
2487
+ this.cleanupLatch.await();
2488
+ } catch (final InterruptedException e) {
2489
+ Thread.currentThread().interrupt();
2490
+ logger.warn("Interrupted while waiting for cleanup");
2491
+ throw new IllegalStateException("Interrupted while waiting for cleanup");
2219
2492
  }
2493
+ logger.info("Cleanup finished, proceeding with download");
2220
2494
  }
2221
2495
 
2222
2496
  public void notifyDownload(final String id, final int percent) {
@@ -2257,11 +2531,15 @@ public class CapacitorUpdaterPlugin extends Plugin {
2257
2531
  call.reject("setUpdateUrl called without url");
2258
2532
  return;
2259
2533
  }
2260
- this.updateUrl = url;
2261
2534
  if (Boolean.TRUE.equals(this.persistModifyUrl)) {
2262
2535
  this.editor.putString(UPDATE_URL_PREF_KEY, url);
2263
- this.editor.apply();
2536
+ if (!this.editor.commit()) {
2537
+ logger.error("Failed to persist updateUrl");
2538
+ call.reject("Failed to persist updateUrl");
2539
+ return;
2540
+ }
2264
2541
  }
2542
+ this.updateUrl = url;
2265
2543
  call.resolve();
2266
2544
  }
2267
2545
 
@@ -2278,11 +2556,15 @@ public class CapacitorUpdaterPlugin extends Plugin {
2278
2556
  call.reject("setStatsUrl called without url");
2279
2557
  return;
2280
2558
  }
2281
- this.implementation.statsUrl = url;
2282
2559
  if (Boolean.TRUE.equals(this.persistModifyUrl)) {
2283
2560
  this.editor.putString(STATS_URL_PREF_KEY, url);
2284
- this.editor.apply();
2561
+ if (!this.editor.commit()) {
2562
+ logger.error("Failed to persist statsUrl");
2563
+ call.reject("Failed to persist statsUrl");
2564
+ return;
2565
+ }
2285
2566
  }
2567
+ this.implementation.statsUrl = url;
2286
2568
  call.resolve();
2287
2569
  }
2288
2570
 
@@ -2299,11 +2581,15 @@ public class CapacitorUpdaterPlugin extends Plugin {
2299
2581
  call.reject("setChannelUrl called without url");
2300
2582
  return;
2301
2583
  }
2302
- this.implementation.channelUrl = url;
2303
2584
  if (Boolean.TRUE.equals(this.persistModifyUrl)) {
2304
2585
  this.editor.putString(CHANNEL_URL_PREF_KEY, url);
2305
- this.editor.apply();
2586
+ if (!this.editor.commit()) {
2587
+ logger.error("Failed to persist channelUrl");
2588
+ call.reject("Failed to persist channelUrl");
2589
+ return;
2590
+ }
2306
2591
  }
2592
+ this.implementation.channelUrl = url;
2307
2593
  call.resolve();
2308
2594
  }
2309
2595
 
@@ -2554,6 +2840,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
2554
2840
  final String checksum,
2555
2841
  final JSONArray manifest
2556
2842
  ) throws IOException {
2843
+ // Manual/preview downloads must wait too — launch orphan sweep can delete their temps.
2844
+ waitForCleanupIfNeeded();
2557
2845
  if (manifest != null) {
2558
2846
  return this.implementation.downloadManifest(url, version, sessionKey, checksum, manifest);
2559
2847
  }
@@ -2727,6 +3015,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
2727
3015
  }
2728
3016
 
2729
3017
  protected boolean _reload() {
3018
+ // Drop any launch pending wait; this reload owns notifyAppReady synchronization.
3019
+ this.clearPendingNotifyAppReadyWait();
2730
3020
  final int phase = this.semaphoreUp();
2731
3021
  this.applyCurrentBundleToBridge();
2732
3022
 
@@ -3722,30 +4012,33 @@ public class CapacitorUpdaterPlugin extends Plugin {
3722
4012
  this.editor.putBoolean(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY, false);
3723
4013
  this.editor.apply();
3724
4014
 
3725
- new Handler(Looper.getMainLooper()).postDelayed(() -> {
3726
- try {
3727
- if (!Boolean.TRUE.equals(this.previewSessionEnabled)) {
3728
- return;
3729
- }
3730
- if (getActivity() == null || getActivity().isFinishing()) {
4015
+ new Handler(Looper.getMainLooper()).postDelayed(
4016
+ () -> {
4017
+ try {
4018
+ if (!Boolean.TRUE.equals(this.previewSessionEnabled)) {
4019
+ return;
4020
+ }
4021
+ if (getActivity() == null || getActivity().isFinishing()) {
4022
+ this.previewSessionAlertPending = true;
4023
+ this.editor.putBoolean(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY, true);
4024
+ this.editor.apply();
4025
+ return;
4026
+ }
4027
+
4028
+ new AlertDialog.Builder(getActivity())
4029
+ .setTitle("Preview started")
4030
+ .setMessage("shake".equals(this.shakeMenuGesture) ? "Shake to open menu." : "Three-finger pinch to open menu.")
4031
+ .setPositiveButton("Got it", (dialog, which) -> dialog.dismiss())
4032
+ .show();
4033
+ } catch (final Exception e) {
3731
4034
  this.previewSessionAlertPending = true;
3732
4035
  this.editor.putBoolean(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY, true);
3733
4036
  this.editor.apply();
3734
- return;
4037
+ logger.warn("Could not show preview session notice: " + e.getMessage());
3735
4038
  }
3736
-
3737
- new AlertDialog.Builder(getActivity())
3738
- .setTitle("Preview started")
3739
- .setMessage("Shake your device anytime to reload or leave the test app.")
3740
- .setPositiveButton("Got it", (dialog, which) -> dialog.dismiss())
3741
- .show();
3742
- } catch (final Exception e) {
3743
- this.previewSessionAlertPending = true;
3744
- this.editor.putBoolean(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY, true);
3745
- this.editor.apply();
3746
- logger.warn("Could not show preview session notice: " + e.getMessage());
3747
- }
3748
- }, 600);
4039
+ },
4040
+ 600
4041
+ );
3749
4042
  }
3750
4043
 
3751
4044
  @PluginMethod
@@ -3954,12 +4247,18 @@ public class CapacitorUpdaterPlugin extends Plugin {
3954
4247
  if (this.shouldBlockAutoUpdateForPreviewSession()) {
3955
4248
  return "preview_session";
3956
4249
  }
3957
- if (this.isDownloadStuckOrTimedOut()) {
3958
- logger.info("Download already in progress, skipping duplicate download request");
3959
- return "already_running";
4250
+ synchronized (this) {
4251
+ final Thread previousTask = this.backgroundDownloadTask;
4252
+ final Thread task = this.backgroundDownload();
4253
+ if (task == null) {
4254
+ return "unavailable";
4255
+ }
4256
+ if (previousTask != null && previousTask == task) {
4257
+ logger.info("Download already in progress, skipping duplicate download request");
4258
+ return "already_running";
4259
+ }
4260
+ return "queued";
3960
4261
  }
3961
- this.backgroundDownload();
3962
- return "queued";
3963
4262
  }
3964
4263
 
3965
4264
  @PluginMethod
@@ -4187,6 +4486,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
4187
4486
  try {
4188
4487
  final BundleInfo bundle = this.implementation.getCurrentBundle();
4189
4488
  this.implementation.setSuccess(bundle, this.autoDeletePrevious);
4489
+ this.reportAppLaunchReady(bundle);
4190
4490
  logger.info("Current bundle loaded successfully. ['notifyAppReady()' was called] " + bundle);
4191
4491
  logger.info("semaphoreReady countDown");
4192
4492
  this.semaphoreDown();
@@ -4471,10 +4771,14 @@ public class CapacitorUpdaterPlugin extends Plugin {
4471
4771
  return true;
4472
4772
  }
4473
4773
 
4474
- private Thread backgroundDownload() {
4774
+ private synchronized Thread backgroundDownload() {
4475
4775
  if (this.shouldBlockAutoUpdateForPreviewSession()) {
4476
4776
  return null;
4477
4777
  }
4778
+ if (this.isDownloadStuckOrTimedOut()) {
4779
+ logger.info("Download already in progress, skipping duplicate download request");
4780
+ return this.backgroundDownloadTask;
4781
+ }
4478
4782
  final boolean plannedDirectUpdate = this.shouldUseDirectUpdate();
4479
4783
  final boolean initialDirectUpdateAllowed = this.isDirectUpdateCurrentlyAllowed(plannedDirectUpdate);
4480
4784
  final String messageUpdate = initialDirectUpdateAllowed
@@ -4483,8 +4787,6 @@ public class CapacitorUpdaterPlugin extends Plugin {
4483
4787
  ? "Update will occur next time app moves to background."
4484
4788
  : "Update will be downloaded and made available.";
4485
4789
  Thread newTask = startNewThread(() -> {
4486
- // Wait for cleanup to complete before starting download
4487
- waitForCleanupIfNeeded();
4488
4790
  if (CapacitorUpdaterPlugin.this.shouldBlockAutoUpdateForPreviewSession()) {
4489
4791
  CapacitorUpdaterPlugin.this.clearBackgroundDownloadState();
4490
4792
  return;
@@ -4497,7 +4799,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
4497
4799
  return;
4498
4800
  }
4499
4801
  JSObject jsRes = InternalUtils.mapToJSObject(res);
4500
- final BundleInfo current = CapacitorUpdaterPlugin.this.implementation.getCurrentBundle();
4802
+ final BundleInfo currentBeforeCleanup = CapacitorUpdaterPlugin.this.implementation.getCurrentBundle();
4501
4803
 
4502
4804
  // Handle network errors and other failures first
4503
4805
  if (jsRes.has("error") || jsRes.has("kind")) {
@@ -4505,8 +4807,15 @@ public class CapacitorUpdaterPlugin extends Plugin {
4505
4807
  String errorMessage = jsRes.has("message") ? jsRes.getString("message") : "server did not provide a message";
4506
4808
  int statusCode = jsRes.has("statusCode") ? jsRes.optInt("statusCode", 0) : 0;
4507
4809
  String kind = CapacitorUpdaterPlugin.this.getUpdateResponseKind(jsRes.has("kind") ? jsRes.getString("kind") : null);
4508
- String latestVersion = jsRes.has("version") ? jsRes.getString("version") : current.getVersionName();
4509
- CapacitorUpdaterPlugin.this.notifyUpdateCheckResult(kind, error, errorMessage, statusCode, latestVersion, current);
4810
+ String latestVersion = jsRes.has("version") ? jsRes.getString("version") : currentBeforeCleanup.getVersionName();
4811
+ CapacitorUpdaterPlugin.this.notifyUpdateCheckResult(
4812
+ kind,
4813
+ error,
4814
+ errorMessage,
4815
+ statusCode,
4816
+ latestVersion,
4817
+ currentBeforeCleanup
4818
+ );
4510
4819
  CapacitorUpdaterPlugin.this.notifyBreakingEventsIfNeeded(
4511
4820
  jsRes,
4512
4821
  jsRes.has("version") ? jsRes.getString("version") : ""
@@ -4526,7 +4835,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
4526
4835
  CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
4527
4836
  errorMessage,
4528
4837
  latestVersion,
4529
- current,
4838
+ currentBeforeCleanup,
4530
4839
  isFailure,
4531
4840
  plannedDirectUpdate,
4532
4841
  "download_fail",
@@ -4536,6 +4845,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
4536
4845
  return;
4537
4846
  }
4538
4847
  try {
4848
+ // File mutations wait here. getLatest already ran in parallel with cleanup.
4849
+ waitForCleanupIfNeeded();
4850
+ final BundleInfo current = CapacitorUpdaterPlugin.this.implementation.getCurrentBundle();
4539
4851
  final String latestVersionName = jsRes.getString("version");
4540
4852
 
4541
4853
  if ("builtin".equals(latestVersionName)) {
@@ -4784,8 +5096,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
4784
5096
  logger.error("error in update check " + e.getMessage());
4785
5097
  CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
4786
5098
  "Error in update check",
4787
- current.getVersionName(),
4788
- current,
5099
+ currentBeforeCleanup.getVersionName(),
5100
+ currentBeforeCleanup,
4789
5101
  true,
4790
5102
  plannedDirectUpdate
4791
5103
  );
@@ -4861,19 +5173,26 @@ public class CapacitorUpdaterPlugin extends Plugin {
4861
5173
  ret.put("bundle", InternalUtils.mapToJSObject(current.toJSONMap()));
4862
5174
  this.persistLastFailedBundle(current);
4863
5175
  this.notifyListeners("updateFailed", ret);
5176
+ this.reportAppLaunchTimeout(current);
4864
5177
  this.implementation.sendStats("update_fail", current.getVersionName());
4865
5178
  this.implementation.setError(current);
4866
5179
  this.performReset(true, false, true);
4867
5180
  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());
5181
+ final String failedId = current.getId();
5182
+ final String failedVersion = current.getVersionName();
5183
+ logger.info("Deleting failing bundle: " + failedVersion);
5184
+ // Mark before async work so kill/OOM still resumes via drainPendingDeletes.
5185
+ CapacitorUpdaterPlugin.this.implementation.saveBundleInfo(failedId, current.setStatus(BundleStatus.DELETING));
5186
+ startNewThread(() -> {
5187
+ try {
5188
+ final Boolean res = CapacitorUpdaterPlugin.this.implementation.delete(failedId, false, false);
5189
+ if (Boolean.TRUE.equals(res)) {
5190
+ logger.info("Failed bundle deleted: " + failedVersion);
5191
+ }
5192
+ } catch (final IOException e) {
5193
+ logger.error("Failed to delete failed bundle: " + failedVersion + " " + e.getMessage());
4873
5194
  }
4874
- } catch (final IOException e) {
4875
- logger.error("Failed to delete failed bundle: " + current.getVersionName() + " " + e.getMessage());
4876
- }
5195
+ });
4877
5196
  }
4878
5197
  } else {
4879
5198
  logger.info("notifyAppReady was called. This is fine: " + current.getId());
@@ -4988,6 +5307,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
4988
5307
 
4989
5308
  // Do other background work after splashscreen is shown
4990
5309
  CapacitorUpdaterPlugin.this.implementation.sendStats("app_moved_to_background", current.getVersionName());
5310
+ CapacitorUpdaterPlugin.this.implementation.persistPendingStats();
4991
5311
  logger.info("Checking for pending update");
4992
5312
 
4993
5313
  try {
@@ -5063,9 +5383,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
5063
5383
 
5064
5384
  // On Android < 14, use activity lifecycle for foreground detection
5065
5385
  // On Android 14+, ProcessLifecycleOwner handles this via AppLifecycleObserver
5066
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
5067
- if (isPreviousMainActivity) {
5068
- logger.info("handleOnStart: appMovedToForeground (Android <14 path)");
5386
+ if (!this.isProcessLifecycleObserverActive()) {
5387
+ if (isPreviousMainActivity || Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
5388
+ logger.info("handleOnStart: appMovedToForeground");
5069
5389
  this.appMovedToForeground();
5070
5390
  }
5071
5391
  isPreviousMainActivity = true;
@@ -5084,11 +5404,16 @@ public class CapacitorUpdaterPlugin extends Plugin {
5084
5404
 
5085
5405
  // On Android < 14, use activity lifecycle for background detection
5086
5406
  // On Android 14+, ProcessLifecycleOwner handles this via AppLifecycleObserver
5087
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
5088
- isPreviousMainActivity = isMainActivity();
5089
- if (isPreviousMainActivity) {
5090
- logger.info("handleOnStop: appMovedToBackground (Android <14 path)");
5407
+ if (!this.isProcessLifecycleObserverActive()) {
5408
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
5409
+ logger.info("handleOnStop: appMovedToBackground");
5091
5410
  this.appMovedToBackground();
5411
+ } else {
5412
+ isPreviousMainActivity = isMainActivity();
5413
+ if (isPreviousMainActivity) {
5414
+ logger.info("handleOnStop: appMovedToBackground (Android <14 path)");
5415
+ this.appMovedToBackground();
5416
+ }
5092
5417
  }
5093
5418
  }
5094
5419
  } catch (Exception e) {