@capgo/capacitor-updater 8.48.0 → 8.49.0

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.
@@ -56,6 +56,7 @@ import java.net.HttpURLConnection;
56
56
  import java.net.MalformedURLException;
57
57
  import java.net.URL;
58
58
  import java.nio.charset.StandardCharsets;
59
+ import java.text.SimpleDateFormat;
59
60
  import java.util.ArrayList;
60
61
  import java.util.Date;
61
62
  import java.util.HashMap;
@@ -64,6 +65,7 @@ import java.util.List;
64
65
  import java.util.Map;
65
66
  import java.util.Objects;
66
67
  import java.util.Set;
68
+ import java.util.TimeZone;
67
69
  import java.util.Timer;
68
70
  import java.util.TimerTask;
69
71
  import java.util.concurrent.Phaser;
@@ -109,6 +111,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
109
111
  private static final String PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY = "CapacitorUpdater.previewPreviousDefaultChannelWasSet";
110
112
  private static final String PREVIEW_APP_ID_PREF_KEY = "CapacitorUpdater.previewAppId";
111
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";
112
117
  private static final String PREVIEW_SESSION_ALERT_PENDING_PREF_KEY = "CapacitorUpdater.previewSessionAlertPending";
113
118
  private static final String[] BREAKING_EVENT_NAMES = { "breakingAvailable", "majorAvailable" };
114
119
  private static final String LAST_FAILED_BUNDLE_PREF_KEY = "CapacitorUpdater.lastFailedBundle";
@@ -132,11 +137,12 @@ public class CapacitorUpdaterPlugin extends Plugin {
132
137
  static final int APPLICATION_EXIT_REASON_USER_REQUESTED = 10;
133
138
  static final int APPLICATION_EXIT_REASON_DEPENDENCY_DIED = 12;
134
139
 
135
- private final String pluginVersion = "8.48.0";
140
+ private final String pluginVersion = "8.49.0";
136
141
  private static final String DELAY_CONDITION_PREFERENCES = "";
137
142
 
138
143
  private SharedPreferences.Editor editor;
139
144
  private SharedPreferences prefs;
145
+ private final Object previewSessionsLock = new Object();
140
146
  protected CapgoUpdater implementation;
141
147
  private Boolean persistCustomId = false;
142
148
  private Boolean persistModifyUrl = false;
@@ -338,6 +344,296 @@ public class CapacitorUpdaterPlugin extends Plugin {
338
344
  }
339
345
  }
340
346
 
347
+ private String nowIsoString() {
348
+ final SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US);
349
+ formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
350
+ return formatter.format(new Date());
351
+ }
352
+
353
+ private String normalizedPreviewMetadataValue(final String rawValue) {
354
+ if (rawValue == null) {
355
+ return null;
356
+ }
357
+
358
+ final String value = rawValue.trim();
359
+ if (value.isEmpty()) {
360
+ return null;
361
+ }
362
+
363
+ final String lowercased = value.toLowerCase(java.util.Locale.ROOT);
364
+ if ("undefined".equals(lowercased) || "null".equals(lowercased)) {
365
+ return null;
366
+ }
367
+
368
+ return value;
369
+ }
370
+
371
+ private JSONObject previewSessionsJson() {
372
+ final String raw = this.prefs == null ? null : this.prefs.getString(PREVIEW_SESSIONS_PREF_KEY, null);
373
+ if (raw == null || raw.trim().isEmpty()) {
374
+ return new JSONObject();
375
+ }
376
+ try {
377
+ return new JSONObject(raw);
378
+ } catch (final JSONException e) {
379
+ logger.warn("Could not parse preview sessions, clearing them: " + e.getMessage());
380
+ this.editor.remove(PREVIEW_SESSIONS_PREF_KEY).apply();
381
+ return new JSONObject();
382
+ }
383
+ }
384
+
385
+ private void savePreviewSessionsJson(final JSONObject sessions) {
386
+ this.editor.putString(PREVIEW_SESSIONS_PREF_KEY, sessions.toString()).apply();
387
+ }
388
+
389
+ private boolean hasSavedPreviewSessions() {
390
+ synchronized (this.previewSessionsLock) {
391
+ return this.previewSessionsJson().length() > 0;
392
+ }
393
+ }
394
+
395
+ private String metadataString(final JSONObject metadata, final String key) {
396
+ return this.normalizedPreviewMetadataValue(metadata.optString(key, null));
397
+ }
398
+
399
+ private String currentPreviewMetadataValue(final String key) {
400
+ return this.normalizedPreviewMetadataValue(this.prefs.getString(key, null));
401
+ }
402
+
403
+ private Set<String> availableBundleIds() {
404
+ final Set<String> ids = new HashSet<>();
405
+ for (final BundleInfo bundle : this.implementation.list(false)) {
406
+ ids.add(bundle.getId());
407
+ }
408
+ return ids;
409
+ }
410
+
411
+ private JSObject previewInfo(
412
+ final String id,
413
+ final JSONObject metadata,
414
+ final Set<String> availableBundleIds,
415
+ final String currentBundleId
416
+ ) throws JSONException {
417
+ final BundleInfo bundle = this.implementation.getBundleInfo(id);
418
+ if (!bundle.isBuiltin() && !availableBundleIds.contains(id)) {
419
+ return null;
420
+ }
421
+ if (bundle.isDeleted() || bundle.isErrorStatus()) {
422
+ return null;
423
+ }
424
+
425
+ final String now = this.nowIsoString();
426
+ final JSObject info = new JSObject();
427
+ info.put("id", id);
428
+ info.put("bundle", InternalUtils.mapToJSObject(bundle.toJSONMap()));
429
+ info.put("createdAt", Objects.requireNonNullElse(this.metadataString(metadata, "createdAt"), now));
430
+ info.put("updatedAt", Objects.requireNonNullElse(this.metadataString(metadata, "updatedAt"), now));
431
+ info.put("lastUsedAt", Objects.requireNonNullElse(this.metadataString(metadata, "lastUsedAt"), now));
432
+ info.put("isActive", Boolean.TRUE.equals(this.previewSessionEnabled) && id.equals(currentBundleId));
433
+
434
+ for (final String key : new String[] { "name", "source", "appId", "payloadUrl" }) {
435
+ final String value = this.metadataString(metadata, key);
436
+ if (value != null) {
437
+ info.put(key, value);
438
+ }
439
+ }
440
+
441
+ return info;
442
+ }
443
+
444
+ private JSArray listPreviewInfos(final boolean cleanup) {
445
+ synchronized (this.previewSessionsLock) {
446
+ final JSONObject sessions = this.previewSessionsJson();
447
+ final Set<String> availableBundleIds = this.availableBundleIds();
448
+ final String currentBundleId = this.implementation.getCurrentBundle().getId();
449
+ final JSArray previews = new JSArray();
450
+ final List<String> staleIds = new ArrayList<>();
451
+
452
+ final JSONArray names = sessions.names();
453
+ if (names == null) {
454
+ return previews;
455
+ }
456
+
457
+ final List<JSObject> sortedPreviews = new ArrayList<>();
458
+ for (int i = 0; i < names.length(); i++) {
459
+ final String id = names.optString(i, "");
460
+ if (id.isEmpty()) {
461
+ continue;
462
+ }
463
+ final JSONObject metadata = sessions.optJSONObject(id);
464
+ if (metadata == null) {
465
+ staleIds.add(id);
466
+ continue;
467
+ }
468
+ try {
469
+ final JSObject info = this.previewInfo(id, metadata, availableBundleIds, currentBundleId);
470
+ if (info == null) {
471
+ staleIds.add(id);
472
+ } else {
473
+ sortedPreviews.add(info);
474
+ }
475
+ } catch (final JSONException e) {
476
+ logger.warn("Could not read preview metadata for " + id + ": " + e.getMessage());
477
+ staleIds.add(id);
478
+ }
479
+ }
480
+
481
+ sortedPreviews.sort((first, second) -> second.optString("lastUsedAt", "").compareTo(first.optString("lastUsedAt", "")));
482
+ for (final JSObject preview : sortedPreviews) {
483
+ previews.put(preview);
484
+ }
485
+
486
+ if (cleanup && !staleIds.isEmpty()) {
487
+ for (final String id : staleIds) {
488
+ sessions.remove(id);
489
+ }
490
+ this.savePreviewSessionsJson(sessions);
491
+ }
492
+
493
+ return previews;
494
+ }
495
+ }
496
+
497
+ private JSObject storedPreviewInfo(final String id) {
498
+ synchronized (this.previewSessionsLock) {
499
+ final JSONObject metadata = this.previewSessionsJson().optJSONObject(id);
500
+ if (metadata == null) {
501
+ return null;
502
+ }
503
+ try {
504
+ return this.previewInfo(id, metadata, this.availableBundleIds(), this.implementation.getCurrentBundle().getId());
505
+ } catch (final JSONException e) {
506
+ logger.warn("Could not read preview metadata for " + id + ": " + e.getMessage());
507
+ return null;
508
+ }
509
+ }
510
+ }
511
+
512
+ private JSObject recordPreviewBundle(final BundleInfo bundle) {
513
+ return this.recordPreviewBundle(bundle, null);
514
+ }
515
+
516
+ private JSObject recordPreviewBundle(final BundleInfo bundle, final String oldId) {
517
+ final String now = this.nowIsoString();
518
+ final String id = bundle.getId();
519
+ synchronized (this.previewSessionsLock) {
520
+ final JSONObject sessions = this.previewSessionsJson();
521
+ JSONObject metadata = sessions.optJSONObject(id);
522
+ final boolean replacingPreview = oldId != null && !oldId.equals(id);
523
+
524
+ try {
525
+ if (metadata == null && replacingPreview) {
526
+ final JSONObject oldMetadata = sessions.optJSONObject(oldId);
527
+ if (oldMetadata != null) {
528
+ metadata = new JSONObject(oldMetadata.toString());
529
+ }
530
+ }
531
+ if (metadata == null) {
532
+ metadata = new JSONObject();
533
+ }
534
+
535
+ if (!metadata.has("createdAt")) {
536
+ metadata.put("createdAt", now);
537
+ }
538
+ metadata.put("updatedAt", now);
539
+ if (metadata.isNull("lastUsedAt") || this.implementation.getCurrentBundle().getId().equals(id)) {
540
+ metadata.put("lastUsedAt", now);
541
+ }
542
+ metadata.put("version", bundle.getVersionName());
543
+
544
+ if (!replacingPreview) {
545
+ final String appId = this.currentPreviewMetadataValue(PREVIEW_APP_ID_PREF_KEY);
546
+ if (appId == null) {
547
+ metadata.remove("appId");
548
+ } else {
549
+ metadata.put("appId", appId);
550
+ }
551
+
552
+ final String payloadUrl = this.currentPreviewMetadataValue(PREVIEW_PAYLOAD_URL_PREF_KEY);
553
+ if (payloadUrl == null) {
554
+ metadata.remove("payloadUrl");
555
+ } else {
556
+ metadata.put("payloadUrl", payloadUrl);
557
+ }
558
+ }
559
+
560
+ if (!replacingPreview) {
561
+ final String name = this.currentPreviewMetadataValue(PREVIEW_NAME_PREF_KEY);
562
+ if (name == null) {
563
+ metadata.remove("name");
564
+ } else {
565
+ metadata.put("name", name);
566
+ }
567
+
568
+ final String source = this.currentPreviewMetadataValue(PREVIEW_SOURCE_PREF_KEY);
569
+ if (source == null) {
570
+ metadata.remove("source");
571
+ } else {
572
+ metadata.put("source", source);
573
+ }
574
+ }
575
+ if (this.metadataString(metadata, "name") == null) {
576
+ metadata.put("name", bundle.getVersionName());
577
+ }
578
+
579
+ if (oldId != null && !oldId.equals(id)) {
580
+ sessions.remove(oldId);
581
+ }
582
+ sessions.put(id, metadata);
583
+ this.savePreviewSessionsJson(sessions);
584
+
585
+ return this.previewInfo(id, metadata, this.availableBundleIds(), this.implementation.getCurrentBundle().getId());
586
+ } catch (final JSONException e) {
587
+ logger.warn("Could not store preview metadata: " + e.getMessage());
588
+ }
589
+ }
590
+
591
+ final JSObject fallback = new JSObject();
592
+ fallback.put("id", id);
593
+ fallback.put("bundle", InternalUtils.mapToJSObject(bundle.toJSONMap()));
594
+ fallback.put("createdAt", now);
595
+ fallback.put("updatedAt", now);
596
+ fallback.put("lastUsedAt", now);
597
+ fallback.put(
598
+ "isActive",
599
+ Boolean.TRUE.equals(this.previewSessionEnabled) && this.implementation.getCurrentBundle().getId().equals(id)
600
+ );
601
+ return fallback;
602
+ }
603
+
604
+ private void updateCurrentPreviewSessionMetadataFrom(final JSObject preview) {
605
+ final String appId = this.normalizedPreviewMetadataValue(preview.optString("appId", null));
606
+ if (appId == null) {
607
+ this.restorePreviewPreviousAppId();
608
+ this.editor.remove(PREVIEW_APP_ID_PREF_KEY);
609
+ } else {
610
+ this.setActiveAppId(appId);
611
+ this.editor.putString(PREVIEW_APP_ID_PREF_KEY, appId);
612
+ }
613
+
614
+ final String payloadUrl = this.normalizedPreviewMetadataValue(preview.optString("payloadUrl", null));
615
+ if (payloadUrl == null) {
616
+ this.editor.remove(PREVIEW_PAYLOAD_URL_PREF_KEY);
617
+ } else {
618
+ this.editor.putString(PREVIEW_PAYLOAD_URL_PREF_KEY, payloadUrl);
619
+ }
620
+
621
+ final String name = this.normalizedPreviewMetadataValue(preview.optString("name", null));
622
+ if (name == null) {
623
+ this.editor.remove(PREVIEW_NAME_PREF_KEY);
624
+ } else {
625
+ this.editor.putString(PREVIEW_NAME_PREF_KEY, name);
626
+ }
627
+
628
+ final String source = this.normalizedPreviewMetadataValue(preview.optString("source", null));
629
+ if (source == null) {
630
+ this.editor.remove(PREVIEW_SOURCE_PREF_KEY);
631
+ } else {
632
+ this.editor.putString(PREVIEW_SOURCE_PREF_KEY, source);
633
+ }
634
+ this.editor.apply();
635
+ }
636
+
341
637
  public Thread startNewThread(final Runnable function, Number waitTime) {
342
638
  Thread bgTask = new Thread(() -> {
343
639
  try {
@@ -576,6 +872,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
576
872
 
577
873
  this.checkForUpdateAfterDelay();
578
874
  this.showPreviewSessionNoticeIfNeeded();
875
+ this.syncShakeMenuLifecycle();
579
876
 
580
877
  // On Android 14+ (API 34+), topActivity in RecentTaskInfo returns null due to
581
878
  // security restrictions (StrandHogg task hijacking mitigations). Use ProcessLifecycleOwner
@@ -2430,8 +2727,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
2430
2727
  call.reject("Update failed, id " + id + " does not exist.");
2431
2728
  } else if (Boolean.TRUE.equals(this.previewSessionEnabled)) {
2432
2729
  logger.info("Preview session set active bundle " + id + " without waiting for preview app readiness");
2730
+ final BundleInfo bundle = this.implementation.getBundleInfo(id);
2731
+ this.recordPreviewBundle(bundle);
2433
2732
  this.reloadWithoutWaitingForAppReady();
2434
- this.notifyBundleSet(this.implementation.getBundleInfo(id));
2733
+ this.notifyBundleSet(bundle);
2435
2734
  this.showPreviewSessionNoticeIfNeeded();
2436
2735
  call.resolve();
2437
2736
  } else if (!this._reload()) {
@@ -2450,6 +2749,51 @@ public class CapacitorUpdaterPlugin extends Plugin {
2450
2749
  });
2451
2750
  }
2452
2751
 
2752
+ private boolean preparePreviewFallbackIfNeeded() {
2753
+ if (Boolean.TRUE.equals(this.previewSessionEnabled)) {
2754
+ return true;
2755
+ }
2756
+
2757
+ final BundleInfo current = this.implementation.getCurrentBundle();
2758
+ if (!this.implementation.setPreviewFallbackBundle(current.getId())) {
2759
+ logger.error("Could not save current bundle as preview fallback");
2760
+ return false;
2761
+ }
2762
+
2763
+ final BundleInfo previousNext = this.implementation.getNextBundle();
2764
+ if (previousNext == null || previousNext.isDeleted() || previousNext.isErrorStatus()) {
2765
+ this.editor.remove(PREVIEW_PREVIOUS_NEXT_BUNDLE_PREF_KEY);
2766
+ } else {
2767
+ this.editor.putString(PREVIEW_PREVIOUS_NEXT_BUNDLE_PREF_KEY, previousNext.getId());
2768
+ }
2769
+
2770
+ this.editor.putString(PREVIEW_PREVIOUS_APP_ID_PREF_KEY, this.implementation.appId);
2771
+ if (this.prefs.contains(DEFAULT_CHANNEL_PREF_KEY)) {
2772
+ this.editor.putString(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_PREF_KEY, this.prefs.getString(DEFAULT_CHANNEL_PREF_KEY, ""));
2773
+ this.editor.putBoolean(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY, true);
2774
+ } else {
2775
+ this.editor.remove(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_PREF_KEY);
2776
+ this.editor.putBoolean(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY, false);
2777
+ }
2778
+ this.editor.putBoolean(PREVIEW_PREVIOUS_SHAKE_MENU_PREF_KEY, Boolean.TRUE.equals(this.shakeMenuEnabled));
2779
+ this.editor.putBoolean(PREVIEW_PREVIOUS_SHAKE_CHANNEL_SELECTOR_PREF_KEY, Boolean.TRUE.equals(this.shakeChannelSelectorEnabled));
2780
+ logger.info("Preview session started with fallback bundle: " + current);
2781
+ return true;
2782
+ }
2783
+
2784
+ private void activatePreviewSessionState() {
2785
+ this.clearIncomingPreviewTransition();
2786
+ this.hidePreviewTransitionLoader("preview-session-started");
2787
+ this.previewSessionEnabled = true;
2788
+ this.previewSessionAlertPending = true;
2789
+ this.implementation.previewSession = true;
2790
+ this.shakeMenuEnabled = true;
2791
+ this.editor.putBoolean(PREVIEW_SESSION_PREF_KEY, true);
2792
+ this.editor.putBoolean(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY, true);
2793
+ this.editor.apply();
2794
+ this.syncShakeMenuLifecycle();
2795
+ }
2796
+
2453
2797
  @PluginMethod
2454
2798
  public void startPreviewSession(final PluginCall call) {
2455
2799
  if (!Boolean.TRUE.equals(this.allowPreview)) {
@@ -2469,39 +2813,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
2469
2813
  }
2470
2814
  startNewThread(() -> {
2471
2815
  try {
2472
- if (!Boolean.TRUE.equals(this.previewSessionEnabled)) {
2473
- final BundleInfo current = this.implementation.getCurrentBundle();
2474
- if (!this.implementation.setPreviewFallbackBundle(current.getId())) {
2475
- this.hidePreviewTransitionLoader("preview-session-fallback-failed");
2476
- logger.error("Could not save current bundle as preview fallback");
2477
- call.reject("Could not save current bundle as preview fallback");
2478
- return;
2479
- }
2480
-
2481
- final BundleInfo previousNext = this.implementation.getNextBundle();
2482
- if (previousNext == null || previousNext.isDeleted() || previousNext.isErrorStatus()) {
2483
- this.editor.remove(PREVIEW_PREVIOUS_NEXT_BUNDLE_PREF_KEY);
2484
- } else {
2485
- this.editor.putString(PREVIEW_PREVIOUS_NEXT_BUNDLE_PREF_KEY, previousNext.getId());
2486
- }
2487
-
2488
- this.editor.putString(PREVIEW_PREVIOUS_APP_ID_PREF_KEY, this.implementation.appId);
2489
- if (this.prefs.contains(DEFAULT_CHANNEL_PREF_KEY)) {
2490
- this.editor.putString(
2491
- PREVIEW_PREVIOUS_DEFAULT_CHANNEL_PREF_KEY,
2492
- this.prefs.getString(DEFAULT_CHANNEL_PREF_KEY, "")
2493
- );
2494
- this.editor.putBoolean(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY, true);
2495
- } else {
2496
- this.editor.remove(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_PREF_KEY);
2497
- this.editor.putBoolean(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY, false);
2498
- }
2499
- this.editor.putBoolean(PREVIEW_PREVIOUS_SHAKE_MENU_PREF_KEY, Boolean.TRUE.equals(this.shakeMenuEnabled));
2500
- this.editor.putBoolean(
2501
- PREVIEW_PREVIOUS_SHAKE_CHANNEL_SELECTOR_PREF_KEY,
2502
- Boolean.TRUE.equals(this.shakeChannelSelectorEnabled)
2503
- );
2504
- logger.info("Preview session started with fallback bundle: " + current);
2816
+ if (!this.preparePreviewFallbackIfNeeded()) {
2817
+ this.hidePreviewTransitionLoader("preview-session-fallback-failed");
2818
+ call.reject("Could not save current bundle as preview fallback");
2819
+ return;
2505
2820
  }
2506
2821
 
2507
2822
  if (previewAppId != null) {
@@ -2517,15 +2832,21 @@ public class CapacitorUpdaterPlugin extends Plugin {
2517
2832
  this.editor.remove(PREVIEW_PAYLOAD_URL_PREF_KEY);
2518
2833
  }
2519
2834
 
2520
- this.hidePreviewTransitionLoader("preview-session-started");
2521
- this.previewSessionEnabled = true;
2522
- this.previewSessionAlertPending = true;
2523
- this.implementation.previewSession = true;
2524
- this.shakeMenuEnabled = true;
2525
- this.editor.putBoolean(PREVIEW_SESSION_PREF_KEY, true);
2526
- this.editor.putBoolean(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY, true);
2527
- this.editor.apply();
2528
- this.ensureShakeMenuStarted();
2835
+ final String previewName = this.normalizedPreviewMetadataValue(call.getString("name"));
2836
+ if (previewName == null) {
2837
+ this.editor.remove(PREVIEW_NAME_PREF_KEY);
2838
+ } else {
2839
+ this.editor.putString(PREVIEW_NAME_PREF_KEY, previewName);
2840
+ }
2841
+
2842
+ final String previewSource = this.normalizedPreviewMetadataValue(call.getString("source"));
2843
+ if (previewSource == null) {
2844
+ this.editor.remove(PREVIEW_SOURCE_PREF_KEY);
2845
+ } else {
2846
+ this.editor.putString(PREVIEW_SOURCE_PREF_KEY, previewSource);
2847
+ }
2848
+
2849
+ this.activatePreviewSessionState();
2529
2850
  call.resolve();
2530
2851
  } catch (final Exception e) {
2531
2852
  this.hidePreviewTransitionLoader("preview-session-failed");
@@ -2535,9 +2856,260 @@ public class CapacitorUpdaterPlugin extends Plugin {
2535
2856
  });
2536
2857
  }
2537
2858
 
2538
- public boolean leavePreviewSessionFromShakeMenu() {
2539
- final BundleInfo previewBundle = this.implementation.getCurrentBundle();
2859
+ @PluginMethod
2860
+ public void listPreviews(final PluginCall call) {
2861
+ if (!Boolean.TRUE.equals(this.allowPreview)) {
2862
+ call.reject("listPreviews not allowed");
2863
+ return;
2864
+ }
2865
+
2866
+ final JSArray previews = this.listPreviewInfos(true);
2867
+ final JSObject ret = new JSObject();
2868
+ ret.put("previews", previews);
2869
+ ret.put("currentBundle", InternalUtils.mapToJSObject(this.implementation.getCurrentBundle().toJSONMap()));
2870
+
2871
+ for (int i = 0; i < previews.length(); i++) {
2872
+ final JSONObject preview = previews.optJSONObject(i);
2873
+ if (preview != null && preview.optBoolean("isActive", false)) {
2874
+ ret.put("current", preview);
2875
+ break;
2876
+ }
2877
+ }
2878
+
2879
+ final BundleInfo liveBundle = this.implementation.getPreviewFallbackBundle();
2880
+ if (liveBundle != null) {
2881
+ ret.put("liveBundle", InternalUtils.mapToJSObject(liveBundle.toJSONMap()));
2882
+ }
2883
+
2884
+ call.resolve(ret);
2885
+ }
2886
+
2887
+ @PluginMethod
2888
+ public void setPreview(final PluginCall call) {
2889
+ if (!Boolean.TRUE.equals(this.allowPreview)) {
2890
+ call.reject("setPreview not allowed");
2891
+ return;
2892
+ }
2893
+ final String id = call.getString("id");
2894
+ if (id == null || id.isEmpty()) {
2895
+ call.reject("setPreview called without id");
2896
+ return;
2897
+ }
2898
+ final JSObject preview = this.storedPreviewInfo(id);
2899
+ if (preview == null) {
2900
+ call.reject("Preview " + id + " is not available locally");
2901
+ return;
2902
+ }
2903
+
2904
+ this.showPreviewTransitionLoader("set-preview");
2905
+ startNewThread(() -> {
2906
+ if (!this.preparePreviewFallbackIfNeeded()) {
2907
+ this.hidePreviewTransitionLoader("set-preview-fallback-failed");
2908
+ call.reject("Could not save current bundle as preview fallback");
2909
+ return;
2910
+ }
2911
+
2912
+ if (!this.implementation.set(id)) {
2913
+ this.hidePreviewTransitionLoader("set-preview-failed");
2914
+ call.reject("Preview " + id + " cannot be applied");
2915
+ return;
2916
+ }
2917
+
2918
+ final BundleInfo bundle = this.implementation.getBundleInfo(id);
2919
+ this.updateCurrentPreviewSessionMetadataFrom(preview);
2920
+ this.activatePreviewSessionState();
2921
+ this.recordPreviewBundle(bundle);
2922
+ if (!this.reloadWithoutWaitingForAppReady()) {
2923
+ this.hidePreviewTransitionLoader("set-preview-reload-failed");
2924
+ call.reject("Reload failed after setting preview " + id);
2925
+ return;
2926
+ }
2927
+
2928
+ this.notifyBundleSet(bundle);
2929
+ this.showPreviewSessionNoticeIfNeeded();
2930
+ call.resolve();
2931
+ });
2932
+ }
2933
+
2934
+ public JSArray previewMenuPreviews() {
2935
+ return this.listPreviewInfos(true);
2936
+ }
2937
+
2938
+ public boolean setPreviewFromShakeMenu(final String id) {
2939
+ final JSObject preview = this.storedPreviewInfo(id);
2940
+ if (!Boolean.TRUE.equals(this.allowPreview) || preview == null) {
2941
+ return false;
2942
+ }
2943
+
2944
+ this.showPreviewTransitionLoader("set-preview-menu");
2945
+ if (!this.preparePreviewFallbackIfNeeded()) {
2946
+ this.hidePreviewTransitionLoader("set-preview-menu-fallback-failed");
2947
+ return false;
2948
+ }
2949
+
2950
+ if (!this.implementation.set(id)) {
2951
+ this.hidePreviewTransitionLoader("set-preview-menu-failed");
2952
+ return false;
2953
+ }
2954
+
2955
+ final BundleInfo bundle = this.implementation.getBundleInfo(id);
2956
+ this.updateCurrentPreviewSessionMetadataFrom(preview);
2957
+ this.activatePreviewSessionState();
2958
+ this.recordPreviewBundle(bundle);
2959
+ if (!this.reloadWithoutWaitingForAppReady()) {
2960
+ this.hidePreviewTransitionLoader("set-preview-menu-reload-failed");
2961
+ return false;
2962
+ }
2963
+
2964
+ this.notifyBundleSet(bundle);
2965
+ this.showPreviewSessionNoticeIfNeeded();
2966
+ return true;
2967
+ }
2968
+
2969
+ @PluginMethod
2970
+ public void resetPreview(final PluginCall call) {
2971
+ if (!Boolean.TRUE.equals(this.previewSessionEnabled)) {
2972
+ call.resolve();
2973
+ return;
2974
+ }
2975
+ startNewThread(() -> {
2976
+ if (this.leavePreviewSessionFromShakeMenu()) {
2977
+ call.resolve();
2978
+ } else {
2979
+ call.reject("Could not leave preview session");
2980
+ }
2981
+ });
2982
+ }
2540
2983
 
2984
+ @PluginMethod
2985
+ public void deletePreview(final PluginCall call) {
2986
+ if (!Boolean.TRUE.equals(this.allowPreview)) {
2987
+ call.reject("deletePreview not allowed");
2988
+ return;
2989
+ }
2990
+ final String id = call.getString("id");
2991
+ if (id == null || id.isEmpty()) {
2992
+ call.reject("deletePreview called without id");
2993
+ return;
2994
+ }
2995
+ if (Boolean.TRUE.equals(this.previewSessionEnabled) && this.implementation.getCurrentBundle().getId().equals(id)) {
2996
+ call.reject("Cannot delete the active preview");
2997
+ return;
2998
+ }
2999
+
3000
+ final boolean removed;
3001
+ synchronized (this.previewSessionsLock) {
3002
+ final JSONObject sessions = this.previewSessionsJson();
3003
+ removed = sessions.has(id);
3004
+ sessions.remove(id);
3005
+ this.savePreviewSessionsJson(sessions);
3006
+ }
3007
+
3008
+ boolean deleted = false;
3009
+ final BundleInfo fallback = this.implementation.getPreviewFallbackBundle();
3010
+ final BundleInfo next = this.implementation.getNextBundle();
3011
+ if (
3012
+ removed &&
3013
+ !BundleInfo.ID_BUILTIN.equals(id) &&
3014
+ (fallback == null || !id.equals(fallback.getId())) &&
3015
+ (next == null || !id.equals(next.getId()))
3016
+ ) {
3017
+ try {
3018
+ deleted = this.implementation.delete(id, false);
3019
+ } catch (final Exception err) {
3020
+ logger.warn("Could not delete preview bundle " + id + ": " + err.getMessage());
3021
+ }
3022
+ }
3023
+
3024
+ final JSObject ret = new JSObject();
3025
+ ret.put("removed", removed);
3026
+ ret.put("deleted", deleted);
3027
+ call.resolve(ret);
3028
+ }
3029
+
3030
+ @PluginMethod
3031
+ public void checkPreviewUpdate(final PluginCall call) {
3032
+ this.handlePreviewUpdate(call, false);
3033
+ }
3034
+
3035
+ @PluginMethod
3036
+ public void updatePreview(final PluginCall call) {
3037
+ this.handlePreviewUpdate(call, true);
3038
+ }
3039
+
3040
+ private void handlePreviewUpdate(final PluginCall call, final boolean shouldDownload) {
3041
+ if (!Boolean.TRUE.equals(this.allowPreview)) {
3042
+ call.reject("Preview updates not allowed");
3043
+ return;
3044
+ }
3045
+ final String id = call.getString("id");
3046
+ if (id == null || id.isEmpty()) {
3047
+ call.reject("Preview update called without id");
3048
+ return;
3049
+ }
3050
+ final JSObject preview = this.storedPreviewInfo(id);
3051
+ final String payloadUrl = preview == null ? null : this.normalizePreviewPayloadUrl(preview.optString("payloadUrl", null));
3052
+ if (payloadUrl == null) {
3053
+ call.reject("Preview " + id + " has no payloadUrl to update from");
3054
+ return;
3055
+ }
3056
+
3057
+ startNewThread(() -> {
3058
+ try {
3059
+ final JSONObject payload = this.fetchPreviewPayload(payloadUrl);
3060
+ final String version = payload.optString("version", "").trim();
3061
+ if (version.isEmpty()) {
3062
+ throw new IOException("Preview payload is missing a version");
3063
+ }
3064
+
3065
+ final BundleInfo currentPreviewBundle = this.implementation.getBundleInfo(id);
3066
+ final boolean upToDate = version.equals(currentPreviewBundle.getVersionName());
3067
+ if (upToDate || !shouldDownload) {
3068
+ final JSObject ret = new JSObject();
3069
+ ret.put("preview", preview);
3070
+ ret.put("latestVersion", version);
3071
+ ret.put("upToDate", upToDate);
3072
+ ret.put("updated", false);
3073
+ ret.put("bundle", InternalUtils.mapToJSObject(currentPreviewBundle.toJSONMap()));
3074
+ call.resolve(ret);
3075
+ return;
3076
+ }
3077
+
3078
+ final BundleInfo next = this.downloadPreviewPayloadBundle(payload);
3079
+ if (next.isErrorStatus()) {
3080
+ throw new IOException("Download failed: " + next.getStatus());
3081
+ }
3082
+
3083
+ final boolean wasActive =
3084
+ Boolean.TRUE.equals(this.previewSessionEnabled) && this.implementation.getCurrentBundle().getId().equals(id);
3085
+ if (wasActive && !this.implementation.set(next.getId())) {
3086
+ throw new IOException("Downloaded preview bundle cannot be applied");
3087
+ }
3088
+
3089
+ final JSObject savedPreview = this.recordPreviewBundle(next, id);
3090
+ if (wasActive) {
3091
+ if (!this.reloadWithoutWaitingForAppReady()) {
3092
+ throw new IOException("Reload failed after updating preview");
3093
+ }
3094
+ this.notifyBundleSet(next);
3095
+ this.showPreviewSessionNoticeIfNeeded();
3096
+ }
3097
+
3098
+ final JSObject ret = new JSObject();
3099
+ ret.put("preview", savedPreview);
3100
+ ret.put("latestVersion", version);
3101
+ ret.put("upToDate", false);
3102
+ ret.put("updated", true);
3103
+ ret.put("bundle", InternalUtils.mapToJSObject(next.toJSONMap()));
3104
+ call.resolve(ret);
3105
+ } catch (final Exception err) {
3106
+ logger.error("Could not update preview: " + err.getMessage());
3107
+ call.reject("Could not update preview: " + err.getMessage());
3108
+ }
3109
+ });
3110
+ }
3111
+
3112
+ public boolean leavePreviewSessionFromShakeMenu() {
2541
3113
  this.showPreviewTransitionLoader("leave-preview-session");
2542
3114
  final boolean didReset = this.resetToPreviewFallbackBundle();
2543
3115
  if (!didReset) {
@@ -2545,16 +3117,12 @@ public class CapacitorUpdaterPlugin extends Plugin {
2545
3117
  return false;
2546
3118
  }
2547
3119
 
2548
- final BundleInfo previewFallbackBundle = this.implementation.getPreviewFallbackBundle();
2549
3120
  this.endPreviewSession(true);
2550
- final BundleInfo restoredNextBundle = this.implementation.getNextBundle();
2551
- this.deletePreviewBundleIfUnused(previewBundle, previewFallbackBundle, restoredNextBundle);
2552
3121
  return true;
2553
3122
  }
2554
3123
 
2555
3124
  private boolean leavePreviewSessionForIncomingPreviewLink() {
2556
3125
  this.showPreviewTransitionLoader("incoming-preview-deeplink");
2557
- final BundleInfo previewBundle = this.implementation.getCurrentBundle();
2558
3126
  final BundleInfo previewFallbackBundle = this.resolvePreviewFallbackBundle("incoming preview deeplink");
2559
3127
  boolean didReload = false;
2560
3128
 
@@ -2577,8 +3145,6 @@ public class CapacitorUpdaterPlugin extends Plugin {
2577
3145
  didReload = true;
2578
3146
 
2579
3147
  this.endPreviewSession(true);
2580
- final BundleInfo restoredNextBundle = this.implementation.getNextBundle();
2581
- this.deletePreviewBundleIfUnused(previewBundle, previewFallbackBundle, restoredNextBundle);
2582
3148
  return true;
2583
3149
  } finally {
2584
3150
  this.clearIncomingPreviewTransition();
@@ -2616,7 +3182,6 @@ public class CapacitorUpdaterPlugin extends Plugin {
2616
3182
  }
2617
3183
 
2618
3184
  private boolean leavePreviewSessionWithoutReload(final boolean keepPreviewGuard) {
2619
- final BundleInfo previewBundle = this.implementation.getCurrentBundle();
2620
3185
  final BundleInfo previewFallbackBundle = this.resolvePreviewFallbackBundle("preview deeplink launch");
2621
3186
  if (previewFallbackBundle == null) {
2622
3187
  return false;
@@ -2627,29 +3192,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
2627
3192
  }
2628
3193
 
2629
3194
  this.endPreviewSession(keepPreviewGuard);
2630
- final BundleInfo restoredNextBundle = this.implementation.getNextBundle();
2631
- this.deletePreviewBundleIfUnused(previewBundle, previewFallbackBundle, restoredNextBundle);
2632
3195
  return true;
2633
3196
  }
2634
3197
 
2635
- private void deletePreviewBundleIfUnused(
2636
- final BundleInfo previewBundle,
2637
- final BundleInfo previewFallbackBundle,
2638
- final BundleInfo restoredNextBundle
2639
- ) {
2640
- if (
2641
- !previewBundle.isBuiltin() &&
2642
- (previewFallbackBundle == null || !previewBundle.getId().equals(previewFallbackBundle.getId())) &&
2643
- (restoredNextBundle == null || !previewBundle.getId().equals(restoredNextBundle.getId()))
2644
- ) {
2645
- try {
2646
- this.implementation.delete(previewBundle.getId(), false);
2647
- } catch (final Exception err) {
2648
- logger.warn("Cannot delete preview bundle " + previewBundle.getId() + ": " + err.getMessage());
2649
- }
2650
- }
2651
- }
2652
-
2653
3198
  public boolean reloadPreviewSessionFromShakeMenu() {
2654
3199
  this.showPreviewTransitionLoader("reload-preview-session");
2655
3200
  final boolean didReload;
@@ -2781,6 +3326,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
2781
3326
  this.editor.remove(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY);
2782
3327
  this.editor.remove(PREVIEW_APP_ID_PREF_KEY);
2783
3328
  this.editor.remove(PREVIEW_PAYLOAD_URL_PREF_KEY);
3329
+ this.editor.remove(PREVIEW_NAME_PREF_KEY);
3330
+ this.editor.remove(PREVIEW_SOURCE_PREF_KEY);
2784
3331
  this.editor.remove(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY);
2785
3332
  this.editor.apply();
2786
3333
  }
@@ -2955,7 +3502,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
2955
3502
  throw new IOException("Preview payload is missing a version");
2956
3503
  }
2957
3504
 
2958
- if (version.equals(this.implementation.getCurrentBundle().getVersionName())) {
3505
+ final BundleInfo current = this.implementation.getCurrentBundle();
3506
+ if (version.equals(current.getVersionName())) {
2959
3507
  logger.info("Preview payload unchanged, reloading current bundle");
2960
3508
  return this.reloadWithoutWaitingForAppReady();
2961
3509
  }
@@ -2968,6 +3516,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2968
3516
  throw new IOException("Downloaded preview bundle cannot be applied");
2969
3517
  }
2970
3518
 
3519
+ this.recordPreviewBundle(next, current.getId());
2971
3520
  this.notifyBundleSet(next);
2972
3521
  return this.reloadWithoutWaitingForAppReady();
2973
3522
  } catch (final Exception err) {
@@ -2977,7 +3526,11 @@ public class CapacitorUpdaterPlugin extends Plugin {
2977
3526
  }
2978
3527
 
2979
3528
  private void clearPreviewSessionForNativeBuildChange() {
2980
- if (!Boolean.TRUE.equals(this.previewSessionEnabled) && this.implementation.getPreviewFallbackBundle() == null) {
3529
+ if (
3530
+ !Boolean.TRUE.equals(this.previewSessionEnabled) &&
3531
+ this.implementation.getPreviewFallbackBundle() == null &&
3532
+ !this.hasSavedPreviewSessions()
3533
+ ) {
2981
3534
  return;
2982
3535
  }
2983
3536
  logger.info("Native build changed; clearing preview session state");
@@ -2994,6 +3547,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
2994
3547
  this.implementation.setPreviewFallbackBundle(null);
2995
3548
  this.implementation.setNextBundle(null);
2996
3549
  this.clearPreviewSessionPreferences();
3550
+ synchronized (this.previewSessionsLock) {
3551
+ this.editor.remove(PREVIEW_SESSIONS_PREF_KEY);
3552
+ this.editor.apply();
3553
+ }
2997
3554
  }
2998
3555
 
2999
3556
  private void restorePreviewPreviousNextBundle() {
@@ -3009,26 +3566,25 @@ public class CapacitorUpdaterPlugin extends Plugin {
3009
3566
  }
3010
3567
 
3011
3568
  private void ensureShakeMenuStarted() {
3012
- if (getActivity() instanceof com.getcapacitor.BridgeActivity && shakeMenu == null) {
3569
+ if (shakeMenu != null && !shakeMenu.usesGesture(this.shakeMenuGesture)) {
3013
3570
  try {
3014
- shakeMenu = new ShakeMenu(this, (com.getcapacitor.BridgeActivity) getActivity(), logger, this.shakeMenuGesture);
3015
- logger.info("Shake menu initialized with " + this.shakeMenuGesture + " gesture");
3571
+ shakeMenu.stop();
3572
+ shakeMenu = null;
3573
+ logger.info("Shake menu restarted for " + this.shakeMenuGesture + " gesture");
3016
3574
  } catch (Exception e) {
3017
- logger.error("Failed to initialize shake menu: " + e.getMessage());
3575
+ logger.error("Failed to restart shake menu: " + e.getMessage());
3576
+ return;
3018
3577
  }
3019
3578
  }
3020
- }
3021
3579
 
3022
- private void restartShakeMenuListener() {
3023
- if (shakeMenu != null) {
3580
+ if (getActivity() instanceof com.getcapacitor.BridgeActivity && shakeMenu == null) {
3024
3581
  try {
3025
- shakeMenu.stop();
3582
+ shakeMenu = new ShakeMenu(this, (com.getcapacitor.BridgeActivity) getActivity(), logger, this.shakeMenuGesture);
3583
+ logger.info("Shake menu initialized with " + this.shakeMenuGesture + " gesture");
3026
3584
  } catch (Exception e) {
3027
- logger.error("Failed to restart shake menu listener: " + e.getMessage());
3585
+ logger.error("Failed to initialize shake menu: " + e.getMessage());
3028
3586
  }
3029
- shakeMenu = null;
3030
3587
  }
3031
- this.syncShakeMenuLifecycle();
3032
3588
  }
3033
3589
 
3034
3590
  private void syncShakeMenuLifecycle() {
@@ -4406,10 +4962,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
4406
4962
  isPreviousMainActivity = true;
4407
4963
  }
4408
4964
 
4409
- // Initialize shake menu if enabled and activity is BridgeActivity
4410
- if (this.shouldListenForShake()) {
4411
- this.ensureShakeMenuStarted();
4412
- }
4965
+ this.syncShakeMenuLifecycle();
4413
4966
  } catch (Exception e) {
4414
4967
  logger.error("Failed to run handleOnStart: " + e.getMessage());
4415
4968
  }
@@ -4441,6 +4994,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
4441
4994
  backgroundTask.interrupt();
4442
4995
  }
4443
4996
  this.implementation.activity = getActivity();
4997
+ this.syncShakeMenuLifecycle();
4444
4998
  } catch (Exception e) {
4445
4999
  logger.error("Failed to run handleOnResume: " + e.getMessage());
4446
5000
  }
@@ -4464,29 +5018,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
4464
5018
  return;
4465
5019
  }
4466
5020
 
4467
- final String gesture = call.getString("gesture", null);
4468
- final boolean gestureChanged;
4469
- if (gesture != null) {
4470
- if (!isSupportedShakeMenuGesture(gesture)) {
4471
- logger.error("Unsupported shake menu gesture: " + gesture);
4472
- call.reject("Unsupported shake menu gesture. Use \"shake\" or \"threeFingerPinch\".");
4473
- return;
4474
- }
4475
- final String normalizedGesture = normalizedShakeMenuGesture(gesture);
4476
- gestureChanged = !normalizedGesture.equals(this.shakeMenuGesture);
4477
- this.shakeMenuGesture = normalizedGesture;
4478
- } else {
4479
- gestureChanged = false;
4480
- }
4481
-
4482
5021
  this.shakeMenuEnabled = enabled;
4483
5022
  logger.info("Shake menu " + (enabled ? "enabled" : "disabled") + " with " + this.shakeMenuGesture + " gesture");
4484
-
4485
- if (gestureChanged) {
4486
- this.restartShakeMenuListener();
4487
- } else {
4488
- this.syncShakeMenuLifecycle();
4489
- }
5023
+ this.syncShakeMenuLifecycle();
4490
5024
 
4491
5025
  call.resolve();
4492
5026
  }