@capgo/capacitor-updater 7.45.10 → 7.50.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +3192 -777
  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,2464 @@ 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;
1477
+ private void installWebViewStatsReporter() {
1478
+ if (this.bridge == null || this.bridge.getWebView() == null || this.webViewStatsListener != null) {
1479
+ return;
924
1480
  }
925
- if (CapacitorUpdaterPlugin.this.implementation.set(latest) && CapacitorUpdaterPlugin.this._reload()) {
926
- this.notifyBundleSet(latest);
927
- sendReadyToJs(latest, "update installed", true);
1481
+
1482
+ final android.webkit.WebView webView = this.bridge.getWebView();
1483
+ final String script = buildWebViewStatsReporterScript();
1484
+ this.installDocumentStartWebViewStatsReporter(webView, script);
1485
+
1486
+ this.webViewStatsListener = new WebViewListener() {
1487
+ @Override
1488
+ public void onPageStarted(final android.webkit.WebView view) {
1489
+ CapacitorUpdaterPlugin.this.evaluateWebViewStatsReporterScript(view, script);
1490
+ }
1491
+
1492
+ @Override
1493
+ public void onPageLoaded(final android.webkit.WebView view) {
1494
+ CapacitorUpdaterPlugin.this.evaluateWebViewStatsReporterScript(view, script);
1495
+ }
1496
+ };
1497
+
1498
+ this.bridge.addWebViewListener(this.webViewStatsListener);
1499
+ // Keep RenderProcessGoneDetail off the Plugin class method table and off this
1500
+ // listener so Android < 8 (API 26) does not crash during plugin reflection.
1501
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1502
+ this.installWebViewRenderProcessGoneReporter();
928
1503
  }
1504
+ this.evaluateWebViewStatsReporterScript(webView, script);
929
1505
  }
930
1506
 
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());
962
-
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");
1507
+ private void installWebViewRenderProcessGoneReporter() {
1508
+ this.bridge.addWebViewListener(
1509
+ new WebViewListener() {
1510
+ @Override
1511
+ public boolean onRenderProcessGone(final android.webkit.WebView view, final RenderProcessGoneDetail detail) {
1512
+ final Map<String, String> metadata = CapacitorUpdaterPlugin.this.buildWebViewRenderProcessGoneMetadata(detail);
1513
+ CapacitorUpdaterPlugin.this.persistPendingWebViewRenderProcessGone(metadata);
1514
+ return false;
977
1515
  }
978
1516
  }
979
- });
1517
+ );
1518
+ }
980
1519
 
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
1520
+ private void installDocumentStartWebViewStatsReporter(final android.webkit.WebView webView, final String script) {
1521
+ try {
1522
+ final Class<?> webViewFeature = Class.forName("androidx.webkit.WebViewFeature");
1523
+ final String feature = (String) webViewFeature.getField("DOCUMENT_START_SCRIPT").get(null);
1524
+ final Boolean supported = (Boolean) webViewFeature.getMethod("isFeatureSupported", String.class).invoke(null, feature);
1525
+ if (!Boolean.TRUE.equals(supported)) {
1526
+ return;
992
1527
  }
993
- });
1528
+
1529
+ final String allowedOrigin = Uri.parse(this.bridge.getAppUrl())
1530
+ .buildUpon()
1531
+ .path(null)
1532
+ .fragment(null)
1533
+ .clearQuery()
1534
+ .build()
1535
+ .toString();
1536
+ final Class<?> webViewCompat = Class.forName("androidx.webkit.WebViewCompat");
1537
+ webViewCompat
1538
+ .getMethod("addDocumentStartJavaScript", android.webkit.WebView.class, String.class, Set.class)
1539
+ .invoke(null, webView, script, java.util.Collections.singleton(allowedOrigin));
1540
+ } catch (final Exception e) {
1541
+ logger.debug("Unable to install document-start WebView stats reporter: " + e.getMessage());
1542
+ }
994
1543
  }
995
1544
 
996
- private void waitForCleanupIfNeeded() {
997
- if (cleanupComplete) {
998
- return; // Already done, no need to wait
1545
+ private void evaluateWebViewStatsReporterScript(final android.webkit.WebView webView, final String script) {
1546
+ if (webView == null) {
1547
+ return;
999
1548
  }
1000
1549
 
1001
- logger.info("Waiting for cleanup to complete before starting download...");
1550
+ this.mainHandler.post(() -> {
1551
+ try {
1552
+ webView.evaluateJavascript(script, null);
1553
+ } catch (final Exception e) {
1554
+ logger.debug("Unable to evaluate WebView stats reporter: " + e.getMessage());
1555
+ }
1556
+ });
1557
+ }
1002
1558
 
1003
- // Wait for cleanup to complete - blocks until lock is released
1004
- synchronized (cleanupLock) {
1005
- logger.info("Cleanup finished, proceeding with download");
1006
- }
1559
+ private Map<String, String> buildWebViewRenderProcessGoneMetadata(final Object detailObj) {
1560
+ final Map<String, String> metadata = new HashMap<>();
1561
+ metadata.put("error_type", "render_process_gone");
1562
+ metadata.put("source", "android_on_render_process_gone");
1563
+ metadata.put("timestamp", Long.toString(System.currentTimeMillis()));
1564
+ // Parameter typed as Object so Android < 8 ART does not resolve
1565
+ // RenderProcessGoneDetail while reflecting CapacitorUpdaterPlugin methods.
1566
+ if (detailObj != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1567
+ final RenderProcessGoneDetail detail = (RenderProcessGoneDetail) detailObj;
1568
+ metadata.put("did_crash", Boolean.toString(detail.didCrash()));
1569
+ metadata.put("renderer_priority_at_exit", Integer.toString(detail.rendererPriorityAtExit()));
1570
+ }
1571
+ return metadata;
1007
1572
  }
1008
1573
 
1009
- public void notifyDownload(final String id, final int percent) {
1574
+ private void persistPendingWebViewRenderProcessGone(final Map<String, String> metadata) {
1010
1575
  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
- }
1028
- }
1576
+ final JSONObject json = new JSONObject(metadata);
1577
+ this.prefs.edit().putString(LAST_WEBVIEW_RENDER_PROCESS_GONE_PREF_KEY, json.toString()).commit();
1029
1578
  } catch (final Exception e) {
1030
- logger.error("Could not notify listeners " + e.getMessage());
1579
+ logger.debug("Unable to persist WebView render process crash metadata: " + e.getMessage());
1031
1580
  }
1032
1581
  }
1033
1582
 
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");
1583
+ private void reportPreviousWebViewRenderProcessGone() {
1584
+ if (this.implementation == null || this.implementation.statsUrl.isEmpty()) {
1039
1585
  return;
1040
1586
  }
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");
1587
+
1588
+ final String rawMetadata = this.prefs.getString(LAST_WEBVIEW_RENDER_PROCESS_GONE_PREF_KEY, "");
1589
+ if (rawMetadata == null || rawMetadata.isEmpty()) {
1045
1590
  return;
1046
1591
  }
1047
- this.updateUrl = url;
1048
- if (Boolean.TRUE.equals(this.persistModifyUrl)) {
1049
- this.editor.putString(UPDATE_URL_PREF_KEY, url);
1050
- this.editor.apply();
1592
+
1593
+ try {
1594
+ final Map<String, String> metadata = jsonObjectToStringMap(new JSONObject(rawMetadata));
1595
+ metadata.put("reported_after_restart", "true");
1596
+ this.reportWebViewStats("webview_render_process_gone", metadata);
1597
+ this.prefs.edit().remove(LAST_WEBVIEW_RENDER_PROCESS_GONE_PREF_KEY).apply();
1598
+ } catch (final JSONException e) {
1599
+ this.prefs.edit().remove(LAST_WEBVIEW_RENDER_PROCESS_GONE_PREF_KEY).apply();
1051
1600
  }
1052
- call.resolve();
1053
1601
  }
1054
1602
 
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");
1060
- return;
1061
- }
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");
1066
- return;
1603
+ private static Map<String, String> jsonObjectToStringMap(final JSONObject json) throws JSONException {
1604
+ final Map<String, String> map = new HashMap<>();
1605
+ final JSONArray names = json.names();
1606
+ if (names == null) {
1607
+ return map;
1067
1608
  }
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();
1609
+
1610
+ for (int i = 0; i < names.length(); i++) {
1611
+ final String key = names.getString(i);
1612
+ final String value = json.optString(key, "");
1613
+ if (!value.isEmpty()) {
1614
+ map.put(key, value);
1615
+ }
1072
1616
  }
1073
- call.resolve();
1617
+ return map;
1074
1618
  }
1075
1619
 
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;
1082
- }
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;
1620
+ private static JSObject jsonObjectToJSObject(final JSONObject json) throws JSONException {
1621
+ final JSObject ret = new JSObject();
1622
+ final JSONArray names = json.names();
1623
+ if (names == null) {
1624
+ return ret;
1088
1625
  }
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();
1626
+ for (int i = 0; i < names.length(); i++) {
1627
+ final String key = names.getString(i);
1628
+ ret.put(key, json.get(key));
1093
1629
  }
1094
- call.resolve();
1630
+ return ret;
1095
1631
  }
1096
1632
 
1097
1633
  @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);
1634
+ public void reportWebViewError(final PluginCall call) {
1635
+ final JSObject data = call.getData();
1636
+ this.reportWebViewStats(
1637
+ statsActionForWebViewErrorType(data.optString("type", "javascript_error")),
1638
+ buildWebViewErrorMetadata(data)
1639
+ );
1640
+ call.resolve();
1641
+ }
1642
+
1643
+ private void reportWebViewStats(final String action, final Map<String, String> metadata) {
1644
+ if (this.implementation == null) {
1645
+ return;
1106
1646
  }
1647
+
1648
+ final BundleInfo current = this.implementation.getCurrentBundle();
1649
+ final String versionName = current == null ? "" : current.getVersionName();
1650
+ this.implementation.sendStats(action, versionName, "", metadata);
1107
1651
  }
1108
1652
 
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);
1653
+ static String statsActionForWebViewErrorType(final String type) {
1654
+ switch (type) {
1655
+ case "unhandled_rejection":
1656
+ return "webview_unhandled_rejection";
1657
+ case "resource_error":
1658
+ return "webview_resource_error";
1659
+ case "security_policy_violation":
1660
+ return "webview_security_policy_violation";
1661
+ case "webview_unclean_restart":
1662
+ return "webview_unclean_restart";
1663
+ case "render_process_gone":
1664
+ return "webview_render_process_gone";
1665
+ case "web_content_process_terminated":
1666
+ return "webview_content_process_terminated";
1667
+ case "javascript_error":
1668
+ default:
1669
+ return "webview_javascript_error";
1118
1670
  }
1119
1671
  }
1120
1672
 
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");
1673
+ static Map<String, String> buildWebViewErrorMetadata(final JSObject data) {
1674
+ final Map<String, String> metadata = new HashMap<>();
1675
+ putStatsMetadataValue(metadata, "error_type", data.optString("type", "javascript_error"), 64);
1676
+ putStatsMetadataValue(metadata, "message", data.optString("message", ""), 1024);
1677
+ putStatsMetadataValue(metadata, "source", sanitizeStatsMetadataUrl(data.optString("source", "")), 512);
1678
+ putStatsMetadataValue(metadata, "line", data.optString("line", data.optString("lineno", "")), 32);
1679
+ putStatsMetadataValue(metadata, "column", data.optString("column", data.optString("colno", "")), 32);
1680
+ putStatsMetadataValue(metadata, "stack", data.optString("stack", ""), 2048);
1681
+ putStatsMetadataValue(metadata, "tag_name", data.optString("tag_name", ""), 64);
1682
+ putStatsMetadataValue(metadata, "href", sanitizeStatsMetadataUrl(data.optString("href", "")), 512);
1683
+ putStatsMetadataValue(metadata, "user_agent", data.optString("user_agent", ""), 256);
1684
+ putStatsMetadataValue(metadata, "session_id", data.optString("session_id", ""), 128);
1685
+ putStatsMetadataValue(metadata, "previous_session_id", data.optString("previous_session_id", ""), 128);
1686
+ putStatsMetadataValue(metadata, "previous_href", sanitizeStatsMetadataUrl(data.optString("previous_href", "")), 512);
1687
+ putStatsMetadataValue(metadata, "previous_started_at", data.optString("previous_started_at", ""), 64);
1688
+ putStatsMetadataValue(metadata, "previous_updated_at", data.optString("previous_updated_at", ""), 64);
1689
+ return metadata;
1690
+ }
1691
+
1692
+ private static void putStatsMetadataValue(
1693
+ final Map<String, String> metadata,
1694
+ final String key,
1695
+ final String value,
1696
+ final int maxLength
1697
+ ) {
1698
+ if (value == null || value.isEmpty()) {
1127
1699
  return;
1128
1700
  }
1129
- this.implementation.customId = customId;
1701
+
1702
+ metadata.put(key, truncateStatsMetadataValue(value, maxLength));
1703
+ }
1704
+
1705
+ static String sanitizeStatsMetadataUrl(final String value) {
1706
+ if (value == null || value.isEmpty()) {
1707
+ return "";
1708
+ }
1709
+
1710
+ try {
1711
+ final java.net.URI uri = new java.net.URI(value);
1712
+ if (uri.getScheme() != null && uri.getHost() != null) {
1713
+ final String path = sanitizeStatsMetadataUrlPath(uri.getPath());
1714
+ return new java.net.URI(
1715
+ uri.getScheme(),
1716
+ null,
1717
+ uri.getHost(),
1718
+ uri.getPort(),
1719
+ path.isEmpty() ? null : path,
1720
+ null,
1721
+ null
1722
+ ).toString();
1723
+ }
1724
+ } catch (Exception ignored) {}
1725
+
1726
+ try {
1727
+ final Uri uri = Uri.parse(value);
1728
+ if (uri.getScheme() != null && uri.getHost() != null) {
1729
+ final String host = stripUrlUserInfo(uri.getHost());
1730
+ if (host.isEmpty()) {
1731
+ return stripUrlQueryAndFragment(value);
1732
+ }
1733
+ final StringBuilder authority = new StringBuilder(host);
1734
+ if (uri.getPort() != -1) {
1735
+ authority.append(':').append(uri.getPort());
1736
+ }
1737
+ final Uri.Builder builder = new Uri.Builder().scheme(uri.getScheme()).authority(authority.toString());
1738
+ final String path = sanitizeStatsMetadataUrlPath(uri.getPath());
1739
+ if (!path.isEmpty()) {
1740
+ builder.path(path);
1741
+ }
1742
+ return builder.build().toString();
1743
+ }
1744
+ } catch (Exception ignored) {}
1745
+
1746
+ return stripUrlQueryAndFragment(value);
1747
+ }
1748
+
1749
+ private static String sanitizeStatsMetadataUrlPath(final String path) {
1750
+ if (path == null || path.isEmpty()) {
1751
+ return "";
1752
+ }
1753
+
1754
+ final String[] segments = path.split("/", -1);
1755
+ for (int index = 0; index < segments.length; index++) {
1756
+ if (isSensitiveUrlPathSegment(segments[index])) {
1757
+ segments[index] = "redacted";
1758
+ }
1759
+ }
1760
+ return String.join("/", segments);
1761
+ }
1762
+
1763
+ private static String stripUrlUserInfo(final String host) {
1764
+ if (host == null || host.isEmpty()) {
1765
+ return "";
1766
+ }
1767
+
1768
+ final int userInfoIndex = host.lastIndexOf('@');
1769
+ if (userInfoIndex < 0) {
1770
+ return host;
1771
+ }
1772
+ return host.substring(userInfoIndex + 1);
1773
+ }
1774
+
1775
+ private static boolean isSensitiveUrlPathSegment(final String segment) {
1776
+ return (
1777
+ segment.matches("[0-9]{6,}") ||
1778
+ segment.matches("[0-9a-fA-F]{16,}") ||
1779
+ 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}")
1780
+ );
1781
+ }
1782
+
1783
+ private static String stripUrlQueryAndFragment(final String value) {
1784
+ int end = value.length();
1785
+ final int queryIndex = value.indexOf('?');
1786
+ final int fragmentIndex = value.indexOf('#');
1787
+ if (queryIndex >= 0) {
1788
+ end = Math.min(end, queryIndex);
1789
+ }
1790
+ if (fragmentIndex >= 0) {
1791
+ end = Math.min(end, fragmentIndex);
1792
+ }
1793
+ return value.substring(0, end);
1794
+ }
1795
+
1796
+ static String buildWebViewStatsReporterScript() {
1797
+ return (
1798
+ "(function(){" +
1799
+ "if(window.__capgoWebViewErrorReporterInstalled){return;}" +
1800
+ "window.__capgoWebViewErrorReporterInstalled=true;" +
1801
+ "var maxReports=20,sentReports=0,queue=[],seen={};" +
1802
+ "var sessionKey='CapacitorUpdater.webViewSession';" +
1803
+ "var sessionId=String(Date.now())+'-'+Math.random().toString(36).slice(2);" +
1804
+ "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 '';}}" +
1805
+ "function stack(value){try{return value&&value.stack?String(value.stack):'';}catch(_){return '';}}" +
1806
+ "function updater(){var cap=window.Capacitor;if(!cap||!cap.Plugins){return null;}return cap.Plugins.CapacitorUpdater||null;}" +
1807
+ "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;}" +
1808
+ "var retries=0;function scheduleFlush(){if(flush()){return;}if(retries++<40){setTimeout(scheduleFlush,250);}}" +
1809
+ "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(_){}}" +
1810
+ "function readSession(){try{return JSON.parse(localStorage.getItem(sessionKey)||'null')||null;}catch(_){return null;}}" +
1811
+ "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(_){}}" +
1812
+ "window.__capgoWebViewSessionStartedAt=String(Date.now());" +
1813
+ "var previous=readSession();" +
1814
+ "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)});}" +
1815
+ "writeSession(true);" +
1816
+ "setInterval(function(){writeSession(true);},15000);" +
1817
+ "function markClean(){writeSession(false);}" +
1818
+ "window.addEventListener('pagehide',markClean,true);" +
1819
+ "window.addEventListener('beforeunload',markClean,true);" +
1820
+ "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);" +
1821
+ "window.addEventListener('unhandledrejection',function(event){var reason=event&&event.reason;send({type:'unhandled_rejection',message:s(reason),stack:stack(reason)});},true);" +
1822
+ "document.addEventListener('securitypolicyviolation',function(event){send({type:'security_policy_violation',message:s(event&&event.violatedDirective),source:s(event&&event.blockedURI)});},true);" +
1823
+ "document.addEventListener('deviceready',scheduleFlush,false);" +
1824
+ "setTimeout(scheduleFlush,0);" +
1825
+ "})();"
1826
+ );
1827
+ }
1828
+
1829
+ private boolean shouldUseDirectUpdate() {
1830
+ if (!Boolean.TRUE.equals(this.autoUpdate) || AUTO_UPDATE_MODE_ONLY_DOWNLOAD.equals(this.autoUpdateMode)) {
1831
+ return false;
1832
+ }
1833
+ if (Boolean.TRUE.equals(this.autoSplashscreenTimedOut)) {
1834
+ return false;
1835
+ }
1836
+ switch (this.directUpdateMode) {
1837
+ case "false":
1838
+ return false;
1839
+ case "always":
1840
+ return true;
1841
+ case "atInstall":
1842
+ if (this.wasRecentlyInstalledOrUpdated) {
1843
+ // Reset the flag after first use to prevent subsequent foreground events from using direct update
1844
+ this.wasRecentlyInstalledOrUpdated = false;
1845
+ return true;
1846
+ }
1847
+ return false;
1848
+ case "onLaunch":
1849
+ if (!this.onLaunchDirectUpdateUsed) {
1850
+ return true;
1851
+ }
1852
+ return false;
1853
+ default:
1854
+ logger.error(
1855
+ "Invalid directUpdateMode: \"" +
1856
+ this.directUpdateMode +
1857
+ "\". Supported values are: \"false\", \"always\", \"atInstall\", \"onLaunch\". Defaulting to \"false\" behavior."
1858
+ );
1859
+ return false;
1860
+ }
1861
+ }
1862
+
1863
+ private void configureAutoUpdateModeFromConfig() {
1864
+ final String configuredMode = this.getConfig().getString("autoUpdate", null);
1865
+ if (configuredMode != null && !configuredMode.isEmpty() && !"true".equals(configuredMode) && !"false".equals(configuredMode)) {
1866
+ this.autoUpdateMode = normalizedAutoUpdateMode(configuredMode);
1867
+ if (!this.autoUpdateMode.equals(configuredMode)) {
1868
+ logger.error(
1869
+ "Invalid autoUpdate value: \"" +
1870
+ configuredMode +
1871
+ "\". Supported values are: true, false, \"off\", \"atBackground\", \"atInstall\", \"onLaunch\", \"always\", \"onlyDownload\". Defaulting to \"atBackground\"."
1872
+ );
1873
+ }
1874
+ } else {
1875
+ final boolean enabled =
1876
+ configuredMode != null
1877
+ ? "true".equals(configuredMode)
1878
+ : Boolean.TRUE.equals(this.getConfig().getBoolean("autoUpdate", true));
1879
+ this.autoUpdateMode = enabled
1880
+ ? autoUpdateModeForLegacyDirectUpdateMode(this.resolveLegacyDirectUpdateModeFromConfig())
1881
+ : AUTO_UPDATE_MODE_OFF;
1882
+ }
1883
+
1884
+ this.autoUpdate = isAutoUpdateModeEnabled(this.autoUpdateMode);
1885
+ this.directUpdateMode = directUpdateModeForAutoUpdateMode(this.autoUpdateMode);
1886
+ this.implementation.directUpdate = isDirectUpdateMode(this.directUpdateMode);
1887
+ }
1888
+
1889
+ private String resolveLegacyDirectUpdateModeFromConfig() {
1890
+ final String directUpdateConfig = this.getConfig().getString("directUpdate", null);
1891
+ if (directUpdateConfig != null) {
1892
+ if ("true".equals(directUpdateConfig)) {
1893
+ return AUTO_UPDATE_MODE_ALWAYS;
1894
+ }
1895
+ if ("false".equals(directUpdateConfig) || isDirectUpdateMode(directUpdateConfig)) {
1896
+ return directUpdateConfig;
1897
+ }
1898
+ logger.error(
1899
+ "Invalid directUpdate value: \"" +
1900
+ directUpdateConfig +
1901
+ "\". Supported values are: false, true, \"always\", \"atInstall\", \"onLaunch\". Defaulting to \"false\"."
1902
+ );
1903
+ return "false";
1904
+ }
1905
+
1906
+ return Boolean.TRUE.equals(this.getConfig().getBoolean("directUpdate", false)) ? AUTO_UPDATE_MODE_ALWAYS : "false";
1907
+ }
1908
+
1909
+ static String normalizedAutoUpdateMode(final String value) {
1910
+ if (value == null) {
1911
+ return AUTO_UPDATE_MODE_BACKGROUND;
1912
+ }
1913
+ switch (value) {
1914
+ case "false":
1915
+ case AUTO_UPDATE_MODE_OFF:
1916
+ return AUTO_UPDATE_MODE_OFF;
1917
+ case "true":
1918
+ case AUTO_UPDATE_MODE_BACKGROUND:
1919
+ return AUTO_UPDATE_MODE_BACKGROUND;
1920
+ case AUTO_UPDATE_MODE_INSTALL:
1921
+ case AUTO_UPDATE_MODE_LAUNCH:
1922
+ case AUTO_UPDATE_MODE_ALWAYS:
1923
+ case AUTO_UPDATE_MODE_ONLY_DOWNLOAD:
1924
+ return value;
1925
+ default:
1926
+ return AUTO_UPDATE_MODE_BACKGROUND;
1927
+ }
1928
+ }
1929
+
1930
+ static String normalizedShakeMenuGesture(final String value) {
1931
+ if (value == null || value.trim().isEmpty()) {
1932
+ return SHAKE_MENU_GESTURE_SHAKE;
1933
+ }
1934
+ final String normalized = value.trim();
1935
+ if (SHAKE_MENU_GESTURE_THREE_FINGER_PINCH.equals(normalized)) {
1936
+ return SHAKE_MENU_GESTURE_THREE_FINGER_PINCH;
1937
+ }
1938
+ return SHAKE_MENU_GESTURE_SHAKE;
1939
+ }
1940
+
1941
+ static boolean isSupportedShakeMenuGesture(final String value) {
1942
+ if (value == null) {
1943
+ return true;
1944
+ }
1945
+ final String normalized = value.trim();
1946
+ if (normalized.isEmpty()) {
1947
+ return false;
1948
+ }
1949
+ return SHAKE_MENU_GESTURE_SHAKE.equals(normalized) || SHAKE_MENU_GESTURE_THREE_FINGER_PINCH.equals(normalized);
1950
+ }
1951
+
1952
+ static String autoUpdateModeForLegacyDirectUpdateMode(final String directUpdateMode) {
1953
+ switch (directUpdateMode) {
1954
+ case AUTO_UPDATE_MODE_INSTALL:
1955
+ case AUTO_UPDATE_MODE_LAUNCH:
1956
+ case AUTO_UPDATE_MODE_ALWAYS:
1957
+ return directUpdateMode;
1958
+ case "false":
1959
+ default:
1960
+ return AUTO_UPDATE_MODE_BACKGROUND;
1961
+ }
1962
+ }
1963
+
1964
+ static String directUpdateModeForAutoUpdateMode(final String autoUpdateMode) {
1965
+ switch (autoUpdateMode) {
1966
+ case AUTO_UPDATE_MODE_INSTALL:
1967
+ case AUTO_UPDATE_MODE_LAUNCH:
1968
+ case AUTO_UPDATE_MODE_ALWAYS:
1969
+ return autoUpdateMode;
1970
+ default:
1971
+ return "false";
1972
+ }
1973
+ }
1974
+
1975
+ static boolean isAutoUpdateModeEnabled(final String autoUpdateMode) {
1976
+ return !AUTO_UPDATE_MODE_OFF.equals(autoUpdateMode);
1977
+ }
1978
+
1979
+ static boolean shouldAutoUpdateModeSetNextBundle(final String autoUpdateMode) {
1980
+ return isAutoUpdateModeEnabled(autoUpdateMode) && !AUTO_UPDATE_MODE_ONLY_DOWNLOAD.equals(autoUpdateMode);
1981
+ }
1982
+
1983
+ static boolean isDirectUpdateMode(final String directUpdateMode) {
1984
+ return (
1985
+ AUTO_UPDATE_MODE_INSTALL.equals(directUpdateMode) ||
1986
+ AUTO_UPDATE_MODE_LAUNCH.equals(directUpdateMode) ||
1987
+ AUTO_UPDATE_MODE_ALWAYS.equals(directUpdateMode)
1988
+ );
1989
+ }
1990
+
1991
+ private boolean shouldAutoSetNextBundle() {
1992
+ return shouldAutoUpdateModeSetNextBundle(this.autoUpdateMode);
1993
+ }
1994
+
1995
+ private boolean isDirectUpdateCurrentlyAllowed(final boolean plannedDirectUpdate) {
1996
+ return plannedDirectUpdate && !Boolean.TRUE.equals(this.autoSplashscreenTimedOut);
1997
+ }
1998
+
1999
+ static boolean shouldConsumeOnLaunchDirectUpdate(final String directUpdateMode, final boolean plannedDirectUpdate) {
2000
+ return plannedDirectUpdate && "onLaunch".equals(directUpdateMode);
2001
+ }
2002
+
2003
+ static int normalizedPeriodCheckDelayMs(final int valueSeconds) {
2004
+ final int normalizedSeconds = normalizedPeriodCheckDelaySeconds(valueSeconds);
2005
+ if (normalizedSeconds <= 0) {
2006
+ return 0;
2007
+ }
2008
+ final long delayMs = (long) normalizedSeconds * 1000L;
2009
+ return delayMs > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) delayMs;
2010
+ }
2011
+
2012
+ static int normalizedPeriodCheckDelaySeconds(final int valueSeconds) {
2013
+ if (valueSeconds <= 0) {
2014
+ return 0;
2015
+ }
2016
+ return Math.max(600, valueSeconds);
2017
+ }
2018
+
2019
+ private void consumeOnLaunchDirectUpdateAttempt(final boolean plannedDirectUpdate) {
2020
+ if (!shouldConsumeOnLaunchDirectUpdate(this.directUpdateMode, plannedDirectUpdate)) {
2021
+ return;
2022
+ }
2023
+
2024
+ this.onLaunchDirectUpdateUsed = true;
2025
+ }
2026
+
2027
+ void configureDirectUpdateModeForTesting(final String directUpdateMode, final boolean onLaunchDirectUpdateUsed) {
2028
+ this.directUpdateMode = directUpdateMode;
2029
+ this.autoUpdateMode = autoUpdateModeForLegacyDirectUpdateMode(directUpdateMode);
2030
+ this.autoUpdate = isAutoUpdateModeEnabled(this.autoUpdateMode);
2031
+ if (this.implementation != null) {
2032
+ this.implementation.directUpdate = isDirectUpdateMode(this.directUpdateMode);
2033
+ }
2034
+ this.onLaunchDirectUpdateUsed = onLaunchDirectUpdateUsed;
2035
+ }
2036
+
2037
+ void setAutoUpdateModeForTesting(final String autoUpdateMode) {
2038
+ this.autoUpdateMode = normalizedAutoUpdateMode(autoUpdateMode);
2039
+ this.autoUpdate = isAutoUpdateModeEnabled(this.autoUpdateMode);
2040
+ this.directUpdateMode = directUpdateModeForAutoUpdateMode(this.autoUpdateMode);
2041
+ if (this.implementation != null) {
2042
+ this.implementation.directUpdate = isDirectUpdateMode(this.directUpdateMode);
2043
+ }
2044
+ }
2045
+
2046
+ boolean shouldUseDirectUpdateForTesting() {
2047
+ return this.shouldUseDirectUpdate();
2048
+ }
2049
+
2050
+ boolean hasConsumedOnLaunchDirectUpdateForTesting() {
2051
+ return this.onLaunchDirectUpdateUsed;
2052
+ }
2053
+
2054
+ boolean isVersionDownloadInProgress(final String version) {
2055
+ return (
2056
+ version != null &&
2057
+ !version.isEmpty() &&
2058
+ this.implementation != null &&
2059
+ this.implementation.activity != null &&
2060
+ DownloadWorkerManager.isVersionDownloading(this.implementation.activity, version)
2061
+ );
2062
+ }
2063
+
2064
+ void setLoggerForTesting(final Logger logger) {
2065
+ this.logger = logger;
2066
+ }
2067
+
2068
+ void completeBackgroundTaskForTesting(final BundleInfo current, final boolean plannedDirectUpdate) {
2069
+ this.endBackGroundTaskWithNotif("test", current.getVersionName(), current, false, plannedDirectUpdate);
2070
+ }
2071
+
2072
+ void scheduleDirectUpdateFinish(final BundleInfo latest) {
2073
+ startNewThread(() -> {
2074
+ try {
2075
+ if (this.shouldBlockAutoUpdateForPreviewSession()) {
2076
+ logger.info("Skipping direct update install while preview session state is active");
2077
+ this.implementation.directUpdate = false;
2078
+ this.clearBackgroundDownloadState();
2079
+ return;
2080
+ }
2081
+ Activity currentActivity = this.getActivity();
2082
+ if (currentActivity != null) {
2083
+ this.implementation.activity = currentActivity;
2084
+ } else {
2085
+ logger.warn("directUpdateFinish: Activity is null, proceeding without refreshing the activity reference");
2086
+ }
2087
+ this.directUpdateFinish(latest);
2088
+ } catch (final Exception e) {
2089
+ logger.error("directUpdateFinish failed: " + e.getMessage());
2090
+ }
2091
+ });
2092
+ }
2093
+
2094
+ private void directUpdateFinish(final BundleInfo latest) {
2095
+ if (this.shouldBlockAutoUpdateForPreviewSession()) {
2096
+ logger.info("Skipping direct update finish while preview session state is active");
2097
+ this.implementation.directUpdate = false;
2098
+ this.clearBackgroundDownloadState();
2099
+ return;
2100
+ }
2101
+ if ("onLaunch".equals(this.directUpdateMode)) {
2102
+ this.onLaunchDirectUpdateUsed = true;
2103
+ this.implementation.directUpdate = false;
2104
+ }
2105
+ if (this.applyDownloadedBundleForDirectUpdate(latest)) {
2106
+ this.implementation.setNextBundle(null);
2107
+ this.notifyBundleSet(latest);
2108
+ sendReadyToJs(latest, "update installed", true);
2109
+ } else {
2110
+ this.implementation.setNextBundle(latest.getId());
2111
+ final JSObject ret = new JSObject();
2112
+ ret.put("bundle", InternalUtils.mapToJSObject(latest.toJSONMap()));
2113
+ this.notifyListeners("updateAvailable", ret);
2114
+ sendReadyToJs(
2115
+ this.implementation.getCurrentBundle(),
2116
+ "Direct update reload failed, update will install next background",
2117
+ false
2118
+ );
2119
+ }
2120
+ }
2121
+
2122
+ private boolean applyDownloadedBundleForDirectUpdate(final BundleInfo latest) {
2123
+ final CapgoUpdater.ResetState previousState = this.implementation.captureResetState();
2124
+ final String previousBundleName = this.implementation.getCurrentBundle().getVersionName();
2125
+
2126
+ if (!this.implementation.stagePendingReload(latest)) {
2127
+ this.implementation.restoreResetState(previousState);
2128
+ logger.error("Direct update failed to stage downloaded bundle: " + latest.toString());
2129
+ return false;
2130
+ }
2131
+
2132
+ if (this._reload()) {
2133
+ this.implementation.finalizePendingReload(latest, previousBundleName);
2134
+ return true;
2135
+ }
2136
+
2137
+ this.implementation.restoreResetState(previousState);
2138
+ this.restoreLiveBundleStateAfterFailedReload();
2139
+ logger.error("Direct update reload failed after staging bundle: " + latest.toString());
2140
+ return false;
2141
+ }
2142
+
2143
+ private void cleanupObsoleteVersions() {
2144
+ cleanupThread = startNewThread(() -> {
2145
+ synchronized (cleanupLock) {
2146
+ try {
2147
+ final String previous = this.getStoredNativeBuildVersion();
2148
+ if (!"".equals(previous) && !Objects.equals(this.currentBuildVersion, previous)) {
2149
+ logger.info("New native build version detected: " + this.currentBuildVersion);
2150
+ this.implementation.reset(true);
2151
+ final List<BundleInfo> installed = this.implementation.list(false);
2152
+ for (final BundleInfo bundle : installed) {
2153
+ // Check if thread was interrupted (cancelled)
2154
+ if (Thread.currentThread().isInterrupted()) {
2155
+ logger.warn("Cleanup was cancelled, stopping");
2156
+ return;
2157
+ }
2158
+ try {
2159
+ logger.info("Deleting obsolete bundle: " + bundle.getId());
2160
+ this.implementation.delete(bundle.getId());
2161
+ } catch (final Exception e) {
2162
+ logger.error("Failed to delete: " + bundle.getId() + " " + e.getMessage());
2163
+ }
2164
+ }
2165
+ final List<BundleInfo> storedBundles = this.implementation.list(true);
2166
+ final Set<String> allowedIds = new HashSet<>();
2167
+ for (final BundleInfo info : storedBundles) {
2168
+ if (info != null && info.getId() != null && !info.getId().isEmpty()) {
2169
+ allowedIds.add(info.getId());
2170
+ }
2171
+ }
2172
+ this.implementation.cleanupDownloadDirectories(allowedIds, Thread.currentThread());
2173
+ this.implementation.cleanupOrphanedTempFolders(Thread.currentThread());
2174
+
2175
+ // Check again before the expensive delta cache cleanup
2176
+ if (Thread.currentThread().isInterrupted()) {
2177
+ logger.warn("Cleanup was cancelled before delta cache cleanup");
2178
+ return;
2179
+ }
2180
+ this.implementation.cleanupDeltaCache(Thread.currentThread());
2181
+ }
2182
+ this.editor.putString("LatestNativeBuildVersion", this.currentBuildVersion);
2183
+ this.editor.apply();
2184
+ } catch (Exception e) {
2185
+ logger.error("Error during cleanupObsoleteVersions: " + e.getMessage());
2186
+ } finally {
2187
+ cleanupComplete = true;
2188
+ logger.info("Cleanup complete");
2189
+ }
2190
+ }
2191
+ });
2192
+
2193
+ // Start a timeout watchdog thread to cancel cleanup if it takes too long
2194
+ final long timeout = this.appReadyTimeout / 2;
2195
+ startNewThread(() -> {
2196
+ try {
2197
+ Thread.sleep(timeout);
2198
+ if (cleanupThread != null && cleanupThread.isAlive() && !cleanupComplete) {
2199
+ logger.warn("Cleanup timeout exceeded (" + timeout + "ms), interrupting cleanup thread");
2200
+ cleanupThread.interrupt();
2201
+ }
2202
+ } catch (InterruptedException e) {
2203
+ // Watchdog thread was interrupted, that's fine
2204
+ }
2205
+ });
2206
+ }
2207
+
2208
+ String getStoredNativeBuildVersion() {
2209
+ String previous = this.prefs.getString("LatestNativeBuildVersion", "");
2210
+ if (previous == null || previous.isEmpty()) {
2211
+ previous = this.prefs.getString("LatestVersionNative", "");
2212
+ }
2213
+ return previous == null ? "" : previous;
2214
+ }
2215
+
2216
+ void persistCurrentNativeBuildVersion() {
2217
+ this.editor.putString("LatestNativeBuildVersion", this.currentBuildVersion);
2218
+ this.editor.apply();
2219
+ }
2220
+
2221
+ private void waitForCleanupIfNeeded() {
2222
+ if (cleanupComplete) {
2223
+ return; // Already done, no need to wait
2224
+ }
2225
+
2226
+ logger.info("Waiting for cleanup to complete before starting download...");
2227
+
2228
+ // Wait for cleanup to complete - blocks until lock is released
2229
+ synchronized (cleanupLock) {
2230
+ logger.info("Cleanup finished, proceeding with download");
2231
+ }
2232
+ }
2233
+
2234
+ public void notifyDownload(final String id, final int percent) {
2235
+ try {
2236
+ final JSObject ret = new JSObject();
2237
+ ret.put("percent", percent);
2238
+ final BundleInfo bundleInfo = this.implementation.getBundleInfo(id);
2239
+ ret.put("bundle", InternalUtils.mapToJSObject(bundleInfo.toJSONMap()));
2240
+ this.notifyListeners("download", ret);
2241
+
2242
+ if (percent == 100) {
2243
+ final JSObject retDownloadComplete = new JSObject(ret, new String[] { "bundle" });
2244
+ this.notifyListeners("downloadComplete", retDownloadComplete);
2245
+ this.implementation.sendStats("download_complete", bundleInfo.getVersionName());
2246
+ lastNotifiedStatPercent = 100;
2247
+ } else {
2248
+ int currentStatPercent = (percent / 10) * 10; // Round down to nearest 10
2249
+ if (currentStatPercent > lastNotifiedStatPercent) {
2250
+ this.implementation.sendStats("download_" + currentStatPercent, bundleInfo.getVersionName());
2251
+ lastNotifiedStatPercent = currentStatPercent;
2252
+ }
2253
+ }
2254
+ } catch (final Exception e) {
2255
+ logger.error("Could not notify listeners " + e.getMessage());
2256
+ }
2257
+ }
2258
+
2259
+ @PluginMethod
2260
+ public void setUpdateUrl(final PluginCall call) {
2261
+ if (!this.getConfig().getBoolean("allowModifyUrl", false)) {
2262
+ logger.error("setUpdateUrl not allowed set allowModifyUrl in your config to true to allow it");
2263
+ call.reject("setUpdateUrl not allowed");
2264
+ return;
2265
+ }
2266
+ final String url = call.getString("url");
2267
+ if (url == null) {
2268
+ logger.error("setUpdateUrl called without url");
2269
+ call.reject("setUpdateUrl called without url");
2270
+ return;
2271
+ }
2272
+ this.updateUrl = url;
2273
+ if (Boolean.TRUE.equals(this.persistModifyUrl)) {
2274
+ this.editor.putString(UPDATE_URL_PREF_KEY, url);
2275
+ this.editor.apply();
2276
+ }
2277
+ call.resolve();
2278
+ }
2279
+
2280
+ @PluginMethod
2281
+ public void setStatsUrl(final PluginCall call) {
2282
+ if (!this.getConfig().getBoolean("allowModifyUrl", false)) {
2283
+ logger.error("setStatsUrl not allowed set allowModifyUrl in your config to true to allow it");
2284
+ call.reject("setStatsUrl not allowed");
2285
+ return;
2286
+ }
2287
+ final String url = call.getString("url");
2288
+ if (url == null) {
2289
+ logger.error("setStatsUrl called without url");
2290
+ call.reject("setStatsUrl called without url");
2291
+ return;
2292
+ }
2293
+ this.implementation.statsUrl = url;
2294
+ if (Boolean.TRUE.equals(this.persistModifyUrl)) {
2295
+ this.editor.putString(STATS_URL_PREF_KEY, url);
2296
+ this.editor.apply();
2297
+ }
2298
+ call.resolve();
2299
+ }
2300
+
2301
+ @PluginMethod
2302
+ public void setChannelUrl(final PluginCall call) {
2303
+ if (!this.getConfig().getBoolean("allowModifyUrl", false)) {
2304
+ logger.error("setChannelUrl not allowed set allowModifyUrl in your config to true to allow it");
2305
+ call.reject("setChannelUrl not allowed");
2306
+ return;
2307
+ }
2308
+ final String url = call.getString("url");
2309
+ if (url == null) {
2310
+ logger.error("setChannelUrl called without url");
2311
+ call.reject("setChannelUrl called without url");
2312
+ return;
2313
+ }
2314
+ this.implementation.channelUrl = url;
2315
+ if (Boolean.TRUE.equals(this.persistModifyUrl)) {
2316
+ this.editor.putString(CHANNEL_URL_PREF_KEY, url);
2317
+ this.editor.apply();
2318
+ }
2319
+ call.resolve();
2320
+ }
2321
+
2322
+ @PluginMethod
2323
+ public void getBuiltinVersion(final PluginCall call) {
2324
+ try {
2325
+ final JSObject ret = new JSObject();
2326
+ ret.put("version", this.implementation.versionBuild);
2327
+ call.resolve(ret);
2328
+ } catch (final Exception e) {
2329
+ logger.error("Could not get version " + e.getMessage());
2330
+ call.reject("Could not get version", e);
2331
+ }
2332
+ }
2333
+
2334
+ @PluginMethod
2335
+ public void getDeviceId(final PluginCall call) {
2336
+ try {
2337
+ final JSObject ret = new JSObject();
2338
+ ret.put("deviceId", this.implementation.deviceID);
2339
+ call.resolve(ret);
2340
+ } catch (final Exception e) {
2341
+ logger.error("Could not get device id " + e.getMessage());
2342
+ call.reject("Could not get device id", e);
2343
+ }
2344
+ }
2345
+
2346
+ @PluginMethod
2347
+ public void setCustomId(final PluginCall call) {
2348
+ final String customId = call.getString("customId");
2349
+ if (customId == null) {
2350
+ logger.error("setCustomId called without customId");
2351
+ call.reject("setCustomId called without customId");
2352
+ return;
2353
+ }
2354
+ this.implementation.customId = customId;
1130
2355
  if (Boolean.TRUE.equals(this.persistCustomId)) {
1131
2356
  if (customId.isEmpty()) {
1132
2357
  this.editor.remove(CUSTOM_ID_PREF_KEY);
1133
2358
  } else {
1134
- this.editor.putString(CUSTOM_ID_PREF_KEY, customId);
2359
+ this.editor.putString(CUSTOM_ID_PREF_KEY, customId);
2360
+ }
2361
+ this.editor.apply();
2362
+ }
2363
+ call.resolve();
2364
+ }
2365
+
2366
+ @PluginMethod
2367
+ public void getPluginVersion(final PluginCall call) {
2368
+ try {
2369
+ final JSObject ret = new JSObject();
2370
+ ret.put("version", this.pluginVersion);
2371
+ call.resolve(ret);
2372
+ } catch (final Exception e) {
2373
+ logger.error("Could not get plugin version " + e.getMessage());
2374
+ call.reject("Could not get plugin version", e);
2375
+ }
2376
+ }
2377
+
2378
+ @PluginMethod
2379
+ public void unsetChannel(final PluginCall call) {
2380
+ final Boolean triggerAutoUpdate = call.getBoolean("triggerAutoUpdate", false);
2381
+
2382
+ try {
2383
+ logger.info("unsetChannel triggerAutoUpdate: " + triggerAutoUpdate);
2384
+ startNewThread(() -> {
2385
+ String configDefaultChannel = CapacitorUpdaterPlugin.this.getConfig().getString("defaultChannel", "");
2386
+ CapacitorUpdaterPlugin.this.implementation.unsetChannel(
2387
+ CapacitorUpdaterPlugin.this.editor,
2388
+ DEFAULT_CHANNEL_PREF_KEY,
2389
+ configDefaultChannel,
2390
+ (res) -> {
2391
+ JSObject jsRes = InternalUtils.mapToJSObject(res);
2392
+ if (jsRes.has("error")) {
2393
+ String errorMessage = jsRes.has("message") ? jsRes.getString("message") : jsRes.getString("error");
2394
+ String errorCode = jsRes.getString("error");
2395
+
2396
+ JSObject errorObj = new JSObject();
2397
+ errorObj.put("message", errorMessage);
2398
+ errorObj.put("error", errorCode);
2399
+
2400
+ call.reject(errorMessage, "UNSETCHANNEL_FAILED", null, errorObj);
2401
+ } else {
2402
+ if (CapacitorUpdaterPlugin.this._isAutoUpdateEnabled() && Boolean.TRUE.equals(triggerAutoUpdate)) {
2403
+ logger.info("Calling autoupdater after channel change!");
2404
+ // Check if download is already in progress (with timeout protection)
2405
+ if (!this.isDownloadStuckOrTimedOut()) {
2406
+ backgroundDownload();
2407
+ } else {
2408
+ logger.info("Download already in progress, skipping duplicate download request");
2409
+ }
2410
+ }
2411
+ call.resolve(jsRes);
2412
+ }
2413
+ }
2414
+ );
2415
+ });
2416
+ } catch (final Exception e) {
2417
+ logger.error("Failed to unsetChannel: " + e.getMessage());
2418
+ call.reject("Failed to unsetChannel: ", e);
2419
+ }
2420
+ }
2421
+
2422
+ @PluginMethod
2423
+ public void setChannel(final PluginCall call) {
2424
+ final String channel = call.getString("channel");
2425
+ final Boolean triggerAutoUpdate = call.getBoolean("triggerAutoUpdate", false);
2426
+
2427
+ if (channel == null) {
2428
+ logger.error("setChannel called without channel");
2429
+ JSObject errorObj = new JSObject();
2430
+ errorObj.put("message", "setChannel called without channel");
2431
+ errorObj.put("error", "missing_parameter");
2432
+ call.reject("setChannel called without channel", "SETCHANNEL_INVALID_PARAMS", null, errorObj);
2433
+ return;
2434
+ }
2435
+ try {
2436
+ logger.info("setChannel " + channel + " triggerAutoUpdate: " + triggerAutoUpdate);
2437
+ startNewThread(() ->
2438
+ CapacitorUpdaterPlugin.this.implementation.setChannel(
2439
+ channel,
2440
+ CapacitorUpdaterPlugin.this.editor,
2441
+ DEFAULT_CHANNEL_PREF_KEY,
2442
+ CapacitorUpdaterPlugin.this.allowSetDefaultChannel,
2443
+ CapacitorUpdaterPlugin.this.getConfig().getString("defaultChannel", ""),
2444
+ (res) -> {
2445
+ JSObject jsRes = InternalUtils.mapToJSObject(res);
2446
+ if (jsRes.has("error")) {
2447
+ String errorMessage = jsRes.has("message") ? jsRes.getString("message") : jsRes.getString("error");
2448
+ String errorCode = jsRes.getString("error");
2449
+
2450
+ // Fire channelPrivate event if channel doesn't allow self-assignment
2451
+ if (
2452
+ errorCode.contains("cannot_update_via_private_channel") ||
2453
+ errorCode.contains("channel_self_set_not_allowed")
2454
+ ) {
2455
+ JSObject eventData = new JSObject();
2456
+ eventData.put("channel", channel);
2457
+ eventData.put("message", errorMessage);
2458
+ notifyListeners("channelPrivate", eventData);
2459
+ }
2460
+
2461
+ JSObject errorObj = new JSObject();
2462
+ errorObj.put("message", errorMessage);
2463
+ errorObj.put("error", errorCode);
2464
+
2465
+ call.reject(errorMessage, "SETCHANNEL_FAILED", null, errorObj);
2466
+ } else {
2467
+ if (CapacitorUpdaterPlugin.this._isAutoUpdateEnabled() && Boolean.TRUE.equals(triggerAutoUpdate)) {
2468
+ logger.info("Calling autoupdater after channel change!");
2469
+ // Check if download is already in progress (with timeout protection)
2470
+ if (!this.isDownloadStuckOrTimedOut()) {
2471
+ backgroundDownload();
2472
+ } else {
2473
+ logger.info("Download already in progress, skipping duplicate download request");
2474
+ }
2475
+ }
2476
+ call.resolve(jsRes);
2477
+ }
2478
+ }
2479
+ )
2480
+ );
2481
+ } catch (final Exception e) {
2482
+ logger.error("Failed to setChannel: " + channel + " " + e.getMessage());
2483
+ call.reject("Failed to setChannel: " + channel, e);
2484
+ }
2485
+ }
2486
+
2487
+ @PluginMethod
2488
+ public void getChannel(final PluginCall call) {
2489
+ try {
2490
+ logger.info("getChannel");
2491
+ startNewThread(() ->
2492
+ CapacitorUpdaterPlugin.this.implementation.getChannel(
2493
+ (res) -> {
2494
+ JSObject jsRes = InternalUtils.mapToJSObject(res);
2495
+ if (jsRes.has("error")) {
2496
+ String errorMessage = jsRes.has("message") ? jsRes.getString("message") : jsRes.getString("error");
2497
+ String errorCode = jsRes.getString("error");
2498
+
2499
+ JSObject errorObj = new JSObject();
2500
+ errorObj.put("message", errorMessage);
2501
+ errorObj.put("error", errorCode);
2502
+
2503
+ call.reject(errorMessage, "GETCHANNEL_FAILED", null, errorObj);
2504
+ } else {
2505
+ call.resolve(jsRes);
2506
+ }
2507
+ },
2508
+ CapacitorUpdaterPlugin.this.editor,
2509
+ DEFAULT_CHANNEL_PREF_KEY
2510
+ )
2511
+ );
2512
+ } catch (final Exception e) {
2513
+ logger.error("Failed to getChannel " + e.getMessage());
2514
+ call.reject("Failed to getChannel", e);
2515
+ }
2516
+ }
2517
+
2518
+ @PluginMethod
2519
+ public void listChannels(final PluginCall call) {
2520
+ try {
2521
+ logger.info("listChannels");
2522
+ startNewThread(() ->
2523
+ CapacitorUpdaterPlugin.this.implementation.listChannels((res) -> {
2524
+ JSObject jsRes = InternalUtils.mapToJSObject(res);
2525
+ Object channels = res.get("channels");
2526
+ if (channels instanceof List<?> channelsList) {
2527
+ JSArray channelsArray = new JSArray();
2528
+ for (Object channel : channelsList) {
2529
+ if (channel instanceof Map<?, ?> channelMap) {
2530
+ JSObject channelObject = new JSObject();
2531
+ for (Map.Entry<?, ?> entry : channelMap.entrySet()) {
2532
+ Object key = entry.getKey();
2533
+ if (key != null) {
2534
+ channelObject.put(key.toString(), entry.getValue());
2535
+ }
2536
+ }
2537
+ channelsArray.put(channelObject);
2538
+ }
2539
+ }
2540
+ jsRes.put("channels", channelsArray);
2541
+ }
2542
+ if (jsRes.has("error")) {
2543
+ String errorMessage = jsRes.has("message") ? jsRes.getString("message") : jsRes.getString("error");
2544
+ String errorCode = jsRes.getString("error");
2545
+
2546
+ JSObject errorObj = new JSObject();
2547
+ errorObj.put("message", errorMessage);
2548
+ errorObj.put("error", errorCode);
2549
+
2550
+ call.reject(errorMessage, "LISTCHANNELS_FAILED", null, errorObj);
2551
+ } else {
2552
+ call.resolve(jsRes);
2553
+ }
2554
+ })
2555
+ );
2556
+ } catch (final Exception e) {
2557
+ logger.error("Failed to listChannels: " + e.getMessage());
2558
+ call.reject("Failed to listChannels", e);
2559
+ }
2560
+ }
2561
+
2562
+ private BundleInfo downloadBundle(
2563
+ final String url,
2564
+ final String version,
2565
+ final String sessionKey,
2566
+ final String checksum,
2567
+ final JSONArray manifest
2568
+ ) throws IOException {
2569
+ if (manifest != null) {
2570
+ return this.implementation.downloadManifest(url, version, sessionKey, checksum, manifest);
2571
+ }
2572
+
2573
+ return this.implementation.download(url, version, sessionKey, checksum);
2574
+ }
2575
+
2576
+ @PluginMethod
2577
+ public void download(final PluginCall call) {
2578
+ final String url = call.getString("url");
2579
+ final String version = call.getString("version");
2580
+ final String sessionKey = call.getString("sessionKey", "");
2581
+ final String checksum = call.getString("checksum", "");
2582
+ final JSONArray manifest = call.getData().optJSONArray("manifest");
2583
+ if (url == null) {
2584
+ logger.error("Download called without url");
2585
+ call.reject("Download called without url");
2586
+ return;
2587
+ }
2588
+ if (version == null) {
2589
+ logger.error("Download called without version");
2590
+ call.reject("Download called without version");
2591
+ return;
2592
+ }
2593
+ try {
2594
+ logger.info("Downloading " + url);
2595
+ startNewThread(() -> {
2596
+ try {
2597
+ final BundleInfo downloaded = this.downloadBundle(url, version, sessionKey, checksum, manifest);
2598
+ if (downloaded.isErrorStatus()) {
2599
+ throw new RuntimeException("Download failed: " + downloaded.getStatus());
2600
+ } else {
2601
+ call.resolve(InternalUtils.mapToJSObject(downloaded.toJSONMap()));
2602
+ }
2603
+ } catch (final Exception e) {
2604
+ logger.error("Failed to download from: " + url + " " + e.getMessage());
2605
+ call.reject("Failed to download from: " + url, e);
2606
+ final JSObject ret = new JSObject();
2607
+ ret.put("version", version);
2608
+ CapacitorUpdaterPlugin.this.notifyListeners("downloadFailed", ret);
2609
+ final BundleInfo current = CapacitorUpdaterPlugin.this.implementation.getCurrentBundle();
2610
+ CapacitorUpdaterPlugin.this.implementation.sendStats("download_fail", current.getVersionName());
2611
+ }
2612
+ });
2613
+ } catch (final Exception e) {
2614
+ logger.error("Failed to download from: " + url + " " + e.getMessage());
2615
+ call.reject("Failed to download from: " + url, e);
2616
+ final JSObject ret = new JSObject();
2617
+ ret.put("version", version);
2618
+ CapacitorUpdaterPlugin.this.notifyListeners("downloadFailed", ret);
2619
+ final BundleInfo current = CapacitorUpdaterPlugin.this.implementation.getCurrentBundle();
2620
+ CapacitorUpdaterPlugin.this.implementation.sendStats("download_fail", current.getVersionName());
2621
+ }
2622
+ }
2623
+
2624
+ private void syncKeepUrlPathFlag(final boolean enabled) {
2625
+ if (this.bridge == null || this.bridge.getWebView() == null) {
2626
+ return;
2627
+ }
2628
+ final String script = enabled
2629
+ ? "(function(){try{localStorage.setItem('" +
2630
+ KEEP_URL_FLAG_KEY +
2631
+ "','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);})();"
2632
+ : "(function(){try{localStorage.removeItem('" +
2633
+ KEEP_URL_FLAG_KEY +
2634
+ "');}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);})();";
2635
+ this.bridge.getWebView().post(() -> this.bridge.getWebView().evaluateJavascript(script, null));
2636
+ }
2637
+
2638
+ private void applyCurrentBundleToBridge() {
2639
+ final String path = this.implementation.getCurrentBundlePath();
2640
+ final boolean usingBuiltin = this.implementation.isUsingBuiltin();
2641
+ if (this.keepUrlPathAfterReload) {
2642
+ this.syncKeepUrlPathFlag(true);
2643
+ }
2644
+ logger.info("Reloading: " + path);
2645
+
2646
+ AtomicReference<URL> url = new AtomicReference<>();
2647
+ if (this.keepUrlPathAfterReload) {
2648
+ try {
2649
+ if (Looper.myLooper() != Looper.getMainLooper()) {
2650
+ Semaphore mainThreadSemaphore = new Semaphore(0);
2651
+ this.bridge.executeOnMainThread(() -> {
2652
+ try {
2653
+ if (this.bridge != null && this.bridge.getWebView() != null) {
2654
+ String currentUrl = this.bridge.getWebView().getUrl();
2655
+ if (currentUrl != null) {
2656
+ url.set(new URL(currentUrl));
2657
+ }
2658
+ }
2659
+ } catch (Exception e) {
2660
+ logger.error("Error executing on main thread " + e.getMessage());
2661
+ }
2662
+ mainThreadSemaphore.release();
2663
+ });
2664
+
2665
+ // Add timeout to prevent indefinite blocking
2666
+ if (!mainThreadSemaphore.tryAcquire(10, TimeUnit.SECONDS)) {
2667
+ logger.error("Timeout waiting for main thread operation");
2668
+ }
2669
+ } else {
2670
+ try {
2671
+ if (this.bridge != null && this.bridge.getWebView() != null) {
2672
+ String currentUrl = this.bridge.getWebView().getUrl();
2673
+ if (currentUrl != null) {
2674
+ url.set(new URL(currentUrl));
2675
+ }
2676
+ }
2677
+ } catch (Exception e) {
2678
+ logger.error("Error executing on main thread " + e.getMessage());
2679
+ }
2680
+ }
2681
+ } catch (InterruptedException e) {
2682
+ logger.error("Error waiting for main thread or getting the current URL from webview " + e.getMessage());
2683
+ Thread.currentThread().interrupt(); // Restore interrupted status
2684
+ }
2685
+ }
2686
+
2687
+ if (url.get() != null) {
2688
+ if (usingBuiltin) {
2689
+ this.bridge.getLocalServer().hostAssets(path);
2690
+ } else {
2691
+ this.bridge.getLocalServer().hostFiles(path);
2692
+ }
2693
+
2694
+ try {
2695
+ URL finalUrl = null;
2696
+ finalUrl = new URL(this.bridge.getAppUrl());
2697
+ finalUrl = new URL(finalUrl.getProtocol(), finalUrl.getHost(), finalUrl.getPort(), url.get().getPath());
2698
+ URL finalUrl1 = finalUrl;
2699
+ this.bridge.getWebView().post(() -> {
2700
+ this.bridge.getWebView().loadUrl(finalUrl1.toString());
2701
+ if (!this.keepUrlPathAfterReload) {
2702
+ this.bridge.getWebView().clearHistory();
2703
+ }
2704
+ });
2705
+ } catch (MalformedURLException e) {
2706
+ logger.error("Cannot get finalUrl from capacitor bridge " + e.getMessage());
2707
+
2708
+ if (usingBuiltin) {
2709
+ this.bridge.setServerAssetPath(path);
2710
+ } else {
2711
+ this.bridge.setServerBasePath(path);
2712
+ }
2713
+ }
2714
+ } else {
2715
+ if (usingBuiltin) {
2716
+ this.bridge.setServerAssetPath(path);
2717
+ } else {
2718
+ this.bridge.setServerBasePath(path);
2719
+ }
2720
+ if (this.bridge != null && this.bridge.getWebView() != null) {
2721
+ this.bridge.getWebView().post(() -> {
2722
+ if (this.bridge.getWebView() != null) {
2723
+ this.bridge.getWebView().loadUrl(this.bridge.getAppUrl());
2724
+ if (!this.keepUrlPathAfterReload) {
2725
+ this.bridge.getWebView().clearHistory();
2726
+ }
2727
+ }
2728
+ });
2729
+ }
2730
+ }
2731
+ }
2732
+
2733
+ protected void restoreLiveBundleStateAfterFailedReload() {
2734
+ try {
2735
+ this.applyCurrentBundleToBridge();
2736
+ } catch (final Exception e) {
2737
+ logger.warn("Failed to restore live bundle after rejected reload: " + e.getMessage());
2738
+ }
2739
+ }
2740
+
2741
+ protected boolean _reload() {
2742
+ final int phase = this.semaphoreUp();
2743
+ this.applyCurrentBundleToBridge();
2744
+
2745
+ final long waitTimeMs = this.resolveAppReadyCheckTimeoutMs();
2746
+ this.checkAppReady(waitTimeMs);
2747
+ this.notifyListeners("appReloaded", new JSObject());
2748
+
2749
+ // Wait for the reload to complete (until notifyAppReady is called)
2750
+ return this.semaphoreWait(phase, waitTimeMs);
2751
+ }
2752
+
2753
+ protected boolean reloadWithoutWaitingForAppReady() {
2754
+ this.applyCurrentBundleToBridge();
2755
+
2756
+ final long waitTimeMs = this.resolveAppReadyCheckTimeoutMs();
2757
+ this.checkAppReady(waitTimeMs);
2758
+ this.notifyListeners("appReloaded", new JSObject());
2759
+ return true;
2760
+ }
2761
+
2762
+ @PluginMethod
2763
+ public void reload(final PluginCall call) {
2764
+ startNewThread(() -> {
2765
+ try {
2766
+ final BundleInfo current = this.implementation.getCurrentBundle();
2767
+ final BundleInfo next = this.implementation.getNextBundle();
2768
+
2769
+ if (!this.isPreviewSessionStateActive() && next != null && !next.isErrorStatus() && !next.getId().equals(current.getId())) {
2770
+ final CapgoUpdater.ResetState previousState = this.implementation.captureResetState();
2771
+ final String previousBundleName = this.implementation.getCurrentBundle().getVersionName();
2772
+ logger.info("Applying pending bundle before reload: " + next.getVersionName());
2773
+ final boolean didApplyPendingBundle;
2774
+ if (next.isBuiltin()) {
2775
+ this.implementation.prepareResetStateForTransition();
2776
+ didApplyPendingBundle = true;
2777
+ } else {
2778
+ didApplyPendingBundle = this.implementation.stagePendingReload(next);
2779
+ }
2780
+ if (didApplyPendingBundle && this._reload()) {
2781
+ if (next.isBuiltin()) {
2782
+ this.implementation.finalizeResetTransition(previousBundleName, false);
2783
+ } else {
2784
+ this.implementation.finalizePendingReload(next, previousBundleName);
2785
+ }
2786
+ this.notifyBundleSet(next);
2787
+ this.implementation.setNextBundle(null);
2788
+ this.showPreviewSessionNoticeIfNeeded();
2789
+ call.resolve();
2790
+ return;
2791
+ }
2792
+ this.implementation.restoreResetState(previousState);
2793
+ this.restoreLiveBundleStateAfterFailedReload();
2794
+ logger.error("Reload failed after applying pending bundle: " + next.getVersionName());
2795
+ call.reject("Reload failed after applying pending bundle: " + next.getVersionName());
2796
+ return;
2797
+ }
2798
+
2799
+ if (this._reload()) {
2800
+ this.showPreviewSessionNoticeIfNeeded();
2801
+ call.resolve();
2802
+ } else {
2803
+ logger.error("Reload failed");
2804
+ call.reject("Reload failed");
2805
+ }
2806
+ } catch (final Exception e) {
2807
+ logger.error("Could not reload " + e.getMessage());
2808
+ call.reject("Could not reload", e);
2809
+ }
2810
+ });
2811
+ }
2812
+
2813
+ @PluginMethod
2814
+ public void next(final PluginCall call) {
2815
+ final String id = call.getString("id");
2816
+ if (id == null) {
2817
+ logger.error("Next called without id");
2818
+ call.reject("Next called without id");
2819
+ return;
2820
+ }
2821
+ try {
2822
+ logger.info("Setting next active id " + id);
2823
+ if (!this.implementation.setNextBundle(id)) {
2824
+ logger.error("Set next id failed. Bundle " + id + " does not exist.");
2825
+ call.reject("Set next id failed. Bundle " + id + " does not exist.");
2826
+ } else {
2827
+ call.resolve(InternalUtils.mapToJSObject(this.implementation.getBundleInfo(id).toJSONMap()));
2828
+ }
2829
+ } catch (final Exception e) {
2830
+ logger.error("Could not set next id " + id + " " + e.getMessage());
2831
+ call.reject("Could not set next id: " + id, e);
2832
+ }
2833
+ }
2834
+
2835
+ @PluginMethod
2836
+ public void set(final PluginCall call) {
2837
+ final String id = call.getString("id");
2838
+ if (id == null) {
2839
+ logger.error("Set called without id");
2840
+ call.reject("Set called without id");
2841
+ return;
2842
+ }
2843
+ startNewThread(() -> {
2844
+ try {
2845
+ logger.info("Setting active bundle " + id);
2846
+ if (!this.implementation.set(id)) {
2847
+ logger.info("No such bundle " + id);
2848
+ call.reject("Update failed, id " + id + " does not exist.");
2849
+ } else if (Boolean.TRUE.equals(this.previewSessionEnabled)) {
2850
+ logger.info("Preview session set active bundle " + id + " without waiting for preview app readiness");
2851
+ final BundleInfo bundle = this.implementation.getBundleInfo(id);
2852
+ this.recordPreviewBundle(bundle);
2853
+ this.reloadWithoutWaitingForAppReady();
2854
+ this.notifyBundleSet(bundle);
2855
+ this.showPreviewSessionNoticeIfNeeded();
2856
+ call.resolve();
2857
+ } else if (!this._reload()) {
2858
+ logger.error("Reload failed after setting bundle " + id);
2859
+ call.reject("Reload failed after setting bundle " + id);
2860
+ } else {
2861
+ logger.info("Bundle successfully set to " + id);
2862
+ this.notifyBundleSet(this.implementation.getBundleInfo(id));
2863
+ this.showPreviewSessionNoticeIfNeeded();
2864
+ call.resolve();
2865
+ }
2866
+ } catch (final Exception e) {
2867
+ logger.error("Could not set id " + id + " " + e.getMessage());
2868
+ call.reject("Could not set id " + id, e);
2869
+ }
2870
+ });
2871
+ }
2872
+
2873
+ private boolean preparePreviewFallbackIfNeeded() {
2874
+ if (Boolean.TRUE.equals(this.previewSessionEnabled)) {
2875
+ return true;
2876
+ }
2877
+
2878
+ final BundleInfo current = this.implementation.getCurrentBundle();
2879
+ if (!this.implementation.setPreviewFallbackBundle(current.getId())) {
2880
+ logger.error("Could not save current bundle as preview fallback");
2881
+ return false;
2882
+ }
2883
+
2884
+ final BundleInfo previousNext = this.implementation.getNextBundle();
2885
+ if (previousNext == null || previousNext.isDeleted() || previousNext.isErrorStatus()) {
2886
+ this.editor.remove(PREVIEW_PREVIOUS_NEXT_BUNDLE_PREF_KEY);
2887
+ } else {
2888
+ this.editor.putString(PREVIEW_PREVIOUS_NEXT_BUNDLE_PREF_KEY, previousNext.getId());
2889
+ }
2890
+
2891
+ this.editor.putString(PREVIEW_PREVIOUS_APP_ID_PREF_KEY, this.implementation.appId);
2892
+ if (this.prefs.contains(DEFAULT_CHANNEL_PREF_KEY)) {
2893
+ this.editor.putString(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_PREF_KEY, this.prefs.getString(DEFAULT_CHANNEL_PREF_KEY, ""));
2894
+ this.editor.putBoolean(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY, true);
2895
+ } else {
2896
+ this.editor.remove(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_PREF_KEY);
2897
+ this.editor.putBoolean(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY, false);
2898
+ }
2899
+ this.editor.putBoolean(PREVIEW_PREVIOUS_SHAKE_MENU_PREF_KEY, Boolean.TRUE.equals(this.shakeMenuEnabled));
2900
+ this.editor.putBoolean(PREVIEW_PREVIOUS_SHAKE_CHANNEL_SELECTOR_PREF_KEY, Boolean.TRUE.equals(this.shakeChannelSelectorEnabled));
2901
+ logger.info("Preview session started with fallback bundle: " + current);
2902
+ return true;
2903
+ }
2904
+
2905
+ private void activatePreviewSessionState() {
2906
+ this.clearIncomingPreviewTransition();
2907
+ this.hidePreviewTransitionLoader("preview-session-started");
2908
+ this.previewSessionEnabled = true;
2909
+ this.previewSessionAlertPending = true;
2910
+ this.implementation.previewSession = true;
2911
+ this.shakeMenuEnabled = true;
2912
+ this.editor.putBoolean(PREVIEW_SESSION_PREF_KEY, true);
2913
+ this.editor.putBoolean(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY, true);
2914
+ this.editor.apply();
2915
+ this.syncShakeMenuLifecycle();
2916
+ }
2917
+
2918
+ @PluginMethod
2919
+ public void startPreviewSession(final PluginCall call) {
2920
+ if (!Boolean.TRUE.equals(this.allowPreview)) {
2921
+ this.hidePreviewTransitionLoader("preview-session-not-allowed");
2922
+ logger.error("startPreviewSession not allowed set allowPreview in your config to true to enable it");
2923
+ call.reject("startPreviewSession not allowed");
2924
+ return;
2925
+ }
2926
+ final String previewAppId = this.normalizePreviewAppId(call.getString("appId"));
2927
+ final String rawPayloadUrl = call.getString("payloadUrl");
2928
+ final String previewPayloadUrl = this.normalizePreviewPayloadUrl(rawPayloadUrl);
2929
+ if (this.hasPreviewPayloadUrl(rawPayloadUrl) && previewPayloadUrl == null) {
2930
+ this.hidePreviewTransitionLoader("preview-session-invalid-payload");
2931
+ logger.error("startPreviewSession called with invalid payloadUrl");
2932
+ call.reject("Invalid preview payloadUrl");
2933
+ return;
2934
+ }
2935
+ startNewThread(() -> {
2936
+ try {
2937
+ if (!this.preparePreviewFallbackIfNeeded()) {
2938
+ this.hidePreviewTransitionLoader("preview-session-fallback-failed");
2939
+ call.reject("Could not save current bundle as preview fallback");
2940
+ return;
2941
+ }
2942
+
2943
+ if (previewAppId != null) {
2944
+ this.setActiveAppId(previewAppId);
2945
+ this.editor.putString(PREVIEW_APP_ID_PREF_KEY, previewAppId);
2946
+ logger.info("Preview session using appId: " + previewAppId);
2947
+ }
2948
+
2949
+ if (previewPayloadUrl != null) {
2950
+ this.editor.putString(PREVIEW_PAYLOAD_URL_PREF_KEY, previewPayloadUrl);
2951
+ logger.info("Preview session using payload URL");
2952
+ } else {
2953
+ this.editor.remove(PREVIEW_PAYLOAD_URL_PREF_KEY);
2954
+ }
2955
+
2956
+ final String previewName = this.normalizedPreviewMetadataValue(call.getString("name"));
2957
+ if (previewName == null) {
2958
+ this.editor.remove(PREVIEW_NAME_PREF_KEY);
2959
+ } else {
2960
+ this.editor.putString(PREVIEW_NAME_PREF_KEY, previewName);
2961
+ }
2962
+
2963
+ final String previewSource = this.normalizedPreviewMetadataValue(call.getString("source"));
2964
+ if (previewSource == null) {
2965
+ this.editor.remove(PREVIEW_SOURCE_PREF_KEY);
2966
+ } else {
2967
+ this.editor.putString(PREVIEW_SOURCE_PREF_KEY, previewSource);
2968
+ }
2969
+
2970
+ this.activatePreviewSessionState();
2971
+ call.resolve();
2972
+ } catch (final Exception e) {
2973
+ this.hidePreviewTransitionLoader("preview-session-failed");
2974
+ logger.error("Could not start preview session " + e.getMessage());
2975
+ call.reject("Could not start preview session", e);
2976
+ }
2977
+ });
2978
+ }
2979
+
2980
+ @PluginMethod
2981
+ public void listPreviews(final PluginCall call) {
2982
+ if (!Boolean.TRUE.equals(this.allowPreview)) {
2983
+ call.reject("listPreviews not allowed");
2984
+ return;
2985
+ }
2986
+
2987
+ final JSArray previews = this.listPreviewInfos(true);
2988
+ final JSObject ret = new JSObject();
2989
+ ret.put("previews", previews);
2990
+ ret.put("currentBundle", InternalUtils.mapToJSObject(this.implementation.getCurrentBundle().toJSONMap()));
2991
+
2992
+ for (int i = 0; i < previews.length(); i++) {
2993
+ final JSONObject preview = previews.optJSONObject(i);
2994
+ if (preview != null && preview.optBoolean("isActive", false)) {
2995
+ ret.put("current", preview);
2996
+ break;
2997
+ }
2998
+ }
2999
+
3000
+ final BundleInfo liveBundle = this.implementation.getPreviewFallbackBundle();
3001
+ if (liveBundle != null) {
3002
+ ret.put("liveBundle", InternalUtils.mapToJSObject(liveBundle.toJSONMap()));
3003
+ }
3004
+
3005
+ call.resolve(ret);
3006
+ }
3007
+
3008
+ @PluginMethod
3009
+ public void setPreview(final PluginCall call) {
3010
+ if (!Boolean.TRUE.equals(this.allowPreview)) {
3011
+ call.reject("setPreview not allowed");
3012
+ return;
3013
+ }
3014
+ final String id = call.getString("id");
3015
+ if (id == null || id.isEmpty()) {
3016
+ call.reject("setPreview called without id");
3017
+ return;
3018
+ }
3019
+ final JSObject preview = this.storedPreviewInfo(id);
3020
+ if (preview == null) {
3021
+ call.reject("Preview " + id + " is not available locally");
3022
+ return;
3023
+ }
3024
+
3025
+ this.showPreviewTransitionLoader("set-preview");
3026
+ startNewThread(() -> {
3027
+ if (!this.preparePreviewFallbackIfNeeded()) {
3028
+ this.hidePreviewTransitionLoader("set-preview-fallback-failed");
3029
+ call.reject("Could not save current bundle as preview fallback");
3030
+ return;
1135
3031
  }
1136
- this.editor.apply();
3032
+
3033
+ if (!this.implementation.set(id)) {
3034
+ this.hidePreviewTransitionLoader("set-preview-failed");
3035
+ call.reject("Preview " + id + " cannot be applied");
3036
+ return;
3037
+ }
3038
+
3039
+ final BundleInfo bundle = this.implementation.getBundleInfo(id);
3040
+ this.updateCurrentPreviewSessionMetadataFrom(preview);
3041
+ this.activatePreviewSessionState();
3042
+ this.recordPreviewBundle(bundle);
3043
+ if (!this.reloadWithoutWaitingForAppReady()) {
3044
+ this.hidePreviewTransitionLoader("set-preview-reload-failed");
3045
+ call.reject("Reload failed after setting preview " + id);
3046
+ return;
3047
+ }
3048
+
3049
+ this.notifyBundleSet(bundle);
3050
+ this.showPreviewSessionNoticeIfNeeded();
3051
+ call.resolve();
3052
+ });
3053
+ }
3054
+
3055
+ public JSArray previewMenuPreviews() {
3056
+ return this.listPreviewInfos(true);
3057
+ }
3058
+
3059
+ public boolean setPreviewFromShakeMenu(final String id) {
3060
+ final JSObject preview = this.storedPreviewInfo(id);
3061
+ if (!Boolean.TRUE.equals(this.allowPreview) || preview == null) {
3062
+ return false;
1137
3063
  }
1138
- call.resolve();
3064
+
3065
+ this.showPreviewTransitionLoader("set-preview-menu");
3066
+ if (!this.preparePreviewFallbackIfNeeded()) {
3067
+ this.hidePreviewTransitionLoader("set-preview-menu-fallback-failed");
3068
+ return false;
3069
+ }
3070
+
3071
+ if (!this.implementation.set(id)) {
3072
+ this.hidePreviewTransitionLoader("set-preview-menu-failed");
3073
+ return false;
3074
+ }
3075
+
3076
+ final BundleInfo bundle = this.implementation.getBundleInfo(id);
3077
+ this.updateCurrentPreviewSessionMetadataFrom(preview);
3078
+ this.activatePreviewSessionState();
3079
+ this.recordPreviewBundle(bundle);
3080
+ if (!this.reloadWithoutWaitingForAppReady()) {
3081
+ this.hidePreviewTransitionLoader("set-preview-menu-reload-failed");
3082
+ return false;
3083
+ }
3084
+
3085
+ this.notifyBundleSet(bundle);
3086
+ this.showPreviewSessionNoticeIfNeeded();
3087
+ return true;
1139
3088
  }
1140
3089
 
1141
3090
  @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);
3091
+ public void resetPreview(final PluginCall call) {
3092
+ if (!Boolean.TRUE.equals(this.previewSessionEnabled)) {
3093
+ call.resolve();
3094
+ return;
1150
3095
  }
3096
+ startNewThread(() -> {
3097
+ if (this.leavePreviewSessionFromShakeMenu()) {
3098
+ call.resolve();
3099
+ } else {
3100
+ call.reject("Could not leave preview session");
3101
+ }
3102
+ });
1151
3103
  }
1152
3104
 
1153
3105
  @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");
3106
+ public void deletePreview(final PluginCall call) {
3107
+ if (!Boolean.TRUE.equals(this.allowPreview)) {
3108
+ call.reject("deletePreview not allowed");
3109
+ return;
3110
+ }
3111
+ final String id = call.getString("id");
3112
+ if (id == null || id.isEmpty()) {
3113
+ call.reject("deletePreview called without id");
3114
+ return;
3115
+ }
3116
+ if (Boolean.TRUE.equals(this.previewSessionEnabled) && this.implementation.getCurrentBundle().getId().equals(id)) {
3117
+ call.reject("Cannot delete the active preview");
3118
+ return;
3119
+ }
1170
3120
 
1171
- JSObject errorObj = new JSObject();
1172
- errorObj.put("message", errorMessage);
1173
- errorObj.put("error", errorCode);
3121
+ final boolean removed;
3122
+ synchronized (this.previewSessionsLock) {
3123
+ final JSONObject sessions = this.previewSessionsJson();
3124
+ removed = sessions.has(id);
3125
+ sessions.remove(id);
3126
+ this.savePreviewSessionsJson(sessions);
3127
+ }
1174
3128
 
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);
3129
+ boolean deleted = false;
3130
+ final BundleInfo fallback = this.implementation.getPreviewFallbackBundle();
3131
+ final BundleInfo next = this.implementation.getNextBundle();
3132
+ if (
3133
+ removed &&
3134
+ !BundleInfo.ID_BUILTIN.equals(id) &&
3135
+ (fallback == null || !id.equals(fallback.getId())) &&
3136
+ (next == null || !id.equals(next.getId()))
3137
+ ) {
3138
+ try {
3139
+ deleted = this.implementation.delete(id, false);
3140
+ } catch (final Exception err) {
3141
+ logger.warn("Could not delete preview bundle " + id + ": " + err.getMessage());
3142
+ }
1194
3143
  }
3144
+
3145
+ final JSObject ret = new JSObject();
3146
+ ret.put("removed", removed);
3147
+ ret.put("deleted", deleted);
3148
+ call.resolve(ret);
1195
3149
  }
1196
3150
 
1197
3151
  @PluginMethod
1198
- public void setChannel(final PluginCall call) {
1199
- final String channel = call.getString("channel");
1200
- final Boolean triggerAutoUpdate = call.getBoolean("triggerAutoUpdate", false);
3152
+ public void checkPreviewUpdate(final PluginCall call) {
3153
+ this.handlePreviewUpdate(call, false);
3154
+ }
1201
3155
 
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);
3156
+ @PluginMethod
3157
+ public void updatePreview(final PluginCall call) {
3158
+ this.handlePreviewUpdate(call, true);
3159
+ }
3160
+
3161
+ private void handlePreviewUpdate(final PluginCall call, final boolean shouldDownload) {
3162
+ if (!Boolean.TRUE.equals(this.allowPreview)) {
3163
+ call.reject("Preview updates not allowed");
3164
+ return;
3165
+ }
3166
+ final String id = call.getString("id");
3167
+ if (id == null || id.isEmpty()) {
3168
+ call.reject("Preview update called without id");
3169
+ return;
3170
+ }
3171
+ final JSObject preview = this.storedPreviewInfo(id);
3172
+ final String payloadUrl = preview == null ? null : this.normalizePreviewPayloadUrl(preview.optString("payloadUrl", null));
3173
+ if (payloadUrl == null) {
3174
+ call.reject("Preview " + id + " has no payloadUrl to update from");
1208
3175
  return;
1209
3176
  }
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
3177
 
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
- }
3178
+ startNewThread(() -> {
3179
+ try {
3180
+ final JSONObject payload = this.fetchPreviewPayload(payloadUrl);
3181
+ final String version = payload.optString("version", "").trim();
3182
+ if (version.isEmpty()) {
3183
+ throw new IOException("Preview payload is missing a version");
3184
+ }
1234
3185
 
1235
- JSObject errorObj = new JSObject();
1236
- errorObj.put("message", errorMessage);
1237
- errorObj.put("error", errorCode);
3186
+ final BundleInfo currentPreviewBundle = this.implementation.getBundleInfo(id);
3187
+ final boolean upToDate = version.equals(currentPreviewBundle.getVersionName());
3188
+ if (upToDate || !shouldDownload) {
3189
+ final JSObject ret = new JSObject();
3190
+ ret.put("preview", preview);
3191
+ ret.put("latestVersion", version);
3192
+ ret.put("upToDate", upToDate);
3193
+ ret.put("updated", false);
3194
+ ret.put("bundle", InternalUtils.mapToJSObject(currentPreviewBundle.toJSONMap()));
3195
+ call.resolve(ret);
3196
+ return;
3197
+ }
1238
3198
 
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
- }
3199
+ final BundleInfo next = this.downloadPreviewPayloadBundle(payload);
3200
+ if (next.isErrorStatus()) {
3201
+ throw new IOException("Download failed: " + next.getStatus());
3202
+ }
3203
+
3204
+ final boolean wasActive =
3205
+ Boolean.TRUE.equals(this.previewSessionEnabled) && this.implementation.getCurrentBundle().getId().equals(id);
3206
+ if (wasActive && !this.implementation.set(next.getId())) {
3207
+ throw new IOException("Downloaded preview bundle cannot be applied");
3208
+ }
3209
+
3210
+ final JSObject savedPreview = this.recordPreviewBundle(next, id);
3211
+ if (wasActive) {
3212
+ if (!this.reloadWithoutWaitingForAppReady()) {
3213
+ throw new IOException("Reload failed after updating preview");
1252
3214
  }
1253
- )
1254
- );
1255
- } catch (final Exception e) {
1256
- logger.error("Failed to setChannel: " + channel + " " + e.getMessage());
1257
- call.reject("Failed to setChannel: " + channel, e);
3215
+ this.notifyBundleSet(next);
3216
+ this.showPreviewSessionNoticeIfNeeded();
3217
+ }
3218
+
3219
+ final JSObject ret = new JSObject();
3220
+ ret.put("preview", savedPreview);
3221
+ ret.put("latestVersion", version);
3222
+ ret.put("upToDate", false);
3223
+ ret.put("updated", true);
3224
+ ret.put("bundle", InternalUtils.mapToJSObject(next.toJSONMap()));
3225
+ call.resolve(ret);
3226
+ } catch (final Exception err) {
3227
+ logger.error("Could not update preview: " + err.getMessage());
3228
+ call.reject("Could not update preview: " + err.getMessage());
3229
+ }
3230
+ });
3231
+ }
3232
+
3233
+ public boolean leavePreviewSessionFromShakeMenu() {
3234
+ this.showPreviewTransitionLoader("leave-preview-session");
3235
+ final boolean didReset = this.resetToPreviewFallbackBundle();
3236
+ if (!didReset) {
3237
+ this.hidePreviewTransitionLoader("leave-preview-session-failed");
3238
+ return false;
1258
3239
  }
3240
+
3241
+ this.endPreviewSession(true);
3242
+ return true;
1259
3243
  }
1260
3244
 
1261
- @PluginMethod
1262
- public void getChannel(final PluginCall call) {
3245
+ private boolean leavePreviewSessionForIncomingPreviewLink() {
3246
+ this.showPreviewTransitionLoader("incoming-preview-deeplink");
3247
+ final BundleInfo previewFallbackBundle = this.resolvePreviewFallbackBundle("incoming preview deeplink");
3248
+ boolean didReload = false;
3249
+
1263
3250
  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");
3251
+ if (previewFallbackBundle == null) {
3252
+ return false;
3253
+ }
1271
3254
 
1272
- JSObject errorObj = new JSObject();
1273
- errorObj.put("message", errorMessage);
1274
- errorObj.put("error", errorCode);
3255
+ final CapgoUpdater.ResetState previousState = this.implementation.captureResetState();
3256
+ if (!this.implementation.stagePreviewFallbackReload(previewFallbackBundle)) {
3257
+ logger.error("Could not stage preview fallback bundle");
3258
+ return false;
3259
+ }
1275
3260
 
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);
3261
+ if (!this.reloadWithoutWaitingForAppReady()) {
3262
+ this.implementation.restoreResetState(previousState);
3263
+ this.restoreLiveBundleStateAfterFailedReload();
3264
+ return false;
3265
+ }
3266
+ didReload = true;
3267
+
3268
+ this.endPreviewSession(true);
3269
+ return true;
3270
+ } finally {
3271
+ this.clearIncomingPreviewTransition();
3272
+ if (!didReload) {
3273
+ this.hidePreviewTransitionLoader("incoming-preview-deeplink-failed");
3274
+ }
3275
+ }
3276
+ }
3277
+
3278
+ private void leavePreviewSessionForLaunchIntentIfNeeded() {
3279
+ final Intent intent = getActivity() == null ? null : getActivity().getIntent();
3280
+ if (
3281
+ intent == null ||
3282
+ !Intent.ACTION_VIEW.equals(intent.getAction()) ||
3283
+ intent.getData() == null ||
3284
+ !Boolean.TRUE.equals(this.previewSessionEnabled) ||
3285
+ !isPreviewDeepLink(intent.getData()) ||
3286
+ Boolean.TRUE.equals(this.isLeavingPreviewForIncomingLink)
3287
+ ) {
3288
+ return;
3289
+ }
3290
+
3291
+ this.isLeavingPreviewForIncomingLink = true;
3292
+ this.showPreviewTransitionLoader("preview-launch-deeplink");
3293
+ logger.info("Preview deeplink launch detected while preview session is active; restoring fallback before initial load");
3294
+ if (!this.leavePreviewSessionWithoutReload()) {
3295
+ logger.error("Could not leave preview session before initial preview deeplink routing");
3296
+ this.isLeavingPreviewForIncomingLink = false;
3297
+ this.hidePreviewTransitionLoader("preview-launch-deeplink-failed");
3298
+ }
3299
+ }
3300
+
3301
+ private boolean leavePreviewSessionWithoutReload() {
3302
+ return this.leavePreviewSessionWithoutReload(false);
3303
+ }
3304
+
3305
+ private boolean leavePreviewSessionWithoutReload(final boolean keepPreviewGuard) {
3306
+ final BundleInfo previewFallbackBundle = this.resolvePreviewFallbackBundle("preview deeplink launch");
3307
+ if (previewFallbackBundle == null) {
3308
+ return false;
3309
+ }
3310
+ if (!this.implementation.stagePreviewFallbackReload(previewFallbackBundle)) {
3311
+ logger.error("Could not stage preview fallback bundle");
3312
+ return false;
3313
+ }
3314
+
3315
+ this.endPreviewSession(keepPreviewGuard);
3316
+ return true;
3317
+ }
3318
+
3319
+ public boolean reloadPreviewSessionFromShakeMenu() {
3320
+ this.showPreviewTransitionLoader("reload-preview-session");
3321
+ final boolean didReload;
3322
+ final String payloadUrl = this.storedPreviewPayloadUrl();
3323
+ if (payloadUrl != null) {
3324
+ didReload = this.refreshPreviewSessionFromPayloadUrl(payloadUrl);
3325
+ } else {
3326
+ didReload = this.reloadWithoutWaitingForAppReady();
3327
+ }
3328
+
3329
+ if (!didReload) {
3330
+ this.hidePreviewTransitionLoader("reload-preview-session-failed");
3331
+ }
3332
+ return didReload;
3333
+ }
3334
+
3335
+ public boolean hasActivePreviewSession() {
3336
+ return Boolean.TRUE.equals(this.previewSessionEnabled);
3337
+ }
3338
+
3339
+ private boolean resetToPreviewFallbackBundle() {
3340
+ final BundleInfo fallback = this.resolvePreviewFallbackBundle("leave preview");
3341
+ if (fallback == null) {
3342
+ return false;
3343
+ }
3344
+
3345
+ final CapgoUpdater.ResetState previousState = this.implementation.captureResetState();
3346
+ final String previousBundleName = this.implementation.getCurrentBundle().getVersionName();
3347
+ logger.info("Resetting to preview fallback bundle: " + fallback.getVersionName());
3348
+ if (this.implementation.stagePreviewFallbackReload(fallback) && this.reloadWithoutWaitingForAppReady()) {
3349
+ this.implementation.finalizeResetTransition(previousBundleName, false);
3350
+ this.notifyBundleSet(fallback);
3351
+ return true;
1285
3352
  }
3353
+ this.implementation.restoreResetState(previousState);
3354
+ this.restoreLiveBundleStateAfterFailedReload();
3355
+ return false;
3356
+ }
3357
+
3358
+ private BundleInfo resolvePreviewFallbackBundle(final String reason) {
3359
+ final BundleInfo fallback = this.implementation.getPreviewFallbackBundle();
3360
+ if (fallback != null && !fallback.isErrorStatus() && this.implementation.canSet(fallback)) {
3361
+ return fallback;
3362
+ }
3363
+
3364
+ if (fallback == null) {
3365
+ logger.warn("No preview fallback bundle available for " + reason + ". Falling back to builtin bundle.");
3366
+ } else if (fallback.isErrorStatus()) {
3367
+ logger.warn("Preview fallback bundle is in error state for " + reason + ". Falling back to builtin bundle.");
3368
+ } else {
3369
+ logger.warn("Preview fallback bundle is not installable for " + reason + ". Falling back to builtin bundle.");
3370
+ }
3371
+
3372
+ final BundleInfo builtin = this.implementation.getBundleInfo(BundleInfo.ID_BUILTIN);
3373
+ if (builtin != null && !builtin.isErrorStatus() && this.implementation.canSet(builtin)) {
3374
+ return builtin;
3375
+ }
3376
+
3377
+ logger.error("Builtin bundle is not available to leave preview for " + reason);
3378
+ return null;
3379
+ }
3380
+
3381
+ private void endPreviewSession() {
3382
+ this.endPreviewSession(false);
3383
+ }
3384
+
3385
+ private void endPreviewSession(final boolean keepPreviewGuard) {
3386
+ final boolean previousShakeMenuEnabled = this.prefs.getBoolean(
3387
+ PREVIEW_PREVIOUS_SHAKE_MENU_PREF_KEY,
3388
+ this.getConfig().getBoolean("shakeMenu", false)
3389
+ );
3390
+ final boolean previousShakeChannelSelectorEnabled = this.prefs.getBoolean(
3391
+ PREVIEW_PREVIOUS_SHAKE_CHANNEL_SELECTOR_PREF_KEY,
3392
+ this.getConfig().getBoolean("allowShakeChannelSelector", false)
3393
+ );
3394
+ this.restorePreviewPreviousNextBundle();
3395
+ this.restorePreviewPreviousAppId();
3396
+ this.restorePreviewPreviousDefaultChannel();
3397
+
3398
+ this.previewSessionEnabled = false;
3399
+ this.previewSessionAlertPending = false;
3400
+ if (keepPreviewGuard) {
3401
+ this.implementation.previewSession = true;
3402
+ } else {
3403
+ this.clearIncomingPreviewTransition();
3404
+ }
3405
+ this.shakeMenuEnabled = previousShakeMenuEnabled;
3406
+ this.shakeChannelSelectorEnabled = previousShakeChannelSelectorEnabled;
3407
+ this.syncShakeMenuLifecycle();
3408
+ this.implementation.setPreviewFallbackBundle(null);
3409
+ this.clearPreviewSessionPreferences();
3410
+ logger.info("Preview session ended");
1286
3411
  }
1287
3412
 
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");
3413
+ private void clearPreviewSessionBecauseDisabled() {
3414
+ logger.info("Preview session disabled by config; restoring preview fallback");
3415
+ final BundleInfo bundleToRestore = this.resolvePreviewFallbackBundle("preview disabled");
3416
+ if (bundleToRestore != null) {
3417
+ this.implementation.stagePreviewFallbackReload(bundleToRestore);
3418
+ } else {
3419
+ logger.warn("Could not restore preview fallback while disabling preview");
3420
+ }
1298
3421
 
1299
- JSObject errorObj = new JSObject();
1300
- errorObj.put("message", errorMessage);
1301
- errorObj.put("error", errorCode);
3422
+ this.restorePreviewPreviousNextBundle();
3423
+ this.restorePreviewPreviousAppId();
3424
+ this.restorePreviewPreviousDefaultChannel();
3425
+ this.previewSessionEnabled = false;
3426
+ this.previewSessionAlertPending = false;
3427
+ this.isLeavingPreviewForIncomingLink = false;
3428
+ this.implementation.previewSession = false;
3429
+ this.hidePreviewTransitionLoader("preview-session-disabled");
3430
+ this.shakeMenuEnabled = this.getConfig().getBoolean("shakeMenu", false);
3431
+ this.shakeChannelSelectorEnabled = this.getConfig().getBoolean("allowShakeChannelSelector", false);
3432
+ this.shakeMenuGesture = normalizedShakeMenuGesture(this.getConfig().getString("shakeMenuGesture", SHAKE_MENU_GESTURE_SHAKE));
3433
+ this.syncShakeMenuLifecycle();
3434
+ this.clearPreviewSessionPreferences();
3435
+ }
1302
3436
 
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);
3437
+ private void clearPreviewSessionPreferences() {
3438
+ if (this.implementation != null) {
3439
+ this.implementation.setPreviewFallbackBundle(null);
3440
+ }
3441
+ this.editor.remove(PREVIEW_SESSION_PREF_KEY);
3442
+ this.editor.remove(PREVIEW_PREVIOUS_SHAKE_MENU_PREF_KEY);
3443
+ this.editor.remove(PREVIEW_PREVIOUS_SHAKE_CHANNEL_SELECTOR_PREF_KEY);
3444
+ this.editor.remove(PREVIEW_PREVIOUS_NEXT_BUNDLE_PREF_KEY);
3445
+ this.editor.remove(PREVIEW_PREVIOUS_APP_ID_PREF_KEY);
3446
+ this.editor.remove(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_PREF_KEY);
3447
+ this.editor.remove(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY);
3448
+ this.editor.remove(PREVIEW_APP_ID_PREF_KEY);
3449
+ this.editor.remove(PREVIEW_PAYLOAD_URL_PREF_KEY);
3450
+ this.editor.remove(PREVIEW_NAME_PREF_KEY);
3451
+ this.editor.remove(PREVIEW_SOURCE_PREF_KEY);
3452
+ this.editor.remove(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY);
3453
+ this.editor.apply();
3454
+ }
3455
+
3456
+ private void setActiveAppId(final String appId) {
3457
+ this.implementation.appId = appId;
3458
+ if (this.implementation.versionOs != null) {
3459
+ DownloadService.updateUserAgent(this.implementation.appId, this.pluginVersion, this.implementation.versionOs);
1312
3460
  }
1313
3461
  }
1314
3462
 
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");
3463
+ private void restorePreviewPreviousAppId() {
3464
+ final String previousAppId = this.prefs.getString(PREVIEW_PREVIOUS_APP_ID_PREF_KEY, "");
3465
+ if (previousAppId == null || previousAppId.isEmpty()) {
1325
3466
  return;
1326
3467
  }
1327
- if (version == null) {
1328
- logger.error("Download called without version");
1329
- call.reject("Download called without version");
3468
+ this.setActiveAppId(previousAppId);
3469
+ logger.info("Restored appId after preview: " + previousAppId);
3470
+ }
3471
+
3472
+ private void restorePreviewPreviousDefaultChannel() {
3473
+ final String configDefaultChannel = this.getConfig().getString("defaultChannel", "");
3474
+ if (this.prefs.getBoolean(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY, false)) {
3475
+ final String previousDefaultChannel = this.prefs.getString(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_PREF_KEY, "");
3476
+ this.editor.putString(DEFAULT_CHANNEL_PREF_KEY, previousDefaultChannel);
3477
+ this.implementation.defaultChannel = previousDefaultChannel;
3478
+ this.editor.apply();
3479
+ logger.info("Restored defaultChannel after preview");
1330
3480
  return;
1331
3481
  }
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
- }
3482
+
3483
+ this.editor.remove(DEFAULT_CHANNEL_PREF_KEY);
3484
+ this.implementation.defaultChannel = configDefaultChannel;
3485
+ this.editor.apply();
3486
+ logger.info("Restored defaultChannel after preview to config value");
1374
3487
  }
1375
3488
 
1376
- private void syncKeepUrlPathFlag(final boolean enabled) {
1377
- if (this.bridge == null || this.bridge.getWebView() == null) {
1378
- return;
3489
+ private String normalizePreviewAppId(final String rawAppId) {
3490
+ if (rawAppId == null) {
3491
+ return null;
1379
3492
  }
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));
3493
+
3494
+ final String appId = rawAppId.trim();
3495
+ if (appId.isEmpty()) {
3496
+ return null;
3497
+ }
3498
+
3499
+ final String lowercasedAppId = appId.toLowerCase(java.util.Locale.ROOT);
3500
+ if ("undefined".equals(lowercasedAppId) || "null".equals(lowercasedAppId)) {
3501
+ return null;
3502
+ }
3503
+
3504
+ return appId;
1388
3505
  }
1389
3506
 
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);
3507
+ private boolean hasPreviewPayloadUrl(final String rawPayloadUrl) {
3508
+ if (rawPayloadUrl == null) {
3509
+ return false;
1395
3510
  }
1396
- logger.info("Reloading: " + path);
1397
3511
 
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
- });
3512
+ final String payloadUrl = rawPayloadUrl.trim();
3513
+ if (payloadUrl.isEmpty()) {
3514
+ return false;
3515
+ }
1416
3516
 
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
- }
3517
+ final String lowercasedPayloadUrl = payloadUrl.toLowerCase(java.util.Locale.ROOT);
3518
+ return !"undefined".equals(lowercasedPayloadUrl) && !"null".equals(lowercasedPayloadUrl);
3519
+ }
3520
+
3521
+ private String normalizePreviewPayloadUrl(final String rawPayloadUrl) {
3522
+ if (!this.hasPreviewPayloadUrl(rawPayloadUrl)) {
3523
+ return null;
1437
3524
  }
1438
3525
 
1439
- if (url.get() != null) {
1440
- if (usingBuiltin) {
1441
- this.bridge.getLocalServer().hostAssets(path);
1442
- } else {
1443
- this.bridge.getLocalServer().hostFiles(path);
3526
+ final String payloadUrl = rawPayloadUrl.trim();
3527
+ try {
3528
+ final URL parsedUrl = new URL(payloadUrl);
3529
+ final String protocol = parsedUrl.getProtocol();
3530
+ if (!"https".equals(protocol) && !"http".equals(protocol)) {
3531
+ return null;
1444
3532
  }
3533
+ return parsedUrl.toString();
3534
+ } catch (final MalformedURLException ignored) {
3535
+ return null;
3536
+ }
3537
+ }
1445
3538
 
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());
3539
+ private String storedPreviewPayloadUrl() {
3540
+ return this.normalizePreviewPayloadUrl(this.prefs.getString(PREVIEW_PAYLOAD_URL_PREF_KEY, null));
3541
+ }
1459
3542
 
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
- });
3543
+ private String previewPathFromUri(final Uri uri) {
3544
+ if ("capgo".equals(uri.getScheme())) {
3545
+ final String host = uri.getHost();
3546
+ final String path = uri.getPath();
3547
+ return ("/" + (host == null ? "" : host) + (path == null ? "" : path)).replaceAll("/+", "/");
3548
+ }
3549
+
3550
+ return uri.getPath();
3551
+ }
3552
+
3553
+ private boolean isPreviewDeepLink(final Uri uri) {
3554
+ final String path = this.previewPathFromUri(uri);
3555
+ return "/preview/channel".equals(path) || "/preview/bundle".equals(path);
3556
+ }
3557
+
3558
+ private String readResponseBody(final InputStream stream) throws IOException {
3559
+ if (stream == null) {
3560
+ return "";
3561
+ }
3562
+
3563
+ try (InputStream input = stream; ByteArrayOutputStream output = new ByteArrayOutputStream()) {
3564
+ final byte[] buffer = new byte[8192];
3565
+ int read;
3566
+ while ((read = input.read(buffer)) != -1) {
3567
+ output.write(buffer, 0, read);
1481
3568
  }
3569
+ return output.toString(StandardCharsets.UTF_8.name());
1482
3570
  }
1483
3571
  }
1484
3572
 
1485
- protected void restoreLiveBundleStateAfterFailedReload() {
3573
+ private JSONObject fetchPreviewPayload(final String payloadUrl) throws IOException, JSONException {
3574
+ final HttpURLConnection connection = (HttpURLConnection) new URL(payloadUrl).openConnection();
3575
+ connection.setRequestMethod("GET");
3576
+ connection.setRequestProperty("Accept", "application/json");
3577
+ connection.setConnectTimeout(30000);
3578
+ connection.setReadTimeout(60000);
3579
+
1486
3580
  try {
1487
- this.applyCurrentBundleToBridge();
1488
- } catch (final Exception e) {
1489
- logger.warn("Failed to restore live bundle after rejected reload: " + e.getMessage());
3581
+ final int statusCode = connection.getResponseCode();
3582
+ final String body = this.readResponseBody(
3583
+ statusCode >= 200 && statusCode < 300 ? connection.getInputStream() : connection.getErrorStream()
3584
+ );
3585
+ final JSONObject payload = new JSONObject(body);
3586
+ if (statusCode < 200 || statusCode >= 300) {
3587
+ throw new IOException(
3588
+ payload.optString("message", payload.optString("error", "Preview payload request failed with HTTP " + statusCode))
3589
+ );
3590
+ }
3591
+ return payload;
3592
+ } finally {
3593
+ connection.disconnect();
1490
3594
  }
1491
3595
  }
1492
3596
 
1493
- protected boolean _reload() {
1494
- final int phase = this.semaphoreUp();
1495
- this.applyCurrentBundleToBridge();
3597
+ private BundleInfo downloadPreviewPayloadBundle(final JSONObject payload) throws IOException, JSONException {
3598
+ final String version = payload.optString("version", "").trim();
3599
+ if (version.isEmpty()) {
3600
+ throw new IOException("Preview payload is missing a version");
3601
+ }
1496
3602
 
1497
- final long waitTimeMs = this.resolveAppReadyCheckTimeoutMs();
1498
- this.checkAppReady(waitTimeMs);
1499
- this.notifyListeners("appReloaded", new JSObject());
3603
+ final JSONArray manifest = payload.optJSONArray("manifest");
3604
+ final String url = payload.optString("url", "");
3605
+ if ((url == null || url.isEmpty()) && (manifest == null || manifest.length() == 0)) {
3606
+ throw new IOException("Preview payload is missing download information");
3607
+ }
1500
3608
 
1501
- // Wait for the reload to complete (until notifyAppReady is called)
1502
- return this.semaphoreWait(phase, waitTimeMs);
3609
+ return this.downloadBundle(
3610
+ url == null || url.isEmpty() ? "https://404.capgo.app/no.zip" : url,
3611
+ version,
3612
+ payload.optString("sessionKey", ""),
3613
+ payload.optString("checksum", ""),
3614
+ manifest
3615
+ );
1503
3616
  }
1504
3617
 
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();
3618
+ private boolean refreshPreviewSessionFromPayloadUrl(final String payloadUrl) {
3619
+ try {
3620
+ final JSONObject payload = this.fetchPreviewPayload(payloadUrl);
3621
+ final String version = payload.optString("version", "").trim();
3622
+ if (version.isEmpty()) {
3623
+ throw new IOException("Preview payload is missing a version");
3624
+ }
1511
3625
 
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
- }
3626
+ final BundleInfo current = this.implementation.getCurrentBundle();
3627
+ if (version.equals(current.getVersionName())) {
3628
+ logger.info("Preview payload unchanged, reloading current bundle");
3629
+ return this.reloadWithoutWaitingForAppReady();
3630
+ }
1540
3631
 
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);
3632
+ final BundleInfo next = this.downloadPreviewPayloadBundle(payload);
3633
+ if (next.isErrorStatus()) {
3634
+ throw new IOException("Download failed: " + next.getStatus());
1550
3635
  }
1551
- });
3636
+ if (!this.implementation.set(next.getId())) {
3637
+ throw new IOException("Downloaded preview bundle cannot be applied");
3638
+ }
3639
+
3640
+ this.recordPreviewBundle(next, current.getId());
3641
+ this.notifyBundleSet(next);
3642
+ return this.reloadWithoutWaitingForAppReady();
3643
+ } catch (final Exception err) {
3644
+ logger.error("Could not refresh preview session: " + err.getMessage());
3645
+ return false;
3646
+ }
1552
3647
  }
1553
3648
 
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");
3649
+ private void clearPreviewSessionForNativeBuildChange() {
3650
+ if (
3651
+ !Boolean.TRUE.equals(this.previewSessionEnabled) &&
3652
+ this.implementation.getPreviewFallbackBundle() == null &&
3653
+ !this.hasSavedPreviewSessions()
3654
+ ) {
1560
3655
  return;
1561
3656
  }
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);
3657
+ logger.info("Native build changed; clearing preview session state");
3658
+ this.previewSessionEnabled = false;
3659
+ this.previewSessionAlertPending = false;
3660
+ this.isLeavingPreviewForIncomingLink = false;
3661
+ this.implementation.previewSession = false;
3662
+ this.shakeMenuEnabled = this.getConfig().getBoolean("shakeMenu", false);
3663
+ this.shakeChannelSelectorEnabled = this.getConfig().getBoolean("allowShakeChannelSelector", false);
3664
+ this.shakeMenuGesture = normalizedShakeMenuGesture(this.getConfig().getString("shakeMenuGesture", SHAKE_MENU_GESTURE_SHAKE));
3665
+ this.syncShakeMenuLifecycle();
3666
+ this.restorePreviewPreviousAppId();
3667
+ this.restorePreviewPreviousDefaultChannel();
3668
+ this.implementation.setPreviewFallbackBundle(null);
3669
+ this.implementation.setNextBundle(null);
3670
+ this.clearPreviewSessionPreferences();
3671
+ synchronized (this.previewSessionsLock) {
3672
+ this.editor.remove(PREVIEW_SESSIONS_PREF_KEY);
3673
+ this.editor.apply();
1573
3674
  }
1574
3675
  }
1575
3676
 
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");
3677
+ private void restorePreviewPreviousNextBundle() {
3678
+ final String previousNextBundleId = this.prefs.getString(PREVIEW_PREVIOUS_NEXT_BUNDLE_PREF_KEY, null);
3679
+ if (previousNextBundleId == null || previousNextBundleId.isEmpty()) {
3680
+ this.implementation.setNextBundle(null);
1582
3681
  return;
1583
3682
  }
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();
3683
+ if (!this.implementation.setNextBundle(previousNextBundleId)) {
3684
+ logger.warn("Could not restore pre-preview next bundle: " + previousNextBundleId);
3685
+ this.implementation.setNextBundle(null);
3686
+ }
3687
+ }
3688
+
3689
+ private void ensureShakeMenuStarted() {
3690
+ if (shakeMenu != null && !shakeMenu.usesGesture(this.shakeMenuGesture)) {
3691
+ try {
3692
+ shakeMenu.stop();
3693
+ shakeMenu = null;
3694
+ logger.info("Shake menu restarted for " + this.shakeMenuGesture + " gesture");
3695
+ } catch (Exception e) {
3696
+ logger.error("Failed to restart shake menu: " + e.getMessage());
3697
+ return;
1596
3698
  }
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
3699
  }
3700
+
3701
+ if (getActivity() instanceof com.getcapacitor.BridgeActivity && shakeMenu == null) {
3702
+ try {
3703
+ shakeMenu = new ShakeMenu(this, (com.getcapacitor.BridgeActivity) getActivity(), logger, this.shakeMenuGesture);
3704
+ logger.info("Shake menu initialized with " + this.shakeMenuGesture + " gesture");
3705
+ } catch (Exception e) {
3706
+ logger.error("Failed to initialize shake menu: " + e.getMessage());
3707
+ }
3708
+ }
3709
+ }
3710
+
3711
+ private void syncShakeMenuLifecycle() {
3712
+ if (this.shouldListenForShake()) {
3713
+ this.ensureShakeMenuStarted();
3714
+ } else if (shakeMenu != null) {
3715
+ try {
3716
+ shakeMenu.stop();
3717
+ shakeMenu = null;
3718
+ logger.info("Shake menu stopped");
3719
+ } catch (Exception e) {
3720
+ logger.error("Failed to stop shake menu: " + e.getMessage());
3721
+ }
3722
+ }
3723
+ }
3724
+
3725
+ private boolean shouldListenForShake() {
3726
+ return Boolean.TRUE.equals(this.shakeMenuEnabled) || Boolean.TRUE.equals(this.shakeChannelSelectorEnabled);
3727
+ }
3728
+
3729
+ private void showPreviewSessionNoticeIfNeeded() {
3730
+ if (!Boolean.TRUE.equals(this.previewSessionEnabled) || !Boolean.TRUE.equals(this.previewSessionAlertPending)) {
3731
+ return;
3732
+ }
3733
+ this.previewSessionAlertPending = false;
3734
+ this.editor.putBoolean(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY, false);
3735
+ this.editor.apply();
3736
+
3737
+ new Handler(Looper.getMainLooper()).postDelayed(() -> {
3738
+ try {
3739
+ if (!Boolean.TRUE.equals(this.previewSessionEnabled)) {
3740
+ return;
3741
+ }
3742
+ if (getActivity() == null || getActivity().isFinishing()) {
3743
+ this.previewSessionAlertPending = true;
3744
+ this.editor.putBoolean(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY, true);
3745
+ this.editor.apply();
3746
+ return;
3747
+ }
3748
+
3749
+ new AlertDialog.Builder(getActivity())
3750
+ .setTitle("Preview started")
3751
+ .setMessage("Shake your device anytime to reload or leave the test app.")
3752
+ .setPositiveButton("Got it", (dialog, which) -> dialog.dismiss())
3753
+ .show();
3754
+ } catch (final Exception e) {
3755
+ this.previewSessionAlertPending = true;
3756
+ this.editor.putBoolean(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY, true);
3757
+ this.editor.apply();
3758
+ logger.warn("Could not show preview session notice: " + e.getMessage());
3759
+ }
3760
+ }, 600);
1601
3761
  }
1602
3762
 
1603
3763
  @PluginMethod
@@ -1681,33 +3841,146 @@ public class CapacitorUpdaterPlugin extends Plugin {
1681
3841
  @PluginMethod
1682
3842
  public void getLatest(final PluginCall call) {
1683
3843
  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;
3844
+ final boolean includeBundleSize = call.getBoolean("includeBundleSize", false);
3845
+ final String previewAppId = this.normalizePreviewAppId(call.getString("appId"));
3846
+ final boolean hasPreviewAppId = previewAppId != null;
3847
+ if (hasPreviewAppId && !Boolean.TRUE.equals(this.allowPreview)) {
3848
+ logger.error("getLatest preview override not allowed set allowPreview in your config to true to enable it");
3849
+ call.reject("getLatest preview override not allowed");
3850
+ return;
3851
+ }
3852
+
3853
+ final Callback latestCallback = (res) -> {
3854
+ JSObject jsRes = InternalUtils.mapToJSObject(res);
3855
+ if (jsRes.has("error") || jsRes.has("kind")) {
3856
+ String error = jsRes.has("error") ? jsRes.getString("error") : "";
3857
+ String errorMessage = jsRes.has("message") ? jsRes.getString("message") : "server did not provide a message";
3858
+ String kind = CapacitorUpdaterPlugin.this.getUpdateResponseKind(jsRes.has("kind") ? jsRes.getString("kind") : null);
3859
+ String latestVersion = jsRes.has("version") ? jsRes.getString("version") : "";
3860
+ jsRes.put("kind", kind);
3861
+ CapacitorUpdaterPlugin.this.notifyBreakingEventsIfNeeded(jsRes, latestVersion);
3862
+ if ("failed".equals(kind)) {
3863
+ logger.error("getLatest failed with error: " + error + ", message: " + errorMessage);
3864
+ call.reject(error.isEmpty() ? errorMessage : error);
1706
3865
  } else {
3866
+ if (!jsRes.has("version") || jsRes.getString("version").isEmpty()) {
3867
+ jsRes.put("version", CapacitorUpdaterPlugin.this.implementation.getCurrentBundle().getVersionName());
3868
+ }
3869
+ logger.info("getLatest returned " + kind + ": " + errorMessage);
1707
3870
  call.resolve(jsRes);
1708
3871
  }
1709
- })
1710
- );
3872
+ return;
3873
+ } else if (jsRes.has("message")) {
3874
+ String latestVersion = jsRes.has("version") ? jsRes.getString("version") : "";
3875
+ CapacitorUpdaterPlugin.this.notifyBreakingEventsIfNeeded(jsRes, latestVersion);
3876
+ call.reject(jsRes.getString("message"));
3877
+ return;
3878
+ } else {
3879
+ if (includeBundleSize) {
3880
+ CapacitorUpdaterPlugin.this.attachBundleSize(jsRes);
3881
+ }
3882
+ call.resolve(jsRes);
3883
+ }
3884
+ };
3885
+
3886
+ startNewThread(() -> {
3887
+ if (hasPreviewAppId) {
3888
+ CapacitorUpdaterPlugin.this.implementation.getLatest(
3889
+ CapacitorUpdaterPlugin.this.updateUrl,
3890
+ channel,
3891
+ previewAppId,
3892
+ latestCallback
3893
+ );
3894
+ return;
3895
+ }
3896
+ CapacitorUpdaterPlugin.this.implementation.getLatest(CapacitorUpdaterPlugin.this.updateUrl, channel, latestCallback);
3897
+ });
3898
+ }
3899
+
3900
+ private void attachBundleSize(final JSObject latest) {
3901
+ try {
3902
+ final JSONArray manifest = latest.optJSONArray("manifest");
3903
+ if (manifest == null || manifest.length() == 0) {
3904
+ return;
3905
+ }
3906
+ final String sessionKey = latest.optString("sessionKey", "");
3907
+ final JSONObject missing = this.implementation.missingBundleFilesResult(manifest, sessionKey);
3908
+ final JSONArray missingManifest = missing.getJSONArray("missing");
3909
+ final JSONObject size = this.implementation.getBundleDownloadSize(
3910
+ this.updateUrl,
3911
+ latest.optString("version", ""),
3912
+ missingManifest
3913
+ );
3914
+ latest.put("missing", missing);
3915
+ latest.put("downloadSize", size);
3916
+ } catch (Exception e) {
3917
+ logger.error("Failed to attach bundle size to getLatest result");
3918
+ logger.debug("Error: " + e.getMessage());
3919
+ }
3920
+ }
3921
+
3922
+ @PluginMethod
3923
+ public void getMissingBundleFiles(final PluginCall call) {
3924
+ final JSONArray manifest = call.getData().optJSONArray("manifest");
3925
+ if (manifest == null) {
3926
+ call.reject("getMissingBundleFiles called without manifest");
3927
+ return;
3928
+ }
3929
+ String sessionKey = call.getString("sessionKey");
3930
+ if (sessionKey == null) {
3931
+ sessionKey = "";
3932
+ }
3933
+ final String finalSessionKey = sessionKey;
3934
+ startNewThread(() -> {
3935
+ try {
3936
+ call.resolve(jsonObjectToJSObject(this.implementation.missingBundleFilesResult(manifest, finalSessionKey)));
3937
+ } catch (Exception e) {
3938
+ call.reject("Could not get missing bundle files", e);
3939
+ }
3940
+ });
3941
+ }
3942
+
3943
+ @PluginMethod
3944
+ public void getBundleDownloadSize(final PluginCall call) {
3945
+ final JSONArray manifest = call.getData().optJSONArray("manifest");
3946
+ if (manifest == null) {
3947
+ call.reject("getBundleDownloadSize called without manifest");
3948
+ return;
3949
+ }
3950
+ final String version = call.getData().optString("version", "");
3951
+ startNewThread(() -> {
3952
+ try {
3953
+ final JSONObject size = this.implementation.getBundleDownloadSize(this.updateUrl, version, manifest);
3954
+ call.resolve(jsonObjectToJSObject(size));
3955
+ } catch (Exception e) {
3956
+ call.reject("Could not get bundle download size", e);
3957
+ }
3958
+ });
3959
+ }
3960
+
3961
+ public String triggerBackgroundUpdateCheck() {
3962
+ if (this.updateUrl == null || this.updateUrl.isEmpty() || !this.isValidURL(this.updateUrl)) {
3963
+ logger.error("Error no url or wrong format");
3964
+ return "unavailable";
3965
+ }
3966
+ if (this.shouldBlockAutoUpdateForPreviewSession()) {
3967
+ return "preview_session";
3968
+ }
3969
+ if (this.isDownloadStuckOrTimedOut()) {
3970
+ logger.info("Download already in progress, skipping duplicate download request");
3971
+ return "already_running";
3972
+ }
3973
+ this.backgroundDownload();
3974
+ return "queued";
3975
+ }
3976
+
3977
+ @PluginMethod
3978
+ public void triggerUpdateCheck(final PluginCall call) {
3979
+ final String status = this.triggerBackgroundUpdateCheck();
3980
+ final JSObject ret = new JSObject();
3981
+ ret.put("status", status);
3982
+ ret.put("queued", "queued".equals(status));
3983
+ call.resolve(ret);
1711
3984
  }
1712
3985
 
1713
3986
  private boolean _reset(final Boolean toLastSuccessful, final Boolean usePendingBundle) {
@@ -1783,19 +4056,21 @@ public class CapacitorUpdaterPlugin extends Plugin {
1783
4056
 
1784
4057
  @PluginMethod
1785
4058
  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;
4059
+ startNewThread(() -> {
4060
+ try {
4061
+ final Boolean toLastSuccessful = call.getBoolean("toLastSuccessful", false);
4062
+ final Boolean usePendingBundle = call.getBoolean("usePendingBundle", false);
4063
+ if (this._reset(toLastSuccessful, usePendingBundle)) {
4064
+ call.resolve();
4065
+ return;
4066
+ }
4067
+ logger.error("Reset failed");
4068
+ call.reject("Reset failed");
4069
+ } catch (final Exception e) {
4070
+ logger.error("Reset failed " + e.getMessage());
4071
+ call.reject("Reset failed", e);
1792
4072
  }
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
- }
4073
+ });
1799
4074
  }
1800
4075
 
1801
4076
  @PluginMethod
@@ -1859,7 +4134,13 @@ public class CapacitorUpdaterPlugin extends Plugin {
1859
4134
  @Override
1860
4135
  public void run() {
1861
4136
  try {
4137
+ if (CapacitorUpdaterPlugin.this.shouldBlockAutoUpdateForPreviewSession()) {
4138
+ return;
4139
+ }
1862
4140
  CapacitorUpdaterPlugin.this.implementation.getLatest(CapacitorUpdaterPlugin.this.updateUrl, null, (res) -> {
4141
+ if (CapacitorUpdaterPlugin.this.shouldBlockAutoUpdateForPreviewSession()) {
4142
+ return;
4143
+ }
1863
4144
  JSObject jsRes = InternalUtils.mapToJSObject(res);
1864
4145
  if (jsRes.has("error") || jsRes.has("kind")) {
1865
4146
  final BundleInfo current = CapacitorUpdaterPlugin.this.implementation.getCurrentBundle();
@@ -1922,6 +4203,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
1922
4203
  logger.info("semaphoreReady countDown");
1923
4204
  this.semaphoreDown();
1924
4205
  logger.info("semaphoreReady countDown done");
4206
+ this.clearIncomingPreviewTransition();
4207
+ this.hidePreviewTransitionLoader("notify-app-ready");
1925
4208
  final JSObject ret = new JSObject();
1926
4209
  ret.put("bundle", InternalUtils.mapToJSObject(bundle.toJSONMap()));
1927
4210
  call.resolve(ret);
@@ -1969,6 +4252,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
1969
4252
  }
1970
4253
 
1971
4254
  private Boolean _isAutoUpdateEnabled() {
4255
+ if (this.isPreviewSessionStateActive()) {
4256
+ return false;
4257
+ }
1972
4258
  final CapConfig config = CapConfig.loadDefault(this.getActivity());
1973
4259
  String serverUrl = config.getServerUrl();
1974
4260
  if (serverUrl != null && !serverUrl.isEmpty()) {
@@ -2012,10 +4298,22 @@ public class CapacitorUpdaterPlugin extends Plugin {
2012
4298
  this.checkAppReady(this.resolveAppReadyCheckTimeoutMs());
2013
4299
  }
2014
4300
 
4301
+ synchronized boolean shouldInterruptAppReadyCheck(final Thread existingCheck, final Thread currentThread) {
4302
+ return existingCheck != null && existingCheck != currentThread;
4303
+ }
4304
+
4305
+ synchronized void clearAppReadyCheckIfCurrent(final Thread expectedThread) {
4306
+ if (this.appReadyCheck == expectedThread) {
4307
+ this.appReadyCheck = null;
4308
+ }
4309
+ }
4310
+
2015
4311
  private void checkAppReady(final long waitTimeMs) {
2016
4312
  try {
2017
- if (this.appReadyCheck != null) {
2018
- this.appReadyCheck.interrupt();
4313
+ final Thread currentThread = Thread.currentThread();
4314
+ final Thread existingCheck = this.appReadyCheck;
4315
+ if (this.shouldInterruptAppReadyCheck(existingCheck, currentThread)) {
4316
+ existingCheck.interrupt();
2019
4317
  }
2020
4318
  this.appReadyCheck = startNewThread(new DeferredNotifyAppReadyCheck(waitTimeMs));
2021
4319
  } catch (final Exception e) {
@@ -2032,13 +4330,17 @@ public class CapacitorUpdaterPlugin extends Plugin {
2032
4330
  }
2033
4331
  }
2034
4332
 
2035
- private String getUpdateResponseKind(final String kind) {
4333
+ static String normalizedUpdateResponseKind(final String kind) {
2036
4334
  if ("up_to_date".equals(kind) || "blocked".equals(kind) || "failed".equals(kind)) {
2037
4335
  return kind;
2038
4336
  }
2039
4337
  return "failed";
2040
4338
  }
2041
4339
 
4340
+ private String getUpdateResponseKind(final String kind) {
4341
+ return normalizedUpdateResponseKind(kind);
4342
+ }
4343
+
2042
4344
  private void notifyUpdateCheckResult(
2043
4345
  final String kind,
2044
4346
  final String error,
@@ -2098,6 +4400,30 @@ public class CapacitorUpdaterPlugin extends Plugin {
2098
4400
  String failureAction,
2099
4401
  String failureEvent,
2100
4402
  boolean shouldSendStats
4403
+ ) {
4404
+ endBackGroundTaskWithNotif(
4405
+ msg,
4406
+ latestVersionName,
4407
+ current,
4408
+ error,
4409
+ plannedDirectUpdate,
4410
+ failureAction,
4411
+ failureEvent,
4412
+ shouldSendStats,
4413
+ true
4414
+ );
4415
+ }
4416
+
4417
+ private void endBackGroundTaskWithNotif(
4418
+ String msg,
4419
+ String latestVersionName,
4420
+ BundleInfo current,
4421
+ Boolean error,
4422
+ Boolean plannedDirectUpdate,
4423
+ String failureAction,
4424
+ String failureEvent,
4425
+ boolean shouldSendStats,
4426
+ boolean shouldNotifyNoNeedUpdate
2101
4427
  ) {
2102
4428
  this.consumeOnLaunchDirectUpdateAttempt(Boolean.TRUE.equals(plannedDirectUpdate));
2103
4429
  if (error) {
@@ -2116,15 +4442,22 @@ public class CapacitorUpdaterPlugin extends Plugin {
2116
4442
  ret.put("version", latestVersionName);
2117
4443
  this.notifyListeners(failureEvent, ret);
2118
4444
  }
2119
- final JSObject ret = new JSObject();
2120
- ret.put("bundle", InternalUtils.mapToJSObject(current.toJSONMap()));
2121
- this.notifyListeners("noNeedUpdate", ret);
4445
+ if (shouldNotifyNoNeedUpdate) {
4446
+ final JSObject ret = new JSObject();
4447
+ ret.put("bundle", InternalUtils.mapToJSObject(current.toJSONMap()));
4448
+ this.notifyListeners("noNeedUpdate", ret);
4449
+ }
2122
4450
  this.sendReadyToJs(current, msg, plannedDirectUpdate);
2123
4451
  this.backgroundDownloadTask = null;
2124
4452
  this.downloadStartTimeMs = 0;
2125
4453
  logger.info("endBackGroundTaskWithNotif " + msg);
2126
4454
  }
2127
4455
 
4456
+ private void clearBackgroundDownloadState() {
4457
+ this.backgroundDownloadTask = null;
4458
+ this.downloadStartTimeMs = 0;
4459
+ }
4460
+
2128
4461
  private boolean isDownloadStuckOrTimedOut() {
2129
4462
  if (this.backgroundDownloadTask == null || !this.backgroundDownloadTask.isAlive()) {
2130
4463
  return false;
@@ -2151,17 +4484,30 @@ public class CapacitorUpdaterPlugin extends Plugin {
2151
4484
  }
2152
4485
 
2153
4486
  private Thread backgroundDownload() {
4487
+ if (this.shouldBlockAutoUpdateForPreviewSession()) {
4488
+ return null;
4489
+ }
2154
4490
  final boolean plannedDirectUpdate = this.shouldUseDirectUpdate();
2155
4491
  final boolean initialDirectUpdateAllowed = this.isDirectUpdateCurrentlyAllowed(plannedDirectUpdate);
2156
4492
  final String messageUpdate = initialDirectUpdateAllowed
2157
4493
  ? "Update will occur now."
2158
- : "Update will occur next time app moves to background.";
4494
+ : this.shouldAutoSetNextBundle()
4495
+ ? "Update will occur next time app moves to background."
4496
+ : "Update will be downloaded and made available.";
2159
4497
  Thread newTask = startNewThread(() -> {
2160
4498
  // Wait for cleanup to complete before starting download
2161
4499
  waitForCleanupIfNeeded();
4500
+ if (CapacitorUpdaterPlugin.this.shouldBlockAutoUpdateForPreviewSession()) {
4501
+ CapacitorUpdaterPlugin.this.clearBackgroundDownloadState();
4502
+ return;
4503
+ }
2162
4504
  logger.info("Check for update via: " + CapacitorUpdaterPlugin.this.updateUrl);
2163
4505
  try {
2164
4506
  CapacitorUpdaterPlugin.this.implementation.getLatest(CapacitorUpdaterPlugin.this.updateUrl, null, (res) -> {
4507
+ if (CapacitorUpdaterPlugin.this.shouldBlockAutoUpdateForPreviewSession()) {
4508
+ CapacitorUpdaterPlugin.this.clearBackgroundDownloadState();
4509
+ return;
4510
+ }
2165
4511
  JSObject jsRes = InternalUtils.mapToJSObject(res);
2166
4512
  final BundleInfo current = CapacitorUpdaterPlugin.this.implementation.getCurrentBundle();
2167
4513
 
@@ -2173,6 +4519,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
2173
4519
  String kind = CapacitorUpdaterPlugin.this.getUpdateResponseKind(jsRes.has("kind") ? jsRes.getString("kind") : null);
2174
4520
  String latestVersion = jsRes.has("version") ? jsRes.getString("version") : current.getVersionName();
2175
4521
  CapacitorUpdaterPlugin.this.notifyUpdateCheckResult(kind, error, errorMessage, statusCode, latestVersion, current);
4522
+ CapacitorUpdaterPlugin.this.notifyBreakingEventsIfNeeded(
4523
+ jsRes,
4524
+ jsRes.has("version") ? jsRes.getString("version") : ""
4525
+ );
2176
4526
 
2177
4527
  if ("up_to_date".equals(kind)) {
2178
4528
  logger.info("No new version available");
@@ -2215,7 +4565,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2215
4565
  false,
2216
4566
  true
2217
4567
  );
2218
- } else {
4568
+ } else if (CapacitorUpdaterPlugin.this.shouldAutoSetNextBundle()) {
2219
4569
  if (plannedDirectUpdate && !directUpdateAllowedNow) {
2220
4570
  logger.info(
2221
4571
  "Direct update skipped because splashscreen timeout occurred. Update will be applied later."
@@ -2230,11 +4580,34 @@ public class CapacitorUpdaterPlugin extends Plugin {
2230
4580
  false,
2231
4581
  plannedDirectUpdate
2232
4582
  );
4583
+ } else {
4584
+ logger.info("autoUpdate is set to onlyDownload, builtin version will not be set as next bundle");
4585
+ final boolean builtinUpdateAvailable = !current.isBuiltin();
4586
+ if (builtinUpdateAvailable) {
4587
+ final JSObject ret = new JSObject();
4588
+ final BundleInfo builtinBundle = CapacitorUpdaterPlugin.this.implementation.getBundleInfo(
4589
+ BundleInfo.ID_BUILTIN
4590
+ );
4591
+ ret.put("bundle", InternalUtils.mapToJSObject(builtinBundle.toJSONMap()));
4592
+ CapacitorUpdaterPlugin.this.notifyListeners("updateAvailable", ret);
4593
+ }
4594
+ CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
4595
+ "Latest version is builtin, autoUpdate onlyDownload",
4596
+ latestVersionName,
4597
+ current,
4598
+ false,
4599
+ plannedDirectUpdate,
4600
+ "download_fail",
4601
+ "downloadFailed",
4602
+ true,
4603
+ !builtinUpdateAvailable
4604
+ );
2233
4605
  }
2234
4606
  return;
2235
4607
  }
2236
4608
 
2237
4609
  if (!jsRes.has("url") || !CapacitorUpdaterPlugin.this.isValidURL(jsRes.getString("url"))) {
4610
+ CapacitorUpdaterPlugin.this.notifyBreakingEventsIfNeeded(jsRes, latestVersionName);
2238
4611
  logger.error("Error no url or wrong format");
2239
4612
  CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
2240
4613
  "Error no url or wrong format",
@@ -2305,7 +4678,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2305
4678
  true
2306
4679
  );
2307
4680
  }
2308
- } else {
4681
+ } else if (CapacitorUpdaterPlugin.this.shouldAutoSetNextBundle()) {
2309
4682
  if (plannedDirectUpdate && !directUpdateAllowedNow) {
2310
4683
  logger.info(
2311
4684
  "Direct update skipped because splashscreen timeout occurred. Update will install on next background."
@@ -2320,6 +4693,20 @@ public class CapacitorUpdaterPlugin extends Plugin {
2320
4693
  false,
2321
4694
  plannedDirectUpdate
2322
4695
  );
4696
+ } else {
4697
+ logger.info("autoUpdate is set to onlyDownload, downloaded update will not be set as next bundle");
4698
+ CapacitorUpdaterPlugin.this.notifyListeners("updateAvailable", ret);
4699
+ CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
4700
+ "update downloaded, autoUpdate onlyDownload",
4701
+ latestVersionName,
4702
+ current,
4703
+ false,
4704
+ plannedDirectUpdate,
4705
+ "download_fail",
4706
+ "downloadFailed",
4707
+ true,
4708
+ false
4709
+ );
2323
4710
  }
2324
4711
  return;
2325
4712
  }
@@ -2345,6 +4732,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
2345
4732
  : initialDirectUpdateAllowed;
2346
4733
  startNewThread(() -> {
2347
4734
  try {
4735
+ if (CapacitorUpdaterPlugin.this.shouldBlockAutoUpdateForPreviewSession()) {
4736
+ CapacitorUpdaterPlugin.this.clearBackgroundDownloadState();
4737
+ return;
4738
+ }
2348
4739
  logger.info(
2349
4740
  "New bundle: " +
2350
4741
  latestVersionName +
@@ -2366,7 +4757,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
2366
4757
  latestVersionName,
2367
4758
  sessionKey,
2368
4759
  checksum,
2369
- manifest
4760
+ manifest,
4761
+ CapacitorUpdaterPlugin.this.shouldAutoSetNextBundle()
2370
4762
  );
2371
4763
  } else {
2372
4764
  // Handle single file download (existing code)
@@ -2375,7 +4767,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
2375
4767
  latestVersionName,
2376
4768
  sessionKey,
2377
4769
  checksum,
2378
- null
4770
+ null,
4771
+ CapacitorUpdaterPlugin.this.shouldAutoSetNextBundle()
2379
4772
  );
2380
4773
  }
2381
4774
  } catch (final Exception e) {
@@ -2429,6 +4822,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
2429
4822
 
2430
4823
  private void installNext() {
2431
4824
  try {
4825
+ if (this.shouldBlockAutoUpdateForPreviewSession()) {
4826
+ return;
4827
+ }
2432
4828
  String delayUpdatePreferences = prefs.getString(DelayUpdateUtils.DELAY_CONDITION_PREFERENCES, "[]");
2433
4829
  ArrayList<DelayCondition> delayConditionList = delayUpdateUtils.parseDelayConditions(delayUpdatePreferences);
2434
4830
  if (!delayConditionList.isEmpty()) {
@@ -2464,6 +4860,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
2464
4860
  logger.info("Built-in bundle is active. We skip the check for notifyAppReady.");
2465
4861
  return;
2466
4862
  }
4863
+ if (this.isPreviewSessionStateActive()) {
4864
+ logger.info("Preview session is active. We skip the check for notifyAppReady.");
4865
+ return;
4866
+ }
2467
4867
  logger.debug("Current bundle is: " + current);
2468
4868
 
2469
4869
  if (BundleStatus.SUCCESS != current.getStatus()) {
@@ -2502,12 +4902,14 @@ public class CapacitorUpdaterPlugin extends Plugin {
2502
4902
 
2503
4903
  @Override
2504
4904
  public void run() {
4905
+ final Thread currentThread = Thread.currentThread();
2505
4906
  try {
2506
4907
  logger.info("Wait for " + this.waitTimeMs + "ms, then check for notifyAppReady");
2507
4908
  Thread.sleep(this.waitTimeMs);
2508
4909
  CapacitorUpdaterPlugin.this.checkRevert();
2509
- CapacitorUpdaterPlugin.this.appReadyCheck = null;
4910
+ CapacitorUpdaterPlugin.this.clearAppReadyCheckIfCurrent(currentThread);
2510
4911
  } catch (final InterruptedException e) {
4912
+ CapacitorUpdaterPlugin.this.clearAppReadyCheckIfCurrent(currentThread);
2511
4913
  logger.info(DeferredNotifyAppReadyCheck.class.getName() + " was interrupted.");
2512
4914
  }
2513
4915
  }
@@ -2636,6 +5038,36 @@ public class CapacitorUpdaterPlugin extends Plugin {
2636
5038
  }
2637
5039
  }
2638
5040
 
5041
+ @Override
5042
+ protected void handleOnNewIntent(Intent intent) {
5043
+ super.handleOnNewIntent(intent);
5044
+ if (
5045
+ intent == null ||
5046
+ !Intent.ACTION_VIEW.equals(intent.getAction()) ||
5047
+ intent.getData() == null ||
5048
+ !Boolean.TRUE.equals(this.previewSessionEnabled) ||
5049
+ !isPreviewDeepLink(intent.getData()) ||
5050
+ Boolean.TRUE.equals(this.isLeavingPreviewForIncomingLink)
5051
+ ) {
5052
+ return;
5053
+ }
5054
+
5055
+ this.isLeavingPreviewForIncomingLink = true;
5056
+ this.showPreviewTransitionLoader("incoming-preview-deeplink");
5057
+ if (getActivity() != null) {
5058
+ getActivity().setIntent(intent);
5059
+ }
5060
+ logger.info("Preview deeplink received while preview session is active; restoring fallback before routing");
5061
+ startNewThread(() -> {
5062
+ final boolean didLeave = this.leavePreviewSessionForIncomingPreviewLink();
5063
+ if (!didLeave) {
5064
+ logger.error("Could not leave preview session before routing incoming preview deeplink");
5065
+ this.isLeavingPreviewForIncomingLink = false;
5066
+ this.hidePreviewTransitionLoader("incoming-preview-deeplink-failed");
5067
+ }
5068
+ });
5069
+ }
5070
+
2639
5071
  @Override
2640
5072
  public void handleOnStart() {
2641
5073
  try {
@@ -2651,15 +5083,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2651
5083
  isPreviousMainActivity = true;
2652
5084
  }
2653
5085
 
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
- }
5086
+ this.syncShakeMenuLifecycle();
2663
5087
  } catch (Exception e) {
2664
5088
  logger.error("Failed to run handleOnStart: " + e.getMessage());
2665
5089
  }
@@ -2691,6 +5115,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2691
5115
  backgroundTask.interrupt();
2692
5116
  }
2693
5117
  this.implementation.activity = getActivity();
5118
+ this.syncShakeMenuLifecycle();
2694
5119
  } catch (Exception e) {
2695
5120
  logger.error("Failed to run handleOnResume: " + e.getMessage());
2696
5121
  }
@@ -2715,25 +5140,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
2715
5140
  }
2716
5141
 
2717
5142
  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
- }
5143
+ logger.info("Shake menu " + (enabled ? "enabled" : "disabled") + " with " + this.shakeMenuGesture + " gesture");
5144
+ this.syncShakeMenuLifecycle();
2737
5145
 
2738
5146
  call.resolve();
2739
5147
  }
@@ -2743,6 +5151,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2743
5151
  try {
2744
5152
  final JSObject ret = new JSObject();
2745
5153
  ret.put("enabled", this.shakeMenuEnabled);
5154
+ ret.put("gesture", this.shakeMenuGesture);
2746
5155
  call.resolve(ret);
2747
5156
  } catch (final Exception e) {
2748
5157
  logger.error("Could not get shake menu status " + e.getMessage());
@@ -2761,6 +5170,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2761
5170
 
2762
5171
  this.shakeChannelSelectorEnabled = enabled;
2763
5172
  logger.info("Shake channel selector " + (enabled ? "enabled" : "disabled"));
5173
+ this.syncShakeMenuLifecycle();
2764
5174
  call.resolve();
2765
5175
  }
2766
5176
 
@@ -2801,7 +5211,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2801
5211
  call.reject("setAppId called without appId");
2802
5212
  return;
2803
5213
  }
2804
- this.implementation.appId = appId;
5214
+ this.setActiveAppId(appId);
2805
5215
  call.resolve();
2806
5216
  }
2807
5217
 
@@ -3139,6 +5549,11 @@ public class CapacitorUpdaterPlugin extends Plugin {
3139
5549
  logger.error("Failed to clean up AppLifecycleObserver: " + e.getMessage());
3140
5550
  }
3141
5551
  }
5552
+
5553
+ if (webViewStatsListener != null && bridge != null) {
5554
+ bridge.removeWebViewListener(webViewStatsListener);
5555
+ webViewStatsListener = null;
5556
+ }
3142
5557
  } catch (Exception e) {
3143
5558
  logger.error("Failed to run handleOnDestroy: " + e.getMessage());
3144
5559
  }