@capgo/capacitor-updater 7.45.10 → 7.50.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.
Files changed (33) hide show
  1. package/Package.swift +1 -1
  2. package/README.md +510 -92
  3. package/android/src/main/java/ee/forgr/capacitor_updater/AndroidAppExitReporter.java +92 -0
  4. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +3182 -779
  5. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +798 -299
  6. package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +45 -30
  7. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +46 -28
  8. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +2 -0
  9. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +3 -3
  10. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +359 -25
  11. package/android/src/main/java/ee/forgr/capacitor_updater/ThreeFingerPinchDetector.java +323 -0
  12. package/dist/docs.json +1283 -169
  13. package/dist/esm/definitions.d.ts +621 -44
  14. package/dist/esm/definitions.js.map +1 -1
  15. package/dist/esm/web.d.ts +11 -1
  16. package/dist/esm/web.js +59 -1
  17. package/dist/esm/web.js.map +1 -1
  18. package/dist/plugin.cjs.js +59 -1
  19. package/dist/plugin.cjs.js.map +1 -1
  20. package/dist/plugin.js +59 -1
  21. package/dist/plugin.js.map +1 -1
  22. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +0 -1
  23. package/ios/Sources/CapacitorUpdaterPlugin/AppHealthTracker.swift +82 -0
  24. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +0 -16
  25. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +2 -2
  26. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +78 -2
  27. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +2116 -223
  28. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +997 -332
  29. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +0 -1
  30. package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +78 -1
  31. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +418 -36
  32. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +276 -0
  33. package/package.json +15 -3
@@ -7,6 +7,7 @@
7
7
  package ee.forgr.capacitor_updater;
8
8
 
9
9
  import android.app.Activity;
10
+ import android.app.AlertDialog;
10
11
  import android.content.Context;
11
12
  import android.content.Intent;
12
13
  import android.content.SharedPreferences;
@@ -20,6 +21,7 @@ import android.os.Looper;
20
21
  import android.view.Gravity;
21
22
  import android.view.View;
22
23
  import android.view.ViewGroup;
24
+ import android.webkit.RenderProcessGoneDetail;
23
25
  import android.widget.FrameLayout;
24
26
  import android.widget.ProgressBar;
25
27
  import androidx.core.content.pm.PackageInfoCompat;
@@ -32,6 +34,7 @@ import com.getcapacitor.PluginCall;
32
34
  import com.getcapacitor.PluginHandle;
33
35
  import com.getcapacitor.PluginMethod;
34
36
  import com.getcapacitor.PluginResult;
37
+ import com.getcapacitor.WebViewListener;
35
38
  import com.getcapacitor.annotation.CapacitorPlugin;
36
39
  import com.getcapacitor.plugin.WebView;
37
40
  import com.google.android.gms.tasks.Task;
@@ -46,16 +49,23 @@ import com.google.android.play.core.install.model.AppUpdateType;
46
49
  import com.google.android.play.core.install.model.InstallStatus;
47
50
  import com.google.android.play.core.install.model.UpdateAvailability;
48
51
  import io.github.g00fy2.versioncompare.Version;
52
+ import java.io.ByteArrayOutputStream;
49
53
  import java.io.IOException;
54
+ import java.io.InputStream;
55
+ import java.net.HttpURLConnection;
50
56
  import java.net.MalformedURLException;
51
57
  import java.net.URL;
58
+ import java.nio.charset.StandardCharsets;
59
+ import java.text.SimpleDateFormat;
52
60
  import java.util.ArrayList;
53
61
  import java.util.Date;
62
+ import java.util.HashMap;
54
63
  import java.util.HashSet;
55
64
  import java.util.List;
56
65
  import java.util.Map;
57
66
  import java.util.Objects;
58
67
  import java.util.Set;
68
+ import java.util.TimeZone;
59
69
  import java.util.Timer;
60
70
  import java.util.TimerTask;
61
71
  import java.util.concurrent.Phaser;
@@ -71,6 +81,16 @@ import org.json.JSONObject;
71
81
  @CapacitorPlugin(name = "CapacitorUpdater")
72
82
  public class CapacitorUpdaterPlugin extends Plugin {
73
83
 
84
+ static final String SHAKE_MENU_GESTURE_SHAKE = "shake";
85
+ static final String SHAKE_MENU_GESTURE_THREE_FINGER_PINCH = "threeFingerPinch";
86
+
87
+ private static final String AUTO_UPDATE_MODE_OFF = "off";
88
+ private static final String AUTO_UPDATE_MODE_BACKGROUND = "atBackground";
89
+ private static final String AUTO_UPDATE_MODE_INSTALL = "atInstall";
90
+ private static final String AUTO_UPDATE_MODE_LAUNCH = "onLaunch";
91
+ private static final String AUTO_UPDATE_MODE_ALWAYS = "always";
92
+ private static final String AUTO_UPDATE_MODE_ONLY_DOWNLOAD = "onlyDownload";
93
+
74
94
  private Logger logger;
75
95
 
76
96
  private static final String updateUrlDefault = "https://plugin.capgo.app/updates";
@@ -82,18 +102,52 @@ public class CapacitorUpdaterPlugin extends Plugin {
82
102
  private static final String STATS_URL_PREF_KEY = "CapacitorUpdater.statsUrl";
83
103
  private static final String CHANNEL_URL_PREF_KEY = "CapacitorUpdater.channelUrl";
84
104
  private static final String DEFAULT_CHANNEL_PREF_KEY = "CapacitorUpdater.defaultChannel";
105
+ private static final String PREVIEW_SESSION_PREF_KEY = "CapacitorUpdater.previewSession";
106
+ private static final String PREVIEW_PREVIOUS_SHAKE_MENU_PREF_KEY = "CapacitorUpdater.previewPreviousShakeMenu";
107
+ private static final String PREVIEW_PREVIOUS_SHAKE_CHANNEL_SELECTOR_PREF_KEY = "CapacitorUpdater.previewPreviousShakeChannelSelector";
108
+ private static final String PREVIEW_PREVIOUS_NEXT_BUNDLE_PREF_KEY = "CapacitorUpdater.previewPreviousNextBundle";
109
+ private static final String PREVIEW_PREVIOUS_APP_ID_PREF_KEY = "CapacitorUpdater.previewPreviousAppId";
110
+ private static final String PREVIEW_PREVIOUS_DEFAULT_CHANNEL_PREF_KEY = "CapacitorUpdater.previewPreviousDefaultChannel";
111
+ private static final String PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY = "CapacitorUpdater.previewPreviousDefaultChannelWasSet";
112
+ private static final String PREVIEW_APP_ID_PREF_KEY = "CapacitorUpdater.previewAppId";
113
+ private static final String PREVIEW_PAYLOAD_URL_PREF_KEY = "CapacitorUpdater.previewPayloadUrl";
114
+ private static final String PREVIEW_NAME_PREF_KEY = "CapacitorUpdater.previewName";
115
+ private static final String PREVIEW_SOURCE_PREF_KEY = "CapacitorUpdater.previewSource";
116
+ private static final String PREVIEW_SESSIONS_PREF_KEY = "CapacitorUpdater.previewSessions";
117
+ private static final String PREVIEW_SESSION_ALERT_PENDING_PREF_KEY = "CapacitorUpdater.previewSessionAlertPending";
85
118
  private static final String[] BREAKING_EVENT_NAMES = { "breakingAvailable", "majorAvailable" };
86
119
  private static final String LAST_FAILED_BUNDLE_PREF_KEY = "CapacitorUpdater.lastFailedBundle";
120
+ private static final String LAST_REPORTED_APP_EXIT_TIMESTAMP_PREF_KEY = "CapacitorUpdater.lastReportedAppExitTimestamp";
121
+ private static final String LAST_WEBVIEW_RENDER_PROCESS_GONE_PREF_KEY = "CapacitorUpdater.lastWebViewRenderProcessGone";
122
+ private static final String LAST_VERSION_OS_PREF_KEY = "CapacitorUpdater.lastVersionOs";
123
+ private static final String LAST_VERSION_BUILD_PREF_KEY = "CapacitorUpdater.lastVersionBuild";
124
+ private static final String LAST_VERSION_CODE_PREF_KEY = "CapacitorUpdater.lastVersionCode";
125
+ private static final String OS_VERSION_CHANGED_ACTION = "os_version_changed";
126
+ private static final String NATIVE_APP_VERSION_CHANGED_ACTION = "native_app_version_changed";
87
127
  private static final String SPLASH_SCREEN_PLUGIN_ID = "SplashScreen";
88
128
  private static final int SPLASH_SCREEN_RETRY_DELAY_MS = 100;
89
129
  private static final int SPLASH_SCREEN_MAX_RETRIES = 20;
90
130
  private static final long PENDING_BUNDLE_APP_READY_MIN_TIMEOUT_MS = 30000L;
91
-
92
- private final String pluginVersion = "7.45.10";
131
+ private static final long PREVIEW_TRANSITION_LOADER_TIMEOUT_MS = 60000L;
132
+ static final int APPLICATION_EXIT_REASON_UNKNOWN = 0;
133
+ static final int APPLICATION_EXIT_REASON_EXIT_SELF = 1;
134
+ static final int APPLICATION_EXIT_REASON_SIGNALED = 2;
135
+ static final int APPLICATION_EXIT_REASON_LOW_MEMORY = 3;
136
+ static final int APPLICATION_EXIT_REASON_CRASH = 4;
137
+ static final int APPLICATION_EXIT_REASON_CRASH_NATIVE = 5;
138
+ static final int APPLICATION_EXIT_REASON_ANR = 6;
139
+ static final int APPLICATION_EXIT_REASON_INITIALIZATION_FAILURE = 7;
140
+ static final int APPLICATION_EXIT_REASON_PERMISSION_CHANGE = 8;
141
+ static final int APPLICATION_EXIT_REASON_EXCESSIVE_RESOURCE_USAGE = 9;
142
+ static final int APPLICATION_EXIT_REASON_USER_REQUESTED = 10;
143
+ static final int APPLICATION_EXIT_REASON_DEPENDENCY_DIED = 12;
144
+
145
+ private final String pluginVersion = "7.50.1";
93
146
  private static final String DELAY_CONDITION_PREFERENCES = "";
94
147
 
95
148
  private SharedPreferences.Editor editor;
96
149
  private SharedPreferences prefs;
150
+ private final Object previewSessionsLock = new Object();
97
151
  protected CapgoUpdater implementation;
98
152
  private Boolean persistCustomId = false;
99
153
  private Boolean persistModifyUrl = false;
@@ -103,6 +157,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
103
157
  private Boolean autoDeleteFailed = true;
104
158
  private Boolean autoDeletePrevious = true;
105
159
  private Boolean autoUpdate = false;
160
+ private String autoUpdateMode = AUTO_UPDATE_MODE_OFF;
106
161
  private String updateUrl = "";
107
162
  private Version currentVersionNative;
108
163
  private String currentBuildVersion;
@@ -119,20 +174,49 @@ public class CapacitorUpdaterPlugin extends Plugin {
119
174
  private volatile boolean onLaunchDirectUpdateUsed = false;
120
175
  Boolean shakeMenuEnabled = false;
121
176
  Boolean shakeChannelSelectorEnabled = false;
177
+ String shakeMenuGesture = SHAKE_MENU_GESTURE_SHAKE;
178
+ volatile Boolean previewSessionEnabled = false;
179
+ private Boolean previewSessionAlertPending = false;
180
+ private volatile Boolean isLeavingPreviewForIncomingLink = false;
122
181
  private Boolean allowManualBundleError = false;
182
+ private Boolean allowPreview = false;
123
183
  Boolean allowSetDefaultChannel = true;
124
184
 
125
185
  String getUpdateUrl() {
126
186
  return this.updateUrl;
127
187
  }
128
188
 
189
+ private boolean isPreviewSessionStateActive() {
190
+ return (
191
+ Boolean.TRUE.equals(this.previewSessionEnabled) ||
192
+ Boolean.TRUE.equals(this.isLeavingPreviewForIncomingLink) ||
193
+ (this.implementation != null && this.implementation.previewSession)
194
+ );
195
+ }
196
+
197
+ private boolean shouldBlockAutoUpdateForPreviewSession() {
198
+ if (!this.isPreviewSessionStateActive()) {
199
+ return false;
200
+ }
201
+
202
+ logger.info("Preview session is active. Skipping normal auto-update work.");
203
+ return true;
204
+ }
205
+
206
+ private void clearIncomingPreviewTransition() {
207
+ this.isLeavingPreviewForIncomingLink = false;
208
+ if (!Boolean.TRUE.equals(this.previewSessionEnabled) && this.implementation != null) {
209
+ this.implementation.previewSession = false;
210
+ }
211
+ }
212
+
129
213
  // Used for activity-based foreground/background detection on Android < 14
130
214
  private Boolean isPreviousMainActivity = true;
131
215
 
132
216
  private volatile Thread backgroundDownloadTask;
133
217
  private volatile Thread appReadyCheck;
134
218
  private volatile long downloadStartTimeMs = 0;
135
- private static final long DOWNLOAD_TIMEOUT_MS = 3600000; // 1 hour timeout
219
+ private static final long DOWNLOAD_TIMEOUT_MS = 600000; // 10 minute timeout
136
220
 
137
221
  private final Phaser semaphoreReady = new Phaser(0) {
138
222
  @Override
@@ -154,6 +238,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
154
238
  private final Handler mainHandler = new Handler(Looper.getMainLooper());
155
239
  private FrameLayout splashscreenLoaderOverlay;
156
240
  private Runnable splashscreenTimeoutRunnable;
241
+ private FrameLayout previewTransitionLoaderOverlay;
242
+ private Runnable previewTransitionLoaderTimeoutRunnable;
243
+ private boolean previewTransitionLoaderRequested = false;
244
+ private WebViewListener webViewStatsListener;
157
245
 
158
246
  private static final class FireAndForgetPluginCall extends PluginCall {
159
247
 
@@ -210,6 +298,26 @@ public class CapacitorUpdaterPlugin extends Plugin {
210
298
  }
211
299
  }
212
300
 
301
+ private boolean shouldNotifyBreakingEvents(final JSObject response) {
302
+ if (response == null) {
303
+ return false;
304
+ }
305
+
306
+ if (response.optBoolean("breaking", false)) {
307
+ return true;
308
+ }
309
+
310
+ final String error = response.optString("error", "");
311
+ final String message = response.optString("message", "");
312
+ return "disable_auto_update_to_major".equals(error) || "store_update_required".equals(message);
313
+ }
314
+
315
+ private void notifyBreakingEventsIfNeeded(final JSObject response, final String version) {
316
+ if (shouldNotifyBreakingEvents(response)) {
317
+ notifyBreakingEvents(version);
318
+ }
319
+ }
320
+
213
321
  private void persistLastFailedBundle(BundleInfo bundle) {
214
322
  if (this.prefs == null) {
215
323
  return;
@@ -241,6 +349,296 @@ public class CapacitorUpdaterPlugin extends Plugin {
241
349
  }
242
350
  }
243
351
 
352
+ private String nowIsoString() {
353
+ final SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US);
354
+ formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
355
+ return formatter.format(new Date());
356
+ }
357
+
358
+ private String normalizedPreviewMetadataValue(final String rawValue) {
359
+ if (rawValue == null) {
360
+ return null;
361
+ }
362
+
363
+ final String value = rawValue.trim();
364
+ if (value.isEmpty()) {
365
+ return null;
366
+ }
367
+
368
+ final String lowercased = value.toLowerCase(java.util.Locale.ROOT);
369
+ if ("undefined".equals(lowercased) || "null".equals(lowercased)) {
370
+ return null;
371
+ }
372
+
373
+ return value;
374
+ }
375
+
376
+ private JSONObject previewSessionsJson() {
377
+ final String raw = this.prefs == null ? null : this.prefs.getString(PREVIEW_SESSIONS_PREF_KEY, null);
378
+ if (raw == null || raw.trim().isEmpty()) {
379
+ return new JSONObject();
380
+ }
381
+ try {
382
+ return new JSONObject(raw);
383
+ } catch (final JSONException e) {
384
+ logger.warn("Could not parse preview sessions, clearing them: " + e.getMessage());
385
+ this.editor.remove(PREVIEW_SESSIONS_PREF_KEY).apply();
386
+ return new JSONObject();
387
+ }
388
+ }
389
+
390
+ private void savePreviewSessionsJson(final JSONObject sessions) {
391
+ this.editor.putString(PREVIEW_SESSIONS_PREF_KEY, sessions.toString()).apply();
392
+ }
393
+
394
+ private boolean hasSavedPreviewSessions() {
395
+ synchronized (this.previewSessionsLock) {
396
+ return this.previewSessionsJson().length() > 0;
397
+ }
398
+ }
399
+
400
+ private String metadataString(final JSONObject metadata, final String key) {
401
+ return this.normalizedPreviewMetadataValue(metadata.optString(key, null));
402
+ }
403
+
404
+ private String currentPreviewMetadataValue(final String key) {
405
+ return this.normalizedPreviewMetadataValue(this.prefs.getString(key, null));
406
+ }
407
+
408
+ private Set<String> availableBundleIds() {
409
+ final Set<String> ids = new HashSet<>();
410
+ for (final BundleInfo bundle : this.implementation.list(false)) {
411
+ ids.add(bundle.getId());
412
+ }
413
+ return ids;
414
+ }
415
+
416
+ private JSObject previewInfo(
417
+ final String id,
418
+ final JSONObject metadata,
419
+ final Set<String> availableBundleIds,
420
+ final String currentBundleId
421
+ ) throws JSONException {
422
+ final BundleInfo bundle = this.implementation.getBundleInfo(id);
423
+ if (!bundle.isBuiltin() && !availableBundleIds.contains(id)) {
424
+ return null;
425
+ }
426
+ if (bundle.isDeleted() || bundle.isErrorStatus()) {
427
+ return null;
428
+ }
429
+
430
+ final String now = this.nowIsoString();
431
+ final JSObject info = new JSObject();
432
+ info.put("id", id);
433
+ info.put("bundle", InternalUtils.mapToJSObject(bundle.toJSONMap()));
434
+ info.put("createdAt", Objects.requireNonNullElse(this.metadataString(metadata, "createdAt"), now));
435
+ info.put("updatedAt", Objects.requireNonNullElse(this.metadataString(metadata, "updatedAt"), now));
436
+ info.put("lastUsedAt", Objects.requireNonNullElse(this.metadataString(metadata, "lastUsedAt"), now));
437
+ info.put("isActive", Boolean.TRUE.equals(this.previewSessionEnabled) && id.equals(currentBundleId));
438
+
439
+ for (final String key : new String[] { "name", "source", "appId", "payloadUrl" }) {
440
+ final String value = this.metadataString(metadata, key);
441
+ if (value != null) {
442
+ info.put(key, value);
443
+ }
444
+ }
445
+
446
+ return info;
447
+ }
448
+
449
+ private JSArray listPreviewInfos(final boolean cleanup) {
450
+ synchronized (this.previewSessionsLock) {
451
+ final JSONObject sessions = this.previewSessionsJson();
452
+ final Set<String> availableBundleIds = this.availableBundleIds();
453
+ final String currentBundleId = this.implementation.getCurrentBundle().getId();
454
+ final JSArray previews = new JSArray();
455
+ final List<String> staleIds = new ArrayList<>();
456
+
457
+ final JSONArray names = sessions.names();
458
+ if (names == null) {
459
+ return previews;
460
+ }
461
+
462
+ final List<JSObject> sortedPreviews = new ArrayList<>();
463
+ for (int i = 0; i < names.length(); i++) {
464
+ final String id = names.optString(i, "");
465
+ if (id.isEmpty()) {
466
+ continue;
467
+ }
468
+ final JSONObject metadata = sessions.optJSONObject(id);
469
+ if (metadata == null) {
470
+ staleIds.add(id);
471
+ continue;
472
+ }
473
+ try {
474
+ final JSObject info = this.previewInfo(id, metadata, availableBundleIds, currentBundleId);
475
+ if (info == null) {
476
+ staleIds.add(id);
477
+ } else {
478
+ sortedPreviews.add(info);
479
+ }
480
+ } catch (final JSONException e) {
481
+ logger.warn("Could not read preview metadata for " + id + ": " + e.getMessage());
482
+ staleIds.add(id);
483
+ }
484
+ }
485
+
486
+ sortedPreviews.sort((first, second) -> second.optString("lastUsedAt", "").compareTo(first.optString("lastUsedAt", "")));
487
+ for (final JSObject preview : sortedPreviews) {
488
+ previews.put(preview);
489
+ }
490
+
491
+ if (cleanup && !staleIds.isEmpty()) {
492
+ for (final String id : staleIds) {
493
+ sessions.remove(id);
494
+ }
495
+ this.savePreviewSessionsJson(sessions);
496
+ }
497
+
498
+ return previews;
499
+ }
500
+ }
501
+
502
+ private JSObject storedPreviewInfo(final String id) {
503
+ synchronized (this.previewSessionsLock) {
504
+ final JSONObject metadata = this.previewSessionsJson().optJSONObject(id);
505
+ if (metadata == null) {
506
+ return null;
507
+ }
508
+ try {
509
+ return this.previewInfo(id, metadata, this.availableBundleIds(), this.implementation.getCurrentBundle().getId());
510
+ } catch (final JSONException e) {
511
+ logger.warn("Could not read preview metadata for " + id + ": " + e.getMessage());
512
+ return null;
513
+ }
514
+ }
515
+ }
516
+
517
+ private JSObject recordPreviewBundle(final BundleInfo bundle) {
518
+ return this.recordPreviewBundle(bundle, null);
519
+ }
520
+
521
+ private JSObject recordPreviewBundle(final BundleInfo bundle, final String oldId) {
522
+ final String now = this.nowIsoString();
523
+ final String id = bundle.getId();
524
+ synchronized (this.previewSessionsLock) {
525
+ final JSONObject sessions = this.previewSessionsJson();
526
+ JSONObject metadata = sessions.optJSONObject(id);
527
+ final boolean replacingPreview = oldId != null && !oldId.equals(id);
528
+
529
+ try {
530
+ if (metadata == null && replacingPreview) {
531
+ final JSONObject oldMetadata = sessions.optJSONObject(oldId);
532
+ if (oldMetadata != null) {
533
+ metadata = new JSONObject(oldMetadata.toString());
534
+ }
535
+ }
536
+ if (metadata == null) {
537
+ metadata = new JSONObject();
538
+ }
539
+
540
+ if (!metadata.has("createdAt")) {
541
+ metadata.put("createdAt", now);
542
+ }
543
+ metadata.put("updatedAt", now);
544
+ if (metadata.isNull("lastUsedAt") || this.implementation.getCurrentBundle().getId().equals(id)) {
545
+ metadata.put("lastUsedAt", now);
546
+ }
547
+ metadata.put("version", bundle.getVersionName());
548
+
549
+ if (!replacingPreview) {
550
+ final String appId = this.currentPreviewMetadataValue(PREVIEW_APP_ID_PREF_KEY);
551
+ if (appId == null) {
552
+ metadata.remove("appId");
553
+ } else {
554
+ metadata.put("appId", appId);
555
+ }
556
+
557
+ final String payloadUrl = this.currentPreviewMetadataValue(PREVIEW_PAYLOAD_URL_PREF_KEY);
558
+ if (payloadUrl == null) {
559
+ metadata.remove("payloadUrl");
560
+ } else {
561
+ metadata.put("payloadUrl", payloadUrl);
562
+ }
563
+ }
564
+
565
+ if (!replacingPreview) {
566
+ final String name = this.currentPreviewMetadataValue(PREVIEW_NAME_PREF_KEY);
567
+ if (name == null) {
568
+ metadata.remove("name");
569
+ } else {
570
+ metadata.put("name", name);
571
+ }
572
+
573
+ final String source = this.currentPreviewMetadataValue(PREVIEW_SOURCE_PREF_KEY);
574
+ if (source == null) {
575
+ metadata.remove("source");
576
+ } else {
577
+ metadata.put("source", source);
578
+ }
579
+ }
580
+ if (this.metadataString(metadata, "name") == null) {
581
+ metadata.put("name", bundle.getVersionName());
582
+ }
583
+
584
+ if (oldId != null && !oldId.equals(id)) {
585
+ sessions.remove(oldId);
586
+ }
587
+ sessions.put(id, metadata);
588
+ this.savePreviewSessionsJson(sessions);
589
+
590
+ return this.previewInfo(id, metadata, this.availableBundleIds(), this.implementation.getCurrentBundle().getId());
591
+ } catch (final JSONException e) {
592
+ logger.warn("Could not store preview metadata: " + e.getMessage());
593
+ }
594
+ }
595
+
596
+ final JSObject fallback = new JSObject();
597
+ fallback.put("id", id);
598
+ fallback.put("bundle", InternalUtils.mapToJSObject(bundle.toJSONMap()));
599
+ fallback.put("createdAt", now);
600
+ fallback.put("updatedAt", now);
601
+ fallback.put("lastUsedAt", now);
602
+ fallback.put(
603
+ "isActive",
604
+ Boolean.TRUE.equals(this.previewSessionEnabled) && this.implementation.getCurrentBundle().getId().equals(id)
605
+ );
606
+ return fallback;
607
+ }
608
+
609
+ private void updateCurrentPreviewSessionMetadataFrom(final JSObject preview) {
610
+ final String appId = this.normalizedPreviewMetadataValue(preview.optString("appId", null));
611
+ if (appId == null) {
612
+ this.restorePreviewPreviousAppId();
613
+ this.editor.remove(PREVIEW_APP_ID_PREF_KEY);
614
+ } else {
615
+ this.setActiveAppId(appId);
616
+ this.editor.putString(PREVIEW_APP_ID_PREF_KEY, appId);
617
+ }
618
+
619
+ final String payloadUrl = this.normalizedPreviewMetadataValue(preview.optString("payloadUrl", null));
620
+ if (payloadUrl == null) {
621
+ this.editor.remove(PREVIEW_PAYLOAD_URL_PREF_KEY);
622
+ } else {
623
+ this.editor.putString(PREVIEW_PAYLOAD_URL_PREF_KEY, payloadUrl);
624
+ }
625
+
626
+ final String name = this.normalizedPreviewMetadataValue(preview.optString("name", null));
627
+ if (name == null) {
628
+ this.editor.remove(PREVIEW_NAME_PREF_KEY);
629
+ } else {
630
+ this.editor.putString(PREVIEW_NAME_PREF_KEY, name);
631
+ }
632
+
633
+ final String source = this.normalizedPreviewMetadataValue(preview.optString("source", null));
634
+ if (source == null) {
635
+ this.editor.remove(PREVIEW_SOURCE_PREF_KEY);
636
+ } else {
637
+ this.editor.putString(PREVIEW_SOURCE_PREF_KEY, source);
638
+ }
639
+ this.editor.apply();
640
+ }
641
+
244
642
  public Thread startNewThread(final Runnable function, Number waitTime) {
245
643
  Thread bgTask = new Thread(() -> {
246
644
  try {
@@ -309,45 +707,6 @@ public class CapacitorUpdaterPlugin extends Plugin {
309
707
  this.implementation.pluginVersion = this.pluginVersion;
310
708
  this.implementation.versionCode = this.getVersionCode(pInfo);
311
709
  // Removed unused OkHttpClient creation - using shared client in DownloadService instead
312
- // Handle directUpdate configuration - support string values and backward compatibility
313
- String directUpdateConfig = this.getConfig().getString("directUpdate", null);
314
- if (directUpdateConfig != null) {
315
- // Handle backward compatibility for boolean true
316
- if (directUpdateConfig.equals("true")) {
317
- this.directUpdateMode = "always";
318
- this.implementation.directUpdate = true;
319
- } else {
320
- this.directUpdateMode = directUpdateConfig;
321
- this.implementation.directUpdate =
322
- directUpdateConfig.equals("always") ||
323
- directUpdateConfig.equals("atInstall") ||
324
- directUpdateConfig.equals("onLaunch");
325
- // Validate directUpdate value
326
- if (
327
- !directUpdateConfig.equals("false") &&
328
- !directUpdateConfig.equals("always") &&
329
- !directUpdateConfig.equals("atInstall") &&
330
- !directUpdateConfig.equals("onLaunch")
331
- ) {
332
- logger.error(
333
- "Invalid directUpdate value: \"" +
334
- directUpdateConfig +
335
- "\". Supported values are: \"false\", \"true\", \"always\", \"atInstall\", \"onLaunch\". Defaulting to \"false\"."
336
- );
337
- this.directUpdateMode = "false";
338
- this.implementation.directUpdate = false;
339
- }
340
- }
341
- } else {
342
- Boolean directUpdateBool = this.getConfig().getBoolean("directUpdate", false);
343
- if (directUpdateBool) {
344
- this.directUpdateMode = "always"; // backward compatibility: true = always
345
- this.implementation.directUpdate = true;
346
- } else {
347
- this.directUpdateMode = "false";
348
- this.implementation.directUpdate = false;
349
- }
350
- }
351
710
  this.currentVersionNative = new Version(this.getConfig().getString("version", pInfo.versionName));
352
711
  this.currentBuildVersion = this.getVersionCode(pInfo);
353
712
  this.delayUpdateUtils = new DelayUpdateUtils(this.prefs, this.editor, this.currentVersionNative, logger);
@@ -383,6 +742,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
383
742
  "appId is missing in capacitor.config.json or plugin config, and cannot be retrieved from the native app, please add it globally or in the plugin config"
384
743
  );
385
744
  }
745
+ this.allowPreview = this.getConfig().getBoolean("allowPreview", false);
386
746
  logger.info("appId: " + implementation.appId);
387
747
 
388
748
  this.persistCustomId = this.getConfig().getBoolean("persistCustomId", false);
@@ -426,13 +786,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
426
786
  this.implementation.defaultChannel = this.getConfig().getString("defaultChannel", "");
427
787
  }
428
788
 
429
- int userValue = this.getConfig().getInt("periodCheckDelay", 0);
430
-
431
- if (userValue >= 0 && userValue <= 600) {
432
- this.periodCheckDelay = 600 * 1000;
433
- } else if (userValue > 600) {
434
- this.periodCheckDelay = userValue * 1000;
435
- }
789
+ this.periodCheckDelay = normalizedPeriodCheckDelayMs(this.getConfig().getInt("periodCheckDelay", 0));
436
790
 
437
791
  this.implementation.documentsDir = this.getContext().getFilesDir();
438
792
  this.implementation.prefs = this.prefs;
@@ -465,7 +819,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
465
819
  }
466
820
  }
467
821
  }
468
- this.autoUpdate = this.getConfig().getBoolean("autoUpdate", true);
822
+ this.configureAutoUpdateModeFromConfig();
469
823
  this.appReadyTimeout = Math.max(1000, this.getConfig().getInt("appReadyTimeout", 10000)); // Minimum 1 second
470
824
  this.keepUrlPathAfterReload = this.getConfig().getBoolean("keepUrlPathAfterReload", false);
471
825
  this.syncKeepUrlPathFlag(this.keepUrlPathAfterReload);
@@ -477,14 +831,45 @@ public class CapacitorUpdaterPlugin extends Plugin {
477
831
  this.implementation.timeout = this.getConfig().getInt("responseTimeout", 20) * 1000;
478
832
  this.shakeMenuEnabled = this.getConfig().getBoolean("shakeMenu", false);
479
833
  this.shakeChannelSelectorEnabled = this.getConfig().getBoolean("allowShakeChannelSelector", false);
834
+ this.shakeMenuGesture = normalizedShakeMenuGesture(this.getConfig().getString("shakeMenuGesture", SHAKE_MENU_GESTURE_SHAKE));
835
+ this.previewSessionEnabled = Boolean.TRUE.equals(this.allowPreview) && this.prefs.getBoolean(PREVIEW_SESSION_PREF_KEY, false);
836
+ if (!Boolean.TRUE.equals(this.allowPreview) && this.prefs.getBoolean(PREVIEW_SESSION_PREF_KEY, false)) {
837
+ this.clearPreviewSessionBecauseDisabled();
838
+ }
839
+ this.implementation.previewSession = Boolean.TRUE.equals(this.previewSessionEnabled);
840
+ if (Boolean.TRUE.equals(this.previewSessionEnabled)) {
841
+ this.previewSessionAlertPending = this.prefs.contains(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY)
842
+ ? this.prefs.getBoolean(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY, false)
843
+ : true;
844
+ final String previewAppId = this.prefs.getString(PREVIEW_APP_ID_PREF_KEY, "");
845
+ if (previewAppId != null && !previewAppId.isEmpty()) {
846
+ this.setActiveAppId(previewAppId);
847
+ logger.info("Using preview appId " + previewAppId);
848
+ }
849
+ this.shakeMenuEnabled = true;
850
+ this.shakeChannelSelectorEnabled = this.prefs.contains(PREVIEW_PREVIOUS_SHAKE_CHANNEL_SELECTOR_PREF_KEY)
851
+ ? this.prefs.getBoolean(PREVIEW_PREVIOUS_SHAKE_CHANNEL_SELECTOR_PREF_KEY, false)
852
+ : this.shakeChannelSelectorEnabled;
853
+ }
480
854
  boolean resetWhenUpdate = this.getConfig().getBoolean("resetWhenUpdate", true);
481
855
 
482
856
  // Check if app was recently installed/updated BEFORE cleanupObsoleteVersions updates LatestVersionNative
483
857
  this.wasRecentlyInstalledOrUpdated = this.checkIfRecentlyInstalledOrUpdated();
858
+ final boolean nativeBuildVersionChanged = this.hasNativeBuildVersionChanged();
484
859
 
485
- this.implementation.autoReset();
860
+ this.implementation.autoReset(this.currentBuildVersion, resetWhenUpdate);
861
+ if (nativeBuildVersionChanged) {
862
+ this.clearPreviewSessionForNativeBuildChange();
863
+ }
864
+ this.leavePreviewSessionForLaunchIntentIfNeeded();
865
+ this.reportNativeVersionStatsIfChanged();
866
+ this.reportPreviousAppExitReasons();
867
+ this.reportPreviousWebViewRenderProcessGone();
868
+ this.installWebViewStatsReporter();
486
869
  if (resetWhenUpdate) {
487
870
  this.cleanupObsoleteVersions();
871
+ } else {
872
+ this.persistCurrentNativeBuildVersion();
488
873
  }
489
874
 
490
875
  // Check for 'kill' delay condition on app launch
@@ -492,6 +877,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
492
877
  this.delayUpdateUtils.checkCancelDelay(DelayUpdateUtils.CancelDelaySource.KILLED);
493
878
 
494
879
  this.checkForUpdateAfterDelay();
880
+ this.showPreviewSessionNoticeIfNeeded();
881
+ this.syncShakeMenuLifecycle();
495
882
 
496
883
  // On Android 14+ (API 34+), topActivity in RecentTaskInfo returns null due to
497
884
  // security restrictions (StrandHogg task hijacking mitigations). Use ProcessLifecycleOwner
@@ -609,6 +996,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
609
996
  if (this.autoSplashscreen) {
610
997
  this.hideSplashscreen();
611
998
  }
999
+ this.hidePreviewTransitionLoader("app-ready");
612
1000
  }
613
1001
 
614
1002
  private void hideSplashscreen() {
@@ -720,6 +1108,38 @@ public class CapacitorUpdaterPlugin extends Plugin {
720
1108
  return requestToken == this.splashscreenInvocationToken;
721
1109
  }
722
1110
 
1111
+ private FrameLayout createLoaderOverlay(final Activity activity, final boolean blocksTouches, final int backgroundColor) {
1112
+ final ProgressBar progressBar = new ProgressBar(activity);
1113
+ progressBar.setIndeterminate(true);
1114
+
1115
+ final FrameLayout overlay = new FrameLayout(activity);
1116
+ overlay.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
1117
+ overlay.setClickable(blocksTouches);
1118
+ overlay.setFocusable(blocksTouches);
1119
+ overlay.setBackgroundColor(backgroundColor);
1120
+ overlay.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
1121
+
1122
+ final FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
1123
+ ViewGroup.LayoutParams.WRAP_CONTENT,
1124
+ ViewGroup.LayoutParams.WRAP_CONTENT
1125
+ );
1126
+ params.gravity = Gravity.CENTER;
1127
+ overlay.addView(progressBar, params);
1128
+ return overlay;
1129
+ }
1130
+
1131
+ private void attachLoaderOverlay(final Activity activity, final FrameLayout overlay) {
1132
+ final ViewGroup decorView = (ViewGroup) activity.getWindow().getDecorView();
1133
+ decorView.addView(overlay);
1134
+ }
1135
+
1136
+ private void removeLoaderOverlay(final FrameLayout overlay) {
1137
+ final ViewGroup parent = (ViewGroup) overlay.getParent();
1138
+ if (parent != null) {
1139
+ parent.removeView(overlay);
1140
+ }
1141
+ }
1142
+
723
1143
  private void addSplashscreenLoaderIfNeeded() {
724
1144
  if (!Boolean.TRUE.equals(this.autoSplashscreenLoader)) {
725
1145
  return;
@@ -736,26 +1156,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
736
1156
  return;
737
1157
  }
738
1158
 
739
- ProgressBar progressBar = new ProgressBar(activity);
740
- progressBar.setIndeterminate(true);
741
-
742
- FrameLayout overlay = new FrameLayout(activity);
743
- overlay.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
744
- overlay.setClickable(false);
745
- overlay.setFocusable(false);
746
- overlay.setBackgroundColor(Color.TRANSPARENT);
747
- overlay.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
748
-
749
- FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
750
- ViewGroup.LayoutParams.WRAP_CONTENT,
751
- ViewGroup.LayoutParams.WRAP_CONTENT
752
- );
753
- params.gravity = Gravity.CENTER;
754
- overlay.addView(progressBar, params);
755
-
756
- ViewGroup decorView = (ViewGroup) activity.getWindow().getDecorView();
757
- decorView.addView(overlay);
758
-
1159
+ FrameLayout overlay = createLoaderOverlay(activity, false, Color.TRANSPARENT);
1160
+ attachLoaderOverlay(activity, overlay);
759
1161
  this.splashscreenLoaderOverlay = overlay;
760
1162
  };
761
1163
 
@@ -769,10 +1171,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
769
1171
  private void removeSplashscreenLoader() {
770
1172
  Runnable removeLoader = () -> {
771
1173
  if (this.splashscreenLoaderOverlay != null) {
772
- ViewGroup parent = (ViewGroup) this.splashscreenLoaderOverlay.getParent();
773
- if (parent != null) {
774
- parent.removeView(this.splashscreenLoaderOverlay);
775
- }
1174
+ removeLoaderOverlay(this.splashscreenLoaderOverlay);
776
1175
  this.splashscreenLoaderOverlay = null;
777
1176
  }
778
1177
  };
@@ -784,6 +1183,84 @@ public class CapacitorUpdaterPlugin extends Plugin {
784
1183
  }
785
1184
  }
786
1185
 
1186
+ private void showPreviewTransitionLoader(final String reason) {
1187
+ this.previewTransitionLoaderRequested = true;
1188
+ final Runnable showLoader = () -> {
1189
+ if (!this.previewTransitionLoaderRequested) {
1190
+ return;
1191
+ }
1192
+
1193
+ if (this.previewTransitionLoaderOverlay != null) {
1194
+ cancelPreviewTransitionLoaderTimeout();
1195
+ schedulePreviewTransitionLoaderTimeout();
1196
+ this.previewTransitionLoaderOverlay.bringToFront();
1197
+ return;
1198
+ }
1199
+
1200
+ final Activity activity = getActivity();
1201
+ if (activity == null) {
1202
+ logger.warn("Preview transition loader unavailable: activity missing for " + reason);
1203
+ this.previewTransitionLoaderRequested = false;
1204
+ return;
1205
+ }
1206
+
1207
+ cancelPreviewTransitionLoaderTimeout();
1208
+ schedulePreviewTransitionLoaderTimeout();
1209
+
1210
+ final FrameLayout overlay = createLoaderOverlay(activity, true, Color.argb(46, 0, 0, 0));
1211
+ attachLoaderOverlay(activity, overlay);
1212
+ this.previewTransitionLoaderOverlay = overlay;
1213
+ logger.info("Preview transition loader shown: " + reason);
1214
+ };
1215
+
1216
+ if (Looper.myLooper() == Looper.getMainLooper()) {
1217
+ showLoader.run();
1218
+ } else {
1219
+ this.mainHandler.post(showLoader);
1220
+ }
1221
+ }
1222
+
1223
+ private void hidePreviewTransitionLoader(final String reason) {
1224
+ if (
1225
+ !this.previewTransitionLoaderRequested &&
1226
+ this.previewTransitionLoaderOverlay == null &&
1227
+ this.previewTransitionLoaderTimeoutRunnable == null
1228
+ ) {
1229
+ return;
1230
+ }
1231
+
1232
+ final Runnable hideLoader = () -> {
1233
+ this.previewTransitionLoaderRequested = false;
1234
+ cancelPreviewTransitionLoaderTimeout();
1235
+ if (this.previewTransitionLoaderOverlay == null) {
1236
+ return;
1237
+ }
1238
+
1239
+ removeLoaderOverlay(this.previewTransitionLoaderOverlay);
1240
+ this.previewTransitionLoaderOverlay = null;
1241
+ logger.info("Preview transition loader hidden: " + reason);
1242
+ };
1243
+
1244
+ if (Looper.myLooper() == Looper.getMainLooper()) {
1245
+ hideLoader.run();
1246
+ } else {
1247
+ this.mainHandler.post(hideLoader);
1248
+ }
1249
+ }
1250
+
1251
+ private void schedulePreviewTransitionLoaderTimeout() {
1252
+ cancelPreviewTransitionLoaderTimeout();
1253
+ this.previewTransitionLoaderTimeoutRunnable = () -> hidePreviewTransitionLoader("preview-transition-timeout");
1254
+ this.mainHandler.postDelayed(this.previewTransitionLoaderTimeoutRunnable, PREVIEW_TRANSITION_LOADER_TIMEOUT_MS);
1255
+ }
1256
+
1257
+ private void cancelPreviewTransitionLoaderTimeout() {
1258
+ if (this.previewTransitionLoaderTimeoutRunnable != null) {
1259
+ this.mainHandler.removeCallbacks(this.previewTransitionLoaderTimeoutRunnable);
1260
+ this.previewTransitionLoaderTimeoutRunnable = null;
1261
+ }
1262
+ }
1263
+
787
1264
  private void scheduleSplashscreenTimeout() {
788
1265
  if (this.autoSplashscreenTimeout == null || this.autoSplashscreenTimeout <= 0) {
789
1266
  return;
@@ -810,7 +1287,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
810
1287
 
811
1288
  private boolean checkIfRecentlyInstalledOrUpdated() {
812
1289
  String currentVersion = this.currentBuildVersion;
813
- String lastKnownVersion = this.prefs.getString("LatestNativeBuildVersion", "");
1290
+ String lastKnownVersion = this.getStoredNativeBuildVersion();
814
1291
 
815
1292
  if (lastKnownVersion.isEmpty()) {
816
1293
  // First time running, consider it as recently installed
@@ -823,781 +1300,2452 @@ public class CapacitorUpdaterPlugin extends Plugin {
823
1300
  return false;
824
1301
  }
825
1302
 
826
- private boolean shouldUseDirectUpdate() {
827
- if (Boolean.TRUE.equals(this.autoSplashscreenTimedOut)) {
828
- return false;
829
- }
830
- switch (this.directUpdateMode) {
831
- case "false":
832
- return false;
833
- case "always":
834
- return true;
835
- case "atInstall":
836
- if (this.wasRecentlyInstalledOrUpdated) {
837
- // Reset the flag after first use to prevent subsequent foreground events from using direct update
838
- this.wasRecentlyInstalledOrUpdated = false;
839
- return true;
840
- }
841
- return false;
842
- case "onLaunch":
843
- if (!this.onLaunchDirectUpdateUsed) {
844
- return true;
845
- }
846
- return false;
847
- default:
848
- logger.error(
849
- "Invalid directUpdateMode: \"" +
850
- this.directUpdateMode +
851
- "\". Supported values are: \"false\", \"always\", \"atInstall\", \"onLaunch\". Defaulting to \"false\" behavior."
852
- );
853
- return false;
854
- }
1303
+ private boolean hasNativeBuildVersionChanged() {
1304
+ final String lastKnownVersion = this.getStoredNativeBuildVersion();
1305
+ return !lastKnownVersion.isEmpty() && !lastKnownVersion.equals(this.currentBuildVersion);
855
1306
  }
856
1307
 
857
- private boolean isDirectUpdateCurrentlyAllowed(final boolean plannedDirectUpdate) {
858
- return plannedDirectUpdate && !Boolean.TRUE.equals(this.autoSplashscreenTimedOut);
859
- }
1308
+ void reportNativeVersionStatsIfChanged() {
1309
+ if (this.implementation == null || this.prefs == null || this.editor == null) {
1310
+ return;
1311
+ }
860
1312
 
861
- static boolean shouldConsumeOnLaunchDirectUpdate(final String directUpdateMode, final boolean plannedDirectUpdate) {
862
- return plannedDirectUpdate && "onLaunch".equals(directUpdateMode);
1313
+ this.reportNativeVersionStatsIfChanged(
1314
+ this.implementation.versionBuild,
1315
+ this.implementation.versionCode,
1316
+ this.implementation.versionOs
1317
+ );
863
1318
  }
864
1319
 
865
- private void consumeOnLaunchDirectUpdateAttempt(final boolean plannedDirectUpdate) {
866
- if (!shouldConsumeOnLaunchDirectUpdate(this.directUpdateMode, plannedDirectUpdate)) {
1320
+ void reportNativeVersionStatsIfChanged(
1321
+ final String currentVersionBuild,
1322
+ final String currentVersionCode,
1323
+ final String currentVersionOs
1324
+ ) {
1325
+ if (this.implementation == null || this.prefs == null || this.editor == null) {
867
1326
  return;
868
1327
  }
869
1328
 
870
- this.onLaunchDirectUpdateUsed = true;
871
- }
1329
+ final String normalizedVersionBuild = this.normalizedStatsValue(currentVersionBuild);
1330
+ final String normalizedVersionCode = this.normalizedStatsValue(currentVersionCode);
1331
+ final String normalizedVersionOs = this.normalizedStatsValue(currentVersionOs);
1332
+ final String previousVersionOs = this.prefs.getString(LAST_VERSION_OS_PREF_KEY, "");
1333
+ final String previousVersionBuild = this.prefs.getString(LAST_VERSION_BUILD_PREF_KEY, "");
1334
+ final String previousVersionCode = this.prefs.getString(LAST_VERSION_CODE_PREF_KEY, "");
1335
+ final boolean osVersionChanged =
1336
+ !normalizedVersionOs.isEmpty() &&
1337
+ previousVersionOs != null &&
1338
+ !previousVersionOs.isEmpty() &&
1339
+ !previousVersionOs.equals(normalizedVersionOs);
1340
+
1341
+ if (osVersionChanged) {
1342
+ final Map<String, String> metadata = new HashMap<>();
1343
+ metadata.put("previous_version_os", previousVersionOs);
1344
+ metadata.put("current_version_os", normalizedVersionOs);
1345
+ this.implementation.sendStats(
1346
+ OS_VERSION_CHANGED_ACTION,
1347
+ this.implementation.getCurrentBundle().getVersionName(),
1348
+ "",
1349
+ metadata,
1350
+ () -> this.persistLastVersionOs(normalizedVersionOs)
1351
+ );
1352
+ }
872
1353
 
873
- void configureDirectUpdateModeForTesting(final String directUpdateMode, final boolean onLaunchDirectUpdateUsed) {
874
- this.directUpdateMode = directUpdateMode;
875
- this.onLaunchDirectUpdateUsed = onLaunchDirectUpdateUsed;
1354
+ final boolean hasPreviousNativeVersion =
1355
+ (previousVersionBuild != null && !previousVersionBuild.isEmpty()) ||
1356
+ (previousVersionCode != null && !previousVersionCode.isEmpty());
1357
+ final boolean nativeVersionChanged =
1358
+ hasPreviousNativeVersion &&
1359
+ (!Objects.equals(previousVersionBuild, normalizedVersionBuild) || !Objects.equals(previousVersionCode, normalizedVersionCode));
1360
+
1361
+ if (nativeVersionChanged) {
1362
+ final Map<String, String> metadata = new HashMap<>();
1363
+ metadata.put("previous_version_build", previousVersionBuild == null ? "" : previousVersionBuild);
1364
+ metadata.put("current_version_build", normalizedVersionBuild);
1365
+ metadata.put("previous_version_code", previousVersionCode == null ? "" : previousVersionCode);
1366
+ metadata.put("current_version_code", normalizedVersionCode);
1367
+ this.implementation.sendStats(
1368
+ NATIVE_APP_VERSION_CHANGED_ACTION,
1369
+ this.implementation.getCurrentBundle().getVersionName(),
1370
+ "",
1371
+ metadata,
1372
+ () -> this.persistLastNativeAppVersion(normalizedVersionBuild, normalizedVersionCode)
1373
+ );
1374
+ }
1375
+
1376
+ if (!osVersionChanged || !nativeVersionChanged) {
1377
+ if (!osVersionChanged) {
1378
+ this.editor.putString(LAST_VERSION_OS_PREF_KEY, normalizedVersionOs);
1379
+ }
1380
+ if (!nativeVersionChanged) {
1381
+ this.editor.putString(LAST_VERSION_BUILD_PREF_KEY, normalizedVersionBuild);
1382
+ this.editor.putString(LAST_VERSION_CODE_PREF_KEY, normalizedVersionCode);
1383
+ }
1384
+ this.editor.apply();
1385
+ }
876
1386
  }
877
1387
 
878
- boolean shouldUseDirectUpdateForTesting() {
879
- return this.shouldUseDirectUpdate();
1388
+ private void persistLastVersionOs(final String versionOs) {
1389
+ if (this.editor == null) {
1390
+ return;
1391
+ }
1392
+
1393
+ this.editor.putString(LAST_VERSION_OS_PREF_KEY, versionOs);
1394
+ this.editor.apply();
880
1395
  }
881
1396
 
882
- boolean hasConsumedOnLaunchDirectUpdateForTesting() {
883
- return this.onLaunchDirectUpdateUsed;
1397
+ private void persistLastNativeAppVersion(final String versionBuild, final String versionCode) {
1398
+ if (this.editor == null) {
1399
+ return;
1400
+ }
1401
+
1402
+ this.editor.putString(LAST_VERSION_BUILD_PREF_KEY, versionBuild);
1403
+ this.editor.putString(LAST_VERSION_CODE_PREF_KEY, versionCode);
1404
+ this.editor.apply();
884
1405
  }
885
1406
 
886
- boolean isVersionDownloadInProgress(final String version) {
887
- return (
888
- version != null &&
889
- !version.isEmpty() &&
890
- this.implementation != null &&
891
- this.implementation.activity != null &&
892
- DownloadWorkerManager.isVersionDownloading(this.implementation.activity, version)
1407
+ private String normalizedStatsValue(final String value) {
1408
+ return value == null ? "" : value;
1409
+ }
1410
+
1411
+ private void reportPreviousAppExitReasons() {
1412
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || this.implementation == null || this.implementation.statsUrl.isEmpty()) {
1413
+ return;
1414
+ }
1415
+
1416
+ AndroidAppExitReporter.reportPreviousAppExitReasons(
1417
+ this.getContext(),
1418
+ this.prefs,
1419
+ this.implementation,
1420
+ this.logger,
1421
+ LAST_REPORTED_APP_EXIT_TIMESTAMP_PREF_KEY
893
1422
  );
894
1423
  }
895
1424
 
896
- void setLoggerForTesting(final Logger logger) {
897
- this.logger = logger;
1425
+ static String statsActionForApplicationExitReason(final int reason) {
1426
+ switch (reason) {
1427
+ case APPLICATION_EXIT_REASON_CRASH:
1428
+ return "app_crash";
1429
+ case APPLICATION_EXIT_REASON_CRASH_NATIVE:
1430
+ return "app_crash_native";
1431
+ case APPLICATION_EXIT_REASON_ANR:
1432
+ return "app_anr";
1433
+ case APPLICATION_EXIT_REASON_LOW_MEMORY:
1434
+ return "app_killed_low_memory";
1435
+ case APPLICATION_EXIT_REASON_EXCESSIVE_RESOURCE_USAGE:
1436
+ return "app_killed_excessive_resource_usage";
1437
+ case APPLICATION_EXIT_REASON_INITIALIZATION_FAILURE:
1438
+ return "app_initialization_failure";
1439
+ default:
1440
+ return null;
1441
+ }
898
1442
  }
899
1443
 
900
- void completeBackgroundTaskForTesting(final BundleInfo current, final boolean plannedDirectUpdate) {
901
- this.endBackGroundTaskWithNotif("test", current.getVersionName(), current, false, plannedDirectUpdate);
1444
+ static String applicationExitReasonName(final int reason) {
1445
+ switch (reason) {
1446
+ case APPLICATION_EXIT_REASON_EXIT_SELF:
1447
+ return "exit_self";
1448
+ case APPLICATION_EXIT_REASON_SIGNALED:
1449
+ return "signaled";
1450
+ case APPLICATION_EXIT_REASON_LOW_MEMORY:
1451
+ return "low_memory";
1452
+ case APPLICATION_EXIT_REASON_CRASH:
1453
+ return "crash";
1454
+ case APPLICATION_EXIT_REASON_CRASH_NATIVE:
1455
+ return "crash_native";
1456
+ case APPLICATION_EXIT_REASON_ANR:
1457
+ return "anr";
1458
+ case APPLICATION_EXIT_REASON_INITIALIZATION_FAILURE:
1459
+ return "initialization_failure";
1460
+ case APPLICATION_EXIT_REASON_PERMISSION_CHANGE:
1461
+ return "permission_change";
1462
+ case APPLICATION_EXIT_REASON_EXCESSIVE_RESOURCE_USAGE:
1463
+ return "excessive_resource_usage";
1464
+ case APPLICATION_EXIT_REASON_USER_REQUESTED:
1465
+ return "user_requested";
1466
+ case APPLICATION_EXIT_REASON_DEPENDENCY_DIED:
1467
+ return "dependency_died";
1468
+ default:
1469
+ return "unknown";
1470
+ }
902
1471
  }
903
1472
 
904
- void scheduleDirectUpdateFinish(final BundleInfo latest) {
905
- startNewThread(() -> {
906
- try {
907
- Activity currentActivity = this.getActivity();
908
- if (currentActivity != null) {
909
- this.implementation.activity = currentActivity;
910
- } else {
911
- logger.warn("directUpdateFinish: Activity is null, proceeding without refreshing the activity reference");
912
- }
913
- this.directUpdateFinish(latest);
914
- } catch (final Exception e) {
915
- logger.error("directUpdateFinish failed: " + e.getMessage());
916
- }
917
- });
1473
+ static String truncateStatsMetadataValue(final String value, final int maxLength) {
1474
+ return value.length() <= maxLength ? value : value.substring(0, maxLength);
918
1475
  }
919
1476
 
920
- private void directUpdateFinish(final BundleInfo latest) {
921
- if ("onLaunch".equals(this.directUpdateMode)) {
922
- this.onLaunchDirectUpdateUsed = true;
923
- this.implementation.directUpdate = false;
924
- }
925
- if (CapacitorUpdaterPlugin.this.implementation.set(latest) && CapacitorUpdaterPlugin.this._reload()) {
926
- this.notifyBundleSet(latest);
927
- sendReadyToJs(latest, "update installed", true);
1477
+ private void installWebViewStatsReporter() {
1478
+ if (this.bridge == null || this.bridge.getWebView() == null || this.webViewStatsListener != null) {
1479
+ return;
928
1480
  }
929
- }
930
1481
 
931
- private void cleanupObsoleteVersions() {
932
- cleanupThread = startNewThread(() -> {
933
- synchronized (cleanupLock) {
934
- try {
935
- final String previous = this.prefs.getString("LatestNativeBuildVersion", "");
936
- if (!"".equals(previous) && !Objects.equals(this.currentBuildVersion, previous)) {
937
- logger.info("New native build version detected: " + this.currentBuildVersion);
938
- this.implementation.reset(true);
939
- final List<BundleInfo> installed = this.implementation.list(false);
940
- for (final BundleInfo bundle : installed) {
941
- // Check if thread was interrupted (cancelled)
942
- if (Thread.currentThread().isInterrupted()) {
943
- logger.warn("Cleanup was cancelled, stopping");
944
- return;
945
- }
946
- try {
947
- logger.info("Deleting obsolete bundle: " + bundle.getId());
948
- this.implementation.delete(bundle.getId());
949
- } catch (final Exception e) {
950
- logger.error("Failed to delete: " + bundle.getId() + " " + e.getMessage());
951
- }
952
- }
953
- final List<BundleInfo> storedBundles = this.implementation.list(true);
954
- final Set<String> allowedIds = new HashSet<>();
955
- for (final BundleInfo info : storedBundles) {
956
- if (info != null && info.getId() != null && !info.getId().isEmpty()) {
957
- allowedIds.add(info.getId());
958
- }
959
- }
960
- this.implementation.cleanupDownloadDirectories(allowedIds, Thread.currentThread());
961
- this.implementation.cleanupOrphanedTempFolders(Thread.currentThread());
1482
+ final android.webkit.WebView webView = this.bridge.getWebView();
1483
+ final String script = buildWebViewStatsReporterScript();
1484
+ this.installDocumentStartWebViewStatsReporter(webView, script);
962
1485
 
963
- // Check again before the expensive delta cache cleanup
964
- if (Thread.currentThread().isInterrupted()) {
965
- logger.warn("Cleanup was cancelled before delta cache cleanup");
966
- return;
967
- }
968
- this.implementation.cleanupDeltaCache(Thread.currentThread());
969
- }
970
- this.editor.putString("LatestNativeBuildVersion", this.currentBuildVersion);
971
- this.editor.apply();
972
- } catch (Exception e) {
973
- logger.error("Error during cleanupObsoleteVersions: " + e.getMessage());
974
- } finally {
975
- cleanupComplete = true;
976
- logger.info("Cleanup complete");
977
- }
1486
+ this.webViewStatsListener = new WebViewListener() {
1487
+ @Override
1488
+ public void onPageStarted(final android.webkit.WebView view) {
1489
+ CapacitorUpdaterPlugin.this.evaluateWebViewStatsReporterScript(view, script);
978
1490
  }
979
- });
980
1491
 
981
- // Start a timeout watchdog thread to cancel cleanup if it takes too long
982
- final long timeout = this.appReadyTimeout / 2;
983
- startNewThread(() -> {
984
- try {
985
- Thread.sleep(timeout);
986
- if (cleanupThread != null && cleanupThread.isAlive() && !cleanupComplete) {
987
- logger.warn("Cleanup timeout exceeded (" + timeout + "ms), interrupting cleanup thread");
988
- cleanupThread.interrupt();
989
- }
990
- } catch (InterruptedException e) {
991
- // Watchdog thread was interrupted, that's fine
1492
+ @Override
1493
+ public void onPageLoaded(final android.webkit.WebView view) {
1494
+ CapacitorUpdaterPlugin.this.evaluateWebViewStatsReporterScript(view, script);
992
1495
  }
993
- });
994
- }
995
-
996
- private void waitForCleanupIfNeeded() {
997
- if (cleanupComplete) {
998
- return; // Already done, no need to wait
999
- }
1000
1496
 
1001
- logger.info("Waiting for cleanup to complete before starting download...");
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
+ };
1002
1504
 
1003
- // Wait for cleanup to complete - blocks until lock is released
1004
- synchronized (cleanupLock) {
1005
- logger.info("Cleanup finished, proceeding with download");
1006
- }
1505
+ this.bridge.addWebViewListener(this.webViewStatsListener);
1506
+ this.evaluateWebViewStatsReporterScript(webView, script);
1007
1507
  }
1008
1508
 
1009
- public void notifyDownload(final String id, final int percent) {
1509
+ private void installDocumentStartWebViewStatsReporter(final android.webkit.WebView webView, final String script) {
1010
1510
  try {
1011
- final JSObject ret = new JSObject();
1012
- ret.put("percent", percent);
1013
- final BundleInfo bundleInfo = this.implementation.getBundleInfo(id);
1014
- ret.put("bundle", InternalUtils.mapToJSObject(bundleInfo.toJSONMap()));
1015
- this.notifyListeners("download", ret);
1016
-
1017
- if (percent == 100) {
1018
- final JSObject retDownloadComplete = new JSObject(ret, new String[] { "bundle" });
1019
- this.notifyListeners("downloadComplete", retDownloadComplete);
1020
- this.implementation.sendStats("download_complete", bundleInfo.getVersionName());
1021
- lastNotifiedStatPercent = 100;
1022
- } else {
1023
- int currentStatPercent = (percent / 10) * 10; // Round down to nearest 10
1024
- if (currentStatPercent > lastNotifiedStatPercent) {
1025
- this.implementation.sendStats("download_" + currentStatPercent, bundleInfo.getVersionName());
1026
- lastNotifiedStatPercent = currentStatPercent;
1027
- }
1511
+ final Class<?> webViewFeature = Class.forName("androidx.webkit.WebViewFeature");
1512
+ final String feature = (String) webViewFeature.getField("DOCUMENT_START_SCRIPT").get(null);
1513
+ final Boolean supported = (Boolean) webViewFeature.getMethod("isFeatureSupported", String.class).invoke(null, feature);
1514
+ if (!Boolean.TRUE.equals(supported)) {
1515
+ return;
1028
1516
  }
1517
+
1518
+ final String allowedOrigin = Uri.parse(this.bridge.getAppUrl())
1519
+ .buildUpon()
1520
+ .path(null)
1521
+ .fragment(null)
1522
+ .clearQuery()
1523
+ .build()
1524
+ .toString();
1525
+ final Class<?> webViewCompat = Class.forName("androidx.webkit.WebViewCompat");
1526
+ webViewCompat
1527
+ .getMethod("addDocumentStartJavaScript", android.webkit.WebView.class, String.class, Set.class)
1528
+ .invoke(null, webView, script, java.util.Collections.singleton(allowedOrigin));
1029
1529
  } catch (final Exception e) {
1030
- logger.error("Could not notify listeners " + e.getMessage());
1530
+ logger.debug("Unable to install document-start WebView stats reporter: " + e.getMessage());
1031
1531
  }
1032
1532
  }
1033
1533
 
1034
- @PluginMethod
1035
- public void setUpdateUrl(final PluginCall call) {
1036
- if (!this.getConfig().getBoolean("allowModifyUrl", false)) {
1037
- logger.error("setUpdateUrl not allowed set allowModifyUrl in your config to true to allow it");
1038
- call.reject("setUpdateUrl not allowed");
1534
+ private void evaluateWebViewStatsReporterScript(final android.webkit.WebView webView, final String script) {
1535
+ if (webView == null) {
1039
1536
  return;
1040
1537
  }
1041
- final String url = call.getString("url");
1042
- if (url == null) {
1043
- logger.error("setUpdateUrl called without url");
1044
- call.reject("setUpdateUrl called without url");
1045
- return;
1538
+
1539
+ this.mainHandler.post(() -> {
1540
+ try {
1541
+ webView.evaluateJavascript(script, null);
1542
+ } catch (final Exception e) {
1543
+ logger.debug("Unable to evaluate WebView stats reporter: " + e.getMessage());
1544
+ }
1545
+ });
1546
+ }
1547
+
1548
+ private Map<String, String> buildWebViewRenderProcessGoneMetadata(final RenderProcessGoneDetail detail) {
1549
+ final Map<String, String> metadata = new HashMap<>();
1550
+ metadata.put("error_type", "render_process_gone");
1551
+ metadata.put("source", "android_on_render_process_gone");
1552
+ metadata.put("timestamp", Long.toString(System.currentTimeMillis()));
1553
+ if (detail != null) {
1554
+ 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
+ }
1046
1558
  }
1047
- this.updateUrl = url;
1048
- if (Boolean.TRUE.equals(this.persistModifyUrl)) {
1049
- this.editor.putString(UPDATE_URL_PREF_KEY, url);
1050
- this.editor.apply();
1559
+ return metadata;
1560
+ }
1561
+
1562
+ private void persistPendingWebViewRenderProcessGone(final Map<String, String> metadata) {
1563
+ try {
1564
+ final JSONObject json = new JSONObject(metadata);
1565
+ this.prefs.edit().putString(LAST_WEBVIEW_RENDER_PROCESS_GONE_PREF_KEY, json.toString()).commit();
1566
+ } catch (final Exception e) {
1567
+ logger.debug("Unable to persist WebView render process crash metadata: " + e.getMessage());
1051
1568
  }
1052
- call.resolve();
1053
1569
  }
1054
1570
 
1055
- @PluginMethod
1056
- public void setStatsUrl(final PluginCall call) {
1057
- if (!this.getConfig().getBoolean("allowModifyUrl", false)) {
1058
- logger.error("setStatsUrl not allowed set allowModifyUrl in your config to true to allow it");
1059
- call.reject("setStatsUrl not allowed");
1571
+ private void reportPreviousWebViewRenderProcessGone() {
1572
+ if (this.implementation == null || this.implementation.statsUrl.isEmpty()) {
1060
1573
  return;
1061
1574
  }
1062
- final String url = call.getString("url");
1063
- if (url == null) {
1064
- logger.error("setStatsUrl called without url");
1065
- call.reject("setStatsUrl called without url");
1575
+
1576
+ final String rawMetadata = this.prefs.getString(LAST_WEBVIEW_RENDER_PROCESS_GONE_PREF_KEY, "");
1577
+ if (rawMetadata == null || rawMetadata.isEmpty()) {
1066
1578
  return;
1067
1579
  }
1068
- this.implementation.statsUrl = url;
1069
- if (Boolean.TRUE.equals(this.persistModifyUrl)) {
1070
- this.editor.putString(STATS_URL_PREF_KEY, url);
1071
- this.editor.apply();
1580
+
1581
+ try {
1582
+ final Map<String, String> metadata = jsonObjectToStringMap(new JSONObject(rawMetadata));
1583
+ metadata.put("reported_after_restart", "true");
1584
+ this.reportWebViewStats("webview_render_process_gone", metadata);
1585
+ this.prefs.edit().remove(LAST_WEBVIEW_RENDER_PROCESS_GONE_PREF_KEY).apply();
1586
+ } catch (final JSONException e) {
1587
+ this.prefs.edit().remove(LAST_WEBVIEW_RENDER_PROCESS_GONE_PREF_KEY).apply();
1072
1588
  }
1073
- call.resolve();
1074
1589
  }
1075
1590
 
1076
- @PluginMethod
1077
- public void setChannelUrl(final PluginCall call) {
1078
- if (!this.getConfig().getBoolean("allowModifyUrl", false)) {
1079
- logger.error("setChannelUrl not allowed set allowModifyUrl in your config to true to allow it");
1080
- call.reject("setChannelUrl not allowed");
1081
- return;
1591
+ private static Map<String, String> jsonObjectToStringMap(final JSONObject json) throws JSONException {
1592
+ final Map<String, String> map = new HashMap<>();
1593
+ final JSONArray names = json.names();
1594
+ if (names == null) {
1595
+ return map;
1082
1596
  }
1083
- final String url = call.getString("url");
1084
- if (url == null) {
1085
- logger.error("setChannelUrl called without url");
1086
- call.reject("setChannelUrl called without url");
1087
- return;
1597
+
1598
+ for (int i = 0; i < names.length(); i++) {
1599
+ final String key = names.getString(i);
1600
+ final String value = json.optString(key, "");
1601
+ if (!value.isEmpty()) {
1602
+ map.put(key, value);
1603
+ }
1088
1604
  }
1089
- this.implementation.channelUrl = url;
1090
- if (Boolean.TRUE.equals(this.persistModifyUrl)) {
1091
- this.editor.putString(CHANNEL_URL_PREF_KEY, url);
1092
- this.editor.apply();
1605
+ return map;
1606
+ }
1607
+
1608
+ private static JSObject jsonObjectToJSObject(final JSONObject json) throws JSONException {
1609
+ final JSObject ret = new JSObject();
1610
+ final JSONArray names = json.names();
1611
+ if (names == null) {
1612
+ return ret;
1093
1613
  }
1094
- call.resolve();
1614
+ for (int i = 0; i < names.length(); i++) {
1615
+ final String key = names.getString(i);
1616
+ ret.put(key, json.get(key));
1617
+ }
1618
+ return ret;
1095
1619
  }
1096
1620
 
1097
1621
  @PluginMethod
1098
- public void getBuiltinVersion(final PluginCall call) {
1099
- try {
1100
- final JSObject ret = new JSObject();
1101
- ret.put("version", this.implementation.versionBuild);
1102
- call.resolve(ret);
1103
- } catch (final Exception e) {
1104
- logger.error("Could not get version " + e.getMessage());
1105
- call.reject("Could not get version", e);
1622
+ public void reportWebViewError(final PluginCall call) {
1623
+ final JSObject data = call.getData();
1624
+ this.reportWebViewStats(
1625
+ statsActionForWebViewErrorType(data.optString("type", "javascript_error")),
1626
+ buildWebViewErrorMetadata(data)
1627
+ );
1628
+ call.resolve();
1629
+ }
1630
+
1631
+ private void reportWebViewStats(final String action, final Map<String, String> metadata) {
1632
+ if (this.implementation == null) {
1633
+ return;
1106
1634
  }
1635
+
1636
+ final BundleInfo current = this.implementation.getCurrentBundle();
1637
+ final String versionName = current == null ? "" : current.getVersionName();
1638
+ this.implementation.sendStats(action, versionName, "", metadata);
1107
1639
  }
1108
1640
 
1109
- @PluginMethod
1110
- public void getDeviceId(final PluginCall call) {
1111
- try {
1112
- final JSObject ret = new JSObject();
1113
- ret.put("deviceId", this.implementation.deviceID);
1114
- call.resolve(ret);
1115
- } catch (final Exception e) {
1116
- logger.error("Could not get device id " + e.getMessage());
1117
- call.reject("Could not get device id", e);
1641
+ static String statsActionForWebViewErrorType(final String type) {
1642
+ switch (type) {
1643
+ case "unhandled_rejection":
1644
+ return "webview_unhandled_rejection";
1645
+ case "resource_error":
1646
+ return "webview_resource_error";
1647
+ case "security_policy_violation":
1648
+ return "webview_security_policy_violation";
1649
+ case "webview_unclean_restart":
1650
+ return "webview_unclean_restart";
1651
+ case "render_process_gone":
1652
+ return "webview_render_process_gone";
1653
+ case "web_content_process_terminated":
1654
+ return "webview_content_process_terminated";
1655
+ case "javascript_error":
1656
+ default:
1657
+ return "webview_javascript_error";
1118
1658
  }
1119
1659
  }
1120
1660
 
1121
- @PluginMethod
1122
- public void setCustomId(final PluginCall call) {
1123
- final String customId = call.getString("customId");
1124
- if (customId == null) {
1125
- logger.error("setCustomId called without customId");
1126
- call.reject("setCustomId called without customId");
1661
+ static Map<String, String> buildWebViewErrorMetadata(final JSObject data) {
1662
+ final Map<String, String> metadata = new HashMap<>();
1663
+ putStatsMetadataValue(metadata, "error_type", data.optString("type", "javascript_error"), 64);
1664
+ putStatsMetadataValue(metadata, "message", data.optString("message", ""), 1024);
1665
+ putStatsMetadataValue(metadata, "source", sanitizeStatsMetadataUrl(data.optString("source", "")), 512);
1666
+ putStatsMetadataValue(metadata, "line", data.optString("line", data.optString("lineno", "")), 32);
1667
+ putStatsMetadataValue(metadata, "column", data.optString("column", data.optString("colno", "")), 32);
1668
+ putStatsMetadataValue(metadata, "stack", data.optString("stack", ""), 2048);
1669
+ putStatsMetadataValue(metadata, "tag_name", data.optString("tag_name", ""), 64);
1670
+ putStatsMetadataValue(metadata, "href", sanitizeStatsMetadataUrl(data.optString("href", "")), 512);
1671
+ putStatsMetadataValue(metadata, "user_agent", data.optString("user_agent", ""), 256);
1672
+ putStatsMetadataValue(metadata, "session_id", data.optString("session_id", ""), 128);
1673
+ putStatsMetadataValue(metadata, "previous_session_id", data.optString("previous_session_id", ""), 128);
1674
+ putStatsMetadataValue(metadata, "previous_href", sanitizeStatsMetadataUrl(data.optString("previous_href", "")), 512);
1675
+ putStatsMetadataValue(metadata, "previous_started_at", data.optString("previous_started_at", ""), 64);
1676
+ putStatsMetadataValue(metadata, "previous_updated_at", data.optString("previous_updated_at", ""), 64);
1677
+ return metadata;
1678
+ }
1679
+
1680
+ private static void putStatsMetadataValue(
1681
+ final Map<String, String> metadata,
1682
+ final String key,
1683
+ final String value,
1684
+ final int maxLength
1685
+ ) {
1686
+ if (value == null || value.isEmpty()) {
1127
1687
  return;
1128
1688
  }
1129
- this.implementation.customId = customId;
1130
- if (Boolean.TRUE.equals(this.persistCustomId)) {
1689
+
1690
+ metadata.put(key, truncateStatsMetadataValue(value, maxLength));
1691
+ }
1692
+
1693
+ static String sanitizeStatsMetadataUrl(final String value) {
1694
+ if (value == null || value.isEmpty()) {
1695
+ return "";
1696
+ }
1697
+
1698
+ try {
1699
+ final java.net.URI uri = new java.net.URI(value);
1700
+ if (uri.getScheme() != null && uri.getHost() != null) {
1701
+ final String path = sanitizeStatsMetadataUrlPath(uri.getPath());
1702
+ return new java.net.URI(
1703
+ uri.getScheme(),
1704
+ null,
1705
+ uri.getHost(),
1706
+ uri.getPort(),
1707
+ path.isEmpty() ? null : path,
1708
+ null,
1709
+ null
1710
+ ).toString();
1711
+ }
1712
+ } catch (Exception ignored) {}
1713
+
1714
+ try {
1715
+ final Uri uri = Uri.parse(value);
1716
+ if (uri.getScheme() != null && uri.getHost() != null) {
1717
+ final String host = stripUrlUserInfo(uri.getHost());
1718
+ if (host.isEmpty()) {
1719
+ return stripUrlQueryAndFragment(value);
1720
+ }
1721
+ final StringBuilder authority = new StringBuilder(host);
1722
+ if (uri.getPort() != -1) {
1723
+ authority.append(':').append(uri.getPort());
1724
+ }
1725
+ final Uri.Builder builder = new Uri.Builder().scheme(uri.getScheme()).authority(authority.toString());
1726
+ final String path = sanitizeStatsMetadataUrlPath(uri.getPath());
1727
+ if (!path.isEmpty()) {
1728
+ builder.path(path);
1729
+ }
1730
+ return builder.build().toString();
1731
+ }
1732
+ } catch (Exception ignored) {}
1733
+
1734
+ return stripUrlQueryAndFragment(value);
1735
+ }
1736
+
1737
+ private static String sanitizeStatsMetadataUrlPath(final String path) {
1738
+ if (path == null || path.isEmpty()) {
1739
+ return "";
1740
+ }
1741
+
1742
+ final String[] segments = path.split("/", -1);
1743
+ for (int index = 0; index < segments.length; index++) {
1744
+ if (isSensitiveUrlPathSegment(segments[index])) {
1745
+ segments[index] = "redacted";
1746
+ }
1747
+ }
1748
+ return String.join("/", segments);
1749
+ }
1750
+
1751
+ private static String stripUrlUserInfo(final String host) {
1752
+ if (host == null || host.isEmpty()) {
1753
+ return "";
1754
+ }
1755
+
1756
+ final int userInfoIndex = host.lastIndexOf('@');
1757
+ if (userInfoIndex < 0) {
1758
+ return host;
1759
+ }
1760
+ return host.substring(userInfoIndex + 1);
1761
+ }
1762
+
1763
+ private static boolean isSensitiveUrlPathSegment(final String segment) {
1764
+ return (
1765
+ segment.matches("[0-9]{6,}") ||
1766
+ segment.matches("[0-9a-fA-F]{16,}") ||
1767
+ segment.matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
1768
+ );
1769
+ }
1770
+
1771
+ private static String stripUrlQueryAndFragment(final String value) {
1772
+ int end = value.length();
1773
+ final int queryIndex = value.indexOf('?');
1774
+ final int fragmentIndex = value.indexOf('#');
1775
+ if (queryIndex >= 0) {
1776
+ end = Math.min(end, queryIndex);
1777
+ }
1778
+ if (fragmentIndex >= 0) {
1779
+ end = Math.min(end, fragmentIndex);
1780
+ }
1781
+ return value.substring(0, end);
1782
+ }
1783
+
1784
+ static String buildWebViewStatsReporterScript() {
1785
+ return (
1786
+ "(function(){" +
1787
+ "if(window.__capgoWebViewErrorReporterInstalled){return;}" +
1788
+ "window.__capgoWebViewErrorReporterInstalled=true;" +
1789
+ "var maxReports=20,sentReports=0,queue=[],seen={};" +
1790
+ "var sessionKey='CapacitorUpdater.webViewSession';" +
1791
+ "var sessionId=String(Date.now())+'-'+Math.random().toString(36).slice(2);" +
1792
+ "function s(value){try{if(value===undefined){return '';}if(value===null){return 'null';}if(typeof value==='string'){return value;}if(value&&typeof value.message==='string'){return value.message;}return String(value);}catch(_){return '';}}" +
1793
+ "function stack(value){try{return value&&value.stack?String(value.stack):'';}catch(_){return '';}}" +
1794
+ "function updater(){var cap=window.Capacitor;if(!cap||!cap.Plugins){return null;}return cap.Plugins.CapacitorUpdater||null;}" +
1795
+ "function flush(){var plugin=updater();if(!plugin||typeof plugin.reportWebViewError!=='function'){return false;}while(queue.length){var payload=queue.shift();try{var result=plugin.reportWebViewError(payload);if(result&&typeof result.catch==='function'){result.catch(function(){});}}catch(_){}}return true;}" +
1796
+ "var retries=0;function scheduleFlush(){if(flush()){return;}if(retries++<40){setTimeout(scheduleFlush,250);}}" +
1797
+ "function send(payload){try{if(sentReports>=maxReports){return;}payload.href=payload.href||location.href||'';payload.user_agent=navigator.userAgent||'';payload.session_id=sessionId;var key=[payload.type,payload.message,payload.source,payload.line,payload.column,payload.tag_name].join('|');if(seen[key]){return;}seen[key]=true;sentReports+=1;queue.push(payload);scheduleFlush();}catch(_){}}" +
1798
+ "function readSession(){try{return JSON.parse(localStorage.getItem(sessionKey)||'null')||null;}catch(_){return null;}}" +
1799
+ "function writeSession(active){try{localStorage.setItem(sessionKey,JSON.stringify({id:sessionId,active:active,href:location.href||'',started_at:window.__capgoWebViewSessionStartedAt,updated_at:String(Date.now())}));}catch(_){}}" +
1800
+ "window.__capgoWebViewSessionStartedAt=String(Date.now());" +
1801
+ "var previous=readSession();" +
1802
+ "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
+ "writeSession(true);" +
1804
+ "setInterval(function(){writeSession(true);},15000);" +
1805
+ "function markClean(){writeSession(false);}" +
1806
+ "window.addEventListener('pagehide',markClean,true);" +
1807
+ "window.addEventListener('beforeunload',markClean,true);" +
1808
+ "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
+ "window.addEventListener('unhandledrejection',function(event){var reason=event&&event.reason;send({type:'unhandled_rejection',message:s(reason),stack:stack(reason)});},true);" +
1810
+ "document.addEventListener('securitypolicyviolation',function(event){send({type:'security_policy_violation',message:s(event&&event.violatedDirective),source:s(event&&event.blockedURI)});},true);" +
1811
+ "document.addEventListener('deviceready',scheduleFlush,false);" +
1812
+ "setTimeout(scheduleFlush,0);" +
1813
+ "})();"
1814
+ );
1815
+ }
1816
+
1817
+ private boolean shouldUseDirectUpdate() {
1818
+ if (!Boolean.TRUE.equals(this.autoUpdate) || AUTO_UPDATE_MODE_ONLY_DOWNLOAD.equals(this.autoUpdateMode)) {
1819
+ return false;
1820
+ }
1821
+ if (Boolean.TRUE.equals(this.autoSplashscreenTimedOut)) {
1822
+ return false;
1823
+ }
1824
+ switch (this.directUpdateMode) {
1825
+ case "false":
1826
+ return false;
1827
+ case "always":
1828
+ return true;
1829
+ case "atInstall":
1830
+ if (this.wasRecentlyInstalledOrUpdated) {
1831
+ // Reset the flag after first use to prevent subsequent foreground events from using direct update
1832
+ this.wasRecentlyInstalledOrUpdated = false;
1833
+ return true;
1834
+ }
1835
+ return false;
1836
+ case "onLaunch":
1837
+ if (!this.onLaunchDirectUpdateUsed) {
1838
+ return true;
1839
+ }
1840
+ return false;
1841
+ default:
1842
+ logger.error(
1843
+ "Invalid directUpdateMode: \"" +
1844
+ this.directUpdateMode +
1845
+ "\". Supported values are: \"false\", \"always\", \"atInstall\", \"onLaunch\". Defaulting to \"false\" behavior."
1846
+ );
1847
+ return false;
1848
+ }
1849
+ }
1850
+
1851
+ private void configureAutoUpdateModeFromConfig() {
1852
+ final String configuredMode = this.getConfig().getString("autoUpdate", null);
1853
+ if (configuredMode != null && !configuredMode.isEmpty() && !"true".equals(configuredMode) && !"false".equals(configuredMode)) {
1854
+ this.autoUpdateMode = normalizedAutoUpdateMode(configuredMode);
1855
+ if (!this.autoUpdateMode.equals(configuredMode)) {
1856
+ logger.error(
1857
+ "Invalid autoUpdate value: \"" +
1858
+ configuredMode +
1859
+ "\". Supported values are: true, false, \"off\", \"atBackground\", \"atInstall\", \"onLaunch\", \"always\", \"onlyDownload\". Defaulting to \"atBackground\"."
1860
+ );
1861
+ }
1862
+ } else {
1863
+ final boolean enabled =
1864
+ configuredMode != null
1865
+ ? "true".equals(configuredMode)
1866
+ : Boolean.TRUE.equals(this.getConfig().getBoolean("autoUpdate", true));
1867
+ this.autoUpdateMode = enabled
1868
+ ? autoUpdateModeForLegacyDirectUpdateMode(this.resolveLegacyDirectUpdateModeFromConfig())
1869
+ : AUTO_UPDATE_MODE_OFF;
1870
+ }
1871
+
1872
+ this.autoUpdate = isAutoUpdateModeEnabled(this.autoUpdateMode);
1873
+ this.directUpdateMode = directUpdateModeForAutoUpdateMode(this.autoUpdateMode);
1874
+ this.implementation.directUpdate = isDirectUpdateMode(this.directUpdateMode);
1875
+ }
1876
+
1877
+ private String resolveLegacyDirectUpdateModeFromConfig() {
1878
+ final String directUpdateConfig = this.getConfig().getString("directUpdate", null);
1879
+ if (directUpdateConfig != null) {
1880
+ if ("true".equals(directUpdateConfig)) {
1881
+ return AUTO_UPDATE_MODE_ALWAYS;
1882
+ }
1883
+ if ("false".equals(directUpdateConfig) || isDirectUpdateMode(directUpdateConfig)) {
1884
+ return directUpdateConfig;
1885
+ }
1886
+ logger.error(
1887
+ "Invalid directUpdate value: \"" +
1888
+ directUpdateConfig +
1889
+ "\". Supported values are: false, true, \"always\", \"atInstall\", \"onLaunch\". Defaulting to \"false\"."
1890
+ );
1891
+ return "false";
1892
+ }
1893
+
1894
+ return Boolean.TRUE.equals(this.getConfig().getBoolean("directUpdate", false)) ? AUTO_UPDATE_MODE_ALWAYS : "false";
1895
+ }
1896
+
1897
+ static String normalizedAutoUpdateMode(final String value) {
1898
+ if (value == null) {
1899
+ return AUTO_UPDATE_MODE_BACKGROUND;
1900
+ }
1901
+ switch (value) {
1902
+ case "false":
1903
+ case AUTO_UPDATE_MODE_OFF:
1904
+ return AUTO_UPDATE_MODE_OFF;
1905
+ case "true":
1906
+ case AUTO_UPDATE_MODE_BACKGROUND:
1907
+ return AUTO_UPDATE_MODE_BACKGROUND;
1908
+ case AUTO_UPDATE_MODE_INSTALL:
1909
+ case AUTO_UPDATE_MODE_LAUNCH:
1910
+ case AUTO_UPDATE_MODE_ALWAYS:
1911
+ case AUTO_UPDATE_MODE_ONLY_DOWNLOAD:
1912
+ return value;
1913
+ default:
1914
+ return AUTO_UPDATE_MODE_BACKGROUND;
1915
+ }
1916
+ }
1917
+
1918
+ static String normalizedShakeMenuGesture(final String value) {
1919
+ if (value == null || value.trim().isEmpty()) {
1920
+ return SHAKE_MENU_GESTURE_SHAKE;
1921
+ }
1922
+ final String normalized = value.trim();
1923
+ if (SHAKE_MENU_GESTURE_THREE_FINGER_PINCH.equals(normalized)) {
1924
+ return SHAKE_MENU_GESTURE_THREE_FINGER_PINCH;
1925
+ }
1926
+ return SHAKE_MENU_GESTURE_SHAKE;
1927
+ }
1928
+
1929
+ static boolean isSupportedShakeMenuGesture(final String value) {
1930
+ if (value == null) {
1931
+ return true;
1932
+ }
1933
+ final String normalized = value.trim();
1934
+ if (normalized.isEmpty()) {
1935
+ return false;
1936
+ }
1937
+ return SHAKE_MENU_GESTURE_SHAKE.equals(normalized) || SHAKE_MENU_GESTURE_THREE_FINGER_PINCH.equals(normalized);
1938
+ }
1939
+
1940
+ static String autoUpdateModeForLegacyDirectUpdateMode(final String directUpdateMode) {
1941
+ switch (directUpdateMode) {
1942
+ case AUTO_UPDATE_MODE_INSTALL:
1943
+ case AUTO_UPDATE_MODE_LAUNCH:
1944
+ case AUTO_UPDATE_MODE_ALWAYS:
1945
+ return directUpdateMode;
1946
+ case "false":
1947
+ default:
1948
+ return AUTO_UPDATE_MODE_BACKGROUND;
1949
+ }
1950
+ }
1951
+
1952
+ static String directUpdateModeForAutoUpdateMode(final String autoUpdateMode) {
1953
+ switch (autoUpdateMode) {
1954
+ case AUTO_UPDATE_MODE_INSTALL:
1955
+ case AUTO_UPDATE_MODE_LAUNCH:
1956
+ case AUTO_UPDATE_MODE_ALWAYS:
1957
+ return autoUpdateMode;
1958
+ default:
1959
+ return "false";
1960
+ }
1961
+ }
1962
+
1963
+ static boolean isAutoUpdateModeEnabled(final String autoUpdateMode) {
1964
+ return !AUTO_UPDATE_MODE_OFF.equals(autoUpdateMode);
1965
+ }
1966
+
1967
+ static boolean shouldAutoUpdateModeSetNextBundle(final String autoUpdateMode) {
1968
+ return isAutoUpdateModeEnabled(autoUpdateMode) && !AUTO_UPDATE_MODE_ONLY_DOWNLOAD.equals(autoUpdateMode);
1969
+ }
1970
+
1971
+ static boolean isDirectUpdateMode(final String directUpdateMode) {
1972
+ return (
1973
+ AUTO_UPDATE_MODE_INSTALL.equals(directUpdateMode) ||
1974
+ AUTO_UPDATE_MODE_LAUNCH.equals(directUpdateMode) ||
1975
+ AUTO_UPDATE_MODE_ALWAYS.equals(directUpdateMode)
1976
+ );
1977
+ }
1978
+
1979
+ private boolean shouldAutoSetNextBundle() {
1980
+ return shouldAutoUpdateModeSetNextBundle(this.autoUpdateMode);
1981
+ }
1982
+
1983
+ private boolean isDirectUpdateCurrentlyAllowed(final boolean plannedDirectUpdate) {
1984
+ return plannedDirectUpdate && !Boolean.TRUE.equals(this.autoSplashscreenTimedOut);
1985
+ }
1986
+
1987
+ static boolean shouldConsumeOnLaunchDirectUpdate(final String directUpdateMode, final boolean plannedDirectUpdate) {
1988
+ return plannedDirectUpdate && "onLaunch".equals(directUpdateMode);
1989
+ }
1990
+
1991
+ static int normalizedPeriodCheckDelayMs(final int valueSeconds) {
1992
+ final int normalizedSeconds = normalizedPeriodCheckDelaySeconds(valueSeconds);
1993
+ if (normalizedSeconds <= 0) {
1994
+ return 0;
1995
+ }
1996
+ final long delayMs = (long) normalizedSeconds * 1000L;
1997
+ return delayMs > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) delayMs;
1998
+ }
1999
+
2000
+ static int normalizedPeriodCheckDelaySeconds(final int valueSeconds) {
2001
+ if (valueSeconds <= 0) {
2002
+ return 0;
2003
+ }
2004
+ return Math.max(600, valueSeconds);
2005
+ }
2006
+
2007
+ private void consumeOnLaunchDirectUpdateAttempt(final boolean plannedDirectUpdate) {
2008
+ if (!shouldConsumeOnLaunchDirectUpdate(this.directUpdateMode, plannedDirectUpdate)) {
2009
+ return;
2010
+ }
2011
+
2012
+ this.onLaunchDirectUpdateUsed = true;
2013
+ }
2014
+
2015
+ void configureDirectUpdateModeForTesting(final String directUpdateMode, final boolean onLaunchDirectUpdateUsed) {
2016
+ this.directUpdateMode = directUpdateMode;
2017
+ this.autoUpdateMode = autoUpdateModeForLegacyDirectUpdateMode(directUpdateMode);
2018
+ this.autoUpdate = isAutoUpdateModeEnabled(this.autoUpdateMode);
2019
+ if (this.implementation != null) {
2020
+ this.implementation.directUpdate = isDirectUpdateMode(this.directUpdateMode);
2021
+ }
2022
+ this.onLaunchDirectUpdateUsed = onLaunchDirectUpdateUsed;
2023
+ }
2024
+
2025
+ void setAutoUpdateModeForTesting(final String autoUpdateMode) {
2026
+ this.autoUpdateMode = normalizedAutoUpdateMode(autoUpdateMode);
2027
+ this.autoUpdate = isAutoUpdateModeEnabled(this.autoUpdateMode);
2028
+ this.directUpdateMode = directUpdateModeForAutoUpdateMode(this.autoUpdateMode);
2029
+ if (this.implementation != null) {
2030
+ this.implementation.directUpdate = isDirectUpdateMode(this.directUpdateMode);
2031
+ }
2032
+ }
2033
+
2034
+ boolean shouldUseDirectUpdateForTesting() {
2035
+ return this.shouldUseDirectUpdate();
2036
+ }
2037
+
2038
+ boolean hasConsumedOnLaunchDirectUpdateForTesting() {
2039
+ return this.onLaunchDirectUpdateUsed;
2040
+ }
2041
+
2042
+ boolean isVersionDownloadInProgress(final String version) {
2043
+ return (
2044
+ version != null &&
2045
+ !version.isEmpty() &&
2046
+ this.implementation != null &&
2047
+ this.implementation.activity != null &&
2048
+ DownloadWorkerManager.isVersionDownloading(this.implementation.activity, version)
2049
+ );
2050
+ }
2051
+
2052
+ void setLoggerForTesting(final Logger logger) {
2053
+ this.logger = logger;
2054
+ }
2055
+
2056
+ void completeBackgroundTaskForTesting(final BundleInfo current, final boolean plannedDirectUpdate) {
2057
+ this.endBackGroundTaskWithNotif("test", current.getVersionName(), current, false, plannedDirectUpdate);
2058
+ }
2059
+
2060
+ void scheduleDirectUpdateFinish(final BundleInfo latest) {
2061
+ startNewThread(() -> {
2062
+ try {
2063
+ if (this.shouldBlockAutoUpdateForPreviewSession()) {
2064
+ logger.info("Skipping direct update install while preview session state is active");
2065
+ this.implementation.directUpdate = false;
2066
+ this.clearBackgroundDownloadState();
2067
+ return;
2068
+ }
2069
+ Activity currentActivity = this.getActivity();
2070
+ if (currentActivity != null) {
2071
+ this.implementation.activity = currentActivity;
2072
+ } else {
2073
+ logger.warn("directUpdateFinish: Activity is null, proceeding without refreshing the activity reference");
2074
+ }
2075
+ this.directUpdateFinish(latest);
2076
+ } catch (final Exception e) {
2077
+ logger.error("directUpdateFinish failed: " + e.getMessage());
2078
+ }
2079
+ });
2080
+ }
2081
+
2082
+ private void directUpdateFinish(final BundleInfo latest) {
2083
+ if (this.shouldBlockAutoUpdateForPreviewSession()) {
2084
+ logger.info("Skipping direct update finish while preview session state is active");
2085
+ this.implementation.directUpdate = false;
2086
+ this.clearBackgroundDownloadState();
2087
+ return;
2088
+ }
2089
+ if ("onLaunch".equals(this.directUpdateMode)) {
2090
+ this.onLaunchDirectUpdateUsed = true;
2091
+ this.implementation.directUpdate = false;
2092
+ }
2093
+ if (this.applyDownloadedBundleForDirectUpdate(latest)) {
2094
+ this.implementation.setNextBundle(null);
2095
+ this.notifyBundleSet(latest);
2096
+ sendReadyToJs(latest, "update installed", true);
2097
+ } else {
2098
+ this.implementation.setNextBundle(latest.getId());
2099
+ final JSObject ret = new JSObject();
2100
+ ret.put("bundle", InternalUtils.mapToJSObject(latest.toJSONMap()));
2101
+ this.notifyListeners("updateAvailable", ret);
2102
+ sendReadyToJs(
2103
+ this.implementation.getCurrentBundle(),
2104
+ "Direct update reload failed, update will install next background",
2105
+ false
2106
+ );
2107
+ }
2108
+ }
2109
+
2110
+ private boolean applyDownloadedBundleForDirectUpdate(final BundleInfo latest) {
2111
+ final CapgoUpdater.ResetState previousState = this.implementation.captureResetState();
2112
+ final String previousBundleName = this.implementation.getCurrentBundle().getVersionName();
2113
+
2114
+ if (!this.implementation.stagePendingReload(latest)) {
2115
+ this.implementation.restoreResetState(previousState);
2116
+ logger.error("Direct update failed to stage downloaded bundle: " + latest.toString());
2117
+ return false;
2118
+ }
2119
+
2120
+ if (this._reload()) {
2121
+ this.implementation.finalizePendingReload(latest, previousBundleName);
2122
+ return true;
2123
+ }
2124
+
2125
+ this.implementation.restoreResetState(previousState);
2126
+ this.restoreLiveBundleStateAfterFailedReload();
2127
+ logger.error("Direct update reload failed after staging bundle: " + latest.toString());
2128
+ return false;
2129
+ }
2130
+
2131
+ private void cleanupObsoleteVersions() {
2132
+ 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());
2158
+ }
2159
+ }
2160
+ this.implementation.cleanupDownloadDirectories(allowedIds, Thread.currentThread());
2161
+ this.implementation.cleanupOrphanedTempFolders(Thread.currentThread());
2162
+
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
+ });
2180
+
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();
2189
+ }
2190
+ } catch (InterruptedException e) {
2191
+ // Watchdog thread was interrupted, that's fine
2192
+ }
2193
+ });
2194
+ }
2195
+
2196
+ String getStoredNativeBuildVersion() {
2197
+ String previous = this.prefs.getString("LatestNativeBuildVersion", "");
2198
+ if (previous == null || previous.isEmpty()) {
2199
+ previous = this.prefs.getString("LatestVersionNative", "");
2200
+ }
2201
+ return previous == null ? "" : previous;
2202
+ }
2203
+
2204
+ void persistCurrentNativeBuildVersion() {
2205
+ this.editor.putString("LatestNativeBuildVersion", this.currentBuildVersion);
2206
+ this.editor.apply();
2207
+ }
2208
+
2209
+ private void waitForCleanupIfNeeded() {
2210
+ if (cleanupComplete) {
2211
+ return; // Already done, no need to wait
2212
+ }
2213
+
2214
+ 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");
2219
+ }
2220
+ }
2221
+
2222
+ public void notifyDownload(final String id, final int percent) {
2223
+ try {
2224
+ final JSObject ret = new JSObject();
2225
+ ret.put("percent", percent);
2226
+ final BundleInfo bundleInfo = this.implementation.getBundleInfo(id);
2227
+ ret.put("bundle", InternalUtils.mapToJSObject(bundleInfo.toJSONMap()));
2228
+ this.notifyListeners("download", ret);
2229
+
2230
+ if (percent == 100) {
2231
+ final JSObject retDownloadComplete = new JSObject(ret, new String[] { "bundle" });
2232
+ this.notifyListeners("downloadComplete", retDownloadComplete);
2233
+ this.implementation.sendStats("download_complete", bundleInfo.getVersionName());
2234
+ lastNotifiedStatPercent = 100;
2235
+ } else {
2236
+ int currentStatPercent = (percent / 10) * 10; // Round down to nearest 10
2237
+ if (currentStatPercent > lastNotifiedStatPercent) {
2238
+ this.implementation.sendStats("download_" + currentStatPercent, bundleInfo.getVersionName());
2239
+ lastNotifiedStatPercent = currentStatPercent;
2240
+ }
2241
+ }
2242
+ } catch (final Exception e) {
2243
+ logger.error("Could not notify listeners " + e.getMessage());
2244
+ }
2245
+ }
2246
+
2247
+ @PluginMethod
2248
+ public void setUpdateUrl(final PluginCall call) {
2249
+ if (!this.getConfig().getBoolean("allowModifyUrl", false)) {
2250
+ logger.error("setUpdateUrl not allowed set allowModifyUrl in your config to true to allow it");
2251
+ call.reject("setUpdateUrl not allowed");
2252
+ return;
2253
+ }
2254
+ final String url = call.getString("url");
2255
+ if (url == null) {
2256
+ logger.error("setUpdateUrl called without url");
2257
+ call.reject("setUpdateUrl called without url");
2258
+ return;
2259
+ }
2260
+ this.updateUrl = url;
2261
+ if (Boolean.TRUE.equals(this.persistModifyUrl)) {
2262
+ this.editor.putString(UPDATE_URL_PREF_KEY, url);
2263
+ this.editor.apply();
2264
+ }
2265
+ call.resolve();
2266
+ }
2267
+
2268
+ @PluginMethod
2269
+ public void setStatsUrl(final PluginCall call) {
2270
+ if (!this.getConfig().getBoolean("allowModifyUrl", false)) {
2271
+ logger.error("setStatsUrl not allowed set allowModifyUrl in your config to true to allow it");
2272
+ call.reject("setStatsUrl not allowed");
2273
+ return;
2274
+ }
2275
+ final String url = call.getString("url");
2276
+ if (url == null) {
2277
+ logger.error("setStatsUrl called without url");
2278
+ call.reject("setStatsUrl called without url");
2279
+ return;
2280
+ }
2281
+ this.implementation.statsUrl = url;
2282
+ if (Boolean.TRUE.equals(this.persistModifyUrl)) {
2283
+ this.editor.putString(STATS_URL_PREF_KEY, url);
2284
+ this.editor.apply();
2285
+ }
2286
+ call.resolve();
2287
+ }
2288
+
2289
+ @PluginMethod
2290
+ public void setChannelUrl(final PluginCall call) {
2291
+ if (!this.getConfig().getBoolean("allowModifyUrl", false)) {
2292
+ logger.error("setChannelUrl not allowed set allowModifyUrl in your config to true to allow it");
2293
+ call.reject("setChannelUrl not allowed");
2294
+ return;
2295
+ }
2296
+ final String url = call.getString("url");
2297
+ if (url == null) {
2298
+ logger.error("setChannelUrl called without url");
2299
+ call.reject("setChannelUrl called without url");
2300
+ return;
2301
+ }
2302
+ this.implementation.channelUrl = url;
2303
+ if (Boolean.TRUE.equals(this.persistModifyUrl)) {
2304
+ this.editor.putString(CHANNEL_URL_PREF_KEY, url);
2305
+ this.editor.apply();
2306
+ }
2307
+ call.resolve();
2308
+ }
2309
+
2310
+ @PluginMethod
2311
+ public void getBuiltinVersion(final PluginCall call) {
2312
+ try {
2313
+ final JSObject ret = new JSObject();
2314
+ ret.put("version", this.implementation.versionBuild);
2315
+ call.resolve(ret);
2316
+ } catch (final Exception e) {
2317
+ logger.error("Could not get version " + e.getMessage());
2318
+ call.reject("Could not get version", e);
2319
+ }
2320
+ }
2321
+
2322
+ @PluginMethod
2323
+ public void getDeviceId(final PluginCall call) {
2324
+ try {
2325
+ final JSObject ret = new JSObject();
2326
+ ret.put("deviceId", this.implementation.deviceID);
2327
+ call.resolve(ret);
2328
+ } catch (final Exception e) {
2329
+ logger.error("Could not get device id " + e.getMessage());
2330
+ call.reject("Could not get device id", e);
2331
+ }
2332
+ }
2333
+
2334
+ @PluginMethod
2335
+ public void setCustomId(final PluginCall call) {
2336
+ final String customId = call.getString("customId");
2337
+ if (customId == null) {
2338
+ logger.error("setCustomId called without customId");
2339
+ call.reject("setCustomId called without customId");
2340
+ return;
2341
+ }
2342
+ this.implementation.customId = customId;
2343
+ if (Boolean.TRUE.equals(this.persistCustomId)) {
1131
2344
  if (customId.isEmpty()) {
1132
2345
  this.editor.remove(CUSTOM_ID_PREF_KEY);
1133
2346
  } else {
1134
- this.editor.putString(CUSTOM_ID_PREF_KEY, customId);
2347
+ this.editor.putString(CUSTOM_ID_PREF_KEY, customId);
2348
+ }
2349
+ this.editor.apply();
2350
+ }
2351
+ call.resolve();
2352
+ }
2353
+
2354
+ @PluginMethod
2355
+ public void getPluginVersion(final PluginCall call) {
2356
+ try {
2357
+ final JSObject ret = new JSObject();
2358
+ ret.put("version", this.pluginVersion);
2359
+ call.resolve(ret);
2360
+ } catch (final Exception e) {
2361
+ logger.error("Could not get plugin version " + e.getMessage());
2362
+ call.reject("Could not get plugin version", e);
2363
+ }
2364
+ }
2365
+
2366
+ @PluginMethod
2367
+ public void unsetChannel(final PluginCall call) {
2368
+ final Boolean triggerAutoUpdate = call.getBoolean("triggerAutoUpdate", false);
2369
+
2370
+ try {
2371
+ logger.info("unsetChannel triggerAutoUpdate: " + triggerAutoUpdate);
2372
+ startNewThread(() -> {
2373
+ String configDefaultChannel = CapacitorUpdaterPlugin.this.getConfig().getString("defaultChannel", "");
2374
+ CapacitorUpdaterPlugin.this.implementation.unsetChannel(
2375
+ CapacitorUpdaterPlugin.this.editor,
2376
+ DEFAULT_CHANNEL_PREF_KEY,
2377
+ configDefaultChannel,
2378
+ (res) -> {
2379
+ JSObject jsRes = InternalUtils.mapToJSObject(res);
2380
+ if (jsRes.has("error")) {
2381
+ String errorMessage = jsRes.has("message") ? jsRes.getString("message") : jsRes.getString("error");
2382
+ String errorCode = jsRes.getString("error");
2383
+
2384
+ JSObject errorObj = new JSObject();
2385
+ errorObj.put("message", errorMessage);
2386
+ errorObj.put("error", errorCode);
2387
+
2388
+ call.reject(errorMessage, "UNSETCHANNEL_FAILED", null, errorObj);
2389
+ } else {
2390
+ if (CapacitorUpdaterPlugin.this._isAutoUpdateEnabled() && Boolean.TRUE.equals(triggerAutoUpdate)) {
2391
+ logger.info("Calling autoupdater after channel change!");
2392
+ // Check if download is already in progress (with timeout protection)
2393
+ if (!this.isDownloadStuckOrTimedOut()) {
2394
+ backgroundDownload();
2395
+ } else {
2396
+ logger.info("Download already in progress, skipping duplicate download request");
2397
+ }
2398
+ }
2399
+ call.resolve(jsRes);
2400
+ }
2401
+ }
2402
+ );
2403
+ });
2404
+ } catch (final Exception e) {
2405
+ logger.error("Failed to unsetChannel: " + e.getMessage());
2406
+ call.reject("Failed to unsetChannel: ", e);
2407
+ }
2408
+ }
2409
+
2410
+ @PluginMethod
2411
+ public void setChannel(final PluginCall call) {
2412
+ final String channel = call.getString("channel");
2413
+ final Boolean triggerAutoUpdate = call.getBoolean("triggerAutoUpdate", false);
2414
+
2415
+ if (channel == null) {
2416
+ logger.error("setChannel called without channel");
2417
+ JSObject errorObj = new JSObject();
2418
+ errorObj.put("message", "setChannel called without channel");
2419
+ errorObj.put("error", "missing_parameter");
2420
+ call.reject("setChannel called without channel", "SETCHANNEL_INVALID_PARAMS", null, errorObj);
2421
+ return;
2422
+ }
2423
+ try {
2424
+ logger.info("setChannel " + channel + " triggerAutoUpdate: " + triggerAutoUpdate);
2425
+ startNewThread(() ->
2426
+ CapacitorUpdaterPlugin.this.implementation.setChannel(
2427
+ channel,
2428
+ CapacitorUpdaterPlugin.this.editor,
2429
+ DEFAULT_CHANNEL_PREF_KEY,
2430
+ CapacitorUpdaterPlugin.this.allowSetDefaultChannel,
2431
+ CapacitorUpdaterPlugin.this.getConfig().getString("defaultChannel", ""),
2432
+ (res) -> {
2433
+ JSObject jsRes = InternalUtils.mapToJSObject(res);
2434
+ if (jsRes.has("error")) {
2435
+ String errorMessage = jsRes.has("message") ? jsRes.getString("message") : jsRes.getString("error");
2436
+ String errorCode = jsRes.getString("error");
2437
+
2438
+ // Fire channelPrivate event if channel doesn't allow self-assignment
2439
+ if (
2440
+ errorCode.contains("cannot_update_via_private_channel") ||
2441
+ errorCode.contains("channel_self_set_not_allowed")
2442
+ ) {
2443
+ JSObject eventData = new JSObject();
2444
+ eventData.put("channel", channel);
2445
+ eventData.put("message", errorMessage);
2446
+ notifyListeners("channelPrivate", eventData);
2447
+ }
2448
+
2449
+ JSObject errorObj = new JSObject();
2450
+ errorObj.put("message", errorMessage);
2451
+ errorObj.put("error", errorCode);
2452
+
2453
+ call.reject(errorMessage, "SETCHANNEL_FAILED", null, errorObj);
2454
+ } else {
2455
+ if (CapacitorUpdaterPlugin.this._isAutoUpdateEnabled() && Boolean.TRUE.equals(triggerAutoUpdate)) {
2456
+ logger.info("Calling autoupdater after channel change!");
2457
+ // Check if download is already in progress (with timeout protection)
2458
+ if (!this.isDownloadStuckOrTimedOut()) {
2459
+ backgroundDownload();
2460
+ } else {
2461
+ logger.info("Download already in progress, skipping duplicate download request");
2462
+ }
2463
+ }
2464
+ call.resolve(jsRes);
2465
+ }
2466
+ }
2467
+ )
2468
+ );
2469
+ } catch (final Exception e) {
2470
+ logger.error("Failed to setChannel: " + channel + " " + e.getMessage());
2471
+ call.reject("Failed to setChannel: " + channel, e);
2472
+ }
2473
+ }
2474
+
2475
+ @PluginMethod
2476
+ public void getChannel(final PluginCall call) {
2477
+ try {
2478
+ logger.info("getChannel");
2479
+ startNewThread(() ->
2480
+ CapacitorUpdaterPlugin.this.implementation.getChannel(
2481
+ (res) -> {
2482
+ JSObject jsRes = InternalUtils.mapToJSObject(res);
2483
+ if (jsRes.has("error")) {
2484
+ String errorMessage = jsRes.has("message") ? jsRes.getString("message") : jsRes.getString("error");
2485
+ String errorCode = jsRes.getString("error");
2486
+
2487
+ JSObject errorObj = new JSObject();
2488
+ errorObj.put("message", errorMessage);
2489
+ errorObj.put("error", errorCode);
2490
+
2491
+ call.reject(errorMessage, "GETCHANNEL_FAILED", null, errorObj);
2492
+ } else {
2493
+ call.resolve(jsRes);
2494
+ }
2495
+ },
2496
+ CapacitorUpdaterPlugin.this.editor,
2497
+ DEFAULT_CHANNEL_PREF_KEY
2498
+ )
2499
+ );
2500
+ } catch (final Exception e) {
2501
+ logger.error("Failed to getChannel " + e.getMessage());
2502
+ call.reject("Failed to getChannel", e);
2503
+ }
2504
+ }
2505
+
2506
+ @PluginMethod
2507
+ public void listChannels(final PluginCall call) {
2508
+ try {
2509
+ logger.info("listChannels");
2510
+ startNewThread(() ->
2511
+ CapacitorUpdaterPlugin.this.implementation.listChannels((res) -> {
2512
+ JSObject jsRes = InternalUtils.mapToJSObject(res);
2513
+ Object channels = res.get("channels");
2514
+ if (channels instanceof List<?> channelsList) {
2515
+ JSArray channelsArray = new JSArray();
2516
+ for (Object channel : channelsList) {
2517
+ if (channel instanceof Map<?, ?> channelMap) {
2518
+ JSObject channelObject = new JSObject();
2519
+ for (Map.Entry<?, ?> entry : channelMap.entrySet()) {
2520
+ Object key = entry.getKey();
2521
+ if (key != null) {
2522
+ channelObject.put(key.toString(), entry.getValue());
2523
+ }
2524
+ }
2525
+ channelsArray.put(channelObject);
2526
+ }
2527
+ }
2528
+ jsRes.put("channels", channelsArray);
2529
+ }
2530
+ if (jsRes.has("error")) {
2531
+ String errorMessage = jsRes.has("message") ? jsRes.getString("message") : jsRes.getString("error");
2532
+ String errorCode = jsRes.getString("error");
2533
+
2534
+ JSObject errorObj = new JSObject();
2535
+ errorObj.put("message", errorMessage);
2536
+ errorObj.put("error", errorCode);
2537
+
2538
+ call.reject(errorMessage, "LISTCHANNELS_FAILED", null, errorObj);
2539
+ } else {
2540
+ call.resolve(jsRes);
2541
+ }
2542
+ })
2543
+ );
2544
+ } catch (final Exception e) {
2545
+ logger.error("Failed to listChannels: " + e.getMessage());
2546
+ call.reject("Failed to listChannels", e);
2547
+ }
2548
+ }
2549
+
2550
+ private BundleInfo downloadBundle(
2551
+ final String url,
2552
+ final String version,
2553
+ final String sessionKey,
2554
+ final String checksum,
2555
+ final JSONArray manifest
2556
+ ) throws IOException {
2557
+ if (manifest != null) {
2558
+ return this.implementation.downloadManifest(url, version, sessionKey, checksum, manifest);
2559
+ }
2560
+
2561
+ return this.implementation.download(url, version, sessionKey, checksum);
2562
+ }
2563
+
2564
+ @PluginMethod
2565
+ public void download(final PluginCall call) {
2566
+ final String url = call.getString("url");
2567
+ final String version = call.getString("version");
2568
+ final String sessionKey = call.getString("sessionKey", "");
2569
+ final String checksum = call.getString("checksum", "");
2570
+ final JSONArray manifest = call.getData().optJSONArray("manifest");
2571
+ if (url == null) {
2572
+ logger.error("Download called without url");
2573
+ call.reject("Download called without url");
2574
+ return;
2575
+ }
2576
+ if (version == null) {
2577
+ logger.error("Download called without version");
2578
+ call.reject("Download called without version");
2579
+ return;
2580
+ }
2581
+ try {
2582
+ logger.info("Downloading " + url);
2583
+ startNewThread(() -> {
2584
+ try {
2585
+ final BundleInfo downloaded = this.downloadBundle(url, version, sessionKey, checksum, manifest);
2586
+ if (downloaded.isErrorStatus()) {
2587
+ throw new RuntimeException("Download failed: " + downloaded.getStatus());
2588
+ } else {
2589
+ call.resolve(InternalUtils.mapToJSObject(downloaded.toJSONMap()));
2590
+ }
2591
+ } catch (final Exception e) {
2592
+ logger.error("Failed to download from: " + url + " " + e.getMessage());
2593
+ call.reject("Failed to download from: " + url, e);
2594
+ final JSObject ret = new JSObject();
2595
+ ret.put("version", version);
2596
+ CapacitorUpdaterPlugin.this.notifyListeners("downloadFailed", ret);
2597
+ final BundleInfo current = CapacitorUpdaterPlugin.this.implementation.getCurrentBundle();
2598
+ CapacitorUpdaterPlugin.this.implementation.sendStats("download_fail", current.getVersionName());
2599
+ }
2600
+ });
2601
+ } catch (final Exception e) {
2602
+ logger.error("Failed to download from: " + url + " " + e.getMessage());
2603
+ call.reject("Failed to download from: " + url, e);
2604
+ final JSObject ret = new JSObject();
2605
+ ret.put("version", version);
2606
+ CapacitorUpdaterPlugin.this.notifyListeners("downloadFailed", ret);
2607
+ final BundleInfo current = CapacitorUpdaterPlugin.this.implementation.getCurrentBundle();
2608
+ CapacitorUpdaterPlugin.this.implementation.sendStats("download_fail", current.getVersionName());
2609
+ }
2610
+ }
2611
+
2612
+ private void syncKeepUrlPathFlag(final boolean enabled) {
2613
+ if (this.bridge == null || this.bridge.getWebView() == null) {
2614
+ return;
2615
+ }
2616
+ final String script = enabled
2617
+ ? "(function(){try{localStorage.setItem('" +
2618
+ KEEP_URL_FLAG_KEY +
2619
+ "','1');}catch(e){}window.__capgoKeepUrlPathAfterReload=true;var evt;try{evt=new CustomEvent('CapacitorUpdaterKeepUrlPathAfterReload',{detail:{enabled:true}});}catch(err){evt=document.createEvent('CustomEvent');evt.initCustomEvent('CapacitorUpdaterKeepUrlPathAfterReload',false,false,{enabled:true});}window.dispatchEvent(evt);})();"
2620
+ : "(function(){try{localStorage.removeItem('" +
2621
+ KEEP_URL_FLAG_KEY +
2622
+ "');}catch(e){}delete window.__capgoKeepUrlPathAfterReload;var evt;try{evt=new CustomEvent('CapacitorUpdaterKeepUrlPathAfterReload',{detail:{enabled:false}});}catch(err){evt=document.createEvent('CustomEvent');evt.initCustomEvent('CapacitorUpdaterKeepUrlPathAfterReload',false,false,{enabled:false});}window.dispatchEvent(evt);})();";
2623
+ this.bridge.getWebView().post(() -> this.bridge.getWebView().evaluateJavascript(script, null));
2624
+ }
2625
+
2626
+ private void applyCurrentBundleToBridge() {
2627
+ final String path = this.implementation.getCurrentBundlePath();
2628
+ final boolean usingBuiltin = this.implementation.isUsingBuiltin();
2629
+ if (this.keepUrlPathAfterReload) {
2630
+ this.syncKeepUrlPathFlag(true);
2631
+ }
2632
+ logger.info("Reloading: " + path);
2633
+
2634
+ AtomicReference<URL> url = new AtomicReference<>();
2635
+ if (this.keepUrlPathAfterReload) {
2636
+ try {
2637
+ if (Looper.myLooper() != Looper.getMainLooper()) {
2638
+ Semaphore mainThreadSemaphore = new Semaphore(0);
2639
+ this.bridge.executeOnMainThread(() -> {
2640
+ try {
2641
+ if (this.bridge != null && this.bridge.getWebView() != null) {
2642
+ String currentUrl = this.bridge.getWebView().getUrl();
2643
+ if (currentUrl != null) {
2644
+ url.set(new URL(currentUrl));
2645
+ }
2646
+ }
2647
+ } catch (Exception e) {
2648
+ logger.error("Error executing on main thread " + e.getMessage());
2649
+ }
2650
+ mainThreadSemaphore.release();
2651
+ });
2652
+
2653
+ // Add timeout to prevent indefinite blocking
2654
+ if (!mainThreadSemaphore.tryAcquire(10, TimeUnit.SECONDS)) {
2655
+ logger.error("Timeout waiting for main thread operation");
2656
+ }
2657
+ } else {
2658
+ try {
2659
+ if (this.bridge != null && this.bridge.getWebView() != null) {
2660
+ String currentUrl = this.bridge.getWebView().getUrl();
2661
+ if (currentUrl != null) {
2662
+ url.set(new URL(currentUrl));
2663
+ }
2664
+ }
2665
+ } catch (Exception e) {
2666
+ logger.error("Error executing on main thread " + e.getMessage());
2667
+ }
2668
+ }
2669
+ } catch (InterruptedException e) {
2670
+ logger.error("Error waiting for main thread or getting the current URL from webview " + e.getMessage());
2671
+ Thread.currentThread().interrupt(); // Restore interrupted status
2672
+ }
2673
+ }
2674
+
2675
+ if (url.get() != null) {
2676
+ if (usingBuiltin) {
2677
+ this.bridge.getLocalServer().hostAssets(path);
2678
+ } else {
2679
+ this.bridge.getLocalServer().hostFiles(path);
2680
+ }
2681
+
2682
+ try {
2683
+ URL finalUrl = null;
2684
+ finalUrl = new URL(this.bridge.getAppUrl());
2685
+ finalUrl = new URL(finalUrl.getProtocol(), finalUrl.getHost(), finalUrl.getPort(), url.get().getPath());
2686
+ URL finalUrl1 = finalUrl;
2687
+ this.bridge.getWebView().post(() -> {
2688
+ this.bridge.getWebView().loadUrl(finalUrl1.toString());
2689
+ if (!this.keepUrlPathAfterReload) {
2690
+ this.bridge.getWebView().clearHistory();
2691
+ }
2692
+ });
2693
+ } catch (MalformedURLException e) {
2694
+ logger.error("Cannot get finalUrl from capacitor bridge " + e.getMessage());
2695
+
2696
+ if (usingBuiltin) {
2697
+ this.bridge.setServerAssetPath(path);
2698
+ } else {
2699
+ this.bridge.setServerBasePath(path);
2700
+ }
2701
+ }
2702
+ } else {
2703
+ if (usingBuiltin) {
2704
+ this.bridge.setServerAssetPath(path);
2705
+ } else {
2706
+ this.bridge.setServerBasePath(path);
2707
+ }
2708
+ if (this.bridge != null && this.bridge.getWebView() != null) {
2709
+ this.bridge.getWebView().post(() -> {
2710
+ if (this.bridge.getWebView() != null) {
2711
+ this.bridge.getWebView().loadUrl(this.bridge.getAppUrl());
2712
+ if (!this.keepUrlPathAfterReload) {
2713
+ this.bridge.getWebView().clearHistory();
2714
+ }
2715
+ }
2716
+ });
2717
+ }
2718
+ }
2719
+ }
2720
+
2721
+ protected void restoreLiveBundleStateAfterFailedReload() {
2722
+ try {
2723
+ this.applyCurrentBundleToBridge();
2724
+ } catch (final Exception e) {
2725
+ logger.warn("Failed to restore live bundle after rejected reload: " + e.getMessage());
2726
+ }
2727
+ }
2728
+
2729
+ protected boolean _reload() {
2730
+ final int phase = this.semaphoreUp();
2731
+ this.applyCurrentBundleToBridge();
2732
+
2733
+ final long waitTimeMs = this.resolveAppReadyCheckTimeoutMs();
2734
+ this.checkAppReady(waitTimeMs);
2735
+ this.notifyListeners("appReloaded", new JSObject());
2736
+
2737
+ // Wait for the reload to complete (until notifyAppReady is called)
2738
+ return this.semaphoreWait(phase, waitTimeMs);
2739
+ }
2740
+
2741
+ protected boolean reloadWithoutWaitingForAppReady() {
2742
+ this.applyCurrentBundleToBridge();
2743
+
2744
+ final long waitTimeMs = this.resolveAppReadyCheckTimeoutMs();
2745
+ this.checkAppReady(waitTimeMs);
2746
+ this.notifyListeners("appReloaded", new JSObject());
2747
+ return true;
2748
+ }
2749
+
2750
+ @PluginMethod
2751
+ public void reload(final PluginCall call) {
2752
+ startNewThread(() -> {
2753
+ try {
2754
+ final BundleInfo current = this.implementation.getCurrentBundle();
2755
+ final BundleInfo next = this.implementation.getNextBundle();
2756
+
2757
+ if (!this.isPreviewSessionStateActive() && next != null && !next.isErrorStatus() && !next.getId().equals(current.getId())) {
2758
+ final CapgoUpdater.ResetState previousState = this.implementation.captureResetState();
2759
+ final String previousBundleName = this.implementation.getCurrentBundle().getVersionName();
2760
+ logger.info("Applying pending bundle before reload: " + next.getVersionName());
2761
+ final boolean didApplyPendingBundle;
2762
+ if (next.isBuiltin()) {
2763
+ this.implementation.prepareResetStateForTransition();
2764
+ didApplyPendingBundle = true;
2765
+ } else {
2766
+ didApplyPendingBundle = this.implementation.stagePendingReload(next);
2767
+ }
2768
+ if (didApplyPendingBundle && this._reload()) {
2769
+ if (next.isBuiltin()) {
2770
+ this.implementation.finalizeResetTransition(previousBundleName, false);
2771
+ } else {
2772
+ this.implementation.finalizePendingReload(next, previousBundleName);
2773
+ }
2774
+ this.notifyBundleSet(next);
2775
+ this.implementation.setNextBundle(null);
2776
+ this.showPreviewSessionNoticeIfNeeded();
2777
+ call.resolve();
2778
+ return;
2779
+ }
2780
+ this.implementation.restoreResetState(previousState);
2781
+ this.restoreLiveBundleStateAfterFailedReload();
2782
+ logger.error("Reload failed after applying pending bundle: " + next.getVersionName());
2783
+ call.reject("Reload failed after applying pending bundle: " + next.getVersionName());
2784
+ return;
2785
+ }
2786
+
2787
+ if (this._reload()) {
2788
+ this.showPreviewSessionNoticeIfNeeded();
2789
+ call.resolve();
2790
+ } else {
2791
+ logger.error("Reload failed");
2792
+ call.reject("Reload failed");
2793
+ }
2794
+ } catch (final Exception e) {
2795
+ logger.error("Could not reload " + e.getMessage());
2796
+ call.reject("Could not reload", e);
2797
+ }
2798
+ });
2799
+ }
2800
+
2801
+ @PluginMethod
2802
+ public void next(final PluginCall call) {
2803
+ final String id = call.getString("id");
2804
+ if (id == null) {
2805
+ logger.error("Next called without id");
2806
+ call.reject("Next called without id");
2807
+ return;
2808
+ }
2809
+ try {
2810
+ logger.info("Setting next active id " + id);
2811
+ if (!this.implementation.setNextBundle(id)) {
2812
+ logger.error("Set next id failed. Bundle " + id + " does not exist.");
2813
+ call.reject("Set next id failed. Bundle " + id + " does not exist.");
2814
+ } else {
2815
+ call.resolve(InternalUtils.mapToJSObject(this.implementation.getBundleInfo(id).toJSONMap()));
2816
+ }
2817
+ } catch (final Exception e) {
2818
+ logger.error("Could not set next id " + id + " " + e.getMessage());
2819
+ call.reject("Could not set next id: " + id, e);
2820
+ }
2821
+ }
2822
+
2823
+ @PluginMethod
2824
+ public void set(final PluginCall call) {
2825
+ final String id = call.getString("id");
2826
+ if (id == null) {
2827
+ logger.error("Set called without id");
2828
+ call.reject("Set called without id");
2829
+ return;
2830
+ }
2831
+ startNewThread(() -> {
2832
+ try {
2833
+ logger.info("Setting active bundle " + id);
2834
+ if (!this.implementation.set(id)) {
2835
+ logger.info("No such bundle " + id);
2836
+ call.reject("Update failed, id " + id + " does not exist.");
2837
+ } else if (Boolean.TRUE.equals(this.previewSessionEnabled)) {
2838
+ logger.info("Preview session set active bundle " + id + " without waiting for preview app readiness");
2839
+ final BundleInfo bundle = this.implementation.getBundleInfo(id);
2840
+ this.recordPreviewBundle(bundle);
2841
+ this.reloadWithoutWaitingForAppReady();
2842
+ this.notifyBundleSet(bundle);
2843
+ this.showPreviewSessionNoticeIfNeeded();
2844
+ call.resolve();
2845
+ } else if (!this._reload()) {
2846
+ logger.error("Reload failed after setting bundle " + id);
2847
+ call.reject("Reload failed after setting bundle " + id);
2848
+ } else {
2849
+ logger.info("Bundle successfully set to " + id);
2850
+ this.notifyBundleSet(this.implementation.getBundleInfo(id));
2851
+ this.showPreviewSessionNoticeIfNeeded();
2852
+ call.resolve();
2853
+ }
2854
+ } catch (final Exception e) {
2855
+ logger.error("Could not set id " + id + " " + e.getMessage());
2856
+ call.reject("Could not set id " + id, e);
2857
+ }
2858
+ });
2859
+ }
2860
+
2861
+ private boolean preparePreviewFallbackIfNeeded() {
2862
+ if (Boolean.TRUE.equals(this.previewSessionEnabled)) {
2863
+ return true;
2864
+ }
2865
+
2866
+ final BundleInfo current = this.implementation.getCurrentBundle();
2867
+ if (!this.implementation.setPreviewFallbackBundle(current.getId())) {
2868
+ logger.error("Could not save current bundle as preview fallback");
2869
+ return false;
2870
+ }
2871
+
2872
+ final BundleInfo previousNext = this.implementation.getNextBundle();
2873
+ if (previousNext == null || previousNext.isDeleted() || previousNext.isErrorStatus()) {
2874
+ this.editor.remove(PREVIEW_PREVIOUS_NEXT_BUNDLE_PREF_KEY);
2875
+ } else {
2876
+ this.editor.putString(PREVIEW_PREVIOUS_NEXT_BUNDLE_PREF_KEY, previousNext.getId());
2877
+ }
2878
+
2879
+ this.editor.putString(PREVIEW_PREVIOUS_APP_ID_PREF_KEY, this.implementation.appId);
2880
+ if (this.prefs.contains(DEFAULT_CHANNEL_PREF_KEY)) {
2881
+ this.editor.putString(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_PREF_KEY, this.prefs.getString(DEFAULT_CHANNEL_PREF_KEY, ""));
2882
+ this.editor.putBoolean(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY, true);
2883
+ } else {
2884
+ this.editor.remove(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_PREF_KEY);
2885
+ this.editor.putBoolean(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY, false);
2886
+ }
2887
+ this.editor.putBoolean(PREVIEW_PREVIOUS_SHAKE_MENU_PREF_KEY, Boolean.TRUE.equals(this.shakeMenuEnabled));
2888
+ this.editor.putBoolean(PREVIEW_PREVIOUS_SHAKE_CHANNEL_SELECTOR_PREF_KEY, Boolean.TRUE.equals(this.shakeChannelSelectorEnabled));
2889
+ logger.info("Preview session started with fallback bundle: " + current);
2890
+ return true;
2891
+ }
2892
+
2893
+ private void activatePreviewSessionState() {
2894
+ this.clearIncomingPreviewTransition();
2895
+ this.hidePreviewTransitionLoader("preview-session-started");
2896
+ this.previewSessionEnabled = true;
2897
+ this.previewSessionAlertPending = true;
2898
+ this.implementation.previewSession = true;
2899
+ this.shakeMenuEnabled = true;
2900
+ this.editor.putBoolean(PREVIEW_SESSION_PREF_KEY, true);
2901
+ this.editor.putBoolean(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY, true);
2902
+ this.editor.apply();
2903
+ this.syncShakeMenuLifecycle();
2904
+ }
2905
+
2906
+ @PluginMethod
2907
+ public void startPreviewSession(final PluginCall call) {
2908
+ if (!Boolean.TRUE.equals(this.allowPreview)) {
2909
+ this.hidePreviewTransitionLoader("preview-session-not-allowed");
2910
+ logger.error("startPreviewSession not allowed set allowPreview in your config to true to enable it");
2911
+ call.reject("startPreviewSession not allowed");
2912
+ return;
2913
+ }
2914
+ final String previewAppId = this.normalizePreviewAppId(call.getString("appId"));
2915
+ final String rawPayloadUrl = call.getString("payloadUrl");
2916
+ final String previewPayloadUrl = this.normalizePreviewPayloadUrl(rawPayloadUrl);
2917
+ if (this.hasPreviewPayloadUrl(rawPayloadUrl) && previewPayloadUrl == null) {
2918
+ this.hidePreviewTransitionLoader("preview-session-invalid-payload");
2919
+ logger.error("startPreviewSession called with invalid payloadUrl");
2920
+ call.reject("Invalid preview payloadUrl");
2921
+ return;
2922
+ }
2923
+ startNewThread(() -> {
2924
+ try {
2925
+ if (!this.preparePreviewFallbackIfNeeded()) {
2926
+ this.hidePreviewTransitionLoader("preview-session-fallback-failed");
2927
+ call.reject("Could not save current bundle as preview fallback");
2928
+ return;
2929
+ }
2930
+
2931
+ if (previewAppId != null) {
2932
+ this.setActiveAppId(previewAppId);
2933
+ this.editor.putString(PREVIEW_APP_ID_PREF_KEY, previewAppId);
2934
+ logger.info("Preview session using appId: " + previewAppId);
2935
+ }
2936
+
2937
+ if (previewPayloadUrl != null) {
2938
+ this.editor.putString(PREVIEW_PAYLOAD_URL_PREF_KEY, previewPayloadUrl);
2939
+ logger.info("Preview session using payload URL");
2940
+ } else {
2941
+ this.editor.remove(PREVIEW_PAYLOAD_URL_PREF_KEY);
2942
+ }
2943
+
2944
+ final String previewName = this.normalizedPreviewMetadataValue(call.getString("name"));
2945
+ if (previewName == null) {
2946
+ this.editor.remove(PREVIEW_NAME_PREF_KEY);
2947
+ } else {
2948
+ this.editor.putString(PREVIEW_NAME_PREF_KEY, previewName);
2949
+ }
2950
+
2951
+ final String previewSource = this.normalizedPreviewMetadataValue(call.getString("source"));
2952
+ if (previewSource == null) {
2953
+ this.editor.remove(PREVIEW_SOURCE_PREF_KEY);
2954
+ } else {
2955
+ this.editor.putString(PREVIEW_SOURCE_PREF_KEY, previewSource);
2956
+ }
2957
+
2958
+ this.activatePreviewSessionState();
2959
+ call.resolve();
2960
+ } catch (final Exception e) {
2961
+ this.hidePreviewTransitionLoader("preview-session-failed");
2962
+ logger.error("Could not start preview session " + e.getMessage());
2963
+ call.reject("Could not start preview session", e);
2964
+ }
2965
+ });
2966
+ }
2967
+
2968
+ @PluginMethod
2969
+ public void listPreviews(final PluginCall call) {
2970
+ if (!Boolean.TRUE.equals(this.allowPreview)) {
2971
+ call.reject("listPreviews not allowed");
2972
+ return;
2973
+ }
2974
+
2975
+ final JSArray previews = this.listPreviewInfos(true);
2976
+ final JSObject ret = new JSObject();
2977
+ ret.put("previews", previews);
2978
+ ret.put("currentBundle", InternalUtils.mapToJSObject(this.implementation.getCurrentBundle().toJSONMap()));
2979
+
2980
+ for (int i = 0; i < previews.length(); i++) {
2981
+ final JSONObject preview = previews.optJSONObject(i);
2982
+ if (preview != null && preview.optBoolean("isActive", false)) {
2983
+ ret.put("current", preview);
2984
+ break;
2985
+ }
2986
+ }
2987
+
2988
+ final BundleInfo liveBundle = this.implementation.getPreviewFallbackBundle();
2989
+ if (liveBundle != null) {
2990
+ ret.put("liveBundle", InternalUtils.mapToJSObject(liveBundle.toJSONMap()));
2991
+ }
2992
+
2993
+ call.resolve(ret);
2994
+ }
2995
+
2996
+ @PluginMethod
2997
+ public void setPreview(final PluginCall call) {
2998
+ if (!Boolean.TRUE.equals(this.allowPreview)) {
2999
+ call.reject("setPreview not allowed");
3000
+ return;
3001
+ }
3002
+ final String id = call.getString("id");
3003
+ if (id == null || id.isEmpty()) {
3004
+ call.reject("setPreview called without id");
3005
+ return;
3006
+ }
3007
+ final JSObject preview = this.storedPreviewInfo(id);
3008
+ if (preview == null) {
3009
+ call.reject("Preview " + id + " is not available locally");
3010
+ return;
3011
+ }
3012
+
3013
+ this.showPreviewTransitionLoader("set-preview");
3014
+ startNewThread(() -> {
3015
+ if (!this.preparePreviewFallbackIfNeeded()) {
3016
+ this.hidePreviewTransitionLoader("set-preview-fallback-failed");
3017
+ call.reject("Could not save current bundle as preview fallback");
3018
+ return;
1135
3019
  }
1136
- this.editor.apply();
3020
+
3021
+ if (!this.implementation.set(id)) {
3022
+ this.hidePreviewTransitionLoader("set-preview-failed");
3023
+ call.reject("Preview " + id + " cannot be applied");
3024
+ return;
3025
+ }
3026
+
3027
+ final BundleInfo bundle = this.implementation.getBundleInfo(id);
3028
+ this.updateCurrentPreviewSessionMetadataFrom(preview);
3029
+ this.activatePreviewSessionState();
3030
+ this.recordPreviewBundle(bundle);
3031
+ if (!this.reloadWithoutWaitingForAppReady()) {
3032
+ this.hidePreviewTransitionLoader("set-preview-reload-failed");
3033
+ call.reject("Reload failed after setting preview " + id);
3034
+ return;
3035
+ }
3036
+
3037
+ this.notifyBundleSet(bundle);
3038
+ this.showPreviewSessionNoticeIfNeeded();
3039
+ call.resolve();
3040
+ });
3041
+ }
3042
+
3043
+ public JSArray previewMenuPreviews() {
3044
+ return this.listPreviewInfos(true);
3045
+ }
3046
+
3047
+ public boolean setPreviewFromShakeMenu(final String id) {
3048
+ final JSObject preview = this.storedPreviewInfo(id);
3049
+ if (!Boolean.TRUE.equals(this.allowPreview) || preview == null) {
3050
+ return false;
1137
3051
  }
1138
- call.resolve();
3052
+
3053
+ this.showPreviewTransitionLoader("set-preview-menu");
3054
+ if (!this.preparePreviewFallbackIfNeeded()) {
3055
+ this.hidePreviewTransitionLoader("set-preview-menu-fallback-failed");
3056
+ return false;
3057
+ }
3058
+
3059
+ if (!this.implementation.set(id)) {
3060
+ this.hidePreviewTransitionLoader("set-preview-menu-failed");
3061
+ return false;
3062
+ }
3063
+
3064
+ final BundleInfo bundle = this.implementation.getBundleInfo(id);
3065
+ this.updateCurrentPreviewSessionMetadataFrom(preview);
3066
+ this.activatePreviewSessionState();
3067
+ this.recordPreviewBundle(bundle);
3068
+ if (!this.reloadWithoutWaitingForAppReady()) {
3069
+ this.hidePreviewTransitionLoader("set-preview-menu-reload-failed");
3070
+ return false;
3071
+ }
3072
+
3073
+ this.notifyBundleSet(bundle);
3074
+ this.showPreviewSessionNoticeIfNeeded();
3075
+ return true;
1139
3076
  }
1140
3077
 
1141
3078
  @PluginMethod
1142
- public void getPluginVersion(final PluginCall call) {
1143
- try {
1144
- final JSObject ret = new JSObject();
1145
- ret.put("version", this.pluginVersion);
1146
- call.resolve(ret);
1147
- } catch (final Exception e) {
1148
- logger.error("Could not get plugin version " + e.getMessage());
1149
- call.reject("Could not get plugin version", e);
3079
+ public void resetPreview(final PluginCall call) {
3080
+ if (!Boolean.TRUE.equals(this.previewSessionEnabled)) {
3081
+ call.resolve();
3082
+ return;
1150
3083
  }
3084
+ startNewThread(() -> {
3085
+ if (this.leavePreviewSessionFromShakeMenu()) {
3086
+ call.resolve();
3087
+ } else {
3088
+ call.reject("Could not leave preview session");
3089
+ }
3090
+ });
1151
3091
  }
1152
3092
 
1153
3093
  @PluginMethod
1154
- public void unsetChannel(final PluginCall call) {
1155
- final Boolean triggerAutoUpdate = call.getBoolean("triggerAutoUpdate", false);
1156
-
1157
- try {
1158
- logger.info("unsetChannel triggerAutoUpdate: " + triggerAutoUpdate);
1159
- startNewThread(() -> {
1160
- String configDefaultChannel = CapacitorUpdaterPlugin.this.getConfig().getString("defaultChannel", "");
1161
- CapacitorUpdaterPlugin.this.implementation.unsetChannel(
1162
- CapacitorUpdaterPlugin.this.editor,
1163
- DEFAULT_CHANNEL_PREF_KEY,
1164
- configDefaultChannel,
1165
- (res) -> {
1166
- JSObject jsRes = InternalUtils.mapToJSObject(res);
1167
- if (jsRes.has("error")) {
1168
- String errorMessage = jsRes.has("message") ? jsRes.getString("message") : jsRes.getString("error");
1169
- String errorCode = jsRes.getString("error");
3094
+ public void deletePreview(final PluginCall call) {
3095
+ if (!Boolean.TRUE.equals(this.allowPreview)) {
3096
+ call.reject("deletePreview not allowed");
3097
+ return;
3098
+ }
3099
+ final String id = call.getString("id");
3100
+ if (id == null || id.isEmpty()) {
3101
+ call.reject("deletePreview called without id");
3102
+ return;
3103
+ }
3104
+ if (Boolean.TRUE.equals(this.previewSessionEnabled) && this.implementation.getCurrentBundle().getId().equals(id)) {
3105
+ call.reject("Cannot delete the active preview");
3106
+ return;
3107
+ }
1170
3108
 
1171
- JSObject errorObj = new JSObject();
1172
- errorObj.put("message", errorMessage);
1173
- errorObj.put("error", errorCode);
3109
+ final boolean removed;
3110
+ synchronized (this.previewSessionsLock) {
3111
+ final JSONObject sessions = this.previewSessionsJson();
3112
+ removed = sessions.has(id);
3113
+ sessions.remove(id);
3114
+ this.savePreviewSessionsJson(sessions);
3115
+ }
1174
3116
 
1175
- call.reject(errorMessage, "UNSETCHANNEL_FAILED", null, errorObj);
1176
- } else {
1177
- if (CapacitorUpdaterPlugin.this._isAutoUpdateEnabled() && Boolean.TRUE.equals(triggerAutoUpdate)) {
1178
- logger.info("Calling autoupdater after channel change!");
1179
- // Check if download is already in progress (with timeout protection)
1180
- if (!this.isDownloadStuckOrTimedOut()) {
1181
- backgroundDownload();
1182
- } else {
1183
- logger.info("Download already in progress, skipping duplicate download request");
1184
- }
1185
- }
1186
- call.resolve(jsRes);
1187
- }
1188
- }
1189
- );
1190
- });
1191
- } catch (final Exception e) {
1192
- logger.error("Failed to unsetChannel: " + e.getMessage());
1193
- call.reject("Failed to unsetChannel: ", e);
3117
+ boolean deleted = false;
3118
+ final BundleInfo fallback = this.implementation.getPreviewFallbackBundle();
3119
+ final BundleInfo next = this.implementation.getNextBundle();
3120
+ if (
3121
+ removed &&
3122
+ !BundleInfo.ID_BUILTIN.equals(id) &&
3123
+ (fallback == null || !id.equals(fallback.getId())) &&
3124
+ (next == null || !id.equals(next.getId()))
3125
+ ) {
3126
+ try {
3127
+ deleted = this.implementation.delete(id, false);
3128
+ } catch (final Exception err) {
3129
+ logger.warn("Could not delete preview bundle " + id + ": " + err.getMessage());
3130
+ }
1194
3131
  }
3132
+
3133
+ final JSObject ret = new JSObject();
3134
+ ret.put("removed", removed);
3135
+ ret.put("deleted", deleted);
3136
+ call.resolve(ret);
1195
3137
  }
1196
3138
 
1197
3139
  @PluginMethod
1198
- public void setChannel(final PluginCall call) {
1199
- final String channel = call.getString("channel");
1200
- final Boolean triggerAutoUpdate = call.getBoolean("triggerAutoUpdate", false);
3140
+ public void checkPreviewUpdate(final PluginCall call) {
3141
+ this.handlePreviewUpdate(call, false);
3142
+ }
1201
3143
 
1202
- if (channel == null) {
1203
- logger.error("setChannel called without channel");
1204
- JSObject errorObj = new JSObject();
1205
- errorObj.put("message", "setChannel called without channel");
1206
- errorObj.put("error", "missing_parameter");
1207
- call.reject("setChannel called without channel", "SETCHANNEL_INVALID_PARAMS", null, errorObj);
3144
+ @PluginMethod
3145
+ public void updatePreview(final PluginCall call) {
3146
+ this.handlePreviewUpdate(call, true);
3147
+ }
3148
+
3149
+ private void handlePreviewUpdate(final PluginCall call, final boolean shouldDownload) {
3150
+ if (!Boolean.TRUE.equals(this.allowPreview)) {
3151
+ call.reject("Preview updates not allowed");
3152
+ return;
3153
+ }
3154
+ final String id = call.getString("id");
3155
+ if (id == null || id.isEmpty()) {
3156
+ call.reject("Preview update called without id");
3157
+ return;
3158
+ }
3159
+ final JSObject preview = this.storedPreviewInfo(id);
3160
+ final String payloadUrl = preview == null ? null : this.normalizePreviewPayloadUrl(preview.optString("payloadUrl", null));
3161
+ if (payloadUrl == null) {
3162
+ call.reject("Preview " + id + " has no payloadUrl to update from");
1208
3163
  return;
1209
3164
  }
1210
- try {
1211
- logger.info("setChannel " + channel + " triggerAutoUpdate: " + triggerAutoUpdate);
1212
- startNewThread(() ->
1213
- CapacitorUpdaterPlugin.this.implementation.setChannel(
1214
- channel,
1215
- CapacitorUpdaterPlugin.this.editor,
1216
- DEFAULT_CHANNEL_PREF_KEY,
1217
- CapacitorUpdaterPlugin.this.allowSetDefaultChannel,
1218
- (res) -> {
1219
- JSObject jsRes = InternalUtils.mapToJSObject(res);
1220
- if (jsRes.has("error")) {
1221
- String errorMessage = jsRes.has("message") ? jsRes.getString("message") : jsRes.getString("error");
1222
- String errorCode = jsRes.getString("error");
1223
3165
 
1224
- // Fire channelPrivate event if channel doesn't allow self-assignment
1225
- if (
1226
- errorCode.contains("cannot_update_via_private_channel") ||
1227
- errorCode.contains("channel_self_set_not_allowed")
1228
- ) {
1229
- JSObject eventData = new JSObject();
1230
- eventData.put("channel", channel);
1231
- eventData.put("message", errorMessage);
1232
- notifyListeners("channelPrivate", eventData);
1233
- }
3166
+ startNewThread(() -> {
3167
+ try {
3168
+ final JSONObject payload = this.fetchPreviewPayload(payloadUrl);
3169
+ final String version = payload.optString("version", "").trim();
3170
+ if (version.isEmpty()) {
3171
+ throw new IOException("Preview payload is missing a version");
3172
+ }
1234
3173
 
1235
- JSObject errorObj = new JSObject();
1236
- errorObj.put("message", errorMessage);
1237
- errorObj.put("error", errorCode);
3174
+ final BundleInfo currentPreviewBundle = this.implementation.getBundleInfo(id);
3175
+ final boolean upToDate = version.equals(currentPreviewBundle.getVersionName());
3176
+ if (upToDate || !shouldDownload) {
3177
+ final JSObject ret = new JSObject();
3178
+ ret.put("preview", preview);
3179
+ ret.put("latestVersion", version);
3180
+ ret.put("upToDate", upToDate);
3181
+ ret.put("updated", false);
3182
+ ret.put("bundle", InternalUtils.mapToJSObject(currentPreviewBundle.toJSONMap()));
3183
+ call.resolve(ret);
3184
+ return;
3185
+ }
1238
3186
 
1239
- call.reject(errorMessage, "SETCHANNEL_FAILED", null, errorObj);
1240
- } else {
1241
- if (CapacitorUpdaterPlugin.this._isAutoUpdateEnabled() && Boolean.TRUE.equals(triggerAutoUpdate)) {
1242
- logger.info("Calling autoupdater after channel change!");
1243
- // Check if download is already in progress (with timeout protection)
1244
- if (!this.isDownloadStuckOrTimedOut()) {
1245
- backgroundDownload();
1246
- } else {
1247
- logger.info("Download already in progress, skipping duplicate download request");
1248
- }
1249
- }
1250
- call.resolve(jsRes);
1251
- }
3187
+ final BundleInfo next = this.downloadPreviewPayloadBundle(payload);
3188
+ if (next.isErrorStatus()) {
3189
+ throw new IOException("Download failed: " + next.getStatus());
3190
+ }
3191
+
3192
+ final boolean wasActive =
3193
+ Boolean.TRUE.equals(this.previewSessionEnabled) && this.implementation.getCurrentBundle().getId().equals(id);
3194
+ if (wasActive && !this.implementation.set(next.getId())) {
3195
+ throw new IOException("Downloaded preview bundle cannot be applied");
3196
+ }
3197
+
3198
+ final JSObject savedPreview = this.recordPreviewBundle(next, id);
3199
+ if (wasActive) {
3200
+ if (!this.reloadWithoutWaitingForAppReady()) {
3201
+ throw new IOException("Reload failed after updating preview");
1252
3202
  }
1253
- )
1254
- );
1255
- } catch (final Exception e) {
1256
- logger.error("Failed to setChannel: " + channel + " " + e.getMessage());
1257
- call.reject("Failed to setChannel: " + channel, e);
3203
+ this.notifyBundleSet(next);
3204
+ this.showPreviewSessionNoticeIfNeeded();
3205
+ }
3206
+
3207
+ final JSObject ret = new JSObject();
3208
+ ret.put("preview", savedPreview);
3209
+ ret.put("latestVersion", version);
3210
+ ret.put("upToDate", false);
3211
+ ret.put("updated", true);
3212
+ ret.put("bundle", InternalUtils.mapToJSObject(next.toJSONMap()));
3213
+ call.resolve(ret);
3214
+ } catch (final Exception err) {
3215
+ logger.error("Could not update preview: " + err.getMessage());
3216
+ call.reject("Could not update preview: " + err.getMessage());
3217
+ }
3218
+ });
3219
+ }
3220
+
3221
+ public boolean leavePreviewSessionFromShakeMenu() {
3222
+ this.showPreviewTransitionLoader("leave-preview-session");
3223
+ final boolean didReset = this.resetToPreviewFallbackBundle();
3224
+ if (!didReset) {
3225
+ this.hidePreviewTransitionLoader("leave-preview-session-failed");
3226
+ return false;
1258
3227
  }
3228
+
3229
+ this.endPreviewSession(true);
3230
+ return true;
1259
3231
  }
1260
3232
 
1261
- @PluginMethod
1262
- public void getChannel(final PluginCall call) {
3233
+ private boolean leavePreviewSessionForIncomingPreviewLink() {
3234
+ this.showPreviewTransitionLoader("incoming-preview-deeplink");
3235
+ final BundleInfo previewFallbackBundle = this.resolvePreviewFallbackBundle("incoming preview deeplink");
3236
+ boolean didReload = false;
3237
+
1263
3238
  try {
1264
- logger.info("getChannel");
1265
- startNewThread(() ->
1266
- CapacitorUpdaterPlugin.this.implementation.getChannel((res) -> {
1267
- JSObject jsRes = InternalUtils.mapToJSObject(res);
1268
- if (jsRes.has("error")) {
1269
- String errorMessage = jsRes.has("message") ? jsRes.getString("message") : jsRes.getString("error");
1270
- String errorCode = jsRes.getString("error");
3239
+ if (previewFallbackBundle == null) {
3240
+ return false;
3241
+ }
1271
3242
 
1272
- JSObject errorObj = new JSObject();
1273
- errorObj.put("message", errorMessage);
1274
- errorObj.put("error", errorCode);
3243
+ final CapgoUpdater.ResetState previousState = this.implementation.captureResetState();
3244
+ if (!this.implementation.stagePreviewFallbackReload(previewFallbackBundle)) {
3245
+ logger.error("Could not stage preview fallback bundle");
3246
+ return false;
3247
+ }
1275
3248
 
1276
- call.reject(errorMessage, "GETCHANNEL_FAILED", null, errorObj);
1277
- } else {
1278
- call.resolve(jsRes);
1279
- }
1280
- })
1281
- );
1282
- } catch (final Exception e) {
1283
- logger.error("Failed to getChannel " + e.getMessage());
1284
- call.reject("Failed to getChannel", e);
3249
+ if (!this.reloadWithoutWaitingForAppReady()) {
3250
+ this.implementation.restoreResetState(previousState);
3251
+ this.restoreLiveBundleStateAfterFailedReload();
3252
+ return false;
3253
+ }
3254
+ didReload = true;
3255
+
3256
+ this.endPreviewSession(true);
3257
+ return true;
3258
+ } finally {
3259
+ this.clearIncomingPreviewTransition();
3260
+ if (!didReload) {
3261
+ this.hidePreviewTransitionLoader("incoming-preview-deeplink-failed");
3262
+ }
3263
+ }
3264
+ }
3265
+
3266
+ private void leavePreviewSessionForLaunchIntentIfNeeded() {
3267
+ final Intent intent = getActivity() == null ? null : getActivity().getIntent();
3268
+ if (
3269
+ intent == null ||
3270
+ !Intent.ACTION_VIEW.equals(intent.getAction()) ||
3271
+ intent.getData() == null ||
3272
+ !Boolean.TRUE.equals(this.previewSessionEnabled) ||
3273
+ !isPreviewDeepLink(intent.getData()) ||
3274
+ Boolean.TRUE.equals(this.isLeavingPreviewForIncomingLink)
3275
+ ) {
3276
+ return;
3277
+ }
3278
+
3279
+ this.isLeavingPreviewForIncomingLink = true;
3280
+ this.showPreviewTransitionLoader("preview-launch-deeplink");
3281
+ logger.info("Preview deeplink launch detected while preview session is active; restoring fallback before initial load");
3282
+ if (!this.leavePreviewSessionWithoutReload()) {
3283
+ logger.error("Could not leave preview session before initial preview deeplink routing");
3284
+ this.isLeavingPreviewForIncomingLink = false;
3285
+ this.hidePreviewTransitionLoader("preview-launch-deeplink-failed");
3286
+ }
3287
+ }
3288
+
3289
+ private boolean leavePreviewSessionWithoutReload() {
3290
+ return this.leavePreviewSessionWithoutReload(false);
3291
+ }
3292
+
3293
+ private boolean leavePreviewSessionWithoutReload(final boolean keepPreviewGuard) {
3294
+ final BundleInfo previewFallbackBundle = this.resolvePreviewFallbackBundle("preview deeplink launch");
3295
+ if (previewFallbackBundle == null) {
3296
+ return false;
3297
+ }
3298
+ if (!this.implementation.stagePreviewFallbackReload(previewFallbackBundle)) {
3299
+ logger.error("Could not stage preview fallback bundle");
3300
+ return false;
3301
+ }
3302
+
3303
+ this.endPreviewSession(keepPreviewGuard);
3304
+ return true;
3305
+ }
3306
+
3307
+ public boolean reloadPreviewSessionFromShakeMenu() {
3308
+ this.showPreviewTransitionLoader("reload-preview-session");
3309
+ final boolean didReload;
3310
+ final String payloadUrl = this.storedPreviewPayloadUrl();
3311
+ if (payloadUrl != null) {
3312
+ didReload = this.refreshPreviewSessionFromPayloadUrl(payloadUrl);
3313
+ } else {
3314
+ didReload = this.reloadWithoutWaitingForAppReady();
3315
+ }
3316
+
3317
+ if (!didReload) {
3318
+ this.hidePreviewTransitionLoader("reload-preview-session-failed");
3319
+ }
3320
+ return didReload;
3321
+ }
3322
+
3323
+ public boolean hasActivePreviewSession() {
3324
+ return Boolean.TRUE.equals(this.previewSessionEnabled);
3325
+ }
3326
+
3327
+ private boolean resetToPreviewFallbackBundle() {
3328
+ final BundleInfo fallback = this.resolvePreviewFallbackBundle("leave preview");
3329
+ if (fallback == null) {
3330
+ return false;
3331
+ }
3332
+
3333
+ final CapgoUpdater.ResetState previousState = this.implementation.captureResetState();
3334
+ final String previousBundleName = this.implementation.getCurrentBundle().getVersionName();
3335
+ logger.info("Resetting to preview fallback bundle: " + fallback.getVersionName());
3336
+ if (this.implementation.stagePreviewFallbackReload(fallback) && this.reloadWithoutWaitingForAppReady()) {
3337
+ this.implementation.finalizeResetTransition(previousBundleName, false);
3338
+ this.notifyBundleSet(fallback);
3339
+ return true;
1285
3340
  }
3341
+ this.implementation.restoreResetState(previousState);
3342
+ this.restoreLiveBundleStateAfterFailedReload();
3343
+ return false;
3344
+ }
3345
+
3346
+ private BundleInfo resolvePreviewFallbackBundle(final String reason) {
3347
+ final BundleInfo fallback = this.implementation.getPreviewFallbackBundle();
3348
+ if (fallback != null && !fallback.isErrorStatus() && this.implementation.canSet(fallback)) {
3349
+ return fallback;
3350
+ }
3351
+
3352
+ if (fallback == null) {
3353
+ logger.warn("No preview fallback bundle available for " + reason + ". Falling back to builtin bundle.");
3354
+ } else if (fallback.isErrorStatus()) {
3355
+ logger.warn("Preview fallback bundle is in error state for " + reason + ". Falling back to builtin bundle.");
3356
+ } else {
3357
+ logger.warn("Preview fallback bundle is not installable for " + reason + ". Falling back to builtin bundle.");
3358
+ }
3359
+
3360
+ final BundleInfo builtin = this.implementation.getBundleInfo(BundleInfo.ID_BUILTIN);
3361
+ if (builtin != null && !builtin.isErrorStatus() && this.implementation.canSet(builtin)) {
3362
+ return builtin;
3363
+ }
3364
+
3365
+ logger.error("Builtin bundle is not available to leave preview for " + reason);
3366
+ return null;
3367
+ }
3368
+
3369
+ private void endPreviewSession() {
3370
+ this.endPreviewSession(false);
3371
+ }
3372
+
3373
+ private void endPreviewSession(final boolean keepPreviewGuard) {
3374
+ final boolean previousShakeMenuEnabled = this.prefs.getBoolean(
3375
+ PREVIEW_PREVIOUS_SHAKE_MENU_PREF_KEY,
3376
+ this.getConfig().getBoolean("shakeMenu", false)
3377
+ );
3378
+ final boolean previousShakeChannelSelectorEnabled = this.prefs.getBoolean(
3379
+ PREVIEW_PREVIOUS_SHAKE_CHANNEL_SELECTOR_PREF_KEY,
3380
+ this.getConfig().getBoolean("allowShakeChannelSelector", false)
3381
+ );
3382
+ this.restorePreviewPreviousNextBundle();
3383
+ this.restorePreviewPreviousAppId();
3384
+ this.restorePreviewPreviousDefaultChannel();
3385
+
3386
+ this.previewSessionEnabled = false;
3387
+ this.previewSessionAlertPending = false;
3388
+ if (keepPreviewGuard) {
3389
+ this.implementation.previewSession = true;
3390
+ } else {
3391
+ this.clearIncomingPreviewTransition();
3392
+ }
3393
+ this.shakeMenuEnabled = previousShakeMenuEnabled;
3394
+ this.shakeChannelSelectorEnabled = previousShakeChannelSelectorEnabled;
3395
+ this.syncShakeMenuLifecycle();
3396
+ this.implementation.setPreviewFallbackBundle(null);
3397
+ this.clearPreviewSessionPreferences();
3398
+ logger.info("Preview session ended");
1286
3399
  }
1287
3400
 
1288
- @PluginMethod
1289
- public void listChannels(final PluginCall call) {
1290
- try {
1291
- logger.info("listChannels");
1292
- startNewThread(() ->
1293
- CapacitorUpdaterPlugin.this.implementation.listChannels((res) -> {
1294
- JSObject jsRes = InternalUtils.mapToJSObject(res);
1295
- if (jsRes.has("error")) {
1296
- String errorMessage = jsRes.has("message") ? jsRes.getString("message") : jsRes.getString("error");
1297
- String errorCode = jsRes.getString("error");
3401
+ private void clearPreviewSessionBecauseDisabled() {
3402
+ logger.info("Preview session disabled by config; restoring preview fallback");
3403
+ final BundleInfo bundleToRestore = this.resolvePreviewFallbackBundle("preview disabled");
3404
+ if (bundleToRestore != null) {
3405
+ this.implementation.stagePreviewFallbackReload(bundleToRestore);
3406
+ } else {
3407
+ logger.warn("Could not restore preview fallback while disabling preview");
3408
+ }
1298
3409
 
1299
- JSObject errorObj = new JSObject();
1300
- errorObj.put("message", errorMessage);
1301
- errorObj.put("error", errorCode);
3410
+ this.restorePreviewPreviousNextBundle();
3411
+ this.restorePreviewPreviousAppId();
3412
+ this.restorePreviewPreviousDefaultChannel();
3413
+ this.previewSessionEnabled = false;
3414
+ this.previewSessionAlertPending = false;
3415
+ this.isLeavingPreviewForIncomingLink = false;
3416
+ this.implementation.previewSession = false;
3417
+ this.hidePreviewTransitionLoader("preview-session-disabled");
3418
+ this.shakeMenuEnabled = this.getConfig().getBoolean("shakeMenu", false);
3419
+ this.shakeChannelSelectorEnabled = this.getConfig().getBoolean("allowShakeChannelSelector", false);
3420
+ this.shakeMenuGesture = normalizedShakeMenuGesture(this.getConfig().getString("shakeMenuGesture", SHAKE_MENU_GESTURE_SHAKE));
3421
+ this.syncShakeMenuLifecycle();
3422
+ this.clearPreviewSessionPreferences();
3423
+ }
1302
3424
 
1303
- call.reject(errorMessage, "LISTCHANNELS_FAILED", null, errorObj);
1304
- } else {
1305
- call.resolve(jsRes);
1306
- }
1307
- })
1308
- );
1309
- } catch (final Exception e) {
1310
- logger.error("Failed to listChannels: " + e.getMessage());
1311
- call.reject("Failed to listChannels", e);
3425
+ private void clearPreviewSessionPreferences() {
3426
+ if (this.implementation != null) {
3427
+ this.implementation.setPreviewFallbackBundle(null);
3428
+ }
3429
+ this.editor.remove(PREVIEW_SESSION_PREF_KEY);
3430
+ this.editor.remove(PREVIEW_PREVIOUS_SHAKE_MENU_PREF_KEY);
3431
+ this.editor.remove(PREVIEW_PREVIOUS_SHAKE_CHANNEL_SELECTOR_PREF_KEY);
3432
+ this.editor.remove(PREVIEW_PREVIOUS_NEXT_BUNDLE_PREF_KEY);
3433
+ this.editor.remove(PREVIEW_PREVIOUS_APP_ID_PREF_KEY);
3434
+ this.editor.remove(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_PREF_KEY);
3435
+ this.editor.remove(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY);
3436
+ this.editor.remove(PREVIEW_APP_ID_PREF_KEY);
3437
+ this.editor.remove(PREVIEW_PAYLOAD_URL_PREF_KEY);
3438
+ this.editor.remove(PREVIEW_NAME_PREF_KEY);
3439
+ this.editor.remove(PREVIEW_SOURCE_PREF_KEY);
3440
+ this.editor.remove(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY);
3441
+ this.editor.apply();
3442
+ }
3443
+
3444
+ private void setActiveAppId(final String appId) {
3445
+ this.implementation.appId = appId;
3446
+ if (this.implementation.versionOs != null) {
3447
+ DownloadService.updateUserAgent(this.implementation.appId, this.pluginVersion, this.implementation.versionOs);
1312
3448
  }
1313
3449
  }
1314
3450
 
1315
- @PluginMethod
1316
- public void download(final PluginCall call) {
1317
- final String url = call.getString("url");
1318
- final String version = call.getString("version");
1319
- final String sessionKey = call.getString("sessionKey", "");
1320
- final String checksum = call.getString("checksum", "");
1321
- final JSONArray manifest = call.getData().optJSONArray("manifest");
1322
- if (url == null) {
1323
- logger.error("Download called without url");
1324
- call.reject("Download called without url");
3451
+ private void restorePreviewPreviousAppId() {
3452
+ final String previousAppId = this.prefs.getString(PREVIEW_PREVIOUS_APP_ID_PREF_KEY, "");
3453
+ if (previousAppId == null || previousAppId.isEmpty()) {
1325
3454
  return;
1326
3455
  }
1327
- if (version == null) {
1328
- logger.error("Download called without version");
1329
- call.reject("Download called without version");
3456
+ this.setActiveAppId(previousAppId);
3457
+ logger.info("Restored appId after preview: " + previousAppId);
3458
+ }
3459
+
3460
+ private void restorePreviewPreviousDefaultChannel() {
3461
+ final String configDefaultChannel = this.getConfig().getString("defaultChannel", "");
3462
+ if (this.prefs.getBoolean(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY, false)) {
3463
+ final String previousDefaultChannel = this.prefs.getString(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_PREF_KEY, "");
3464
+ this.editor.putString(DEFAULT_CHANNEL_PREF_KEY, previousDefaultChannel);
3465
+ this.implementation.defaultChannel = previousDefaultChannel;
3466
+ this.editor.apply();
3467
+ logger.info("Restored defaultChannel after preview");
1330
3468
  return;
1331
3469
  }
1332
- try {
1333
- logger.info("Downloading " + url);
1334
- startNewThread(() -> {
1335
- try {
1336
- final BundleInfo downloaded;
1337
- if (manifest != null) {
1338
- // For manifest downloads, we need to handle this asynchronously
1339
- // to avoid automatically scheduling/applying the downloaded bundle.
1340
- // Manual download must not schedule/apply the bundle automatically.
1341
- CapacitorUpdaterPlugin.this.implementation.downloadBackground(url, version, sessionKey, checksum, manifest, false);
1342
- // Return immediately with a pending status - the actual result will come via listeners
1343
- final String id = CapacitorUpdaterPlugin.this.implementation.randomString();
1344
- downloaded = new BundleInfo(id, version, BundleStatus.DOWNLOADING, new Date(System.currentTimeMillis()), "");
1345
- call.resolve(InternalUtils.mapToJSObject(downloaded.toJSONMap()));
1346
- return;
1347
- } else {
1348
- downloaded = CapacitorUpdaterPlugin.this.implementation.download(url, version, sessionKey, checksum);
1349
- }
1350
- if (downloaded.isErrorStatus()) {
1351
- throw new RuntimeException("Download failed: " + downloaded.getStatus());
1352
- } else {
1353
- call.resolve(InternalUtils.mapToJSObject(downloaded.toJSONMap()));
1354
- }
1355
- } catch (final Exception e) {
1356
- logger.error("Failed to download from: " + url + " " + e.getMessage());
1357
- call.reject("Failed to download from: " + url, e);
1358
- final JSObject ret = new JSObject();
1359
- ret.put("version", version);
1360
- CapacitorUpdaterPlugin.this.notifyListeners("downloadFailed", ret);
1361
- final BundleInfo current = CapacitorUpdaterPlugin.this.implementation.getCurrentBundle();
1362
- CapacitorUpdaterPlugin.this.implementation.sendStats("download_fail", current.getVersionName());
1363
- }
1364
- });
1365
- } catch (final Exception e) {
1366
- logger.error("Failed to download from: " + url + " " + e.getMessage());
1367
- call.reject("Failed to download from: " + url, e);
1368
- final JSObject ret = new JSObject();
1369
- ret.put("version", version);
1370
- CapacitorUpdaterPlugin.this.notifyListeners("downloadFailed", ret);
1371
- final BundleInfo current = CapacitorUpdaterPlugin.this.implementation.getCurrentBundle();
1372
- CapacitorUpdaterPlugin.this.implementation.sendStats("download_fail", current.getVersionName());
1373
- }
3470
+
3471
+ this.editor.remove(DEFAULT_CHANNEL_PREF_KEY);
3472
+ this.implementation.defaultChannel = configDefaultChannel;
3473
+ this.editor.apply();
3474
+ logger.info("Restored defaultChannel after preview to config value");
1374
3475
  }
1375
3476
 
1376
- private void syncKeepUrlPathFlag(final boolean enabled) {
1377
- if (this.bridge == null || this.bridge.getWebView() == null) {
1378
- return;
3477
+ private String normalizePreviewAppId(final String rawAppId) {
3478
+ if (rawAppId == null) {
3479
+ return null;
1379
3480
  }
1380
- final String script = enabled
1381
- ? "(function(){try{localStorage.setItem('" +
1382
- KEEP_URL_FLAG_KEY +
1383
- "','1');}catch(e){}window.__capgoKeepUrlPathAfterReload=true;var evt;try{evt=new CustomEvent('CapacitorUpdaterKeepUrlPathAfterReload',{detail:{enabled:true}});}catch(err){evt=document.createEvent('CustomEvent');evt.initCustomEvent('CapacitorUpdaterKeepUrlPathAfterReload',false,false,{enabled:true});}window.dispatchEvent(evt);})();"
1384
- : "(function(){try{localStorage.removeItem('" +
1385
- KEEP_URL_FLAG_KEY +
1386
- "');}catch(e){}delete window.__capgoKeepUrlPathAfterReload;var evt;try{evt=new CustomEvent('CapacitorUpdaterKeepUrlPathAfterReload',{detail:{enabled:false}});}catch(err){evt=document.createEvent('CustomEvent');evt.initCustomEvent('CapacitorUpdaterKeepUrlPathAfterReload',false,false,{enabled:false});}window.dispatchEvent(evt);})();";
1387
- this.bridge.getWebView().post(() -> this.bridge.getWebView().evaluateJavascript(script, null));
3481
+
3482
+ final String appId = rawAppId.trim();
3483
+ if (appId.isEmpty()) {
3484
+ return null;
3485
+ }
3486
+
3487
+ final String lowercasedAppId = appId.toLowerCase(java.util.Locale.ROOT);
3488
+ if ("undefined".equals(lowercasedAppId) || "null".equals(lowercasedAppId)) {
3489
+ return null;
3490
+ }
3491
+
3492
+ return appId;
1388
3493
  }
1389
3494
 
1390
- private void applyCurrentBundleToBridge() {
1391
- final String path = this.implementation.getCurrentBundlePath();
1392
- final boolean usingBuiltin = this.implementation.isUsingBuiltin();
1393
- if (this.keepUrlPathAfterReload) {
1394
- this.syncKeepUrlPathFlag(true);
3495
+ private boolean hasPreviewPayloadUrl(final String rawPayloadUrl) {
3496
+ if (rawPayloadUrl == null) {
3497
+ return false;
1395
3498
  }
1396
- logger.info("Reloading: " + path);
1397
3499
 
1398
- AtomicReference<URL> url = new AtomicReference<>();
1399
- if (this.keepUrlPathAfterReload) {
1400
- try {
1401
- if (Looper.myLooper() != Looper.getMainLooper()) {
1402
- Semaphore mainThreadSemaphore = new Semaphore(0);
1403
- this.bridge.executeOnMainThread(() -> {
1404
- try {
1405
- if (this.bridge != null && this.bridge.getWebView() != null) {
1406
- String currentUrl = this.bridge.getWebView().getUrl();
1407
- if (currentUrl != null) {
1408
- url.set(new URL(currentUrl));
1409
- }
1410
- }
1411
- } catch (Exception e) {
1412
- logger.error("Error executing on main thread " + e.getMessage());
1413
- }
1414
- mainThreadSemaphore.release();
1415
- });
3500
+ final String payloadUrl = rawPayloadUrl.trim();
3501
+ if (payloadUrl.isEmpty()) {
3502
+ return false;
3503
+ }
1416
3504
 
1417
- // Add timeout to prevent indefinite blocking
1418
- if (!mainThreadSemaphore.tryAcquire(10, TimeUnit.SECONDS)) {
1419
- logger.error("Timeout waiting for main thread operation");
1420
- }
1421
- } else {
1422
- try {
1423
- if (this.bridge != null && this.bridge.getWebView() != null) {
1424
- String currentUrl = this.bridge.getWebView().getUrl();
1425
- if (currentUrl != null) {
1426
- url.set(new URL(currentUrl));
1427
- }
1428
- }
1429
- } catch (Exception e) {
1430
- logger.error("Error executing on main thread " + e.getMessage());
1431
- }
1432
- }
1433
- } catch (InterruptedException e) {
1434
- logger.error("Error waiting for main thread or getting the current URL from webview " + e.getMessage());
1435
- Thread.currentThread().interrupt(); // Restore interrupted status
1436
- }
3505
+ final String lowercasedPayloadUrl = payloadUrl.toLowerCase(java.util.Locale.ROOT);
3506
+ return !"undefined".equals(lowercasedPayloadUrl) && !"null".equals(lowercasedPayloadUrl);
3507
+ }
3508
+
3509
+ private String normalizePreviewPayloadUrl(final String rawPayloadUrl) {
3510
+ if (!this.hasPreviewPayloadUrl(rawPayloadUrl)) {
3511
+ return null;
1437
3512
  }
1438
3513
 
1439
- if (url.get() != null) {
1440
- if (usingBuiltin) {
1441
- this.bridge.getLocalServer().hostAssets(path);
1442
- } else {
1443
- this.bridge.getLocalServer().hostFiles(path);
3514
+ final String payloadUrl = rawPayloadUrl.trim();
3515
+ try {
3516
+ final URL parsedUrl = new URL(payloadUrl);
3517
+ final String protocol = parsedUrl.getProtocol();
3518
+ if (!"https".equals(protocol) && !"http".equals(protocol)) {
3519
+ return null;
1444
3520
  }
3521
+ return parsedUrl.toString();
3522
+ } catch (final MalformedURLException ignored) {
3523
+ return null;
3524
+ }
3525
+ }
1445
3526
 
1446
- try {
1447
- URL finalUrl = null;
1448
- finalUrl = new URL(this.bridge.getAppUrl());
1449
- finalUrl = new URL(finalUrl.getProtocol(), finalUrl.getHost(), finalUrl.getPort(), url.get().getPath());
1450
- URL finalUrl1 = finalUrl;
1451
- this.bridge.getWebView().post(() -> {
1452
- this.bridge.getWebView().loadUrl(finalUrl1.toString());
1453
- if (!this.keepUrlPathAfterReload) {
1454
- this.bridge.getWebView().clearHistory();
1455
- }
1456
- });
1457
- } catch (MalformedURLException e) {
1458
- logger.error("Cannot get finalUrl from capacitor bridge " + e.getMessage());
3527
+ private String storedPreviewPayloadUrl() {
3528
+ return this.normalizePreviewPayloadUrl(this.prefs.getString(PREVIEW_PAYLOAD_URL_PREF_KEY, null));
3529
+ }
1459
3530
 
1460
- if (usingBuiltin) {
1461
- this.bridge.setServerAssetPath(path);
1462
- } else {
1463
- this.bridge.setServerBasePath(path);
1464
- }
1465
- }
1466
- } else {
1467
- if (usingBuiltin) {
1468
- this.bridge.setServerAssetPath(path);
1469
- } else {
1470
- this.bridge.setServerBasePath(path);
1471
- }
1472
- if (this.bridge != null && this.bridge.getWebView() != null) {
1473
- this.bridge.getWebView().post(() -> {
1474
- if (this.bridge.getWebView() != null) {
1475
- this.bridge.getWebView().loadUrl(this.bridge.getAppUrl());
1476
- if (!this.keepUrlPathAfterReload) {
1477
- this.bridge.getWebView().clearHistory();
1478
- }
1479
- }
1480
- });
3531
+ private String previewPathFromUri(final Uri uri) {
3532
+ if ("capgo".equals(uri.getScheme())) {
3533
+ final String host = uri.getHost();
3534
+ final String path = uri.getPath();
3535
+ return ("/" + (host == null ? "" : host) + (path == null ? "" : path)).replaceAll("/+", "/");
3536
+ }
3537
+
3538
+ return uri.getPath();
3539
+ }
3540
+
3541
+ private boolean isPreviewDeepLink(final Uri uri) {
3542
+ final String path = this.previewPathFromUri(uri);
3543
+ return "/preview/channel".equals(path) || "/preview/bundle".equals(path);
3544
+ }
3545
+
3546
+ private String readResponseBody(final InputStream stream) throws IOException {
3547
+ if (stream == null) {
3548
+ return "";
3549
+ }
3550
+
3551
+ try (InputStream input = stream; ByteArrayOutputStream output = new ByteArrayOutputStream()) {
3552
+ final byte[] buffer = new byte[8192];
3553
+ int read;
3554
+ while ((read = input.read(buffer)) != -1) {
3555
+ output.write(buffer, 0, read);
1481
3556
  }
3557
+ return output.toString(StandardCharsets.UTF_8.name());
1482
3558
  }
1483
3559
  }
1484
3560
 
1485
- protected void restoreLiveBundleStateAfterFailedReload() {
3561
+ private JSONObject fetchPreviewPayload(final String payloadUrl) throws IOException, JSONException {
3562
+ final HttpURLConnection connection = (HttpURLConnection) new URL(payloadUrl).openConnection();
3563
+ connection.setRequestMethod("GET");
3564
+ connection.setRequestProperty("Accept", "application/json");
3565
+ connection.setConnectTimeout(30000);
3566
+ connection.setReadTimeout(60000);
3567
+
1486
3568
  try {
1487
- this.applyCurrentBundleToBridge();
1488
- } catch (final Exception e) {
1489
- logger.warn("Failed to restore live bundle after rejected reload: " + e.getMessage());
3569
+ final int statusCode = connection.getResponseCode();
3570
+ final String body = this.readResponseBody(
3571
+ statusCode >= 200 && statusCode < 300 ? connection.getInputStream() : connection.getErrorStream()
3572
+ );
3573
+ final JSONObject payload = new JSONObject(body);
3574
+ if (statusCode < 200 || statusCode >= 300) {
3575
+ throw new IOException(
3576
+ payload.optString("message", payload.optString("error", "Preview payload request failed with HTTP " + statusCode))
3577
+ );
3578
+ }
3579
+ return payload;
3580
+ } finally {
3581
+ connection.disconnect();
1490
3582
  }
1491
3583
  }
1492
3584
 
1493
- protected boolean _reload() {
1494
- final int phase = this.semaphoreUp();
1495
- this.applyCurrentBundleToBridge();
3585
+ private BundleInfo downloadPreviewPayloadBundle(final JSONObject payload) throws IOException, JSONException {
3586
+ final String version = payload.optString("version", "").trim();
3587
+ if (version.isEmpty()) {
3588
+ throw new IOException("Preview payload is missing a version");
3589
+ }
1496
3590
 
1497
- final long waitTimeMs = this.resolveAppReadyCheckTimeoutMs();
1498
- this.checkAppReady(waitTimeMs);
1499
- this.notifyListeners("appReloaded", new JSObject());
3591
+ final JSONArray manifest = payload.optJSONArray("manifest");
3592
+ final String url = payload.optString("url", "");
3593
+ if ((url == null || url.isEmpty()) && (manifest == null || manifest.length() == 0)) {
3594
+ throw new IOException("Preview payload is missing download information");
3595
+ }
1500
3596
 
1501
- // Wait for the reload to complete (until notifyAppReady is called)
1502
- return this.semaphoreWait(phase, waitTimeMs);
3597
+ return this.downloadBundle(
3598
+ url == null || url.isEmpty() ? "https://404.capgo.app/no.zip" : url,
3599
+ version,
3600
+ payload.optString("sessionKey", ""),
3601
+ payload.optString("checksum", ""),
3602
+ manifest
3603
+ );
1503
3604
  }
1504
3605
 
1505
- @PluginMethod
1506
- public void reload(final PluginCall call) {
1507
- startNewThread(() -> {
1508
- try {
1509
- final BundleInfo current = this.implementation.getCurrentBundle();
1510
- final BundleInfo next = this.implementation.getNextBundle();
3606
+ private boolean refreshPreviewSessionFromPayloadUrl(final String payloadUrl) {
3607
+ try {
3608
+ final JSONObject payload = this.fetchPreviewPayload(payloadUrl);
3609
+ final String version = payload.optString("version", "").trim();
3610
+ if (version.isEmpty()) {
3611
+ throw new IOException("Preview payload is missing a version");
3612
+ }
1511
3613
 
1512
- if (next != null && !next.isErrorStatus() && !next.getId().equals(current.getId())) {
1513
- final CapgoUpdater.ResetState previousState = this.implementation.captureResetState();
1514
- final String previousBundleName = this.implementation.getCurrentBundle().getVersionName();
1515
- logger.info("Applying pending bundle before reload: " + next.getVersionName());
1516
- final boolean didApplyPendingBundle;
1517
- if (next.isBuiltin()) {
1518
- this.implementation.prepareResetStateForTransition();
1519
- didApplyPendingBundle = true;
1520
- } else {
1521
- didApplyPendingBundle = this.implementation.stagePendingReload(next);
1522
- }
1523
- if (didApplyPendingBundle && this._reload()) {
1524
- if (next.isBuiltin()) {
1525
- this.implementation.finalizeResetTransition(previousBundleName, false);
1526
- } else {
1527
- this.implementation.finalizePendingReload(next, previousBundleName);
1528
- }
1529
- this.notifyBundleSet(next);
1530
- this.implementation.setNextBundle(null);
1531
- call.resolve();
1532
- return;
1533
- }
1534
- this.implementation.restoreResetState(previousState);
1535
- this.restoreLiveBundleStateAfterFailedReload();
1536
- logger.error("Reload failed after applying pending bundle: " + next.getVersionName());
1537
- call.reject("Reload failed after applying pending bundle: " + next.getVersionName());
1538
- return;
1539
- }
3614
+ final BundleInfo current = this.implementation.getCurrentBundle();
3615
+ if (version.equals(current.getVersionName())) {
3616
+ logger.info("Preview payload unchanged, reloading current bundle");
3617
+ return this.reloadWithoutWaitingForAppReady();
3618
+ }
1540
3619
 
1541
- if (this._reload()) {
1542
- call.resolve();
1543
- } else {
1544
- logger.error("Reload failed");
1545
- call.reject("Reload failed");
1546
- }
1547
- } catch (final Exception e) {
1548
- logger.error("Could not reload " + e.getMessage());
1549
- call.reject("Could not reload", e);
3620
+ final BundleInfo next = this.downloadPreviewPayloadBundle(payload);
3621
+ if (next.isErrorStatus()) {
3622
+ throw new IOException("Download failed: " + next.getStatus());
1550
3623
  }
1551
- });
3624
+ if (!this.implementation.set(next.getId())) {
3625
+ throw new IOException("Downloaded preview bundle cannot be applied");
3626
+ }
3627
+
3628
+ this.recordPreviewBundle(next, current.getId());
3629
+ this.notifyBundleSet(next);
3630
+ return this.reloadWithoutWaitingForAppReady();
3631
+ } catch (final Exception err) {
3632
+ logger.error("Could not refresh preview session: " + err.getMessage());
3633
+ return false;
3634
+ }
1552
3635
  }
1553
3636
 
1554
- @PluginMethod
1555
- public void next(final PluginCall call) {
1556
- final String id = call.getString("id");
1557
- if (id == null) {
1558
- logger.error("Next called without id");
1559
- call.reject("Next called without id");
3637
+ private void clearPreviewSessionForNativeBuildChange() {
3638
+ if (
3639
+ !Boolean.TRUE.equals(this.previewSessionEnabled) &&
3640
+ this.implementation.getPreviewFallbackBundle() == null &&
3641
+ !this.hasSavedPreviewSessions()
3642
+ ) {
1560
3643
  return;
1561
3644
  }
1562
- try {
1563
- logger.info("Setting next active id " + id);
1564
- if (!this.implementation.setNextBundle(id)) {
1565
- logger.error("Set next id failed. Bundle " + id + " does not exist.");
1566
- call.reject("Set next id failed. Bundle " + id + " does not exist.");
1567
- } else {
1568
- call.resolve(InternalUtils.mapToJSObject(this.implementation.getBundleInfo(id).toJSONMap()));
1569
- }
1570
- } catch (final Exception e) {
1571
- logger.error("Could not set next id " + id + " " + e.getMessage());
1572
- call.reject("Could not set next id: " + id, e);
3645
+ logger.info("Native build changed; clearing preview session state");
3646
+ this.previewSessionEnabled = false;
3647
+ this.previewSessionAlertPending = false;
3648
+ this.isLeavingPreviewForIncomingLink = false;
3649
+ this.implementation.previewSession = false;
3650
+ this.shakeMenuEnabled = this.getConfig().getBoolean("shakeMenu", false);
3651
+ this.shakeChannelSelectorEnabled = this.getConfig().getBoolean("allowShakeChannelSelector", false);
3652
+ this.shakeMenuGesture = normalizedShakeMenuGesture(this.getConfig().getString("shakeMenuGesture", SHAKE_MENU_GESTURE_SHAKE));
3653
+ this.syncShakeMenuLifecycle();
3654
+ this.restorePreviewPreviousAppId();
3655
+ this.restorePreviewPreviousDefaultChannel();
3656
+ this.implementation.setPreviewFallbackBundle(null);
3657
+ this.implementation.setNextBundle(null);
3658
+ this.clearPreviewSessionPreferences();
3659
+ synchronized (this.previewSessionsLock) {
3660
+ this.editor.remove(PREVIEW_SESSIONS_PREF_KEY);
3661
+ this.editor.apply();
1573
3662
  }
1574
3663
  }
1575
3664
 
1576
- @PluginMethod
1577
- public void set(final PluginCall call) {
1578
- final String id = call.getString("id");
1579
- if (id == null) {
1580
- logger.error("Set called without id");
1581
- call.reject("Set called without id");
3665
+ private void restorePreviewPreviousNextBundle() {
3666
+ final String previousNextBundleId = this.prefs.getString(PREVIEW_PREVIOUS_NEXT_BUNDLE_PREF_KEY, null);
3667
+ if (previousNextBundleId == null || previousNextBundleId.isEmpty()) {
3668
+ this.implementation.setNextBundle(null);
1582
3669
  return;
1583
3670
  }
1584
- try {
1585
- logger.info("Setting active bundle " + id);
1586
- if (!this.implementation.set(id)) {
1587
- logger.info("No such bundle " + id);
1588
- call.reject("Update failed, id " + id + " does not exist.");
1589
- } else if (!this._reload()) {
1590
- logger.error("Reload failed after setting bundle " + id);
1591
- call.reject("Reload failed after setting bundle " + id);
1592
- } else {
1593
- logger.info("Bundle successfully set to " + id);
1594
- this.notifyBundleSet(this.implementation.getBundleInfo(id));
1595
- call.resolve();
3671
+ if (!this.implementation.setNextBundle(previousNextBundleId)) {
3672
+ logger.warn("Could not restore pre-preview next bundle: " + previousNextBundleId);
3673
+ this.implementation.setNextBundle(null);
3674
+ }
3675
+ }
3676
+
3677
+ private void ensureShakeMenuStarted() {
3678
+ if (shakeMenu != null && !shakeMenu.usesGesture(this.shakeMenuGesture)) {
3679
+ try {
3680
+ shakeMenu.stop();
3681
+ shakeMenu = null;
3682
+ logger.info("Shake menu restarted for " + this.shakeMenuGesture + " gesture");
3683
+ } catch (Exception e) {
3684
+ logger.error("Failed to restart shake menu: " + e.getMessage());
3685
+ return;
1596
3686
  }
1597
- } catch (final Exception e) {
1598
- logger.error("Could not set id " + id + " " + e.getMessage());
1599
- call.reject("Could not set id " + id, e);
1600
3687
  }
3688
+
3689
+ if (getActivity() instanceof com.getcapacitor.BridgeActivity && shakeMenu == null) {
3690
+ try {
3691
+ shakeMenu = new ShakeMenu(this, (com.getcapacitor.BridgeActivity) getActivity(), logger, this.shakeMenuGesture);
3692
+ logger.info("Shake menu initialized with " + this.shakeMenuGesture + " gesture");
3693
+ } catch (Exception e) {
3694
+ logger.error("Failed to initialize shake menu: " + e.getMessage());
3695
+ }
3696
+ }
3697
+ }
3698
+
3699
+ private void syncShakeMenuLifecycle() {
3700
+ if (this.shouldListenForShake()) {
3701
+ this.ensureShakeMenuStarted();
3702
+ } else if (shakeMenu != null) {
3703
+ try {
3704
+ shakeMenu.stop();
3705
+ shakeMenu = null;
3706
+ logger.info("Shake menu stopped");
3707
+ } catch (Exception e) {
3708
+ logger.error("Failed to stop shake menu: " + e.getMessage());
3709
+ }
3710
+ }
3711
+ }
3712
+
3713
+ private boolean shouldListenForShake() {
3714
+ return Boolean.TRUE.equals(this.shakeMenuEnabled) || Boolean.TRUE.equals(this.shakeChannelSelectorEnabled);
3715
+ }
3716
+
3717
+ private void showPreviewSessionNoticeIfNeeded() {
3718
+ if (!Boolean.TRUE.equals(this.previewSessionEnabled) || !Boolean.TRUE.equals(this.previewSessionAlertPending)) {
3719
+ return;
3720
+ }
3721
+ this.previewSessionAlertPending = false;
3722
+ this.editor.putBoolean(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY, false);
3723
+ this.editor.apply();
3724
+
3725
+ new Handler(Looper.getMainLooper()).postDelayed(() -> {
3726
+ try {
3727
+ if (!Boolean.TRUE.equals(this.previewSessionEnabled)) {
3728
+ return;
3729
+ }
3730
+ if (getActivity() == null || getActivity().isFinishing()) {
3731
+ this.previewSessionAlertPending = true;
3732
+ this.editor.putBoolean(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY, true);
3733
+ this.editor.apply();
3734
+ return;
3735
+ }
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);
1601
3749
  }
1602
3750
 
1603
3751
  @PluginMethod
@@ -1681,33 +3829,146 @@ public class CapacitorUpdaterPlugin extends Plugin {
1681
3829
  @PluginMethod
1682
3830
  public void getLatest(final PluginCall call) {
1683
3831
  final String channel = call.getString("channel");
1684
- startNewThread(() ->
1685
- CapacitorUpdaterPlugin.this.implementation.getLatest(CapacitorUpdaterPlugin.this.updateUrl, channel, (res) -> {
1686
- JSObject jsRes = InternalUtils.mapToJSObject(res);
1687
- if (jsRes.has("error") || jsRes.has("kind")) {
1688
- String error = jsRes.has("error") ? jsRes.getString("error") : "";
1689
- String errorMessage = jsRes.has("message") ? jsRes.getString("message") : "server did not provide a message";
1690
- String kind = CapacitorUpdaterPlugin.this.getUpdateResponseKind(jsRes.has("kind") ? jsRes.getString("kind") : null);
1691
- jsRes.put("kind", kind);
1692
- if ("failed".equals(kind)) {
1693
- logger.error("getLatest failed with error: " + error + ", message: " + errorMessage);
1694
- call.reject(error.isEmpty() ? errorMessage : error);
1695
- } else {
1696
- if (!jsRes.has("version") || jsRes.getString("version").isEmpty()) {
1697
- jsRes.put("version", CapacitorUpdaterPlugin.this.implementation.getCurrentBundle().getVersionName());
1698
- }
1699
- logger.info("getLatest returned " + kind + ": " + errorMessage);
1700
- call.resolve(jsRes);
1701
- }
1702
- return;
1703
- } else if (jsRes.has("message")) {
1704
- call.reject(jsRes.getString("message"));
1705
- return;
3832
+ final boolean includeBundleSize = call.getBoolean("includeBundleSize", false);
3833
+ final String previewAppId = this.normalizePreviewAppId(call.getString("appId"));
3834
+ final boolean hasPreviewAppId = previewAppId != null;
3835
+ if (hasPreviewAppId && !Boolean.TRUE.equals(this.allowPreview)) {
3836
+ logger.error("getLatest preview override not allowed set allowPreview in your config to true to enable it");
3837
+ call.reject("getLatest preview override not allowed");
3838
+ return;
3839
+ }
3840
+
3841
+ final Callback latestCallback = (res) -> {
3842
+ JSObject jsRes = InternalUtils.mapToJSObject(res);
3843
+ if (jsRes.has("error") || jsRes.has("kind")) {
3844
+ String error = jsRes.has("error") ? jsRes.getString("error") : "";
3845
+ String errorMessage = jsRes.has("message") ? jsRes.getString("message") : "server did not provide a message";
3846
+ String kind = CapacitorUpdaterPlugin.this.getUpdateResponseKind(jsRes.has("kind") ? jsRes.getString("kind") : null);
3847
+ String latestVersion = jsRes.has("version") ? jsRes.getString("version") : "";
3848
+ jsRes.put("kind", kind);
3849
+ CapacitorUpdaterPlugin.this.notifyBreakingEventsIfNeeded(jsRes, latestVersion);
3850
+ if ("failed".equals(kind)) {
3851
+ logger.error("getLatest failed with error: " + error + ", message: " + errorMessage);
3852
+ call.reject(error.isEmpty() ? errorMessage : error);
1706
3853
  } else {
3854
+ if (!jsRes.has("version") || jsRes.getString("version").isEmpty()) {
3855
+ jsRes.put("version", CapacitorUpdaterPlugin.this.implementation.getCurrentBundle().getVersionName());
3856
+ }
3857
+ logger.info("getLatest returned " + kind + ": " + errorMessage);
1707
3858
  call.resolve(jsRes);
1708
3859
  }
1709
- })
1710
- );
3860
+ return;
3861
+ } else if (jsRes.has("message")) {
3862
+ String latestVersion = jsRes.has("version") ? jsRes.getString("version") : "";
3863
+ CapacitorUpdaterPlugin.this.notifyBreakingEventsIfNeeded(jsRes, latestVersion);
3864
+ call.reject(jsRes.getString("message"));
3865
+ return;
3866
+ } else {
3867
+ if (includeBundleSize) {
3868
+ CapacitorUpdaterPlugin.this.attachBundleSize(jsRes);
3869
+ }
3870
+ call.resolve(jsRes);
3871
+ }
3872
+ };
3873
+
3874
+ startNewThread(() -> {
3875
+ if (hasPreviewAppId) {
3876
+ CapacitorUpdaterPlugin.this.implementation.getLatest(
3877
+ CapacitorUpdaterPlugin.this.updateUrl,
3878
+ channel,
3879
+ previewAppId,
3880
+ latestCallback
3881
+ );
3882
+ return;
3883
+ }
3884
+ CapacitorUpdaterPlugin.this.implementation.getLatest(CapacitorUpdaterPlugin.this.updateUrl, channel, latestCallback);
3885
+ });
3886
+ }
3887
+
3888
+ private void attachBundleSize(final JSObject latest) {
3889
+ try {
3890
+ final JSONArray manifest = latest.optJSONArray("manifest");
3891
+ if (manifest == null || manifest.length() == 0) {
3892
+ return;
3893
+ }
3894
+ final String sessionKey = latest.optString("sessionKey", "");
3895
+ final JSONObject missing = this.implementation.missingBundleFilesResult(manifest, sessionKey);
3896
+ final JSONArray missingManifest = missing.getJSONArray("missing");
3897
+ final JSONObject size = this.implementation.getBundleDownloadSize(
3898
+ this.updateUrl,
3899
+ latest.optString("version", ""),
3900
+ missingManifest
3901
+ );
3902
+ latest.put("missing", missing);
3903
+ latest.put("downloadSize", size);
3904
+ } catch (Exception e) {
3905
+ logger.error("Failed to attach bundle size to getLatest result");
3906
+ logger.debug("Error: " + e.getMessage());
3907
+ }
3908
+ }
3909
+
3910
+ @PluginMethod
3911
+ public void getMissingBundleFiles(final PluginCall call) {
3912
+ final JSONArray manifest = call.getData().optJSONArray("manifest");
3913
+ if (manifest == null) {
3914
+ call.reject("getMissingBundleFiles called without manifest");
3915
+ return;
3916
+ }
3917
+ String sessionKey = call.getString("sessionKey");
3918
+ if (sessionKey == null) {
3919
+ sessionKey = "";
3920
+ }
3921
+ final String finalSessionKey = sessionKey;
3922
+ startNewThread(() -> {
3923
+ try {
3924
+ call.resolve(jsonObjectToJSObject(this.implementation.missingBundleFilesResult(manifest, finalSessionKey)));
3925
+ } catch (Exception e) {
3926
+ call.reject("Could not get missing bundle files", e);
3927
+ }
3928
+ });
3929
+ }
3930
+
3931
+ @PluginMethod
3932
+ public void getBundleDownloadSize(final PluginCall call) {
3933
+ final JSONArray manifest = call.getData().optJSONArray("manifest");
3934
+ if (manifest == null) {
3935
+ call.reject("getBundleDownloadSize called without manifest");
3936
+ return;
3937
+ }
3938
+ final String version = call.getData().optString("version", "");
3939
+ startNewThread(() -> {
3940
+ try {
3941
+ final JSONObject size = this.implementation.getBundleDownloadSize(this.updateUrl, version, manifest);
3942
+ call.resolve(jsonObjectToJSObject(size));
3943
+ } catch (Exception e) {
3944
+ call.reject("Could not get bundle download size", e);
3945
+ }
3946
+ });
3947
+ }
3948
+
3949
+ public String triggerBackgroundUpdateCheck() {
3950
+ if (this.updateUrl == null || this.updateUrl.isEmpty() || !this.isValidURL(this.updateUrl)) {
3951
+ logger.error("Error no url or wrong format");
3952
+ return "unavailable";
3953
+ }
3954
+ if (this.shouldBlockAutoUpdateForPreviewSession()) {
3955
+ return "preview_session";
3956
+ }
3957
+ if (this.isDownloadStuckOrTimedOut()) {
3958
+ logger.info("Download already in progress, skipping duplicate download request");
3959
+ return "already_running";
3960
+ }
3961
+ this.backgroundDownload();
3962
+ return "queued";
3963
+ }
3964
+
3965
+ @PluginMethod
3966
+ public void triggerUpdateCheck(final PluginCall call) {
3967
+ final String status = this.triggerBackgroundUpdateCheck();
3968
+ final JSObject ret = new JSObject();
3969
+ ret.put("status", status);
3970
+ ret.put("queued", "queued".equals(status));
3971
+ call.resolve(ret);
1711
3972
  }
1712
3973
 
1713
3974
  private boolean _reset(final Boolean toLastSuccessful, final Boolean usePendingBundle) {
@@ -1783,19 +4044,21 @@ public class CapacitorUpdaterPlugin extends Plugin {
1783
4044
 
1784
4045
  @PluginMethod
1785
4046
  public void reset(final PluginCall call) {
1786
- try {
1787
- final Boolean toLastSuccessful = call.getBoolean("toLastSuccessful", false);
1788
- final Boolean usePendingBundle = call.getBoolean("usePendingBundle", false);
1789
- if (this._reset(toLastSuccessful, usePendingBundle)) {
1790
- call.resolve();
1791
- return;
4047
+ startNewThread(() -> {
4048
+ try {
4049
+ final Boolean toLastSuccessful = call.getBoolean("toLastSuccessful", false);
4050
+ final Boolean usePendingBundle = call.getBoolean("usePendingBundle", false);
4051
+ if (this._reset(toLastSuccessful, usePendingBundle)) {
4052
+ call.resolve();
4053
+ return;
4054
+ }
4055
+ logger.error("Reset failed");
4056
+ call.reject("Reset failed");
4057
+ } catch (final Exception e) {
4058
+ logger.error("Reset failed " + e.getMessage());
4059
+ call.reject("Reset failed", e);
1792
4060
  }
1793
- logger.error("Reset failed");
1794
- call.reject("Reset failed");
1795
- } catch (final Exception e) {
1796
- logger.error("Reset failed " + e.getMessage());
1797
- call.reject("Reset failed", e);
1798
- }
4061
+ });
1799
4062
  }
1800
4063
 
1801
4064
  @PluginMethod
@@ -1859,7 +4122,13 @@ public class CapacitorUpdaterPlugin extends Plugin {
1859
4122
  @Override
1860
4123
  public void run() {
1861
4124
  try {
4125
+ if (CapacitorUpdaterPlugin.this.shouldBlockAutoUpdateForPreviewSession()) {
4126
+ return;
4127
+ }
1862
4128
  CapacitorUpdaterPlugin.this.implementation.getLatest(CapacitorUpdaterPlugin.this.updateUrl, null, (res) -> {
4129
+ if (CapacitorUpdaterPlugin.this.shouldBlockAutoUpdateForPreviewSession()) {
4130
+ return;
4131
+ }
1863
4132
  JSObject jsRes = InternalUtils.mapToJSObject(res);
1864
4133
  if (jsRes.has("error") || jsRes.has("kind")) {
1865
4134
  final BundleInfo current = CapacitorUpdaterPlugin.this.implementation.getCurrentBundle();
@@ -1922,6 +4191,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
1922
4191
  logger.info("semaphoreReady countDown");
1923
4192
  this.semaphoreDown();
1924
4193
  logger.info("semaphoreReady countDown done");
4194
+ this.clearIncomingPreviewTransition();
4195
+ this.hidePreviewTransitionLoader("notify-app-ready");
1925
4196
  final JSObject ret = new JSObject();
1926
4197
  ret.put("bundle", InternalUtils.mapToJSObject(bundle.toJSONMap()));
1927
4198
  call.resolve(ret);
@@ -1969,6 +4240,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
1969
4240
  }
1970
4241
 
1971
4242
  private Boolean _isAutoUpdateEnabled() {
4243
+ if (this.isPreviewSessionStateActive()) {
4244
+ return false;
4245
+ }
1972
4246
  final CapConfig config = CapConfig.loadDefault(this.getActivity());
1973
4247
  String serverUrl = config.getServerUrl();
1974
4248
  if (serverUrl != null && !serverUrl.isEmpty()) {
@@ -2012,10 +4286,22 @@ public class CapacitorUpdaterPlugin extends Plugin {
2012
4286
  this.checkAppReady(this.resolveAppReadyCheckTimeoutMs());
2013
4287
  }
2014
4288
 
4289
+ synchronized boolean shouldInterruptAppReadyCheck(final Thread existingCheck, final Thread currentThread) {
4290
+ return existingCheck != null && existingCheck != currentThread;
4291
+ }
4292
+
4293
+ synchronized void clearAppReadyCheckIfCurrent(final Thread expectedThread) {
4294
+ if (this.appReadyCheck == expectedThread) {
4295
+ this.appReadyCheck = null;
4296
+ }
4297
+ }
4298
+
2015
4299
  private void checkAppReady(final long waitTimeMs) {
2016
4300
  try {
2017
- if (this.appReadyCheck != null) {
2018
- this.appReadyCheck.interrupt();
4301
+ final Thread currentThread = Thread.currentThread();
4302
+ final Thread existingCheck = this.appReadyCheck;
4303
+ if (this.shouldInterruptAppReadyCheck(existingCheck, currentThread)) {
4304
+ existingCheck.interrupt();
2019
4305
  }
2020
4306
  this.appReadyCheck = startNewThread(new DeferredNotifyAppReadyCheck(waitTimeMs));
2021
4307
  } catch (final Exception e) {
@@ -2032,13 +4318,17 @@ public class CapacitorUpdaterPlugin extends Plugin {
2032
4318
  }
2033
4319
  }
2034
4320
 
2035
- private String getUpdateResponseKind(final String kind) {
4321
+ static String normalizedUpdateResponseKind(final String kind) {
2036
4322
  if ("up_to_date".equals(kind) || "blocked".equals(kind) || "failed".equals(kind)) {
2037
4323
  return kind;
2038
4324
  }
2039
4325
  return "failed";
2040
4326
  }
2041
4327
 
4328
+ private String getUpdateResponseKind(final String kind) {
4329
+ return normalizedUpdateResponseKind(kind);
4330
+ }
4331
+
2042
4332
  private void notifyUpdateCheckResult(
2043
4333
  final String kind,
2044
4334
  final String error,
@@ -2098,6 +4388,30 @@ public class CapacitorUpdaterPlugin extends Plugin {
2098
4388
  String failureAction,
2099
4389
  String failureEvent,
2100
4390
  boolean shouldSendStats
4391
+ ) {
4392
+ endBackGroundTaskWithNotif(
4393
+ msg,
4394
+ latestVersionName,
4395
+ current,
4396
+ error,
4397
+ plannedDirectUpdate,
4398
+ failureAction,
4399
+ failureEvent,
4400
+ shouldSendStats,
4401
+ true
4402
+ );
4403
+ }
4404
+
4405
+ private void endBackGroundTaskWithNotif(
4406
+ String msg,
4407
+ String latestVersionName,
4408
+ BundleInfo current,
4409
+ Boolean error,
4410
+ Boolean plannedDirectUpdate,
4411
+ String failureAction,
4412
+ String failureEvent,
4413
+ boolean shouldSendStats,
4414
+ boolean shouldNotifyNoNeedUpdate
2101
4415
  ) {
2102
4416
  this.consumeOnLaunchDirectUpdateAttempt(Boolean.TRUE.equals(plannedDirectUpdate));
2103
4417
  if (error) {
@@ -2116,15 +4430,22 @@ public class CapacitorUpdaterPlugin extends Plugin {
2116
4430
  ret.put("version", latestVersionName);
2117
4431
  this.notifyListeners(failureEvent, ret);
2118
4432
  }
2119
- final JSObject ret = new JSObject();
2120
- ret.put("bundle", InternalUtils.mapToJSObject(current.toJSONMap()));
2121
- this.notifyListeners("noNeedUpdate", ret);
4433
+ if (shouldNotifyNoNeedUpdate) {
4434
+ final JSObject ret = new JSObject();
4435
+ ret.put("bundle", InternalUtils.mapToJSObject(current.toJSONMap()));
4436
+ this.notifyListeners("noNeedUpdate", ret);
4437
+ }
2122
4438
  this.sendReadyToJs(current, msg, plannedDirectUpdate);
2123
4439
  this.backgroundDownloadTask = null;
2124
4440
  this.downloadStartTimeMs = 0;
2125
4441
  logger.info("endBackGroundTaskWithNotif " + msg);
2126
4442
  }
2127
4443
 
4444
+ private void clearBackgroundDownloadState() {
4445
+ this.backgroundDownloadTask = null;
4446
+ this.downloadStartTimeMs = 0;
4447
+ }
4448
+
2128
4449
  private boolean isDownloadStuckOrTimedOut() {
2129
4450
  if (this.backgroundDownloadTask == null || !this.backgroundDownloadTask.isAlive()) {
2130
4451
  return false;
@@ -2151,17 +4472,30 @@ public class CapacitorUpdaterPlugin extends Plugin {
2151
4472
  }
2152
4473
 
2153
4474
  private Thread backgroundDownload() {
4475
+ if (this.shouldBlockAutoUpdateForPreviewSession()) {
4476
+ return null;
4477
+ }
2154
4478
  final boolean plannedDirectUpdate = this.shouldUseDirectUpdate();
2155
4479
  final boolean initialDirectUpdateAllowed = this.isDirectUpdateCurrentlyAllowed(plannedDirectUpdate);
2156
4480
  final String messageUpdate = initialDirectUpdateAllowed
2157
4481
  ? "Update will occur now."
2158
- : "Update will occur next time app moves to background.";
4482
+ : this.shouldAutoSetNextBundle()
4483
+ ? "Update will occur next time app moves to background."
4484
+ : "Update will be downloaded and made available.";
2159
4485
  Thread newTask = startNewThread(() -> {
2160
4486
  // Wait for cleanup to complete before starting download
2161
4487
  waitForCleanupIfNeeded();
4488
+ if (CapacitorUpdaterPlugin.this.shouldBlockAutoUpdateForPreviewSession()) {
4489
+ CapacitorUpdaterPlugin.this.clearBackgroundDownloadState();
4490
+ return;
4491
+ }
2162
4492
  logger.info("Check for update via: " + CapacitorUpdaterPlugin.this.updateUrl);
2163
4493
  try {
2164
4494
  CapacitorUpdaterPlugin.this.implementation.getLatest(CapacitorUpdaterPlugin.this.updateUrl, null, (res) -> {
4495
+ if (CapacitorUpdaterPlugin.this.shouldBlockAutoUpdateForPreviewSession()) {
4496
+ CapacitorUpdaterPlugin.this.clearBackgroundDownloadState();
4497
+ return;
4498
+ }
2165
4499
  JSObject jsRes = InternalUtils.mapToJSObject(res);
2166
4500
  final BundleInfo current = CapacitorUpdaterPlugin.this.implementation.getCurrentBundle();
2167
4501
 
@@ -2173,6 +4507,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
2173
4507
  String kind = CapacitorUpdaterPlugin.this.getUpdateResponseKind(jsRes.has("kind") ? jsRes.getString("kind") : null);
2174
4508
  String latestVersion = jsRes.has("version") ? jsRes.getString("version") : current.getVersionName();
2175
4509
  CapacitorUpdaterPlugin.this.notifyUpdateCheckResult(kind, error, errorMessage, statusCode, latestVersion, current);
4510
+ CapacitorUpdaterPlugin.this.notifyBreakingEventsIfNeeded(
4511
+ jsRes,
4512
+ jsRes.has("version") ? jsRes.getString("version") : ""
4513
+ );
2176
4514
 
2177
4515
  if ("up_to_date".equals(kind)) {
2178
4516
  logger.info("No new version available");
@@ -2215,7 +4553,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2215
4553
  false,
2216
4554
  true
2217
4555
  );
2218
- } else {
4556
+ } else if (CapacitorUpdaterPlugin.this.shouldAutoSetNextBundle()) {
2219
4557
  if (plannedDirectUpdate && !directUpdateAllowedNow) {
2220
4558
  logger.info(
2221
4559
  "Direct update skipped because splashscreen timeout occurred. Update will be applied later."
@@ -2230,11 +4568,34 @@ public class CapacitorUpdaterPlugin extends Plugin {
2230
4568
  false,
2231
4569
  plannedDirectUpdate
2232
4570
  );
4571
+ } else {
4572
+ logger.info("autoUpdate is set to onlyDownload, builtin version will not be set as next bundle");
4573
+ final boolean builtinUpdateAvailable = !current.isBuiltin();
4574
+ if (builtinUpdateAvailable) {
4575
+ final JSObject ret = new JSObject();
4576
+ final BundleInfo builtinBundle = CapacitorUpdaterPlugin.this.implementation.getBundleInfo(
4577
+ BundleInfo.ID_BUILTIN
4578
+ );
4579
+ ret.put("bundle", InternalUtils.mapToJSObject(builtinBundle.toJSONMap()));
4580
+ CapacitorUpdaterPlugin.this.notifyListeners("updateAvailable", ret);
4581
+ }
4582
+ CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
4583
+ "Latest version is builtin, autoUpdate onlyDownload",
4584
+ latestVersionName,
4585
+ current,
4586
+ false,
4587
+ plannedDirectUpdate,
4588
+ "download_fail",
4589
+ "downloadFailed",
4590
+ true,
4591
+ !builtinUpdateAvailable
4592
+ );
2233
4593
  }
2234
4594
  return;
2235
4595
  }
2236
4596
 
2237
4597
  if (!jsRes.has("url") || !CapacitorUpdaterPlugin.this.isValidURL(jsRes.getString("url"))) {
4598
+ CapacitorUpdaterPlugin.this.notifyBreakingEventsIfNeeded(jsRes, latestVersionName);
2238
4599
  logger.error("Error no url or wrong format");
2239
4600
  CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
2240
4601
  "Error no url or wrong format",
@@ -2305,7 +4666,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2305
4666
  true
2306
4667
  );
2307
4668
  }
2308
- } else {
4669
+ } else if (CapacitorUpdaterPlugin.this.shouldAutoSetNextBundle()) {
2309
4670
  if (plannedDirectUpdate && !directUpdateAllowedNow) {
2310
4671
  logger.info(
2311
4672
  "Direct update skipped because splashscreen timeout occurred. Update will install on next background."
@@ -2320,6 +4681,20 @@ public class CapacitorUpdaterPlugin extends Plugin {
2320
4681
  false,
2321
4682
  plannedDirectUpdate
2322
4683
  );
4684
+ } else {
4685
+ logger.info("autoUpdate is set to onlyDownload, downloaded update will not be set as next bundle");
4686
+ CapacitorUpdaterPlugin.this.notifyListeners("updateAvailable", ret);
4687
+ CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
4688
+ "update downloaded, autoUpdate onlyDownload",
4689
+ latestVersionName,
4690
+ current,
4691
+ false,
4692
+ plannedDirectUpdate,
4693
+ "download_fail",
4694
+ "downloadFailed",
4695
+ true,
4696
+ false
4697
+ );
2323
4698
  }
2324
4699
  return;
2325
4700
  }
@@ -2345,6 +4720,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
2345
4720
  : initialDirectUpdateAllowed;
2346
4721
  startNewThread(() -> {
2347
4722
  try {
4723
+ if (CapacitorUpdaterPlugin.this.shouldBlockAutoUpdateForPreviewSession()) {
4724
+ CapacitorUpdaterPlugin.this.clearBackgroundDownloadState();
4725
+ return;
4726
+ }
2348
4727
  logger.info(
2349
4728
  "New bundle: " +
2350
4729
  latestVersionName +
@@ -2366,7 +4745,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
2366
4745
  latestVersionName,
2367
4746
  sessionKey,
2368
4747
  checksum,
2369
- manifest
4748
+ manifest,
4749
+ CapacitorUpdaterPlugin.this.shouldAutoSetNextBundle()
2370
4750
  );
2371
4751
  } else {
2372
4752
  // Handle single file download (existing code)
@@ -2375,7 +4755,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
2375
4755
  latestVersionName,
2376
4756
  sessionKey,
2377
4757
  checksum,
2378
- null
4758
+ null,
4759
+ CapacitorUpdaterPlugin.this.shouldAutoSetNextBundle()
2379
4760
  );
2380
4761
  }
2381
4762
  } catch (final Exception e) {
@@ -2429,6 +4810,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
2429
4810
 
2430
4811
  private void installNext() {
2431
4812
  try {
4813
+ if (this.shouldBlockAutoUpdateForPreviewSession()) {
4814
+ return;
4815
+ }
2432
4816
  String delayUpdatePreferences = prefs.getString(DelayUpdateUtils.DELAY_CONDITION_PREFERENCES, "[]");
2433
4817
  ArrayList<DelayCondition> delayConditionList = delayUpdateUtils.parseDelayConditions(delayUpdatePreferences);
2434
4818
  if (!delayConditionList.isEmpty()) {
@@ -2464,6 +4848,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
2464
4848
  logger.info("Built-in bundle is active. We skip the check for notifyAppReady.");
2465
4849
  return;
2466
4850
  }
4851
+ if (this.isPreviewSessionStateActive()) {
4852
+ logger.info("Preview session is active. We skip the check for notifyAppReady.");
4853
+ return;
4854
+ }
2467
4855
  logger.debug("Current bundle is: " + current);
2468
4856
 
2469
4857
  if (BundleStatus.SUCCESS != current.getStatus()) {
@@ -2502,12 +4890,14 @@ public class CapacitorUpdaterPlugin extends Plugin {
2502
4890
 
2503
4891
  @Override
2504
4892
  public void run() {
4893
+ final Thread currentThread = Thread.currentThread();
2505
4894
  try {
2506
4895
  logger.info("Wait for " + this.waitTimeMs + "ms, then check for notifyAppReady");
2507
4896
  Thread.sleep(this.waitTimeMs);
2508
4897
  CapacitorUpdaterPlugin.this.checkRevert();
2509
- CapacitorUpdaterPlugin.this.appReadyCheck = null;
4898
+ CapacitorUpdaterPlugin.this.clearAppReadyCheckIfCurrent(currentThread);
2510
4899
  } catch (final InterruptedException e) {
4900
+ CapacitorUpdaterPlugin.this.clearAppReadyCheckIfCurrent(currentThread);
2511
4901
  logger.info(DeferredNotifyAppReadyCheck.class.getName() + " was interrupted.");
2512
4902
  }
2513
4903
  }
@@ -2636,6 +5026,36 @@ public class CapacitorUpdaterPlugin extends Plugin {
2636
5026
  }
2637
5027
  }
2638
5028
 
5029
+ @Override
5030
+ protected void handleOnNewIntent(Intent intent) {
5031
+ super.handleOnNewIntent(intent);
5032
+ if (
5033
+ intent == null ||
5034
+ !Intent.ACTION_VIEW.equals(intent.getAction()) ||
5035
+ intent.getData() == null ||
5036
+ !Boolean.TRUE.equals(this.previewSessionEnabled) ||
5037
+ !isPreviewDeepLink(intent.getData()) ||
5038
+ Boolean.TRUE.equals(this.isLeavingPreviewForIncomingLink)
5039
+ ) {
5040
+ return;
5041
+ }
5042
+
5043
+ this.isLeavingPreviewForIncomingLink = true;
5044
+ this.showPreviewTransitionLoader("incoming-preview-deeplink");
5045
+ if (getActivity() != null) {
5046
+ getActivity().setIntent(intent);
5047
+ }
5048
+ logger.info("Preview deeplink received while preview session is active; restoring fallback before routing");
5049
+ startNewThread(() -> {
5050
+ final boolean didLeave = this.leavePreviewSessionForIncomingPreviewLink();
5051
+ if (!didLeave) {
5052
+ logger.error("Could not leave preview session before routing incoming preview deeplink");
5053
+ this.isLeavingPreviewForIncomingLink = false;
5054
+ this.hidePreviewTransitionLoader("incoming-preview-deeplink-failed");
5055
+ }
5056
+ });
5057
+ }
5058
+
2639
5059
  @Override
2640
5060
  public void handleOnStart() {
2641
5061
  try {
@@ -2651,15 +5071,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2651
5071
  isPreviousMainActivity = true;
2652
5072
  }
2653
5073
 
2654
- // Initialize shake menu if enabled and activity is BridgeActivity
2655
- if (shakeMenuEnabled && getActivity() instanceof com.getcapacitor.BridgeActivity && shakeMenu == null) {
2656
- try {
2657
- shakeMenu = new ShakeMenu(this, (com.getcapacitor.BridgeActivity) getActivity(), logger);
2658
- logger.info("Shake menu initialized");
2659
- } catch (Exception e) {
2660
- logger.error("Failed to initialize shake menu: " + e.getMessage());
2661
- }
2662
- }
5074
+ this.syncShakeMenuLifecycle();
2663
5075
  } catch (Exception e) {
2664
5076
  logger.error("Failed to run handleOnStart: " + e.getMessage());
2665
5077
  }
@@ -2691,6 +5103,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2691
5103
  backgroundTask.interrupt();
2692
5104
  }
2693
5105
  this.implementation.activity = getActivity();
5106
+ this.syncShakeMenuLifecycle();
2694
5107
  } catch (Exception e) {
2695
5108
  logger.error("Failed to run handleOnResume: " + e.getMessage());
2696
5109
  }
@@ -2715,25 +5128,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
2715
5128
  }
2716
5129
 
2717
5130
  this.shakeMenuEnabled = enabled;
2718
- logger.info("Shake menu " + (enabled ? "enabled" : "disabled"));
2719
-
2720
- // Manage shake menu instance based on enabled state
2721
- if (enabled && getActivity() instanceof com.getcapacitor.BridgeActivity && shakeMenu == null) {
2722
- try {
2723
- shakeMenu = new ShakeMenu(this, (com.getcapacitor.BridgeActivity) getActivity(), logger);
2724
- logger.info("Shake menu initialized");
2725
- } catch (Exception e) {
2726
- logger.error("Failed to initialize shake menu: " + e.getMessage());
2727
- }
2728
- } else if (!enabled && shakeMenu != null) {
2729
- try {
2730
- shakeMenu.stop();
2731
- shakeMenu = null;
2732
- logger.info("Shake menu stopped");
2733
- } catch (Exception e) {
2734
- logger.error("Failed to stop shake menu: " + e.getMessage());
2735
- }
2736
- }
5131
+ logger.info("Shake menu " + (enabled ? "enabled" : "disabled") + " with " + this.shakeMenuGesture + " gesture");
5132
+ this.syncShakeMenuLifecycle();
2737
5133
 
2738
5134
  call.resolve();
2739
5135
  }
@@ -2743,6 +5139,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2743
5139
  try {
2744
5140
  final JSObject ret = new JSObject();
2745
5141
  ret.put("enabled", this.shakeMenuEnabled);
5142
+ ret.put("gesture", this.shakeMenuGesture);
2746
5143
  call.resolve(ret);
2747
5144
  } catch (final Exception e) {
2748
5145
  logger.error("Could not get shake menu status " + e.getMessage());
@@ -2761,6 +5158,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2761
5158
 
2762
5159
  this.shakeChannelSelectorEnabled = enabled;
2763
5160
  logger.info("Shake channel selector " + (enabled ? "enabled" : "disabled"));
5161
+ this.syncShakeMenuLifecycle();
2764
5162
  call.resolve();
2765
5163
  }
2766
5164
 
@@ -2801,7 +5199,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2801
5199
  call.reject("setAppId called without appId");
2802
5200
  return;
2803
5201
  }
2804
- this.implementation.appId = appId;
5202
+ this.setActiveAppId(appId);
2805
5203
  call.resolve();
2806
5204
  }
2807
5205
 
@@ -3139,6 +5537,11 @@ public class CapacitorUpdaterPlugin extends Plugin {
3139
5537
  logger.error("Failed to clean up AppLifecycleObserver: " + e.getMessage());
3140
5538
  }
3141
5539
  }
5540
+
5541
+ if (webViewStatsListener != null && bridge != null) {
5542
+ bridge.removeWebViewListener(webViewStatsListener);
5543
+ webViewStatsListener = null;
5544
+ }
3142
5545
  } catch (Exception e) {
3143
5546
  logger.error("Failed to run handleOnDestroy: " + e.getMessage());
3144
5547
  }