@capgo/capacitor-updater 7.43.3 → 7.50.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/Package.swift +5 -2
  2. package/README.md +643 -115
  3. package/android/build.gradle +3 -3
  4. package/android/src/main/java/ee/forgr/capacitor_updater/AndroidAppExitReporter.java +92 -0
  5. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +3121 -353
  6. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +924 -290
  7. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +49 -13
  8. package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +45 -30
  9. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +75 -32
  10. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +2 -0
  11. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +3 -3
  12. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +401 -27
  13. package/android/src/main/java/ee/forgr/capacitor_updater/ThreeFingerPinchDetector.java +323 -0
  14. package/dist/docs.json +1590 -196
  15. package/dist/esm/definitions.d.ts +755 -66
  16. package/dist/esm/definitions.js.map +1 -1
  17. package/dist/esm/web.d.ts +11 -2
  18. package/dist/esm/web.js +59 -5
  19. package/dist/esm/web.js.map +1 -1
  20. package/dist/plugin.cjs.js +59 -5
  21. package/dist/plugin.cjs.js.map +1 -1
  22. package/dist/plugin.js +59 -5
  23. package/dist/plugin.js.map +1 -1
  24. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +0 -1
  25. package/ios/Sources/CapacitorUpdaterPlugin/AppHealthTracker.swift +82 -0
  26. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +0 -16
  27. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +2 -2
  28. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +78 -2
  29. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +2732 -417
  30. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1191 -363
  31. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +0 -1
  32. package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +37 -16
  33. package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +80 -1
  34. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +438 -39
  35. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +276 -0
  36. package/package.json +24 -9
@@ -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,8 +21,11 @@ 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;
27
+ import androidx.core.content.pm.PackageInfoCompat;
28
+ import com.getcapacitor.Bridge;
25
29
  import com.getcapacitor.CapConfig;
26
30
  import com.getcapacitor.JSArray;
27
31
  import com.getcapacitor.JSObject;
@@ -29,6 +33,8 @@ import com.getcapacitor.Plugin;
29
33
  import com.getcapacitor.PluginCall;
30
34
  import com.getcapacitor.PluginHandle;
31
35
  import com.getcapacitor.PluginMethod;
36
+ import com.getcapacitor.PluginResult;
37
+ import com.getcapacitor.WebViewListener;
32
38
  import com.getcapacitor.annotation.CapacitorPlugin;
33
39
  import com.getcapacitor.plugin.WebView;
34
40
  import com.google.android.gms.tasks.Task;
@@ -43,20 +49,25 @@ import com.google.android.play.core.install.model.AppUpdateType;
43
49
  import com.google.android.play.core.install.model.InstallStatus;
44
50
  import com.google.android.play.core.install.model.UpdateAvailability;
45
51
  import io.github.g00fy2.versioncompare.Version;
52
+ import java.io.ByteArrayOutputStream;
46
53
  import java.io.IOException;
54
+ import java.io.InputStream;
55
+ import java.net.HttpURLConnection;
47
56
  import java.net.MalformedURLException;
48
57
  import java.net.URL;
58
+ import java.nio.charset.StandardCharsets;
59
+ import java.text.SimpleDateFormat;
49
60
  import java.util.ArrayList;
50
- import java.util.Arrays;
51
61
  import java.util.Date;
62
+ import java.util.HashMap;
52
63
  import java.util.HashSet;
53
64
  import java.util.List;
54
65
  import java.util.Map;
55
66
  import java.util.Objects;
56
67
  import java.util.Set;
68
+ import java.util.TimeZone;
57
69
  import java.util.Timer;
58
70
  import java.util.TimerTask;
59
- import java.util.UUID;
60
71
  import java.util.concurrent.Phaser;
61
72
  import java.util.concurrent.Semaphore;
62
73
  import java.util.concurrent.TimeUnit;
@@ -70,6 +81,16 @@ import org.json.JSONObject;
70
81
  @CapacitorPlugin(name = "CapacitorUpdater")
71
82
  public class CapacitorUpdaterPlugin extends Plugin {
72
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
+
73
94
  private Logger logger;
74
95
 
75
96
  private static final String updateUrlDefault = "https://plugin.capgo.app/updates";
@@ -81,14 +102,52 @@ public class CapacitorUpdaterPlugin extends Plugin {
81
102
  private static final String STATS_URL_PREF_KEY = "CapacitorUpdater.statsUrl";
82
103
  private static final String CHANNEL_URL_PREF_KEY = "CapacitorUpdater.channelUrl";
83
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";
84
118
  private static final String[] BREAKING_EVENT_NAMES = { "breakingAvailable", "majorAvailable" };
85
119
  private static final String LAST_FAILED_BUNDLE_PREF_KEY = "CapacitorUpdater.lastFailedBundle";
86
-
87
- private final String pluginVersion = "7.43.3";
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";
127
+ private static final String SPLASH_SCREEN_PLUGIN_ID = "SplashScreen";
128
+ private static final int SPLASH_SCREEN_RETRY_DELAY_MS = 100;
129
+ private static final int SPLASH_SCREEN_MAX_RETRIES = 20;
130
+ private static final long PENDING_BUNDLE_APP_READY_MIN_TIMEOUT_MS = 30000L;
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";
88
146
  private static final String DELAY_CONDITION_PREFERENCES = "";
89
147
 
90
148
  private SharedPreferences.Editor editor;
91
149
  private SharedPreferences prefs;
150
+ private final Object previewSessionsLock = new Object();
92
151
  protected CapgoUpdater implementation;
93
152
  private Boolean persistCustomId = false;
94
153
  private Boolean persistModifyUrl = false;
@@ -98,6 +157,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
98
157
  private Boolean autoDeleteFailed = true;
99
158
  private Boolean autoDeletePrevious = true;
100
159
  private Boolean autoUpdate = false;
160
+ private String autoUpdateMode = AUTO_UPDATE_MODE_OFF;
101
161
  private String updateUrl = "";
102
162
  private Version currentVersionNative;
103
163
  private String currentBuildVersion;
@@ -108,28 +168,62 @@ public class CapacitorUpdaterPlugin extends Plugin {
108
168
  private Boolean autoSplashscreenLoader = false;
109
169
  private Integer autoSplashscreenTimeout = 10000;
110
170
  private Boolean autoSplashscreenTimedOut = false;
171
+ private int splashscreenInvocationToken = 0;
111
172
  private String directUpdateMode = "false";
112
173
  private Boolean wasRecentlyInstalledOrUpdated = false;
113
- private Boolean onLaunchDirectUpdateUsed = false;
174
+ private volatile boolean onLaunchDirectUpdateUsed = false;
114
175
  Boolean shakeMenuEnabled = false;
115
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;
116
181
  private Boolean allowManualBundleError = false;
182
+ private Boolean allowPreview = false;
117
183
  Boolean allowSetDefaultChannel = true;
118
184
 
119
185
  String getUpdateUrl() {
120
186
  return this.updateUrl;
121
187
  }
122
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
+
123
213
  // Used for activity-based foreground/background detection on Android < 14
124
214
  private Boolean isPreviousMainActivity = true;
125
215
 
126
216
  private volatile Thread backgroundDownloadTask;
127
217
  private volatile Thread appReadyCheck;
128
218
  private volatile long downloadStartTimeMs = 0;
129
- private static final long DOWNLOAD_TIMEOUT_MS = 3600000; // 1 hour timeout
219
+ private static final long DOWNLOAD_TIMEOUT_MS = 600000; // 10 minute timeout
130
220
 
131
- // private static final CountDownLatch semaphoreReady = new CountDownLatch(1);
132
- private static final Phaser semaphoreReady = new Phaser(1);
221
+ private final Phaser semaphoreReady = new Phaser(0) {
222
+ @Override
223
+ protected boolean onAdvance(final int phase, final int registeredParties) {
224
+ return false;
225
+ }
226
+ };
133
227
 
134
228
  // Lock to ensure cleanup completes before downloads start
135
229
  private final Object cleanupLock = new Object();
@@ -144,6 +238,32 @@ public class CapacitorUpdaterPlugin extends Plugin {
144
238
  private final Handler mainHandler = new Handler(Looper.getMainLooper());
145
239
  private FrameLayout splashscreenLoaderOverlay;
146
240
  private Runnable splashscreenTimeoutRunnable;
241
+ private FrameLayout previewTransitionLoaderOverlay;
242
+ private Runnable previewTransitionLoaderTimeoutRunnable;
243
+ private boolean previewTransitionLoaderRequested = false;
244
+ private WebViewListener webViewStatsListener;
245
+
246
+ private static final class FireAndForgetPluginCall extends PluginCall {
247
+
248
+ FireAndForgetPluginCall(final String methodName, final JSObject data) {
249
+ super(null, SPLASH_SCREEN_PLUGIN_ID, PluginCall.CALLBACK_ID_DANGLING, methodName, data);
250
+ }
251
+
252
+ @Override
253
+ public void successCallback(final PluginResult successResult) {}
254
+
255
+ @Override
256
+ public void resolve(final JSObject data) {}
257
+
258
+ @Override
259
+ public void resolve() {}
260
+
261
+ @Override
262
+ public void errorCallback(final String msg) {}
263
+
264
+ @Override
265
+ public void reject(final String msg, final String code, final Exception ex, final JSObject data) {}
266
+ }
147
267
 
148
268
  // App lifecycle observer using ProcessLifecycleOwner for reliable foreground/background detection
149
269
  private AppLifecycleObserver appLifecycleObserver;
@@ -154,6 +274,19 @@ public class CapacitorUpdaterPlugin extends Plugin {
154
274
  private static final int APP_UPDATE_REQUEST_CODE = 9001;
155
275
  private InstallStateUpdatedListener installStateUpdatedListener;
156
276
 
277
+ private PackageInfo getCurrentPackageInfo() throws PackageManager.NameNotFoundException {
278
+ final PackageManager packageManager = this.getContext().getPackageManager();
279
+ final String packageName = this.getContext().getPackageName();
280
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
281
+ return packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0));
282
+ }
283
+ return packageManager.getPackageInfo(packageName, 0);
284
+ }
285
+
286
+ private String getVersionCode(final PackageInfo packageInfo) {
287
+ return Long.toString(PackageInfoCompat.getLongVersionCode(packageInfo));
288
+ }
289
+
157
290
  private void notifyBreakingEvents(final String version) {
158
291
  if (version == null || version.isEmpty()) {
159
292
  return;
@@ -165,6 +298,26 @@ public class CapacitorUpdaterPlugin extends Plugin {
165
298
  }
166
299
  }
167
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
+
168
321
  private void persistLastFailedBundle(BundleInfo bundle) {
169
322
  if (this.prefs == null) {
170
323
  return;
@@ -196,6 +349,296 @@ public class CapacitorUpdaterPlugin extends Plugin {
196
349
  }
197
350
  }
198
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
+
199
642
  public Thread startNewThread(final Runnable function, Number waitTime) {
200
643
  Thread bgTask = new Thread(() -> {
201
644
  try {
@@ -243,13 +686,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
243
686
 
244
687
  @Override
245
688
  public void directUpdateFinish(final BundleInfo latest) {
246
- if (activity != null) {
247
- activity.runOnUiThread(() -> {
248
- CapacitorUpdaterPlugin.this.directUpdateFinish(latest);
249
- });
250
- } else {
251
- logger.warn("directUpdateFinish: Activity is null, skipping notification");
252
- }
689
+ CapacitorUpdaterPlugin.this.scheduleDirectUpdateFinish(latest);
253
690
  }
254
691
 
255
692
  @Override
@@ -263,54 +700,15 @@ public class CapacitorUpdaterPlugin extends Plugin {
263
700
  }
264
701
  }
265
702
  };
266
- final PackageInfo pInfo = this.getContext().getPackageManager().getPackageInfo(this.getContext().getPackageName(), 0);
703
+ final PackageInfo pInfo = this.getCurrentPackageInfo();
267
704
  this.implementation.activity = this.getActivity();
268
705
  this.implementation.versionBuild = this.getConfig().getString("version", pInfo.versionName);
269
706
  this.implementation.CAP_SERVER_PATH = WebView.CAP_SERVER_PATH;
270
707
  this.implementation.pluginVersion = this.pluginVersion;
271
- this.implementation.versionCode = Integer.toString(pInfo.versionCode);
708
+ this.implementation.versionCode = this.getVersionCode(pInfo);
272
709
  // Removed unused OkHttpClient creation - using shared client in DownloadService instead
273
- // Handle directUpdate configuration - support string values and backward compatibility
274
- String directUpdateConfig = this.getConfig().getString("directUpdate", null);
275
- if (directUpdateConfig != null) {
276
- // Handle backward compatibility for boolean true
277
- if (directUpdateConfig.equals("true")) {
278
- this.directUpdateMode = "always";
279
- this.implementation.directUpdate = true;
280
- } else {
281
- this.directUpdateMode = directUpdateConfig;
282
- this.implementation.directUpdate =
283
- directUpdateConfig.equals("always") ||
284
- directUpdateConfig.equals("atInstall") ||
285
- directUpdateConfig.equals("onLaunch");
286
- // Validate directUpdate value
287
- if (
288
- !directUpdateConfig.equals("false") &&
289
- !directUpdateConfig.equals("always") &&
290
- !directUpdateConfig.equals("atInstall") &&
291
- !directUpdateConfig.equals("onLaunch")
292
- ) {
293
- logger.error(
294
- "Invalid directUpdate value: \"" +
295
- directUpdateConfig +
296
- "\". Supported values are: \"false\", \"true\", \"always\", \"atInstall\", \"onLaunch\". Defaulting to \"false\"."
297
- );
298
- this.directUpdateMode = "false";
299
- this.implementation.directUpdate = false;
300
- }
301
- }
302
- } else {
303
- Boolean directUpdateBool = this.getConfig().getBoolean("directUpdate", false);
304
- if (directUpdateBool) {
305
- this.directUpdateMode = "always"; // backward compatibility: true = always
306
- this.implementation.directUpdate = true;
307
- } else {
308
- this.directUpdateMode = "false";
309
- this.implementation.directUpdate = false;
310
- }
311
- }
312
710
  this.currentVersionNative = new Version(this.getConfig().getString("version", pInfo.versionName));
313
- this.currentBuildVersion = Integer.toString(pInfo.versionCode);
711
+ this.currentBuildVersion = this.getVersionCode(pInfo);
314
712
  this.delayUpdateUtils = new DelayUpdateUtils(this.prefs, this.editor, this.currentVersionNative, logger);
315
713
  } catch (final PackageManager.NameNotFoundException e) {
316
714
  logger.error("Error instantiating implementation " + e.getMessage());
@@ -344,6 +742,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
344
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"
345
743
  );
346
744
  }
745
+ this.allowPreview = this.getConfig().getBoolean("allowPreview", false);
347
746
  logger.info("appId: " + implementation.appId);
348
747
 
349
748
  this.persistCustomId = this.getConfig().getBoolean("persistCustomId", false);
@@ -387,13 +786,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
387
786
  this.implementation.defaultChannel = this.getConfig().getString("defaultChannel", "");
388
787
  }
389
788
 
390
- int userValue = this.getConfig().getInt("periodCheckDelay", 0);
391
-
392
- if (userValue >= 0 && userValue <= 600) {
393
- this.periodCheckDelay = 600 * 1000;
394
- } else if (userValue > 600) {
395
- this.periodCheckDelay = userValue * 1000;
396
- }
789
+ this.periodCheckDelay = normalizedPeriodCheckDelayMs(this.getConfig().getInt("periodCheckDelay", 0));
397
790
 
398
791
  this.implementation.documentsDir = this.getContext().getFilesDir();
399
792
  this.implementation.prefs = this.prefs;
@@ -426,7 +819,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
426
819
  }
427
820
  }
428
821
  }
429
- this.autoUpdate = this.getConfig().getBoolean("autoUpdate", true);
822
+ this.configureAutoUpdateModeFromConfig();
430
823
  this.appReadyTimeout = Math.max(1000, this.getConfig().getInt("appReadyTimeout", 10000)); // Minimum 1 second
431
824
  this.keepUrlPathAfterReload = this.getConfig().getBoolean("keepUrlPathAfterReload", false);
432
825
  this.syncKeepUrlPathFlag(this.keepUrlPathAfterReload);
@@ -438,14 +831,45 @@ public class CapacitorUpdaterPlugin extends Plugin {
438
831
  this.implementation.timeout = this.getConfig().getInt("responseTimeout", 20) * 1000;
439
832
  this.shakeMenuEnabled = this.getConfig().getBoolean("shakeMenu", false);
440
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
+ }
441
854
  boolean resetWhenUpdate = this.getConfig().getBoolean("resetWhenUpdate", true);
442
855
 
443
856
  // Check if app was recently installed/updated BEFORE cleanupObsoleteVersions updates LatestVersionNative
444
857
  this.wasRecentlyInstalledOrUpdated = this.checkIfRecentlyInstalledOrUpdated();
858
+ final boolean nativeBuildVersionChanged = this.hasNativeBuildVersionChanged();
445
859
 
446
- 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();
447
869
  if (resetWhenUpdate) {
448
870
  this.cleanupObsoleteVersions();
871
+ } else {
872
+ this.persistCurrentNativeBuildVersion();
449
873
  }
450
874
 
451
875
  // Check for 'kill' delay condition on app launch
@@ -453,6 +877,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
453
877
  this.delayUpdateUtils.checkCancelDelay(DelayUpdateUtils.CancelDelaySource.KILLED);
454
878
 
455
879
  this.checkForUpdateAfterDelay();
880
+ this.showPreviewSessionNoticeIfNeeded();
881
+ this.syncShakeMenuLifecycle();
456
882
 
457
883
  // On Android 14+ (API 34+), topActivity in RecentTaskInfo returns null due to
458
884
  // security restrictions (StrandHogg task hijacking mitigations). Use ProcessLifecycleOwner
@@ -480,34 +906,82 @@ public class CapacitorUpdaterPlugin extends Plugin {
480
906
  }
481
907
  }
482
908
 
483
- private void semaphoreWait(Number waitTime) {
909
+ private boolean semaphoreWait(final int phase, Number waitTime) {
484
910
  try {
485
- semaphoreReady.awaitAdvanceInterruptibly(semaphoreReady.getPhase(), waitTime.longValue(), TimeUnit.SECONDS);
911
+ semaphoreReady.awaitAdvanceInterruptibly(phase, waitTime.longValue(), TimeUnit.MILLISECONDS);
486
912
  logger.info("semaphoreReady count " + semaphoreReady.getPhase());
913
+ return true;
487
914
  } catch (InterruptedException e) {
488
915
  logger.info("semaphoreWait InterruptedException");
916
+ cleanupTimedOutSemaphoreWait(phase);
489
917
  Thread.currentThread().interrupt(); // Restore interrupted status
918
+ return false;
490
919
  } catch (TimeoutException e) {
491
920
  logger.error("Semaphore timeout: " + e.getMessage());
492
- // Don't throw runtime exception, just log and continue
921
+ cleanupTimedOutSemaphoreWait(phase);
922
+ return false;
493
923
  }
494
924
  }
495
925
 
496
- private void semaphoreUp() {
926
+ private int semaphoreUp() {
497
927
  logger.info("semaphoreUp");
498
- semaphoreReady.register();
928
+ return semaphoreReady.register();
499
929
  }
500
930
 
501
931
  private void semaphoreDown() {
932
+ if (semaphoreReady.getRegisteredParties() == 0) {
933
+ logger.info("semaphoreDown skipped, no pending app ready wait");
934
+ return;
935
+ }
502
936
  logger.info("semaphoreDown");
503
937
  logger.info("semaphoreDown count " + semaphoreReady.getPhase());
504
938
  semaphoreReady.arriveAndDeregister();
505
939
  }
506
940
 
941
+ private void cleanupTimedOutSemaphoreWait(final int phase) {
942
+ if (semaphoreReady.getPhase() != phase || semaphoreReady.getRegisteredParties() == 0) {
943
+ return;
944
+ }
945
+ logger.info("Cleaning up stale app ready wait for phase " + phase);
946
+ semaphoreReady.arriveAndDeregister();
947
+ }
948
+
949
+ protected long getMinimumPendingBundleAppReadyTimeoutMs() {
950
+ return PENDING_BUNDLE_APP_READY_MIN_TIMEOUT_MS;
951
+ }
952
+
953
+ private long resolveAppReadyCheckTimeoutMs() {
954
+ long configuredTimeoutMs = this.appReadyTimeout.longValue();
955
+ try {
956
+ if (this.implementation == null) {
957
+ return configuredTimeoutMs;
958
+ }
959
+
960
+ final BundleInfo current = this.implementation.getCurrentBundle();
961
+ if (current == null || BundleStatus.SUCCESS == current.getStatus()) {
962
+ return configuredTimeoutMs;
963
+ }
964
+
965
+ return Math.max(configuredTimeoutMs, this.getMinimumPendingBundleAppReadyTimeoutMs());
966
+ } catch (final Exception e) {
967
+ logger.warn("Falling back to configured appReadyTimeout: " + e.getMessage());
968
+ return configuredTimeoutMs;
969
+ }
970
+ }
971
+
507
972
  private void sendReadyToJs(final BundleInfo current, final String msg) {
508
973
  sendReadyToJs(current, msg, false);
509
974
  }
510
975
 
976
+ private void notifyBundleSet(final BundleInfo bundle) {
977
+ if (bundle == null) {
978
+ return;
979
+ }
980
+ final JSObject ret = new JSObject();
981
+ ret.put("bundle", InternalUtils.mapToJSObject(bundle.toJSONMap()));
982
+ this.notifyListeners("set", ret, true);
983
+ }
984
+
511
985
  private void sendReadyToJs(final BundleInfo current, final String msg, final boolean isDirectUpdate) {
512
986
  logger.info("sendReadyToJs: " + msg);
513
987
  final JSObject ret = new JSObject();
@@ -522,6 +996,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
522
996
  if (this.autoSplashscreen) {
523
997
  this.hideSplashscreen();
524
998
  }
999
+ this.hidePreviewTransitionLoader("app-ready");
525
1000
  }
526
1001
 
527
1002
  private void hideSplashscreen() {
@@ -535,47 +1010,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
535
1010
  private void hideSplashscreenInternal() {
536
1011
  cancelSplashscreenTimeout();
537
1012
  removeSplashscreenLoader();
538
-
539
- try {
540
- if (getBridge() == null) {
541
- logger.warn("Bridge not ready for hiding splashscreen with autoSplashscreen");
542
- return;
543
- }
544
-
545
- // Try to call the SplashScreen plugin directly through the bridge
546
- PluginHandle splashScreenPlugin = getBridge().getPlugin("SplashScreen");
547
- if (splashScreenPlugin != null) {
548
- try {
549
- // Create a plugin call for the hide method using reflection to access private msgHandler
550
- JSObject options = new JSObject();
551
- java.lang.reflect.Field msgHandlerField = getBridge().getClass().getDeclaredField("msgHandler");
552
- msgHandlerField.setAccessible(true);
553
- Object msgHandler = msgHandlerField.get(getBridge());
554
-
555
- PluginCall call = new PluginCall(
556
- (com.getcapacitor.MessageHandler) msgHandler,
557
- "SplashScreen",
558
- "FAKE_CALLBACK_ID_HIDE",
559
- "hide",
560
- options
561
- );
562
-
563
- // Call the hide method directly
564
- splashScreenPlugin.invoke("hide", call);
565
- logger.info("Splashscreen hidden automatically via direct plugin call");
566
- } catch (Exception e) {
567
- logger.error("Failed to call SplashScreen hide method: " + e.getMessage());
568
- }
569
- } else {
570
- logger.warn("autoSplashscreen: SplashScreen plugin not found. Install @capacitor/splash-screen plugin.");
571
- }
572
- } catch (Exception e) {
573
- logger.error(
574
- "Error hiding splashscreen with autoSplashscreen: " +
575
- e.getMessage() +
576
- ". Make sure @capacitor/splash-screen plugin is installed and configured."
577
- );
578
- }
1013
+ invokeSplashScreenPluginMethod("hide", new JSObject(), SPLASH_SCREEN_MAX_RETRIES, ++this.splashscreenInvocationToken);
579
1014
  }
580
1015
 
581
1016
  private void showSplashscreen() {
@@ -590,75 +1025,139 @@ public class CapacitorUpdaterPlugin extends Plugin {
590
1025
  cancelSplashscreenTimeout();
591
1026
  this.autoSplashscreenTimedOut = false;
592
1027
 
593
- try {
594
- if (getBridge() == null) {
595
- logger.warn("Bridge not ready for showing splashscreen with autoSplashscreen");
596
- } else {
597
- PluginHandle splashScreenPlugin = getBridge().getPlugin("SplashScreen");
598
- if (splashScreenPlugin != null) {
599
- JSObject options = new JSObject();
600
- java.lang.reflect.Field msgHandlerField = getBridge().getClass().getDeclaredField("msgHandler");
601
- msgHandlerField.setAccessible(true);
602
- Object msgHandler = msgHandlerField.get(getBridge());
603
-
604
- PluginCall call = new PluginCall(
605
- (com.getcapacitor.MessageHandler) msgHandler,
606
- "SplashScreen",
607
- "FAKE_CALLBACK_ID_SHOW",
608
- "show",
609
- options
610
- );
611
-
612
- splashScreenPlugin.invoke("show", call);
613
- logger.info("Splashscreen shown synchronously to prevent flash");
614
- } else {
615
- logger.warn("autoSplashscreen: SplashScreen plugin not found");
616
- }
617
- }
618
- } catch (Exception e) {
619
- logger.error("Failed to show splashscreen synchronously: " + e.getMessage());
620
- }
1028
+ final JSObject options = new JSObject();
1029
+ options.put("autoHide", false);
1030
+ invokeSplashScreenPluginMethod("show", options, SPLASH_SCREEN_MAX_RETRIES, ++this.splashscreenInvocationToken);
621
1031
 
622
1032
  addSplashscreenLoaderIfNeeded();
623
1033
  scheduleSplashscreenTimeout();
624
1034
  }
625
1035
 
626
- private void addSplashscreenLoaderIfNeeded() {
627
- if (!Boolean.TRUE.equals(this.autoSplashscreenLoader)) {
1036
+ private void invokeSplashScreenPluginMethod(
1037
+ final String methodName,
1038
+ final JSObject options,
1039
+ final int retriesRemaining,
1040
+ final int requestToken
1041
+ ) {
1042
+ if (requestToken != this.splashscreenInvocationToken) {
628
1043
  return;
629
1044
  }
630
1045
 
631
- Runnable addLoader = () -> {
632
- if (this.splashscreenLoaderOverlay != null) {
1046
+ try {
1047
+ final Bridge bridge = getBridge();
1048
+ if (bridge == null) {
1049
+ retrySplashScreenInvocation(
1050
+ methodName,
1051
+ options,
1052
+ retriesRemaining,
1053
+ requestToken,
1054
+ "Bridge not ready for " + ("show".equals(methodName) ? "showing" : "hiding") + " splashscreen"
1055
+ );
633
1056
  return;
634
1057
  }
635
1058
 
636
- Activity activity = getActivity();
637
- if (activity == null) {
638
- logger.warn("autoSplashscreen: Activity not available for loader overlay");
1059
+ final PluginHandle splashScreenPlugin = bridge.getPlugin(SPLASH_SCREEN_PLUGIN_ID);
1060
+ if (splashScreenPlugin == null) {
1061
+ retrySplashScreenInvocation(
1062
+ methodName,
1063
+ options,
1064
+ retriesRemaining,
1065
+ requestToken,
1066
+ "autoSplashscreen: SplashScreen plugin not found. Install @capacitor/splash-screen plugin."
1067
+ );
639
1068
  return;
640
1069
  }
641
1070
 
642
- ProgressBar progressBar = new ProgressBar(activity);
643
- progressBar.setIndeterminate(true);
644
-
645
- FrameLayout overlay = new FrameLayout(activity);
646
- overlay.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
647
- overlay.setClickable(false);
648
- overlay.setFocusable(false);
649
- overlay.setBackgroundColor(Color.TRANSPARENT);
650
- overlay.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
1071
+ splashScreenPlugin.invoke(methodName, new FireAndForgetPluginCall(methodName, options));
1072
+ logger.info("Splashscreen " + methodName + " invoked automatically");
1073
+ } catch (final Exception e) {
1074
+ retrySplashScreenInvocation(
1075
+ methodName,
1076
+ options,
1077
+ retriesRemaining,
1078
+ requestToken,
1079
+ "Failed to call SplashScreen " + methodName + " method: " + e.getMessage()
1080
+ );
1081
+ }
1082
+ }
651
1083
 
652
- FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
653
- ViewGroup.LayoutParams.WRAP_CONTENT,
654
- ViewGroup.LayoutParams.WRAP_CONTENT
1084
+ private void retrySplashScreenInvocation(
1085
+ final String methodName,
1086
+ final JSObject options,
1087
+ final int retriesRemaining,
1088
+ final int requestToken,
1089
+ final String message
1090
+ ) {
1091
+ if (retriesRemaining > 0) {
1092
+ logger.info(message + ". Retrying.");
1093
+ this.mainHandler.postDelayed(
1094
+ () -> invokeSplashScreenPluginMethod(methodName, options, retriesRemaining - 1, requestToken),
1095
+ SPLASH_SCREEN_RETRY_DELAY_MS
655
1096
  );
656
- params.gravity = Gravity.CENTER;
657
- overlay.addView(progressBar, params);
1097
+ return;
1098
+ }
658
1099
 
659
- ViewGroup decorView = (ViewGroup) activity.getWindow().getDecorView();
660
- decorView.addView(overlay);
1100
+ if ("show".equals(methodName)) {
1101
+ logger.warn(message);
1102
+ } else {
1103
+ logger.error(message);
1104
+ }
1105
+ }
1106
+
1107
+ boolean isCurrentSplashscreenInvocationTokenForTesting(final int requestToken) {
1108
+ return requestToken == this.splashscreenInvocationToken;
1109
+ }
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
+
1143
+ private void addSplashscreenLoaderIfNeeded() {
1144
+ if (!Boolean.TRUE.equals(this.autoSplashscreenLoader)) {
1145
+ return;
1146
+ }
1147
+
1148
+ Runnable addLoader = () -> {
1149
+ if (this.splashscreenLoaderOverlay != null) {
1150
+ return;
1151
+ }
1152
+
1153
+ Activity activity = getActivity();
1154
+ if (activity == null) {
1155
+ logger.warn("autoSplashscreen: Activity not available for loader overlay");
1156
+ return;
1157
+ }
661
1158
 
1159
+ FrameLayout overlay = createLoaderOverlay(activity, false, Color.TRANSPARENT);
1160
+ attachLoaderOverlay(activity, overlay);
662
1161
  this.splashscreenLoaderOverlay = overlay;
663
1162
  };
664
1163
 
@@ -672,10 +1171,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
672
1171
  private void removeSplashscreenLoader() {
673
1172
  Runnable removeLoader = () -> {
674
1173
  if (this.splashscreenLoaderOverlay != null) {
675
- ViewGroup parent = (ViewGroup) this.splashscreenLoaderOverlay.getParent();
676
- if (parent != null) {
677
- parent.removeView(this.splashscreenLoaderOverlay);
678
- }
1174
+ removeLoaderOverlay(this.splashscreenLoaderOverlay);
679
1175
  this.splashscreenLoaderOverlay = null;
680
1176
  }
681
1177
  };
@@ -687,6 +1183,84 @@ public class CapacitorUpdaterPlugin extends Plugin {
687
1183
  }
688
1184
  }
689
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
+
690
1264
  private void scheduleSplashscreenTimeout() {
691
1265
  if (this.autoSplashscreenTimeout == null || this.autoSplashscreenTimeout <= 0) {
692
1266
  return;
@@ -713,7 +1287,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
713
1287
 
714
1288
  private boolean checkIfRecentlyInstalledOrUpdated() {
715
1289
  String currentVersion = this.currentBuildVersion;
716
- String lastKnownVersion = this.prefs.getString("LatestNativeBuildVersion", "");
1290
+ String lastKnownVersion = this.getStoredNativeBuildVersion();
717
1291
 
718
1292
  if (lastKnownVersion.isEmpty()) {
719
1293
  // First time running, consider it as recently installed
@@ -726,7 +1300,524 @@ public class CapacitorUpdaterPlugin extends Plugin {
726
1300
  return false;
727
1301
  }
728
1302
 
1303
+ private boolean hasNativeBuildVersionChanged() {
1304
+ final String lastKnownVersion = this.getStoredNativeBuildVersion();
1305
+ return !lastKnownVersion.isEmpty() && !lastKnownVersion.equals(this.currentBuildVersion);
1306
+ }
1307
+
1308
+ void reportNativeVersionStatsIfChanged() {
1309
+ if (this.implementation == null || this.prefs == null || this.editor == null) {
1310
+ return;
1311
+ }
1312
+
1313
+ this.reportNativeVersionStatsIfChanged(
1314
+ this.implementation.versionBuild,
1315
+ this.implementation.versionCode,
1316
+ this.implementation.versionOs
1317
+ );
1318
+ }
1319
+
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) {
1326
+ return;
1327
+ }
1328
+
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
+ }
1353
+
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
+ }
1386
+ }
1387
+
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();
1395
+ }
1396
+
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();
1405
+ }
1406
+
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
1422
+ );
1423
+ }
1424
+
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
+ }
1442
+ }
1443
+
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
+ }
1471
+ }
1472
+
1473
+ static String truncateStatsMetadataValue(final String value, final int maxLength) {
1474
+ return value.length() <= maxLength ? value : value.substring(0, maxLength);
1475
+ }
1476
+
1477
+ private void installWebViewStatsReporter() {
1478
+ if (this.bridge == null || this.bridge.getWebView() == null || this.webViewStatsListener != null) {
1479
+ return;
1480
+ }
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
+ @Override
1498
+ public boolean onRenderProcessGone(final android.webkit.WebView view, final RenderProcessGoneDetail detail) {
1499
+ final Map<String, String> metadata = CapacitorUpdaterPlugin.this.buildWebViewRenderProcessGoneMetadata(detail);
1500
+ CapacitorUpdaterPlugin.this.persistPendingWebViewRenderProcessGone(metadata);
1501
+ return false;
1502
+ }
1503
+ };
1504
+
1505
+ this.bridge.addWebViewListener(this.webViewStatsListener);
1506
+ this.evaluateWebViewStatsReporterScript(webView, script);
1507
+ }
1508
+
1509
+ private void installDocumentStartWebViewStatsReporter(final android.webkit.WebView webView, final String script) {
1510
+ try {
1511
+ final Class<?> webViewFeature = Class.forName("androidx.webkit.WebViewFeature");
1512
+ final String feature = (String) webViewFeature.getField("DOCUMENT_START_SCRIPT").get(null);
1513
+ final Boolean supported = (Boolean) webViewFeature.getMethod("isFeatureSupported", String.class).invoke(null, feature);
1514
+ if (!Boolean.TRUE.equals(supported)) {
1515
+ return;
1516
+ }
1517
+
1518
+ final String allowedOrigin = Uri.parse(this.bridge.getAppUrl())
1519
+ .buildUpon()
1520
+ .path(null)
1521
+ .fragment(null)
1522
+ .clearQuery()
1523
+ .build()
1524
+ .toString();
1525
+ final Class<?> webViewCompat = Class.forName("androidx.webkit.WebViewCompat");
1526
+ webViewCompat
1527
+ .getMethod("addDocumentStartJavaScript", android.webkit.WebView.class, String.class, Set.class)
1528
+ .invoke(null, webView, script, java.util.Collections.singleton(allowedOrigin));
1529
+ } catch (final Exception e) {
1530
+ logger.debug("Unable to install document-start WebView stats reporter: " + e.getMessage());
1531
+ }
1532
+ }
1533
+
1534
+ private void evaluateWebViewStatsReporterScript(final android.webkit.WebView webView, final String script) {
1535
+ if (webView == null) {
1536
+ return;
1537
+ }
1538
+
1539
+ this.mainHandler.post(() -> {
1540
+ try {
1541
+ webView.evaluateJavascript(script, null);
1542
+ } catch (final Exception e) {
1543
+ logger.debug("Unable to evaluate WebView stats reporter: " + e.getMessage());
1544
+ }
1545
+ });
1546
+ }
1547
+
1548
+ private Map<String, String> buildWebViewRenderProcessGoneMetadata(final RenderProcessGoneDetail detail) {
1549
+ final Map<String, String> metadata = new HashMap<>();
1550
+ metadata.put("error_type", "render_process_gone");
1551
+ metadata.put("source", "android_on_render_process_gone");
1552
+ metadata.put("timestamp", Long.toString(System.currentTimeMillis()));
1553
+ if (detail != null) {
1554
+ metadata.put("did_crash", Boolean.toString(detail.didCrash()));
1555
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1556
+ metadata.put("renderer_priority_at_exit", Integer.toString(detail.rendererPriorityAtExit()));
1557
+ }
1558
+ }
1559
+ return metadata;
1560
+ }
1561
+
1562
+ private void persistPendingWebViewRenderProcessGone(final Map<String, String> metadata) {
1563
+ try {
1564
+ final JSONObject json = new JSONObject(metadata);
1565
+ this.prefs.edit().putString(LAST_WEBVIEW_RENDER_PROCESS_GONE_PREF_KEY, json.toString()).commit();
1566
+ } catch (final Exception e) {
1567
+ logger.debug("Unable to persist WebView render process crash metadata: " + e.getMessage());
1568
+ }
1569
+ }
1570
+
1571
+ private void reportPreviousWebViewRenderProcessGone() {
1572
+ if (this.implementation == null || this.implementation.statsUrl.isEmpty()) {
1573
+ return;
1574
+ }
1575
+
1576
+ final String rawMetadata = this.prefs.getString(LAST_WEBVIEW_RENDER_PROCESS_GONE_PREF_KEY, "");
1577
+ if (rawMetadata == null || rawMetadata.isEmpty()) {
1578
+ return;
1579
+ }
1580
+
1581
+ try {
1582
+ final Map<String, String> metadata = jsonObjectToStringMap(new JSONObject(rawMetadata));
1583
+ metadata.put("reported_after_restart", "true");
1584
+ this.reportWebViewStats("webview_render_process_gone", metadata);
1585
+ this.prefs.edit().remove(LAST_WEBVIEW_RENDER_PROCESS_GONE_PREF_KEY).apply();
1586
+ } catch (final JSONException e) {
1587
+ this.prefs.edit().remove(LAST_WEBVIEW_RENDER_PROCESS_GONE_PREF_KEY).apply();
1588
+ }
1589
+ }
1590
+
1591
+ private static Map<String, String> jsonObjectToStringMap(final JSONObject json) throws JSONException {
1592
+ final Map<String, String> map = new HashMap<>();
1593
+ final JSONArray names = json.names();
1594
+ if (names == null) {
1595
+ return map;
1596
+ }
1597
+
1598
+ for (int i = 0; i < names.length(); i++) {
1599
+ final String key = names.getString(i);
1600
+ final String value = json.optString(key, "");
1601
+ if (!value.isEmpty()) {
1602
+ map.put(key, value);
1603
+ }
1604
+ }
1605
+ return map;
1606
+ }
1607
+
1608
+ private static JSObject jsonObjectToJSObject(final JSONObject json) throws JSONException {
1609
+ final JSObject ret = new JSObject();
1610
+ final JSONArray names = json.names();
1611
+ if (names == null) {
1612
+ return ret;
1613
+ }
1614
+ for (int i = 0; i < names.length(); i++) {
1615
+ final String key = names.getString(i);
1616
+ ret.put(key, json.get(key));
1617
+ }
1618
+ return ret;
1619
+ }
1620
+
1621
+ @PluginMethod
1622
+ public void reportWebViewError(final PluginCall call) {
1623
+ final JSObject data = call.getData();
1624
+ this.reportWebViewStats(
1625
+ statsActionForWebViewErrorType(data.optString("type", "javascript_error")),
1626
+ buildWebViewErrorMetadata(data)
1627
+ );
1628
+ call.resolve();
1629
+ }
1630
+
1631
+ private void reportWebViewStats(final String action, final Map<String, String> metadata) {
1632
+ if (this.implementation == null) {
1633
+ return;
1634
+ }
1635
+
1636
+ final BundleInfo current = this.implementation.getCurrentBundle();
1637
+ final String versionName = current == null ? "" : current.getVersionName();
1638
+ this.implementation.sendStats(action, versionName, "", metadata);
1639
+ }
1640
+
1641
+ static String statsActionForWebViewErrorType(final String type) {
1642
+ switch (type) {
1643
+ case "unhandled_rejection":
1644
+ return "webview_unhandled_rejection";
1645
+ case "resource_error":
1646
+ return "webview_resource_error";
1647
+ case "security_policy_violation":
1648
+ return "webview_security_policy_violation";
1649
+ case "webview_unclean_restart":
1650
+ return "webview_unclean_restart";
1651
+ case "render_process_gone":
1652
+ return "webview_render_process_gone";
1653
+ case "web_content_process_terminated":
1654
+ return "webview_content_process_terminated";
1655
+ case "javascript_error":
1656
+ default:
1657
+ return "webview_javascript_error";
1658
+ }
1659
+ }
1660
+
1661
+ static Map<String, String> buildWebViewErrorMetadata(final JSObject data) {
1662
+ final Map<String, String> metadata = new HashMap<>();
1663
+ putStatsMetadataValue(metadata, "error_type", data.optString("type", "javascript_error"), 64);
1664
+ putStatsMetadataValue(metadata, "message", data.optString("message", ""), 1024);
1665
+ putStatsMetadataValue(metadata, "source", sanitizeStatsMetadataUrl(data.optString("source", "")), 512);
1666
+ putStatsMetadataValue(metadata, "line", data.optString("line", data.optString("lineno", "")), 32);
1667
+ putStatsMetadataValue(metadata, "column", data.optString("column", data.optString("colno", "")), 32);
1668
+ putStatsMetadataValue(metadata, "stack", data.optString("stack", ""), 2048);
1669
+ putStatsMetadataValue(metadata, "tag_name", data.optString("tag_name", ""), 64);
1670
+ putStatsMetadataValue(metadata, "href", sanitizeStatsMetadataUrl(data.optString("href", "")), 512);
1671
+ putStatsMetadataValue(metadata, "user_agent", data.optString("user_agent", ""), 256);
1672
+ putStatsMetadataValue(metadata, "session_id", data.optString("session_id", ""), 128);
1673
+ putStatsMetadataValue(metadata, "previous_session_id", data.optString("previous_session_id", ""), 128);
1674
+ putStatsMetadataValue(metadata, "previous_href", sanitizeStatsMetadataUrl(data.optString("previous_href", "")), 512);
1675
+ putStatsMetadataValue(metadata, "previous_started_at", data.optString("previous_started_at", ""), 64);
1676
+ putStatsMetadataValue(metadata, "previous_updated_at", data.optString("previous_updated_at", ""), 64);
1677
+ return metadata;
1678
+ }
1679
+
1680
+ private static void putStatsMetadataValue(
1681
+ final Map<String, String> metadata,
1682
+ final String key,
1683
+ final String value,
1684
+ final int maxLength
1685
+ ) {
1686
+ if (value == null || value.isEmpty()) {
1687
+ return;
1688
+ }
1689
+
1690
+ metadata.put(key, truncateStatsMetadataValue(value, maxLength));
1691
+ }
1692
+
1693
+ static String sanitizeStatsMetadataUrl(final String value) {
1694
+ if (value == null || value.isEmpty()) {
1695
+ return "";
1696
+ }
1697
+
1698
+ try {
1699
+ final java.net.URI uri = new java.net.URI(value);
1700
+ if (uri.getScheme() != null && uri.getHost() != null) {
1701
+ final String path = sanitizeStatsMetadataUrlPath(uri.getPath());
1702
+ return new java.net.URI(
1703
+ uri.getScheme(),
1704
+ null,
1705
+ uri.getHost(),
1706
+ uri.getPort(),
1707
+ path.isEmpty() ? null : path,
1708
+ null,
1709
+ null
1710
+ ).toString();
1711
+ }
1712
+ } catch (Exception ignored) {}
1713
+
1714
+ try {
1715
+ final Uri uri = Uri.parse(value);
1716
+ if (uri.getScheme() != null && uri.getHost() != null) {
1717
+ final String host = stripUrlUserInfo(uri.getHost());
1718
+ if (host.isEmpty()) {
1719
+ return stripUrlQueryAndFragment(value);
1720
+ }
1721
+ final StringBuilder authority = new StringBuilder(host);
1722
+ if (uri.getPort() != -1) {
1723
+ authority.append(':').append(uri.getPort());
1724
+ }
1725
+ final Uri.Builder builder = new Uri.Builder().scheme(uri.getScheme()).authority(authority.toString());
1726
+ final String path = sanitizeStatsMetadataUrlPath(uri.getPath());
1727
+ if (!path.isEmpty()) {
1728
+ builder.path(path);
1729
+ }
1730
+ return builder.build().toString();
1731
+ }
1732
+ } catch (Exception ignored) {}
1733
+
1734
+ return stripUrlQueryAndFragment(value);
1735
+ }
1736
+
1737
+ private static String sanitizeStatsMetadataUrlPath(final String path) {
1738
+ if (path == null || path.isEmpty()) {
1739
+ return "";
1740
+ }
1741
+
1742
+ final String[] segments = path.split("/", -1);
1743
+ for (int index = 0; index < segments.length; index++) {
1744
+ if (isSensitiveUrlPathSegment(segments[index])) {
1745
+ segments[index] = "redacted";
1746
+ }
1747
+ }
1748
+ return String.join("/", segments);
1749
+ }
1750
+
1751
+ private static String stripUrlUserInfo(final String host) {
1752
+ if (host == null || host.isEmpty()) {
1753
+ return "";
1754
+ }
1755
+
1756
+ final int userInfoIndex = host.lastIndexOf('@');
1757
+ if (userInfoIndex < 0) {
1758
+ return host;
1759
+ }
1760
+ return host.substring(userInfoIndex + 1);
1761
+ }
1762
+
1763
+ private static boolean isSensitiveUrlPathSegment(final String segment) {
1764
+ return (
1765
+ segment.matches("[0-9]{6,}") ||
1766
+ segment.matches("[0-9a-fA-F]{16,}") ||
1767
+ segment.matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
1768
+ );
1769
+ }
1770
+
1771
+ private static String stripUrlQueryAndFragment(final String value) {
1772
+ int end = value.length();
1773
+ final int queryIndex = value.indexOf('?');
1774
+ final int fragmentIndex = value.indexOf('#');
1775
+ if (queryIndex >= 0) {
1776
+ end = Math.min(end, queryIndex);
1777
+ }
1778
+ if (fragmentIndex >= 0) {
1779
+ end = Math.min(end, fragmentIndex);
1780
+ }
1781
+ return value.substring(0, end);
1782
+ }
1783
+
1784
+ static String buildWebViewStatsReporterScript() {
1785
+ return (
1786
+ "(function(){" +
1787
+ "if(window.__capgoWebViewErrorReporterInstalled){return;}" +
1788
+ "window.__capgoWebViewErrorReporterInstalled=true;" +
1789
+ "var maxReports=20,sentReports=0,queue=[],seen={};" +
1790
+ "var sessionKey='CapacitorUpdater.webViewSession';" +
1791
+ "var sessionId=String(Date.now())+'-'+Math.random().toString(36).slice(2);" +
1792
+ "function s(value){try{if(value===undefined){return '';}if(value===null){return 'null';}if(typeof value==='string'){return value;}if(value&&typeof value.message==='string'){return value.message;}return String(value);}catch(_){return '';}}" +
1793
+ "function stack(value){try{return value&&value.stack?String(value.stack):'';}catch(_){return '';}}" +
1794
+ "function updater(){var cap=window.Capacitor;if(!cap||!cap.Plugins){return null;}return cap.Plugins.CapacitorUpdater||null;}" +
1795
+ "function flush(){var plugin=updater();if(!plugin||typeof plugin.reportWebViewError!=='function'){return false;}while(queue.length){var payload=queue.shift();try{var result=plugin.reportWebViewError(payload);if(result&&typeof result.catch==='function'){result.catch(function(){});}}catch(_){}}return true;}" +
1796
+ "var retries=0;function scheduleFlush(){if(flush()){return;}if(retries++<40){setTimeout(scheduleFlush,250);}}" +
1797
+ "function send(payload){try{if(sentReports>=maxReports){return;}payload.href=payload.href||location.href||'';payload.user_agent=navigator.userAgent||'';payload.session_id=sessionId;var key=[payload.type,payload.message,payload.source,payload.line,payload.column,payload.tag_name].join('|');if(seen[key]){return;}seen[key]=true;sentReports+=1;queue.push(payload);scheduleFlush();}catch(_){}}" +
1798
+ "function readSession(){try{return JSON.parse(localStorage.getItem(sessionKey)||'null')||null;}catch(_){return null;}}" +
1799
+ "function writeSession(active){try{localStorage.setItem(sessionKey,JSON.stringify({id:sessionId,active:active,href:location.href||'',started_at:window.__capgoWebViewSessionStartedAt,updated_at:String(Date.now())}));}catch(_){}}" +
1800
+ "window.__capgoWebViewSessionStartedAt=String(Date.now());" +
1801
+ "var previous=readSession();" +
1802
+ "if(previous&&previous.active){send({type:'webview_unclean_restart',message:'WebView restarted without a clean page unload',previous_session_id:s(previous.id),previous_href:s(previous.href),previous_started_at:s(previous.started_at),previous_updated_at:s(previous.updated_at)});}" +
1803
+ "writeSession(true);" +
1804
+ "setInterval(function(){writeSession(true);},15000);" +
1805
+ "function markClean(){writeSession(false);}" +
1806
+ "window.addEventListener('pagehide',markClean,true);" +
1807
+ "window.addEventListener('beforeunload',markClean,true);" +
1808
+ "window.addEventListener('error',function(event){var target=event&&event.target;if(target&&target!==window&&(target.src||target.href)){send({type:'resource_error',message:'Resource failed to load',source:s(target.src||target.href),tag_name:s(target.tagName)});return;}send({type:'javascript_error',message:s((event&&event.message)||(event&&event.error)),source:s(event&&event.filename),line:s(event&&event.lineno),column:s(event&&event.colno),stack:stack(event&&event.error)});},true);" +
1809
+ "window.addEventListener('unhandledrejection',function(event){var reason=event&&event.reason;send({type:'unhandled_rejection',message:s(reason),stack:stack(reason)});},true);" +
1810
+ "document.addEventListener('securitypolicyviolation',function(event){send({type:'security_policy_violation',message:s(event&&event.violatedDirective),source:s(event&&event.blockedURI)});},true);" +
1811
+ "document.addEventListener('deviceready',scheduleFlush,false);" +
1812
+ "setTimeout(scheduleFlush,0);" +
1813
+ "})();"
1814
+ );
1815
+ }
1816
+
729
1817
  private boolean shouldUseDirectUpdate() {
1818
+ if (!Boolean.TRUE.equals(this.autoUpdate) || AUTO_UPDATE_MODE_ONLY_DOWNLOAD.equals(this.autoUpdateMode)) {
1819
+ return false;
1820
+ }
730
1821
  if (Boolean.TRUE.equals(this.autoSplashscreenTimedOut)) {
731
1822
  return false;
732
1823
  }
@@ -757,25 +1848,291 @@ public class CapacitorUpdaterPlugin extends Plugin {
757
1848
  }
758
1849
  }
759
1850
 
760
- private boolean isDirectUpdateCurrentlyAllowed(final boolean plannedDirectUpdate) {
761
- return plannedDirectUpdate && !Boolean.TRUE.equals(this.autoSplashscreenTimedOut);
762
- }
763
-
764
- private void directUpdateFinish(final BundleInfo latest) {
765
- if ("onLaunch".equals(this.directUpdateMode)) {
766
- this.onLaunchDirectUpdateUsed = true;
767
- this.implementation.directUpdate = false;
768
- }
769
- CapacitorUpdaterPlugin.this.implementation.set(latest);
770
- CapacitorUpdaterPlugin.this._reload();
771
- sendReadyToJs(latest, "update installed", true);
1851
+ private void configureAutoUpdateModeFromConfig() {
1852
+ final String configuredMode = this.getConfig().getString("autoUpdate", null);
1853
+ if (configuredMode != null && !configuredMode.isEmpty() && !"true".equals(configuredMode) && !"false".equals(configuredMode)) {
1854
+ this.autoUpdateMode = normalizedAutoUpdateMode(configuredMode);
1855
+ if (!this.autoUpdateMode.equals(configuredMode)) {
1856
+ logger.error(
1857
+ "Invalid autoUpdate value: \"" +
1858
+ configuredMode +
1859
+ "\". Supported values are: true, false, \"off\", \"atBackground\", \"atInstall\", \"onLaunch\", \"always\", \"onlyDownload\". Defaulting to \"atBackground\"."
1860
+ );
1861
+ }
1862
+ } else {
1863
+ final boolean enabled =
1864
+ configuredMode != null
1865
+ ? "true".equals(configuredMode)
1866
+ : Boolean.TRUE.equals(this.getConfig().getBoolean("autoUpdate", true));
1867
+ this.autoUpdateMode = enabled
1868
+ ? autoUpdateModeForLegacyDirectUpdateMode(this.resolveLegacyDirectUpdateModeFromConfig())
1869
+ : AUTO_UPDATE_MODE_OFF;
1870
+ }
1871
+
1872
+ this.autoUpdate = isAutoUpdateModeEnabled(this.autoUpdateMode);
1873
+ this.directUpdateMode = directUpdateModeForAutoUpdateMode(this.autoUpdateMode);
1874
+ this.implementation.directUpdate = isDirectUpdateMode(this.directUpdateMode);
1875
+ }
1876
+
1877
+ private String resolveLegacyDirectUpdateModeFromConfig() {
1878
+ final String directUpdateConfig = this.getConfig().getString("directUpdate", null);
1879
+ if (directUpdateConfig != null) {
1880
+ if ("true".equals(directUpdateConfig)) {
1881
+ return AUTO_UPDATE_MODE_ALWAYS;
1882
+ }
1883
+ if ("false".equals(directUpdateConfig) || isDirectUpdateMode(directUpdateConfig)) {
1884
+ return directUpdateConfig;
1885
+ }
1886
+ logger.error(
1887
+ "Invalid directUpdate value: \"" +
1888
+ directUpdateConfig +
1889
+ "\". Supported values are: false, true, \"always\", \"atInstall\", \"onLaunch\". Defaulting to \"false\"."
1890
+ );
1891
+ return "false";
1892
+ }
1893
+
1894
+ return Boolean.TRUE.equals(this.getConfig().getBoolean("directUpdate", false)) ? AUTO_UPDATE_MODE_ALWAYS : "false";
1895
+ }
1896
+
1897
+ static String normalizedAutoUpdateMode(final String value) {
1898
+ if (value == null) {
1899
+ return AUTO_UPDATE_MODE_BACKGROUND;
1900
+ }
1901
+ switch (value) {
1902
+ case "false":
1903
+ case AUTO_UPDATE_MODE_OFF:
1904
+ return AUTO_UPDATE_MODE_OFF;
1905
+ case "true":
1906
+ case AUTO_UPDATE_MODE_BACKGROUND:
1907
+ return AUTO_UPDATE_MODE_BACKGROUND;
1908
+ case AUTO_UPDATE_MODE_INSTALL:
1909
+ case AUTO_UPDATE_MODE_LAUNCH:
1910
+ case AUTO_UPDATE_MODE_ALWAYS:
1911
+ case AUTO_UPDATE_MODE_ONLY_DOWNLOAD:
1912
+ return value;
1913
+ default:
1914
+ return AUTO_UPDATE_MODE_BACKGROUND;
1915
+ }
1916
+ }
1917
+
1918
+ static String normalizedShakeMenuGesture(final String value) {
1919
+ if (value == null || value.trim().isEmpty()) {
1920
+ return SHAKE_MENU_GESTURE_SHAKE;
1921
+ }
1922
+ final String normalized = value.trim();
1923
+ if (SHAKE_MENU_GESTURE_THREE_FINGER_PINCH.equals(normalized)) {
1924
+ return SHAKE_MENU_GESTURE_THREE_FINGER_PINCH;
1925
+ }
1926
+ return SHAKE_MENU_GESTURE_SHAKE;
1927
+ }
1928
+
1929
+ static boolean isSupportedShakeMenuGesture(final String value) {
1930
+ if (value == null) {
1931
+ return true;
1932
+ }
1933
+ final String normalized = value.trim();
1934
+ if (normalized.isEmpty()) {
1935
+ return false;
1936
+ }
1937
+ return SHAKE_MENU_GESTURE_SHAKE.equals(normalized) || SHAKE_MENU_GESTURE_THREE_FINGER_PINCH.equals(normalized);
1938
+ }
1939
+
1940
+ static String autoUpdateModeForLegacyDirectUpdateMode(final String directUpdateMode) {
1941
+ switch (directUpdateMode) {
1942
+ case AUTO_UPDATE_MODE_INSTALL:
1943
+ case AUTO_UPDATE_MODE_LAUNCH:
1944
+ case AUTO_UPDATE_MODE_ALWAYS:
1945
+ return directUpdateMode;
1946
+ case "false":
1947
+ default:
1948
+ return AUTO_UPDATE_MODE_BACKGROUND;
1949
+ }
1950
+ }
1951
+
1952
+ static String directUpdateModeForAutoUpdateMode(final String autoUpdateMode) {
1953
+ switch (autoUpdateMode) {
1954
+ case AUTO_UPDATE_MODE_INSTALL:
1955
+ case AUTO_UPDATE_MODE_LAUNCH:
1956
+ case AUTO_UPDATE_MODE_ALWAYS:
1957
+ return autoUpdateMode;
1958
+ default:
1959
+ return "false";
1960
+ }
1961
+ }
1962
+
1963
+ static boolean isAutoUpdateModeEnabled(final String autoUpdateMode) {
1964
+ return !AUTO_UPDATE_MODE_OFF.equals(autoUpdateMode);
1965
+ }
1966
+
1967
+ static boolean shouldAutoUpdateModeSetNextBundle(final String autoUpdateMode) {
1968
+ return isAutoUpdateModeEnabled(autoUpdateMode) && !AUTO_UPDATE_MODE_ONLY_DOWNLOAD.equals(autoUpdateMode);
1969
+ }
1970
+
1971
+ static boolean isDirectUpdateMode(final String directUpdateMode) {
1972
+ return (
1973
+ AUTO_UPDATE_MODE_INSTALL.equals(directUpdateMode) ||
1974
+ AUTO_UPDATE_MODE_LAUNCH.equals(directUpdateMode) ||
1975
+ AUTO_UPDATE_MODE_ALWAYS.equals(directUpdateMode)
1976
+ );
1977
+ }
1978
+
1979
+ private boolean shouldAutoSetNextBundle() {
1980
+ return shouldAutoUpdateModeSetNextBundle(this.autoUpdateMode);
1981
+ }
1982
+
1983
+ private boolean isDirectUpdateCurrentlyAllowed(final boolean plannedDirectUpdate) {
1984
+ return plannedDirectUpdate && !Boolean.TRUE.equals(this.autoSplashscreenTimedOut);
1985
+ }
1986
+
1987
+ static boolean shouldConsumeOnLaunchDirectUpdate(final String directUpdateMode, final boolean plannedDirectUpdate) {
1988
+ return plannedDirectUpdate && "onLaunch".equals(directUpdateMode);
1989
+ }
1990
+
1991
+ static int normalizedPeriodCheckDelayMs(final int valueSeconds) {
1992
+ final int normalizedSeconds = normalizedPeriodCheckDelaySeconds(valueSeconds);
1993
+ if (normalizedSeconds <= 0) {
1994
+ return 0;
1995
+ }
1996
+ final long delayMs = (long) normalizedSeconds * 1000L;
1997
+ return delayMs > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) delayMs;
1998
+ }
1999
+
2000
+ static int normalizedPeriodCheckDelaySeconds(final int valueSeconds) {
2001
+ if (valueSeconds <= 0) {
2002
+ return 0;
2003
+ }
2004
+ return Math.max(600, valueSeconds);
2005
+ }
2006
+
2007
+ private void consumeOnLaunchDirectUpdateAttempt(final boolean plannedDirectUpdate) {
2008
+ if (!shouldConsumeOnLaunchDirectUpdate(this.directUpdateMode, plannedDirectUpdate)) {
2009
+ return;
2010
+ }
2011
+
2012
+ this.onLaunchDirectUpdateUsed = true;
2013
+ }
2014
+
2015
+ void configureDirectUpdateModeForTesting(final String directUpdateMode, final boolean onLaunchDirectUpdateUsed) {
2016
+ this.directUpdateMode = directUpdateMode;
2017
+ this.autoUpdateMode = autoUpdateModeForLegacyDirectUpdateMode(directUpdateMode);
2018
+ this.autoUpdate = isAutoUpdateModeEnabled(this.autoUpdateMode);
2019
+ if (this.implementation != null) {
2020
+ this.implementation.directUpdate = isDirectUpdateMode(this.directUpdateMode);
2021
+ }
2022
+ this.onLaunchDirectUpdateUsed = onLaunchDirectUpdateUsed;
2023
+ }
2024
+
2025
+ void setAutoUpdateModeForTesting(final String autoUpdateMode) {
2026
+ this.autoUpdateMode = normalizedAutoUpdateMode(autoUpdateMode);
2027
+ this.autoUpdate = isAutoUpdateModeEnabled(this.autoUpdateMode);
2028
+ this.directUpdateMode = directUpdateModeForAutoUpdateMode(this.autoUpdateMode);
2029
+ if (this.implementation != null) {
2030
+ this.implementation.directUpdate = isDirectUpdateMode(this.directUpdateMode);
2031
+ }
2032
+ }
2033
+
2034
+ boolean shouldUseDirectUpdateForTesting() {
2035
+ return this.shouldUseDirectUpdate();
2036
+ }
2037
+
2038
+ boolean hasConsumedOnLaunchDirectUpdateForTesting() {
2039
+ return this.onLaunchDirectUpdateUsed;
2040
+ }
2041
+
2042
+ boolean isVersionDownloadInProgress(final String version) {
2043
+ return (
2044
+ version != null &&
2045
+ !version.isEmpty() &&
2046
+ this.implementation != null &&
2047
+ this.implementation.activity != null &&
2048
+ DownloadWorkerManager.isVersionDownloading(this.implementation.activity, version)
2049
+ );
2050
+ }
2051
+
2052
+ void setLoggerForTesting(final Logger logger) {
2053
+ this.logger = logger;
2054
+ }
2055
+
2056
+ void completeBackgroundTaskForTesting(final BundleInfo current, final boolean plannedDirectUpdate) {
2057
+ this.endBackGroundTaskWithNotif("test", current.getVersionName(), current, false, plannedDirectUpdate);
2058
+ }
2059
+
2060
+ void scheduleDirectUpdateFinish(final BundleInfo latest) {
2061
+ startNewThread(() -> {
2062
+ try {
2063
+ if (this.shouldBlockAutoUpdateForPreviewSession()) {
2064
+ logger.info("Skipping direct update install while preview session state is active");
2065
+ this.implementation.directUpdate = false;
2066
+ this.clearBackgroundDownloadState();
2067
+ return;
2068
+ }
2069
+ Activity currentActivity = this.getActivity();
2070
+ if (currentActivity != null) {
2071
+ this.implementation.activity = currentActivity;
2072
+ } else {
2073
+ logger.warn("directUpdateFinish: Activity is null, proceeding without refreshing the activity reference");
2074
+ }
2075
+ this.directUpdateFinish(latest);
2076
+ } catch (final Exception e) {
2077
+ logger.error("directUpdateFinish failed: " + e.getMessage());
2078
+ }
2079
+ });
2080
+ }
2081
+
2082
+ private void directUpdateFinish(final BundleInfo latest) {
2083
+ if (this.shouldBlockAutoUpdateForPreviewSession()) {
2084
+ logger.info("Skipping direct update finish while preview session state is active");
2085
+ this.implementation.directUpdate = false;
2086
+ this.clearBackgroundDownloadState();
2087
+ return;
2088
+ }
2089
+ if ("onLaunch".equals(this.directUpdateMode)) {
2090
+ this.onLaunchDirectUpdateUsed = true;
2091
+ this.implementation.directUpdate = false;
2092
+ }
2093
+ if (this.applyDownloadedBundleForDirectUpdate(latest)) {
2094
+ this.implementation.setNextBundle(null);
2095
+ this.notifyBundleSet(latest);
2096
+ sendReadyToJs(latest, "update installed", true);
2097
+ } else {
2098
+ this.implementation.setNextBundle(latest.getId());
2099
+ final JSObject ret = new JSObject();
2100
+ ret.put("bundle", InternalUtils.mapToJSObject(latest.toJSONMap()));
2101
+ this.notifyListeners("updateAvailable", ret);
2102
+ sendReadyToJs(
2103
+ this.implementation.getCurrentBundle(),
2104
+ "Direct update reload failed, update will install next background",
2105
+ false
2106
+ );
2107
+ }
2108
+ }
2109
+
2110
+ private boolean applyDownloadedBundleForDirectUpdate(final BundleInfo latest) {
2111
+ final CapgoUpdater.ResetState previousState = this.implementation.captureResetState();
2112
+ final String previousBundleName = this.implementation.getCurrentBundle().getVersionName();
2113
+
2114
+ if (!this.implementation.stagePendingReload(latest)) {
2115
+ this.implementation.restoreResetState(previousState);
2116
+ logger.error("Direct update failed to stage downloaded bundle: " + latest.toString());
2117
+ return false;
2118
+ }
2119
+
2120
+ if (this._reload()) {
2121
+ this.implementation.finalizePendingReload(latest, previousBundleName);
2122
+ return true;
2123
+ }
2124
+
2125
+ this.implementation.restoreResetState(previousState);
2126
+ this.restoreLiveBundleStateAfterFailedReload();
2127
+ logger.error("Direct update reload failed after staging bundle: " + latest.toString());
2128
+ return false;
772
2129
  }
773
2130
 
774
2131
  private void cleanupObsoleteVersions() {
775
2132
  cleanupThread = startNewThread(() -> {
776
2133
  synchronized (cleanupLock) {
777
2134
  try {
778
- final String previous = this.prefs.getString("LatestNativeBuildVersion", "");
2135
+ final String previous = this.getStoredNativeBuildVersion();
779
2136
  if (!"".equals(previous) && !Objects.equals(this.currentBuildVersion, previous)) {
780
2137
  logger.info("New native build version detected: " + this.currentBuildVersion);
781
2138
  this.implementation.reset(true);
@@ -836,6 +2193,19 @@ public class CapacitorUpdaterPlugin extends Plugin {
836
2193
  });
837
2194
  }
838
2195
 
2196
+ String getStoredNativeBuildVersion() {
2197
+ String previous = this.prefs.getString("LatestNativeBuildVersion", "");
2198
+ if (previous == null || previous.isEmpty()) {
2199
+ previous = this.prefs.getString("LatestVersionNative", "");
2200
+ }
2201
+ return previous == null ? "" : previous;
2202
+ }
2203
+
2204
+ void persistCurrentNativeBuildVersion() {
2205
+ this.editor.putString("LatestNativeBuildVersion", this.currentBuildVersion);
2206
+ this.editor.apply();
2207
+ }
2208
+
839
2209
  private void waitForCleanupIfNeeded() {
840
2210
  if (cleanupComplete) {
841
2211
  return; // Already done, no need to wait
@@ -1058,6 +2428,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
1058
2428
  CapacitorUpdaterPlugin.this.editor,
1059
2429
  DEFAULT_CHANNEL_PREF_KEY,
1060
2430
  CapacitorUpdaterPlugin.this.allowSetDefaultChannel,
2431
+ CapacitorUpdaterPlugin.this.getConfig().getString("defaultChannel", ""),
1061
2432
  (res) -> {
1062
2433
  JSObject jsRes = InternalUtils.mapToJSObject(res);
1063
2434
  if (jsRes.has("error")) {
@@ -1106,21 +2477,25 @@ public class CapacitorUpdaterPlugin extends Plugin {
1106
2477
  try {
1107
2478
  logger.info("getChannel");
1108
2479
  startNewThread(() ->
1109
- CapacitorUpdaterPlugin.this.implementation.getChannel((res) -> {
1110
- JSObject jsRes = InternalUtils.mapToJSObject(res);
1111
- if (jsRes.has("error")) {
1112
- String errorMessage = jsRes.has("message") ? jsRes.getString("message") : jsRes.getString("error");
1113
- String errorCode = jsRes.getString("error");
2480
+ CapacitorUpdaterPlugin.this.implementation.getChannel(
2481
+ (res) -> {
2482
+ JSObject jsRes = InternalUtils.mapToJSObject(res);
2483
+ if (jsRes.has("error")) {
2484
+ String errorMessage = jsRes.has("message") ? jsRes.getString("message") : jsRes.getString("error");
2485
+ String errorCode = jsRes.getString("error");
1114
2486
 
1115
- JSObject errorObj = new JSObject();
1116
- errorObj.put("message", errorMessage);
1117
- errorObj.put("error", errorCode);
2487
+ JSObject errorObj = new JSObject();
2488
+ errorObj.put("message", errorMessage);
2489
+ errorObj.put("error", errorCode);
1118
2490
 
1119
- call.reject(errorMessage, "GETCHANNEL_FAILED", null, errorObj);
1120
- } else {
1121
- call.resolve(jsRes);
1122
- }
1123
- })
2491
+ call.reject(errorMessage, "GETCHANNEL_FAILED", null, errorObj);
2492
+ } else {
2493
+ call.resolve(jsRes);
2494
+ }
2495
+ },
2496
+ CapacitorUpdaterPlugin.this.editor,
2497
+ DEFAULT_CHANNEL_PREF_KEY
2498
+ )
1124
2499
  );
1125
2500
  } catch (final Exception e) {
1126
2501
  logger.error("Failed to getChannel " + e.getMessage());
@@ -1135,6 +2510,23 @@ public class CapacitorUpdaterPlugin extends Plugin {
1135
2510
  startNewThread(() ->
1136
2511
  CapacitorUpdaterPlugin.this.implementation.listChannels((res) -> {
1137
2512
  JSObject jsRes = InternalUtils.mapToJSObject(res);
2513
+ Object channels = res.get("channels");
2514
+ if (channels instanceof List<?> channelsList) {
2515
+ JSArray channelsArray = new JSArray();
2516
+ for (Object channel : channelsList) {
2517
+ if (channel instanceof Map<?, ?> channelMap) {
2518
+ JSObject channelObject = new JSObject();
2519
+ for (Map.Entry<?, ?> entry : channelMap.entrySet()) {
2520
+ Object key = entry.getKey();
2521
+ if (key != null) {
2522
+ channelObject.put(key.toString(), entry.getValue());
2523
+ }
2524
+ }
2525
+ channelsArray.put(channelObject);
2526
+ }
2527
+ }
2528
+ jsRes.put("channels", channelsArray);
2529
+ }
1138
2530
  if (jsRes.has("error")) {
1139
2531
  String errorMessage = jsRes.has("message") ? jsRes.getString("message") : jsRes.getString("error");
1140
2532
  String errorCode = jsRes.getString("error");
@@ -1155,6 +2547,20 @@ public class CapacitorUpdaterPlugin extends Plugin {
1155
2547
  }
1156
2548
  }
1157
2549
 
2550
+ private BundleInfo downloadBundle(
2551
+ final String url,
2552
+ final String version,
2553
+ final String sessionKey,
2554
+ final String checksum,
2555
+ final JSONArray manifest
2556
+ ) throws IOException {
2557
+ if (manifest != null) {
2558
+ return this.implementation.downloadManifest(url, version, sessionKey, checksum, manifest);
2559
+ }
2560
+
2561
+ return this.implementation.download(url, version, sessionKey, checksum);
2562
+ }
2563
+
1158
2564
  @PluginMethod
1159
2565
  public void download(final PluginCall call) {
1160
2566
  final String url = call.getString("url");
@@ -1176,19 +2582,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
1176
2582
  logger.info("Downloading " + url);
1177
2583
  startNewThread(() -> {
1178
2584
  try {
1179
- final BundleInfo downloaded;
1180
- if (manifest != null) {
1181
- // For manifest downloads, we need to handle this asynchronously
1182
- // since there's no synchronous downloadManifest method in Java
1183
- CapacitorUpdaterPlugin.this.implementation.downloadBackground(url, version, sessionKey, checksum, manifest);
1184
- // Return immediately with a pending status - the actual result will come via listeners
1185
- final String id = CapacitorUpdaterPlugin.this.implementation.randomString();
1186
- downloaded = new BundleInfo(id, version, BundleStatus.DOWNLOADING, new Date(System.currentTimeMillis()), "");
1187
- call.resolve(InternalUtils.mapToJSObject(downloaded.toJSONMap()));
1188
- return;
1189
- } else {
1190
- downloaded = CapacitorUpdaterPlugin.this.implementation.download(url, version, sessionKey, checksum);
1191
- }
2585
+ final BundleInfo downloaded = this.downloadBundle(url, version, sessionKey, checksum, manifest);
1192
2586
  if (downloaded.isErrorStatus()) {
1193
2587
  throw new RuntimeException("Download failed: " + downloaded.getStatus());
1194
2588
  } else {
@@ -1229,12 +2623,12 @@ public class CapacitorUpdaterPlugin extends Plugin {
1229
2623
  this.bridge.getWebView().post(() -> this.bridge.getWebView().evaluateJavascript(script, null));
1230
2624
  }
1231
2625
 
1232
- protected boolean _reload() {
2626
+ private void applyCurrentBundleToBridge() {
1233
2627
  final String path = this.implementation.getCurrentBundlePath();
2628
+ final boolean usingBuiltin = this.implementation.isUsingBuiltin();
1234
2629
  if (this.keepUrlPathAfterReload) {
1235
2630
  this.syncKeepUrlPathFlag(true);
1236
2631
  }
1237
- this.semaphoreUp();
1238
2632
  logger.info("Reloading: " + path);
1239
2633
 
1240
2634
  AtomicReference<URL> url = new AtomicReference<>();
@@ -1279,7 +2673,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
1279
2673
  }
1280
2674
 
1281
2675
  if (url.get() != null) {
1282
- if (this.implementation.isUsingBuiltin()) {
2676
+ if (usingBuiltin) {
1283
2677
  this.bridge.getLocalServer().hostAssets(path);
1284
2678
  } else {
1285
2679
  this.bridge.getLocalServer().hostFiles(path);
@@ -1299,14 +2693,14 @@ public class CapacitorUpdaterPlugin extends Plugin {
1299
2693
  } catch (MalformedURLException e) {
1300
2694
  logger.error("Cannot get finalUrl from capacitor bridge " + e.getMessage());
1301
2695
 
1302
- if (this.implementation.isUsingBuiltin()) {
2696
+ if (usingBuiltin) {
1303
2697
  this.bridge.setServerAssetPath(path);
1304
2698
  } else {
1305
2699
  this.bridge.setServerBasePath(path);
1306
2700
  }
1307
2701
  }
1308
2702
  } else {
1309
- if (this.implementation.isUsingBuiltin()) {
2703
+ if (usingBuiltin) {
1310
2704
  this.bridge.setServerAssetPath(path);
1311
2705
  } else {
1312
2706
  this.bridge.setServerBasePath(path);
@@ -1322,34 +2716,86 @@ public class CapacitorUpdaterPlugin extends Plugin {
1322
2716
  });
1323
2717
  }
1324
2718
  }
2719
+ }
1325
2720
 
1326
- this.checkAppReady();
2721
+ protected void restoreLiveBundleStateAfterFailedReload() {
2722
+ try {
2723
+ this.applyCurrentBundleToBridge();
2724
+ } catch (final Exception e) {
2725
+ logger.warn("Failed to restore live bundle after rejected reload: " + e.getMessage());
2726
+ }
2727
+ }
2728
+
2729
+ protected boolean _reload() {
2730
+ final int phase = this.semaphoreUp();
2731
+ this.applyCurrentBundleToBridge();
2732
+
2733
+ final long waitTimeMs = this.resolveAppReadyCheckTimeoutMs();
2734
+ this.checkAppReady(waitTimeMs);
1327
2735
  this.notifyListeners("appReloaded", new JSObject());
1328
2736
 
1329
2737
  // Wait for the reload to complete (until notifyAppReady is called)
1330
- try {
1331
- this.semaphoreWait(this.appReadyTimeout);
1332
- } catch (Exception e) {
1333
- logger.error("Error waiting for app ready: " + e.getMessage());
1334
- return false;
1335
- }
2738
+ return this.semaphoreWait(phase, waitTimeMs);
2739
+ }
2740
+
2741
+ protected boolean reloadWithoutWaitingForAppReady() {
2742
+ this.applyCurrentBundleToBridge();
1336
2743
 
2744
+ final long waitTimeMs = this.resolveAppReadyCheckTimeoutMs();
2745
+ this.checkAppReady(waitTimeMs);
2746
+ this.notifyListeners("appReloaded", new JSObject());
1337
2747
  return true;
1338
2748
  }
1339
2749
 
1340
2750
  @PluginMethod
1341
2751
  public void reload(final PluginCall call) {
1342
- try {
1343
- if (this._reload()) {
1344
- call.resolve();
1345
- } else {
1346
- logger.error("Reload failed");
1347
- call.reject("Reload failed");
2752
+ startNewThread(() -> {
2753
+ try {
2754
+ final BundleInfo current = this.implementation.getCurrentBundle();
2755
+ final BundleInfo next = this.implementation.getNextBundle();
2756
+
2757
+ if (!this.isPreviewSessionStateActive() && next != null && !next.isErrorStatus() && !next.getId().equals(current.getId())) {
2758
+ final CapgoUpdater.ResetState previousState = this.implementation.captureResetState();
2759
+ final String previousBundleName = this.implementation.getCurrentBundle().getVersionName();
2760
+ logger.info("Applying pending bundle before reload: " + next.getVersionName());
2761
+ final boolean didApplyPendingBundle;
2762
+ if (next.isBuiltin()) {
2763
+ this.implementation.prepareResetStateForTransition();
2764
+ didApplyPendingBundle = true;
2765
+ } else {
2766
+ didApplyPendingBundle = this.implementation.stagePendingReload(next);
2767
+ }
2768
+ if (didApplyPendingBundle && this._reload()) {
2769
+ if (next.isBuiltin()) {
2770
+ this.implementation.finalizeResetTransition(previousBundleName, false);
2771
+ } else {
2772
+ this.implementation.finalizePendingReload(next, previousBundleName);
2773
+ }
2774
+ this.notifyBundleSet(next);
2775
+ this.implementation.setNextBundle(null);
2776
+ this.showPreviewSessionNoticeIfNeeded();
2777
+ call.resolve();
2778
+ return;
2779
+ }
2780
+ this.implementation.restoreResetState(previousState);
2781
+ this.restoreLiveBundleStateAfterFailedReload();
2782
+ logger.error("Reload failed after applying pending bundle: " + next.getVersionName());
2783
+ call.reject("Reload failed after applying pending bundle: " + next.getVersionName());
2784
+ return;
2785
+ }
2786
+
2787
+ if (this._reload()) {
2788
+ this.showPreviewSessionNoticeIfNeeded();
2789
+ call.resolve();
2790
+ } else {
2791
+ logger.error("Reload failed");
2792
+ call.reject("Reload failed");
2793
+ }
2794
+ } catch (final Exception e) {
2795
+ logger.error("Could not reload " + e.getMessage());
2796
+ call.reject("Could not reload", e);
1348
2797
  }
1349
- } catch (final Exception e) {
1350
- logger.error("Could not reload " + e.getMessage());
1351
- call.reject("Could not reload", e);
1352
- }
2798
+ });
1353
2799
  }
1354
2800
 
1355
2801
  @PluginMethod
@@ -1382,19 +2828,924 @@ public class CapacitorUpdaterPlugin extends Plugin {
1382
2828
  call.reject("Set called without id");
1383
2829
  return;
1384
2830
  }
1385
- try {
1386
- logger.info("Setting active bundle " + id);
1387
- if (!this.implementation.set(id)) {
1388
- logger.info("No such bundle " + id);
1389
- call.reject("Update failed, id " + id + " does not exist.");
1390
- } else {
1391
- logger.info("Bundle successfully set to " + id);
1392
- this.reload(call);
2831
+ startNewThread(() -> {
2832
+ try {
2833
+ logger.info("Setting active bundle " + id);
2834
+ if (!this.implementation.set(id)) {
2835
+ logger.info("No such bundle " + id);
2836
+ call.reject("Update failed, id " + id + " does not exist.");
2837
+ } else if (Boolean.TRUE.equals(this.previewSessionEnabled)) {
2838
+ logger.info("Preview session set active bundle " + id + " without waiting for preview app readiness");
2839
+ final BundleInfo bundle = this.implementation.getBundleInfo(id);
2840
+ this.recordPreviewBundle(bundle);
2841
+ this.reloadWithoutWaitingForAppReady();
2842
+ this.notifyBundleSet(bundle);
2843
+ this.showPreviewSessionNoticeIfNeeded();
2844
+ call.resolve();
2845
+ } else if (!this._reload()) {
2846
+ logger.error("Reload failed after setting bundle " + id);
2847
+ call.reject("Reload failed after setting bundle " + id);
2848
+ } else {
2849
+ logger.info("Bundle successfully set to " + id);
2850
+ this.notifyBundleSet(this.implementation.getBundleInfo(id));
2851
+ this.showPreviewSessionNoticeIfNeeded();
2852
+ call.resolve();
2853
+ }
2854
+ } catch (final Exception e) {
2855
+ logger.error("Could not set id " + id + " " + e.getMessage());
2856
+ call.reject("Could not set id " + id, e);
2857
+ }
2858
+ });
2859
+ }
2860
+
2861
+ private boolean preparePreviewFallbackIfNeeded() {
2862
+ if (Boolean.TRUE.equals(this.previewSessionEnabled)) {
2863
+ return true;
2864
+ }
2865
+
2866
+ final BundleInfo current = this.implementation.getCurrentBundle();
2867
+ if (!this.implementation.setPreviewFallbackBundle(current.getId())) {
2868
+ logger.error("Could not save current bundle as preview fallback");
2869
+ return false;
2870
+ }
2871
+
2872
+ final BundleInfo previousNext = this.implementation.getNextBundle();
2873
+ if (previousNext == null || previousNext.isDeleted() || previousNext.isErrorStatus()) {
2874
+ this.editor.remove(PREVIEW_PREVIOUS_NEXT_BUNDLE_PREF_KEY);
2875
+ } else {
2876
+ this.editor.putString(PREVIEW_PREVIOUS_NEXT_BUNDLE_PREF_KEY, previousNext.getId());
2877
+ }
2878
+
2879
+ this.editor.putString(PREVIEW_PREVIOUS_APP_ID_PREF_KEY, this.implementation.appId);
2880
+ if (this.prefs.contains(DEFAULT_CHANNEL_PREF_KEY)) {
2881
+ this.editor.putString(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_PREF_KEY, this.prefs.getString(DEFAULT_CHANNEL_PREF_KEY, ""));
2882
+ this.editor.putBoolean(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY, true);
2883
+ } else {
2884
+ this.editor.remove(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_PREF_KEY);
2885
+ this.editor.putBoolean(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY, false);
2886
+ }
2887
+ this.editor.putBoolean(PREVIEW_PREVIOUS_SHAKE_MENU_PREF_KEY, Boolean.TRUE.equals(this.shakeMenuEnabled));
2888
+ this.editor.putBoolean(PREVIEW_PREVIOUS_SHAKE_CHANNEL_SELECTOR_PREF_KEY, Boolean.TRUE.equals(this.shakeChannelSelectorEnabled));
2889
+ logger.info("Preview session started with fallback bundle: " + current);
2890
+ return true;
2891
+ }
2892
+
2893
+ private void activatePreviewSessionState() {
2894
+ this.clearIncomingPreviewTransition();
2895
+ this.hidePreviewTransitionLoader("preview-session-started");
2896
+ this.previewSessionEnabled = true;
2897
+ this.previewSessionAlertPending = true;
2898
+ this.implementation.previewSession = true;
2899
+ this.shakeMenuEnabled = true;
2900
+ this.editor.putBoolean(PREVIEW_SESSION_PREF_KEY, true);
2901
+ this.editor.putBoolean(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY, true);
2902
+ this.editor.apply();
2903
+ this.syncShakeMenuLifecycle();
2904
+ }
2905
+
2906
+ @PluginMethod
2907
+ public void startPreviewSession(final PluginCall call) {
2908
+ if (!Boolean.TRUE.equals(this.allowPreview)) {
2909
+ this.hidePreviewTransitionLoader("preview-session-not-allowed");
2910
+ logger.error("startPreviewSession not allowed set allowPreview in your config to true to enable it");
2911
+ call.reject("startPreviewSession not allowed");
2912
+ return;
2913
+ }
2914
+ final String previewAppId = this.normalizePreviewAppId(call.getString("appId"));
2915
+ final String rawPayloadUrl = call.getString("payloadUrl");
2916
+ final String previewPayloadUrl = this.normalizePreviewPayloadUrl(rawPayloadUrl);
2917
+ if (this.hasPreviewPayloadUrl(rawPayloadUrl) && previewPayloadUrl == null) {
2918
+ this.hidePreviewTransitionLoader("preview-session-invalid-payload");
2919
+ logger.error("startPreviewSession called with invalid payloadUrl");
2920
+ call.reject("Invalid preview payloadUrl");
2921
+ return;
2922
+ }
2923
+ startNewThread(() -> {
2924
+ try {
2925
+ if (!this.preparePreviewFallbackIfNeeded()) {
2926
+ this.hidePreviewTransitionLoader("preview-session-fallback-failed");
2927
+ call.reject("Could not save current bundle as preview fallback");
2928
+ return;
2929
+ }
2930
+
2931
+ if (previewAppId != null) {
2932
+ this.setActiveAppId(previewAppId);
2933
+ this.editor.putString(PREVIEW_APP_ID_PREF_KEY, previewAppId);
2934
+ logger.info("Preview session using appId: " + previewAppId);
2935
+ }
2936
+
2937
+ if (previewPayloadUrl != null) {
2938
+ this.editor.putString(PREVIEW_PAYLOAD_URL_PREF_KEY, previewPayloadUrl);
2939
+ logger.info("Preview session using payload URL");
2940
+ } else {
2941
+ this.editor.remove(PREVIEW_PAYLOAD_URL_PREF_KEY);
2942
+ }
2943
+
2944
+ final String previewName = this.normalizedPreviewMetadataValue(call.getString("name"));
2945
+ if (previewName == null) {
2946
+ this.editor.remove(PREVIEW_NAME_PREF_KEY);
2947
+ } else {
2948
+ this.editor.putString(PREVIEW_NAME_PREF_KEY, previewName);
2949
+ }
2950
+
2951
+ final String previewSource = this.normalizedPreviewMetadataValue(call.getString("source"));
2952
+ if (previewSource == null) {
2953
+ this.editor.remove(PREVIEW_SOURCE_PREF_KEY);
2954
+ } else {
2955
+ this.editor.putString(PREVIEW_SOURCE_PREF_KEY, previewSource);
2956
+ }
2957
+
2958
+ this.activatePreviewSessionState();
2959
+ call.resolve();
2960
+ } catch (final Exception e) {
2961
+ this.hidePreviewTransitionLoader("preview-session-failed");
2962
+ logger.error("Could not start preview session " + e.getMessage());
2963
+ call.reject("Could not start preview session", e);
2964
+ }
2965
+ });
2966
+ }
2967
+
2968
+ @PluginMethod
2969
+ public void listPreviews(final PluginCall call) {
2970
+ if (!Boolean.TRUE.equals(this.allowPreview)) {
2971
+ call.reject("listPreviews not allowed");
2972
+ return;
2973
+ }
2974
+
2975
+ final JSArray previews = this.listPreviewInfos(true);
2976
+ final JSObject ret = new JSObject();
2977
+ ret.put("previews", previews);
2978
+ ret.put("currentBundle", InternalUtils.mapToJSObject(this.implementation.getCurrentBundle().toJSONMap()));
2979
+
2980
+ for (int i = 0; i < previews.length(); i++) {
2981
+ final JSONObject preview = previews.optJSONObject(i);
2982
+ if (preview != null && preview.optBoolean("isActive", false)) {
2983
+ ret.put("current", preview);
2984
+ break;
2985
+ }
2986
+ }
2987
+
2988
+ final BundleInfo liveBundle = this.implementation.getPreviewFallbackBundle();
2989
+ if (liveBundle != null) {
2990
+ ret.put("liveBundle", InternalUtils.mapToJSObject(liveBundle.toJSONMap()));
2991
+ }
2992
+
2993
+ call.resolve(ret);
2994
+ }
2995
+
2996
+ @PluginMethod
2997
+ public void setPreview(final PluginCall call) {
2998
+ if (!Boolean.TRUE.equals(this.allowPreview)) {
2999
+ call.reject("setPreview not allowed");
3000
+ return;
3001
+ }
3002
+ final String id = call.getString("id");
3003
+ if (id == null || id.isEmpty()) {
3004
+ call.reject("setPreview called without id");
3005
+ return;
3006
+ }
3007
+ final JSObject preview = this.storedPreviewInfo(id);
3008
+ if (preview == null) {
3009
+ call.reject("Preview " + id + " is not available locally");
3010
+ return;
3011
+ }
3012
+
3013
+ this.showPreviewTransitionLoader("set-preview");
3014
+ startNewThread(() -> {
3015
+ if (!this.preparePreviewFallbackIfNeeded()) {
3016
+ this.hidePreviewTransitionLoader("set-preview-fallback-failed");
3017
+ call.reject("Could not save current bundle as preview fallback");
3018
+ return;
3019
+ }
3020
+
3021
+ if (!this.implementation.set(id)) {
3022
+ this.hidePreviewTransitionLoader("set-preview-failed");
3023
+ call.reject("Preview " + id + " cannot be applied");
3024
+ return;
3025
+ }
3026
+
3027
+ final BundleInfo bundle = this.implementation.getBundleInfo(id);
3028
+ this.updateCurrentPreviewSessionMetadataFrom(preview);
3029
+ this.activatePreviewSessionState();
3030
+ this.recordPreviewBundle(bundle);
3031
+ if (!this.reloadWithoutWaitingForAppReady()) {
3032
+ this.hidePreviewTransitionLoader("set-preview-reload-failed");
3033
+ call.reject("Reload failed after setting preview " + id);
3034
+ return;
3035
+ }
3036
+
3037
+ this.notifyBundleSet(bundle);
3038
+ this.showPreviewSessionNoticeIfNeeded();
3039
+ call.resolve();
3040
+ });
3041
+ }
3042
+
3043
+ public JSArray previewMenuPreviews() {
3044
+ return this.listPreviewInfos(true);
3045
+ }
3046
+
3047
+ public boolean setPreviewFromShakeMenu(final String id) {
3048
+ final JSObject preview = this.storedPreviewInfo(id);
3049
+ if (!Boolean.TRUE.equals(this.allowPreview) || preview == null) {
3050
+ return false;
3051
+ }
3052
+
3053
+ this.showPreviewTransitionLoader("set-preview-menu");
3054
+ if (!this.preparePreviewFallbackIfNeeded()) {
3055
+ this.hidePreviewTransitionLoader("set-preview-menu-fallback-failed");
3056
+ return false;
3057
+ }
3058
+
3059
+ if (!this.implementation.set(id)) {
3060
+ this.hidePreviewTransitionLoader("set-preview-menu-failed");
3061
+ return false;
3062
+ }
3063
+
3064
+ final BundleInfo bundle = this.implementation.getBundleInfo(id);
3065
+ this.updateCurrentPreviewSessionMetadataFrom(preview);
3066
+ this.activatePreviewSessionState();
3067
+ this.recordPreviewBundle(bundle);
3068
+ if (!this.reloadWithoutWaitingForAppReady()) {
3069
+ this.hidePreviewTransitionLoader("set-preview-menu-reload-failed");
3070
+ return false;
3071
+ }
3072
+
3073
+ this.notifyBundleSet(bundle);
3074
+ this.showPreviewSessionNoticeIfNeeded();
3075
+ return true;
3076
+ }
3077
+
3078
+ @PluginMethod
3079
+ public void resetPreview(final PluginCall call) {
3080
+ if (!Boolean.TRUE.equals(this.previewSessionEnabled)) {
3081
+ call.resolve();
3082
+ return;
3083
+ }
3084
+ startNewThread(() -> {
3085
+ if (this.leavePreviewSessionFromShakeMenu()) {
3086
+ call.resolve();
3087
+ } else {
3088
+ call.reject("Could not leave preview session");
3089
+ }
3090
+ });
3091
+ }
3092
+
3093
+ @PluginMethod
3094
+ public void deletePreview(final PluginCall call) {
3095
+ if (!Boolean.TRUE.equals(this.allowPreview)) {
3096
+ call.reject("deletePreview not allowed");
3097
+ return;
3098
+ }
3099
+ final String id = call.getString("id");
3100
+ if (id == null || id.isEmpty()) {
3101
+ call.reject("deletePreview called without id");
3102
+ return;
3103
+ }
3104
+ if (Boolean.TRUE.equals(this.previewSessionEnabled) && this.implementation.getCurrentBundle().getId().equals(id)) {
3105
+ call.reject("Cannot delete the active preview");
3106
+ return;
3107
+ }
3108
+
3109
+ final boolean removed;
3110
+ synchronized (this.previewSessionsLock) {
3111
+ final JSONObject sessions = this.previewSessionsJson();
3112
+ removed = sessions.has(id);
3113
+ sessions.remove(id);
3114
+ this.savePreviewSessionsJson(sessions);
3115
+ }
3116
+
3117
+ boolean deleted = false;
3118
+ final BundleInfo fallback = this.implementation.getPreviewFallbackBundle();
3119
+ final BundleInfo next = this.implementation.getNextBundle();
3120
+ if (
3121
+ removed &&
3122
+ !BundleInfo.ID_BUILTIN.equals(id) &&
3123
+ (fallback == null || !id.equals(fallback.getId())) &&
3124
+ (next == null || !id.equals(next.getId()))
3125
+ ) {
3126
+ try {
3127
+ deleted = this.implementation.delete(id, false);
3128
+ } catch (final Exception err) {
3129
+ logger.warn("Could not delete preview bundle " + id + ": " + err.getMessage());
3130
+ }
3131
+ }
3132
+
3133
+ final JSObject ret = new JSObject();
3134
+ ret.put("removed", removed);
3135
+ ret.put("deleted", deleted);
3136
+ call.resolve(ret);
3137
+ }
3138
+
3139
+ @PluginMethod
3140
+ public void checkPreviewUpdate(final PluginCall call) {
3141
+ this.handlePreviewUpdate(call, false);
3142
+ }
3143
+
3144
+ @PluginMethod
3145
+ public void updatePreview(final PluginCall call) {
3146
+ this.handlePreviewUpdate(call, true);
3147
+ }
3148
+
3149
+ private void handlePreviewUpdate(final PluginCall call, final boolean shouldDownload) {
3150
+ if (!Boolean.TRUE.equals(this.allowPreview)) {
3151
+ call.reject("Preview updates not allowed");
3152
+ return;
3153
+ }
3154
+ final String id = call.getString("id");
3155
+ if (id == null || id.isEmpty()) {
3156
+ call.reject("Preview update called without id");
3157
+ return;
3158
+ }
3159
+ final JSObject preview = this.storedPreviewInfo(id);
3160
+ final String payloadUrl = preview == null ? null : this.normalizePreviewPayloadUrl(preview.optString("payloadUrl", null));
3161
+ if (payloadUrl == null) {
3162
+ call.reject("Preview " + id + " has no payloadUrl to update from");
3163
+ return;
3164
+ }
3165
+
3166
+ startNewThread(() -> {
3167
+ try {
3168
+ final JSONObject payload = this.fetchPreviewPayload(payloadUrl);
3169
+ final String version = payload.optString("version", "").trim();
3170
+ if (version.isEmpty()) {
3171
+ throw new IOException("Preview payload is missing a version");
3172
+ }
3173
+
3174
+ final BundleInfo currentPreviewBundle = this.implementation.getBundleInfo(id);
3175
+ final boolean upToDate = version.equals(currentPreviewBundle.getVersionName());
3176
+ if (upToDate || !shouldDownload) {
3177
+ final JSObject ret = new JSObject();
3178
+ ret.put("preview", preview);
3179
+ ret.put("latestVersion", version);
3180
+ ret.put("upToDate", upToDate);
3181
+ ret.put("updated", false);
3182
+ ret.put("bundle", InternalUtils.mapToJSObject(currentPreviewBundle.toJSONMap()));
3183
+ call.resolve(ret);
3184
+ return;
3185
+ }
3186
+
3187
+ final BundleInfo next = this.downloadPreviewPayloadBundle(payload);
3188
+ if (next.isErrorStatus()) {
3189
+ throw new IOException("Download failed: " + next.getStatus());
3190
+ }
3191
+
3192
+ final boolean wasActive =
3193
+ Boolean.TRUE.equals(this.previewSessionEnabled) && this.implementation.getCurrentBundle().getId().equals(id);
3194
+ if (wasActive && !this.implementation.set(next.getId())) {
3195
+ throw new IOException("Downloaded preview bundle cannot be applied");
3196
+ }
3197
+
3198
+ final JSObject savedPreview = this.recordPreviewBundle(next, id);
3199
+ if (wasActive) {
3200
+ if (!this.reloadWithoutWaitingForAppReady()) {
3201
+ throw new IOException("Reload failed after updating preview");
3202
+ }
3203
+ this.notifyBundleSet(next);
3204
+ this.showPreviewSessionNoticeIfNeeded();
3205
+ }
3206
+
3207
+ final JSObject ret = new JSObject();
3208
+ ret.put("preview", savedPreview);
3209
+ ret.put("latestVersion", version);
3210
+ ret.put("upToDate", false);
3211
+ ret.put("updated", true);
3212
+ ret.put("bundle", InternalUtils.mapToJSObject(next.toJSONMap()));
3213
+ call.resolve(ret);
3214
+ } catch (final Exception err) {
3215
+ logger.error("Could not update preview: " + err.getMessage());
3216
+ call.reject("Could not update preview: " + err.getMessage());
3217
+ }
3218
+ });
3219
+ }
3220
+
3221
+ public boolean leavePreviewSessionFromShakeMenu() {
3222
+ this.showPreviewTransitionLoader("leave-preview-session");
3223
+ final boolean didReset = this.resetToPreviewFallbackBundle();
3224
+ if (!didReset) {
3225
+ this.hidePreviewTransitionLoader("leave-preview-session-failed");
3226
+ return false;
3227
+ }
3228
+
3229
+ this.endPreviewSession(true);
3230
+ return true;
3231
+ }
3232
+
3233
+ private boolean leavePreviewSessionForIncomingPreviewLink() {
3234
+ this.showPreviewTransitionLoader("incoming-preview-deeplink");
3235
+ final BundleInfo previewFallbackBundle = this.resolvePreviewFallbackBundle("incoming preview deeplink");
3236
+ boolean didReload = false;
3237
+
3238
+ try {
3239
+ if (previewFallbackBundle == null) {
3240
+ return false;
3241
+ }
3242
+
3243
+ final CapgoUpdater.ResetState previousState = this.implementation.captureResetState();
3244
+ if (!this.implementation.stagePreviewFallbackReload(previewFallbackBundle)) {
3245
+ logger.error("Could not stage preview fallback bundle");
3246
+ return false;
3247
+ }
3248
+
3249
+ if (!this.reloadWithoutWaitingForAppReady()) {
3250
+ this.implementation.restoreResetState(previousState);
3251
+ this.restoreLiveBundleStateAfterFailedReload();
3252
+ return false;
3253
+ }
3254
+ didReload = true;
3255
+
3256
+ this.endPreviewSession(true);
3257
+ return true;
3258
+ } finally {
3259
+ this.clearIncomingPreviewTransition();
3260
+ if (!didReload) {
3261
+ this.hidePreviewTransitionLoader("incoming-preview-deeplink-failed");
3262
+ }
3263
+ }
3264
+ }
3265
+
3266
+ private void leavePreviewSessionForLaunchIntentIfNeeded() {
3267
+ final Intent intent = getActivity() == null ? null : getActivity().getIntent();
3268
+ if (
3269
+ intent == null ||
3270
+ !Intent.ACTION_VIEW.equals(intent.getAction()) ||
3271
+ intent.getData() == null ||
3272
+ !Boolean.TRUE.equals(this.previewSessionEnabled) ||
3273
+ !isPreviewDeepLink(intent.getData()) ||
3274
+ Boolean.TRUE.equals(this.isLeavingPreviewForIncomingLink)
3275
+ ) {
3276
+ return;
3277
+ }
3278
+
3279
+ this.isLeavingPreviewForIncomingLink = true;
3280
+ this.showPreviewTransitionLoader("preview-launch-deeplink");
3281
+ logger.info("Preview deeplink launch detected while preview session is active; restoring fallback before initial load");
3282
+ if (!this.leavePreviewSessionWithoutReload()) {
3283
+ logger.error("Could not leave preview session before initial preview deeplink routing");
3284
+ this.isLeavingPreviewForIncomingLink = false;
3285
+ this.hidePreviewTransitionLoader("preview-launch-deeplink-failed");
3286
+ }
3287
+ }
3288
+
3289
+ private boolean leavePreviewSessionWithoutReload() {
3290
+ return this.leavePreviewSessionWithoutReload(false);
3291
+ }
3292
+
3293
+ private boolean leavePreviewSessionWithoutReload(final boolean keepPreviewGuard) {
3294
+ final BundleInfo previewFallbackBundle = this.resolvePreviewFallbackBundle("preview deeplink launch");
3295
+ if (previewFallbackBundle == null) {
3296
+ return false;
3297
+ }
3298
+ if (!this.implementation.stagePreviewFallbackReload(previewFallbackBundle)) {
3299
+ logger.error("Could not stage preview fallback bundle");
3300
+ return false;
3301
+ }
3302
+
3303
+ this.endPreviewSession(keepPreviewGuard);
3304
+ return true;
3305
+ }
3306
+
3307
+ public boolean reloadPreviewSessionFromShakeMenu() {
3308
+ this.showPreviewTransitionLoader("reload-preview-session");
3309
+ final boolean didReload;
3310
+ final String payloadUrl = this.storedPreviewPayloadUrl();
3311
+ if (payloadUrl != null) {
3312
+ didReload = this.refreshPreviewSessionFromPayloadUrl(payloadUrl);
3313
+ } else {
3314
+ didReload = this.reloadWithoutWaitingForAppReady();
3315
+ }
3316
+
3317
+ if (!didReload) {
3318
+ this.hidePreviewTransitionLoader("reload-preview-session-failed");
3319
+ }
3320
+ return didReload;
3321
+ }
3322
+
3323
+ public boolean hasActivePreviewSession() {
3324
+ return Boolean.TRUE.equals(this.previewSessionEnabled);
3325
+ }
3326
+
3327
+ private boolean resetToPreviewFallbackBundle() {
3328
+ final BundleInfo fallback = this.resolvePreviewFallbackBundle("leave preview");
3329
+ if (fallback == null) {
3330
+ return false;
3331
+ }
3332
+
3333
+ final CapgoUpdater.ResetState previousState = this.implementation.captureResetState();
3334
+ final String previousBundleName = this.implementation.getCurrentBundle().getVersionName();
3335
+ logger.info("Resetting to preview fallback bundle: " + fallback.getVersionName());
3336
+ if (this.implementation.stagePreviewFallbackReload(fallback) && this.reloadWithoutWaitingForAppReady()) {
3337
+ this.implementation.finalizeResetTransition(previousBundleName, false);
3338
+ this.notifyBundleSet(fallback);
3339
+ return true;
3340
+ }
3341
+ this.implementation.restoreResetState(previousState);
3342
+ this.restoreLiveBundleStateAfterFailedReload();
3343
+ return false;
3344
+ }
3345
+
3346
+ private BundleInfo resolvePreviewFallbackBundle(final String reason) {
3347
+ final BundleInfo fallback = this.implementation.getPreviewFallbackBundle();
3348
+ if (fallback != null && !fallback.isErrorStatus() && this.implementation.canSet(fallback)) {
3349
+ return fallback;
3350
+ }
3351
+
3352
+ if (fallback == null) {
3353
+ logger.warn("No preview fallback bundle available for " + reason + ". Falling back to builtin bundle.");
3354
+ } else if (fallback.isErrorStatus()) {
3355
+ logger.warn("Preview fallback bundle is in error state for " + reason + ". Falling back to builtin bundle.");
3356
+ } else {
3357
+ logger.warn("Preview fallback bundle is not installable for " + reason + ". Falling back to builtin bundle.");
3358
+ }
3359
+
3360
+ final BundleInfo builtin = this.implementation.getBundleInfo(BundleInfo.ID_BUILTIN);
3361
+ if (builtin != null && !builtin.isErrorStatus() && this.implementation.canSet(builtin)) {
3362
+ return builtin;
3363
+ }
3364
+
3365
+ logger.error("Builtin bundle is not available to leave preview for " + reason);
3366
+ return null;
3367
+ }
3368
+
3369
+ private void endPreviewSession() {
3370
+ this.endPreviewSession(false);
3371
+ }
3372
+
3373
+ private void endPreviewSession(final boolean keepPreviewGuard) {
3374
+ final boolean previousShakeMenuEnabled = this.prefs.getBoolean(
3375
+ PREVIEW_PREVIOUS_SHAKE_MENU_PREF_KEY,
3376
+ this.getConfig().getBoolean("shakeMenu", false)
3377
+ );
3378
+ final boolean previousShakeChannelSelectorEnabled = this.prefs.getBoolean(
3379
+ PREVIEW_PREVIOUS_SHAKE_CHANNEL_SELECTOR_PREF_KEY,
3380
+ this.getConfig().getBoolean("allowShakeChannelSelector", false)
3381
+ );
3382
+ this.restorePreviewPreviousNextBundle();
3383
+ this.restorePreviewPreviousAppId();
3384
+ this.restorePreviewPreviousDefaultChannel();
3385
+
3386
+ this.previewSessionEnabled = false;
3387
+ this.previewSessionAlertPending = false;
3388
+ if (keepPreviewGuard) {
3389
+ this.implementation.previewSession = true;
3390
+ } else {
3391
+ this.clearIncomingPreviewTransition();
3392
+ }
3393
+ this.shakeMenuEnabled = previousShakeMenuEnabled;
3394
+ this.shakeChannelSelectorEnabled = previousShakeChannelSelectorEnabled;
3395
+ this.syncShakeMenuLifecycle();
3396
+ this.implementation.setPreviewFallbackBundle(null);
3397
+ this.clearPreviewSessionPreferences();
3398
+ logger.info("Preview session ended");
3399
+ }
3400
+
3401
+ private void clearPreviewSessionBecauseDisabled() {
3402
+ logger.info("Preview session disabled by config; restoring preview fallback");
3403
+ final BundleInfo bundleToRestore = this.resolvePreviewFallbackBundle("preview disabled");
3404
+ if (bundleToRestore != null) {
3405
+ this.implementation.stagePreviewFallbackReload(bundleToRestore);
3406
+ } else {
3407
+ logger.warn("Could not restore preview fallback while disabling preview");
3408
+ }
3409
+
3410
+ this.restorePreviewPreviousNextBundle();
3411
+ this.restorePreviewPreviousAppId();
3412
+ this.restorePreviewPreviousDefaultChannel();
3413
+ this.previewSessionEnabled = false;
3414
+ this.previewSessionAlertPending = false;
3415
+ this.isLeavingPreviewForIncomingLink = false;
3416
+ this.implementation.previewSession = false;
3417
+ this.hidePreviewTransitionLoader("preview-session-disabled");
3418
+ this.shakeMenuEnabled = this.getConfig().getBoolean("shakeMenu", false);
3419
+ this.shakeChannelSelectorEnabled = this.getConfig().getBoolean("allowShakeChannelSelector", false);
3420
+ this.shakeMenuGesture = normalizedShakeMenuGesture(this.getConfig().getString("shakeMenuGesture", SHAKE_MENU_GESTURE_SHAKE));
3421
+ this.syncShakeMenuLifecycle();
3422
+ this.clearPreviewSessionPreferences();
3423
+ }
3424
+
3425
+ private void clearPreviewSessionPreferences() {
3426
+ if (this.implementation != null) {
3427
+ this.implementation.setPreviewFallbackBundle(null);
3428
+ }
3429
+ this.editor.remove(PREVIEW_SESSION_PREF_KEY);
3430
+ this.editor.remove(PREVIEW_PREVIOUS_SHAKE_MENU_PREF_KEY);
3431
+ this.editor.remove(PREVIEW_PREVIOUS_SHAKE_CHANNEL_SELECTOR_PREF_KEY);
3432
+ this.editor.remove(PREVIEW_PREVIOUS_NEXT_BUNDLE_PREF_KEY);
3433
+ this.editor.remove(PREVIEW_PREVIOUS_APP_ID_PREF_KEY);
3434
+ this.editor.remove(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_PREF_KEY);
3435
+ this.editor.remove(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY);
3436
+ this.editor.remove(PREVIEW_APP_ID_PREF_KEY);
3437
+ this.editor.remove(PREVIEW_PAYLOAD_URL_PREF_KEY);
3438
+ this.editor.remove(PREVIEW_NAME_PREF_KEY);
3439
+ this.editor.remove(PREVIEW_SOURCE_PREF_KEY);
3440
+ this.editor.remove(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY);
3441
+ this.editor.apply();
3442
+ }
3443
+
3444
+ private void setActiveAppId(final String appId) {
3445
+ this.implementation.appId = appId;
3446
+ if (this.implementation.versionOs != null) {
3447
+ DownloadService.updateUserAgent(this.implementation.appId, this.pluginVersion, this.implementation.versionOs);
3448
+ }
3449
+ }
3450
+
3451
+ private void restorePreviewPreviousAppId() {
3452
+ final String previousAppId = this.prefs.getString(PREVIEW_PREVIOUS_APP_ID_PREF_KEY, "");
3453
+ if (previousAppId == null || previousAppId.isEmpty()) {
3454
+ return;
3455
+ }
3456
+ this.setActiveAppId(previousAppId);
3457
+ logger.info("Restored appId after preview: " + previousAppId);
3458
+ }
3459
+
3460
+ private void restorePreviewPreviousDefaultChannel() {
3461
+ final String configDefaultChannel = this.getConfig().getString("defaultChannel", "");
3462
+ if (this.prefs.getBoolean(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_WAS_SET_PREF_KEY, false)) {
3463
+ final String previousDefaultChannel = this.prefs.getString(PREVIEW_PREVIOUS_DEFAULT_CHANNEL_PREF_KEY, "");
3464
+ this.editor.putString(DEFAULT_CHANNEL_PREF_KEY, previousDefaultChannel);
3465
+ this.implementation.defaultChannel = previousDefaultChannel;
3466
+ this.editor.apply();
3467
+ logger.info("Restored defaultChannel after preview");
3468
+ return;
3469
+ }
3470
+
3471
+ this.editor.remove(DEFAULT_CHANNEL_PREF_KEY);
3472
+ this.implementation.defaultChannel = configDefaultChannel;
3473
+ this.editor.apply();
3474
+ logger.info("Restored defaultChannel after preview to config value");
3475
+ }
3476
+
3477
+ private String normalizePreviewAppId(final String rawAppId) {
3478
+ if (rawAppId == null) {
3479
+ return null;
3480
+ }
3481
+
3482
+ final String appId = rawAppId.trim();
3483
+ if (appId.isEmpty()) {
3484
+ return null;
3485
+ }
3486
+
3487
+ final String lowercasedAppId = appId.toLowerCase(java.util.Locale.ROOT);
3488
+ if ("undefined".equals(lowercasedAppId) || "null".equals(lowercasedAppId)) {
3489
+ return null;
3490
+ }
3491
+
3492
+ return appId;
3493
+ }
3494
+
3495
+ private boolean hasPreviewPayloadUrl(final String rawPayloadUrl) {
3496
+ if (rawPayloadUrl == null) {
3497
+ return false;
3498
+ }
3499
+
3500
+ final String payloadUrl = rawPayloadUrl.trim();
3501
+ if (payloadUrl.isEmpty()) {
3502
+ return false;
3503
+ }
3504
+
3505
+ final String lowercasedPayloadUrl = payloadUrl.toLowerCase(java.util.Locale.ROOT);
3506
+ return !"undefined".equals(lowercasedPayloadUrl) && !"null".equals(lowercasedPayloadUrl);
3507
+ }
3508
+
3509
+ private String normalizePreviewPayloadUrl(final String rawPayloadUrl) {
3510
+ if (!this.hasPreviewPayloadUrl(rawPayloadUrl)) {
3511
+ return null;
3512
+ }
3513
+
3514
+ final String payloadUrl = rawPayloadUrl.trim();
3515
+ try {
3516
+ final URL parsedUrl = new URL(payloadUrl);
3517
+ final String protocol = parsedUrl.getProtocol();
3518
+ if (!"https".equals(protocol) && !"http".equals(protocol)) {
3519
+ return null;
3520
+ }
3521
+ return parsedUrl.toString();
3522
+ } catch (final MalformedURLException ignored) {
3523
+ return null;
3524
+ }
3525
+ }
3526
+
3527
+ private String storedPreviewPayloadUrl() {
3528
+ return this.normalizePreviewPayloadUrl(this.prefs.getString(PREVIEW_PAYLOAD_URL_PREF_KEY, null));
3529
+ }
3530
+
3531
+ private String previewPathFromUri(final Uri uri) {
3532
+ if ("capgo".equals(uri.getScheme())) {
3533
+ final String host = uri.getHost();
3534
+ final String path = uri.getPath();
3535
+ return ("/" + (host == null ? "" : host) + (path == null ? "" : path)).replaceAll("/+", "/");
3536
+ }
3537
+
3538
+ return uri.getPath();
3539
+ }
3540
+
3541
+ private boolean isPreviewDeepLink(final Uri uri) {
3542
+ final String path = this.previewPathFromUri(uri);
3543
+ return "/preview/channel".equals(path) || "/preview/bundle".equals(path);
3544
+ }
3545
+
3546
+ private String readResponseBody(final InputStream stream) throws IOException {
3547
+ if (stream == null) {
3548
+ return "";
3549
+ }
3550
+
3551
+ try (InputStream input = stream; ByteArrayOutputStream output = new ByteArrayOutputStream()) {
3552
+ final byte[] buffer = new byte[8192];
3553
+ int read;
3554
+ while ((read = input.read(buffer)) != -1) {
3555
+ output.write(buffer, 0, read);
3556
+ }
3557
+ return output.toString(StandardCharsets.UTF_8.name());
3558
+ }
3559
+ }
3560
+
3561
+ private JSONObject fetchPreviewPayload(final String payloadUrl) throws IOException, JSONException {
3562
+ final HttpURLConnection connection = (HttpURLConnection) new URL(payloadUrl).openConnection();
3563
+ connection.setRequestMethod("GET");
3564
+ connection.setRequestProperty("Accept", "application/json");
3565
+ connection.setConnectTimeout(30000);
3566
+ connection.setReadTimeout(60000);
3567
+
3568
+ try {
3569
+ final int statusCode = connection.getResponseCode();
3570
+ final String body = this.readResponseBody(
3571
+ statusCode >= 200 && statusCode < 300 ? connection.getInputStream() : connection.getErrorStream()
3572
+ );
3573
+ final JSONObject payload = new JSONObject(body);
3574
+ if (statusCode < 200 || statusCode >= 300) {
3575
+ throw new IOException(
3576
+ payload.optString("message", payload.optString("error", "Preview payload request failed with HTTP " + statusCode))
3577
+ );
3578
+ }
3579
+ return payload;
3580
+ } finally {
3581
+ connection.disconnect();
3582
+ }
3583
+ }
3584
+
3585
+ private BundleInfo downloadPreviewPayloadBundle(final JSONObject payload) throws IOException, JSONException {
3586
+ final String version = payload.optString("version", "").trim();
3587
+ if (version.isEmpty()) {
3588
+ throw new IOException("Preview payload is missing a version");
3589
+ }
3590
+
3591
+ final JSONArray manifest = payload.optJSONArray("manifest");
3592
+ final String url = payload.optString("url", "");
3593
+ if ((url == null || url.isEmpty()) && (manifest == null || manifest.length() == 0)) {
3594
+ throw new IOException("Preview payload is missing download information");
3595
+ }
3596
+
3597
+ return this.downloadBundle(
3598
+ url == null || url.isEmpty() ? "https://404.capgo.app/no.zip" : url,
3599
+ version,
3600
+ payload.optString("sessionKey", ""),
3601
+ payload.optString("checksum", ""),
3602
+ manifest
3603
+ );
3604
+ }
3605
+
3606
+ private boolean refreshPreviewSessionFromPayloadUrl(final String payloadUrl) {
3607
+ try {
3608
+ final JSONObject payload = this.fetchPreviewPayload(payloadUrl);
3609
+ final String version = payload.optString("version", "").trim();
3610
+ if (version.isEmpty()) {
3611
+ throw new IOException("Preview payload is missing a version");
3612
+ }
3613
+
3614
+ final BundleInfo current = this.implementation.getCurrentBundle();
3615
+ if (version.equals(current.getVersionName())) {
3616
+ logger.info("Preview payload unchanged, reloading current bundle");
3617
+ return this.reloadWithoutWaitingForAppReady();
3618
+ }
3619
+
3620
+ final BundleInfo next = this.downloadPreviewPayloadBundle(payload);
3621
+ if (next.isErrorStatus()) {
3622
+ throw new IOException("Download failed: " + next.getStatus());
3623
+ }
3624
+ if (!this.implementation.set(next.getId())) {
3625
+ throw new IOException("Downloaded preview bundle cannot be applied");
3626
+ }
3627
+
3628
+ this.recordPreviewBundle(next, current.getId());
3629
+ this.notifyBundleSet(next);
3630
+ return this.reloadWithoutWaitingForAppReady();
3631
+ } catch (final Exception err) {
3632
+ logger.error("Could not refresh preview session: " + err.getMessage());
3633
+ return false;
3634
+ }
3635
+ }
3636
+
3637
+ private void clearPreviewSessionForNativeBuildChange() {
3638
+ if (
3639
+ !Boolean.TRUE.equals(this.previewSessionEnabled) &&
3640
+ this.implementation.getPreviewFallbackBundle() == null &&
3641
+ !this.hasSavedPreviewSessions()
3642
+ ) {
3643
+ return;
3644
+ }
3645
+ logger.info("Native build changed; clearing preview session state");
3646
+ this.previewSessionEnabled = false;
3647
+ this.previewSessionAlertPending = false;
3648
+ this.isLeavingPreviewForIncomingLink = false;
3649
+ this.implementation.previewSession = false;
3650
+ this.shakeMenuEnabled = this.getConfig().getBoolean("shakeMenu", false);
3651
+ this.shakeChannelSelectorEnabled = this.getConfig().getBoolean("allowShakeChannelSelector", false);
3652
+ this.shakeMenuGesture = normalizedShakeMenuGesture(this.getConfig().getString("shakeMenuGesture", SHAKE_MENU_GESTURE_SHAKE));
3653
+ this.syncShakeMenuLifecycle();
3654
+ this.restorePreviewPreviousAppId();
3655
+ this.restorePreviewPreviousDefaultChannel();
3656
+ this.implementation.setPreviewFallbackBundle(null);
3657
+ this.implementation.setNextBundle(null);
3658
+ this.clearPreviewSessionPreferences();
3659
+ synchronized (this.previewSessionsLock) {
3660
+ this.editor.remove(PREVIEW_SESSIONS_PREF_KEY);
3661
+ this.editor.apply();
3662
+ }
3663
+ }
3664
+
3665
+ private void restorePreviewPreviousNextBundle() {
3666
+ final String previousNextBundleId = this.prefs.getString(PREVIEW_PREVIOUS_NEXT_BUNDLE_PREF_KEY, null);
3667
+ if (previousNextBundleId == null || previousNextBundleId.isEmpty()) {
3668
+ this.implementation.setNextBundle(null);
3669
+ return;
3670
+ }
3671
+ if (!this.implementation.setNextBundle(previousNextBundleId)) {
3672
+ logger.warn("Could not restore pre-preview next bundle: " + previousNextBundleId);
3673
+ this.implementation.setNextBundle(null);
3674
+ }
3675
+ }
3676
+
3677
+ private void ensureShakeMenuStarted() {
3678
+ if (shakeMenu != null && !shakeMenu.usesGesture(this.shakeMenuGesture)) {
3679
+ try {
3680
+ shakeMenu.stop();
3681
+ shakeMenu = null;
3682
+ logger.info("Shake menu restarted for " + this.shakeMenuGesture + " gesture");
3683
+ } catch (Exception e) {
3684
+ logger.error("Failed to restart shake menu: " + e.getMessage());
3685
+ return;
3686
+ }
3687
+ }
3688
+
3689
+ if (getActivity() instanceof com.getcapacitor.BridgeActivity && shakeMenu == null) {
3690
+ try {
3691
+ shakeMenu = new ShakeMenu(this, (com.getcapacitor.BridgeActivity) getActivity(), logger, this.shakeMenuGesture);
3692
+ logger.info("Shake menu initialized with " + this.shakeMenuGesture + " gesture");
3693
+ } catch (Exception e) {
3694
+ logger.error("Failed to initialize shake menu: " + e.getMessage());
3695
+ }
3696
+ }
3697
+ }
3698
+
3699
+ private void syncShakeMenuLifecycle() {
3700
+ if (this.shouldListenForShake()) {
3701
+ this.ensureShakeMenuStarted();
3702
+ } else if (shakeMenu != null) {
3703
+ try {
3704
+ shakeMenu.stop();
3705
+ shakeMenu = null;
3706
+ logger.info("Shake menu stopped");
3707
+ } catch (Exception e) {
3708
+ logger.error("Failed to stop shake menu: " + e.getMessage());
3709
+ }
3710
+ }
3711
+ }
3712
+
3713
+ private boolean shouldListenForShake() {
3714
+ return Boolean.TRUE.equals(this.shakeMenuEnabled) || Boolean.TRUE.equals(this.shakeChannelSelectorEnabled);
3715
+ }
3716
+
3717
+ private void showPreviewSessionNoticeIfNeeded() {
3718
+ if (!Boolean.TRUE.equals(this.previewSessionEnabled) || !Boolean.TRUE.equals(this.previewSessionAlertPending)) {
3719
+ return;
3720
+ }
3721
+ this.previewSessionAlertPending = false;
3722
+ this.editor.putBoolean(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY, false);
3723
+ this.editor.apply();
3724
+
3725
+ new Handler(Looper.getMainLooper()).postDelayed(() -> {
3726
+ try {
3727
+ if (!Boolean.TRUE.equals(this.previewSessionEnabled)) {
3728
+ return;
3729
+ }
3730
+ if (getActivity() == null || getActivity().isFinishing()) {
3731
+ this.previewSessionAlertPending = true;
3732
+ this.editor.putBoolean(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY, true);
3733
+ this.editor.apply();
3734
+ return;
3735
+ }
3736
+
3737
+ new AlertDialog.Builder(getActivity())
3738
+ .setTitle("Preview started")
3739
+ .setMessage("Shake your device anytime to reload or leave the test app.")
3740
+ .setPositiveButton("Got it", (dialog, which) -> dialog.dismiss())
3741
+ .show();
3742
+ } catch (final Exception e) {
3743
+ this.previewSessionAlertPending = true;
3744
+ this.editor.putBoolean(PREVIEW_SESSION_ALERT_PENDING_PREF_KEY, true);
3745
+ this.editor.apply();
3746
+ logger.warn("Could not show preview session notice: " + e.getMessage());
1393
3747
  }
1394
- } catch (final Exception e) {
1395
- logger.error("Could not set id " + id + " " + e.getMessage());
1396
- call.reject("Could not set id " + id, e);
1397
- }
3748
+ }, 600);
1398
3749
  }
1399
3750
 
1400
3751
  @PluginMethod
@@ -1478,52 +3829,236 @@ public class CapacitorUpdaterPlugin extends Plugin {
1478
3829
  @PluginMethod
1479
3830
  public void getLatest(final PluginCall call) {
1480
3831
  final String channel = call.getString("channel");
1481
- startNewThread(() ->
1482
- CapacitorUpdaterPlugin.this.implementation.getLatest(CapacitorUpdaterPlugin.this.updateUrl, channel, (res) -> {
1483
- JSObject jsRes = InternalUtils.mapToJSObject(res);
1484
- if (jsRes.has("error")) {
1485
- String error = jsRes.getString("error");
1486
- String errorMessage = jsRes.has("message") ? jsRes.getString("message") : "server did not provide a message";
3832
+ final boolean includeBundleSize = call.getBoolean("includeBundleSize", false);
3833
+ final String previewAppId = this.normalizePreviewAppId(call.getString("appId"));
3834
+ final boolean hasPreviewAppId = previewAppId != null;
3835
+ if (hasPreviewAppId && !Boolean.TRUE.equals(this.allowPreview)) {
3836
+ logger.error("getLatest preview override not allowed set allowPreview in your config to true to enable it");
3837
+ call.reject("getLatest preview override not allowed");
3838
+ return;
3839
+ }
3840
+
3841
+ final Callback latestCallback = (res) -> {
3842
+ JSObject jsRes = InternalUtils.mapToJSObject(res);
3843
+ if (jsRes.has("error") || jsRes.has("kind")) {
3844
+ String error = jsRes.has("error") ? jsRes.getString("error") : "";
3845
+ String errorMessage = jsRes.has("message") ? jsRes.getString("message") : "server did not provide a message";
3846
+ String kind = CapacitorUpdaterPlugin.this.getUpdateResponseKind(jsRes.has("kind") ? jsRes.getString("kind") : null);
3847
+ String latestVersion = jsRes.has("version") ? jsRes.getString("version") : "";
3848
+ jsRes.put("kind", kind);
3849
+ CapacitorUpdaterPlugin.this.notifyBreakingEventsIfNeeded(jsRes, latestVersion);
3850
+ if ("failed".equals(kind)) {
1487
3851
  logger.error("getLatest failed with error: " + error + ", message: " + errorMessage);
1488
- call.reject(jsRes.getString("error"));
1489
- return;
1490
- } else if (jsRes.has("message")) {
1491
- call.reject(jsRes.getString("message"));
1492
- return;
3852
+ call.reject(error.isEmpty() ? errorMessage : error);
1493
3853
  } else {
3854
+ if (!jsRes.has("version") || jsRes.getString("version").isEmpty()) {
3855
+ jsRes.put("version", CapacitorUpdaterPlugin.this.implementation.getCurrentBundle().getVersionName());
3856
+ }
3857
+ logger.info("getLatest returned " + kind + ": " + errorMessage);
1494
3858
  call.resolve(jsRes);
1495
3859
  }
1496
- })
1497
- );
3860
+ return;
3861
+ } else if (jsRes.has("message")) {
3862
+ String latestVersion = jsRes.has("version") ? jsRes.getString("version") : "";
3863
+ CapacitorUpdaterPlugin.this.notifyBreakingEventsIfNeeded(jsRes, latestVersion);
3864
+ call.reject(jsRes.getString("message"));
3865
+ return;
3866
+ } else {
3867
+ if (includeBundleSize) {
3868
+ CapacitorUpdaterPlugin.this.attachBundleSize(jsRes);
3869
+ }
3870
+ call.resolve(jsRes);
3871
+ }
3872
+ };
3873
+
3874
+ startNewThread(() -> {
3875
+ if (hasPreviewAppId) {
3876
+ CapacitorUpdaterPlugin.this.implementation.getLatest(
3877
+ CapacitorUpdaterPlugin.this.updateUrl,
3878
+ channel,
3879
+ previewAppId,
3880
+ latestCallback
3881
+ );
3882
+ return;
3883
+ }
3884
+ CapacitorUpdaterPlugin.this.implementation.getLatest(CapacitorUpdaterPlugin.this.updateUrl, channel, latestCallback);
3885
+ });
3886
+ }
3887
+
3888
+ private void attachBundleSize(final JSObject latest) {
3889
+ try {
3890
+ final JSONArray manifest = latest.optJSONArray("manifest");
3891
+ if (manifest == null || manifest.length() == 0) {
3892
+ return;
3893
+ }
3894
+ final String sessionKey = latest.optString("sessionKey", "");
3895
+ final JSONObject missing = this.implementation.missingBundleFilesResult(manifest, sessionKey);
3896
+ final JSONArray missingManifest = missing.getJSONArray("missing");
3897
+ final JSONObject size = this.implementation.getBundleDownloadSize(
3898
+ this.updateUrl,
3899
+ latest.optString("version", ""),
3900
+ missingManifest
3901
+ );
3902
+ latest.put("missing", missing);
3903
+ latest.put("downloadSize", size);
3904
+ } catch (Exception e) {
3905
+ logger.error("Failed to attach bundle size to getLatest result");
3906
+ logger.debug("Error: " + e.getMessage());
3907
+ }
3908
+ }
3909
+
3910
+ @PluginMethod
3911
+ public void getMissingBundleFiles(final PluginCall call) {
3912
+ final JSONArray manifest = call.getData().optJSONArray("manifest");
3913
+ if (manifest == null) {
3914
+ call.reject("getMissingBundleFiles called without manifest");
3915
+ return;
3916
+ }
3917
+ String sessionKey = call.getString("sessionKey");
3918
+ if (sessionKey == null) {
3919
+ sessionKey = "";
3920
+ }
3921
+ final String finalSessionKey = sessionKey;
3922
+ startNewThread(() -> {
3923
+ try {
3924
+ call.resolve(jsonObjectToJSObject(this.implementation.missingBundleFilesResult(manifest, finalSessionKey)));
3925
+ } catch (Exception e) {
3926
+ call.reject("Could not get missing bundle files", e);
3927
+ }
3928
+ });
3929
+ }
3930
+
3931
+ @PluginMethod
3932
+ public void getBundleDownloadSize(final PluginCall call) {
3933
+ final JSONArray manifest = call.getData().optJSONArray("manifest");
3934
+ if (manifest == null) {
3935
+ call.reject("getBundleDownloadSize called without manifest");
3936
+ return;
3937
+ }
3938
+ final String version = call.getData().optString("version", "");
3939
+ startNewThread(() -> {
3940
+ try {
3941
+ final JSONObject size = this.implementation.getBundleDownloadSize(this.updateUrl, version, manifest);
3942
+ call.resolve(jsonObjectToJSObject(size));
3943
+ } catch (Exception e) {
3944
+ call.reject("Could not get bundle download size", e);
3945
+ }
3946
+ });
3947
+ }
3948
+
3949
+ public String triggerBackgroundUpdateCheck() {
3950
+ if (this.updateUrl == null || this.updateUrl.isEmpty() || !this.isValidURL(this.updateUrl)) {
3951
+ logger.error("Error no url or wrong format");
3952
+ return "unavailable";
3953
+ }
3954
+ if (this.shouldBlockAutoUpdateForPreviewSession()) {
3955
+ return "preview_session";
3956
+ }
3957
+ if (this.isDownloadStuckOrTimedOut()) {
3958
+ logger.info("Download already in progress, skipping duplicate download request");
3959
+ return "already_running";
3960
+ }
3961
+ this.backgroundDownload();
3962
+ return "queued";
3963
+ }
3964
+
3965
+ @PluginMethod
3966
+ public void triggerUpdateCheck(final PluginCall call) {
3967
+ final String status = this.triggerBackgroundUpdateCheck();
3968
+ final JSObject ret = new JSObject();
3969
+ ret.put("status", status);
3970
+ ret.put("queued", "queued".equals(status));
3971
+ call.resolve(ret);
3972
+ }
3973
+
3974
+ private boolean _reset(final Boolean toLastSuccessful, final Boolean usePendingBundle) {
3975
+ return this.performReset(toLastSuccessful, usePendingBundle, false);
1498
3976
  }
1499
3977
 
1500
- private boolean _reset(final Boolean toLastSuccessful) {
3978
+ private boolean performReset(final Boolean toLastSuccessful, final Boolean usePendingBundle, final boolean internal) {
1501
3979
  final BundleInfo fallback = this.implementation.getFallbackBundle();
1502
- this.implementation.reset();
3980
+ final BundleInfo pending = this.implementation.getNextBundle();
3981
+ final CapgoUpdater.ResetState previousState = this.implementation.captureResetState();
3982
+ final String previousBundleName = this.implementation.getCurrentBundle().getVersionName();
3983
+
3984
+ if (Boolean.TRUE.equals(usePendingBundle)) {
3985
+ if (pending == null || pending.isErrorStatus()) {
3986
+ logger.error("No pending bundle available to reset to");
3987
+ return false;
3988
+ }
3989
+ if (!this.implementation.canSet(pending)) {
3990
+ logger.error("Pending bundle is not installable");
3991
+ return false;
3992
+ }
3993
+ this.implementation.prepareResetStateForTransition();
3994
+ logger.info("Resetting to pending bundle: " + pending.getVersionName());
3995
+ final boolean didApplyPendingBundle;
3996
+ if (pending.isBuiltin()) {
3997
+ didApplyPendingBundle = true;
3998
+ } else {
3999
+ didApplyPendingBundle = this.implementation.set(pending);
4000
+ }
4001
+ if (didApplyPendingBundle && this._reload()) {
4002
+ this.implementation.finalizeResetTransition(previousBundleName, internal);
4003
+ this.notifyBundleSet(pending);
4004
+ this.implementation.setNextBundle(null);
4005
+ return true;
4006
+ }
4007
+ this.implementation.restoreResetState(previousState);
4008
+ this.restoreLiveBundleStateAfterFailedReload();
4009
+ return false;
4010
+ }
1503
4011
 
1504
- if (toLastSuccessful && !fallback.isBuiltin()) {
1505
- logger.info("Resetting to: " + fallback);
1506
- return this.implementation.set(fallback) && this._reload();
4012
+ if (Boolean.TRUE.equals(toLastSuccessful) && !fallback.isBuiltin()) {
4013
+ if (this.implementation.canSet(fallback)) {
4014
+ this.implementation.prepareResetStateForTransition();
4015
+ logger.info("Resetting to: " + fallback);
4016
+ if (this.implementation.set(fallback) && this._reload()) {
4017
+ this.implementation.finalizeResetTransition(previousBundleName, internal);
4018
+ this.notifyBundleSet(fallback);
4019
+ return true;
4020
+ }
4021
+ if (!internal) {
4022
+ this.implementation.restoreResetState(previousState);
4023
+ this.restoreLiveBundleStateAfterFailedReload();
4024
+ return false;
4025
+ }
4026
+ logger.warn("Fallback reload failed during internal reset, resetting to native instead");
4027
+ } else {
4028
+ logger.warn("Fallback bundle is not installable, resetting to native instead");
4029
+ }
1507
4030
  }
1508
4031
 
4032
+ this.implementation.prepareResetStateForTransition();
1509
4033
  logger.info("Resetting to native.");
1510
- return this._reload();
4034
+ if (this._reload()) {
4035
+ this.implementation.finalizeResetTransition(previousBundleName, internal);
4036
+ return true;
4037
+ }
4038
+ if (!internal) {
4039
+ this.implementation.restoreResetState(previousState);
4040
+ this.restoreLiveBundleStateAfterFailedReload();
4041
+ }
4042
+ return false;
1511
4043
  }
1512
4044
 
1513
4045
  @PluginMethod
1514
4046
  public void reset(final PluginCall call) {
1515
- try {
1516
- final Boolean toLastSuccessful = call.getBoolean("toLastSuccessful", false);
1517
- if (this._reset(toLastSuccessful)) {
1518
- call.resolve();
1519
- return;
4047
+ startNewThread(() -> {
4048
+ try {
4049
+ final Boolean toLastSuccessful = call.getBoolean("toLastSuccessful", false);
4050
+ final Boolean usePendingBundle = call.getBoolean("usePendingBundle", false);
4051
+ if (this._reset(toLastSuccessful, usePendingBundle)) {
4052
+ call.resolve();
4053
+ return;
4054
+ }
4055
+ logger.error("Reset failed");
4056
+ call.reject("Reset failed");
4057
+ } catch (final Exception e) {
4058
+ logger.error("Reset failed " + e.getMessage());
4059
+ call.reject("Reset failed", e);
1520
4060
  }
1521
- logger.error("Reset failed");
1522
- call.reject("Reset failed");
1523
- } catch (final Exception e) {
1524
- logger.error("Reset failed " + e.getMessage());
1525
- call.reject("Reset failed", e);
1526
- }
4061
+ });
1527
4062
  }
1528
4063
 
1529
4064
  @PluginMethod
@@ -1587,14 +4122,41 @@ public class CapacitorUpdaterPlugin extends Plugin {
1587
4122
  @Override
1588
4123
  public void run() {
1589
4124
  try {
4125
+ if (CapacitorUpdaterPlugin.this.shouldBlockAutoUpdateForPreviewSession()) {
4126
+ return;
4127
+ }
1590
4128
  CapacitorUpdaterPlugin.this.implementation.getLatest(CapacitorUpdaterPlugin.this.updateUrl, null, (res) -> {
4129
+ if (CapacitorUpdaterPlugin.this.shouldBlockAutoUpdateForPreviewSession()) {
4130
+ return;
4131
+ }
1591
4132
  JSObject jsRes = InternalUtils.mapToJSObject(res);
1592
- if (jsRes.has("error")) {
1593
- String error = jsRes.getString("error");
4133
+ if (jsRes.has("error") || jsRes.has("kind")) {
4134
+ final BundleInfo current = CapacitorUpdaterPlugin.this.implementation.getCurrentBundle();
4135
+ String error = jsRes.has("error") ? jsRes.getString("error") : "";
1594
4136
  String errorMessage = jsRes.has("message")
1595
4137
  ? jsRes.getString("message")
1596
4138
  : "server did not provide a message";
1597
- logger.error("getLatest failed with error: " + error + ", message: " + errorMessage);
4139
+ int statusCode = jsRes.has("statusCode") ? jsRes.optInt("statusCode", 0) : 0;
4140
+ String kind = CapacitorUpdaterPlugin.this.getUpdateResponseKind(
4141
+ jsRes.has("kind") ? jsRes.getString("kind") : null
4142
+ );
4143
+ String latestVersion = jsRes.has("version") ? jsRes.getString("version") : current.getVersionName();
4144
+ CapacitorUpdaterPlugin.this.notifyUpdateCheckResult(
4145
+ kind,
4146
+ error,
4147
+ errorMessage,
4148
+ statusCode,
4149
+ latestVersion,
4150
+ current
4151
+ );
4152
+
4153
+ if ("failed".equals(kind)) {
4154
+ logger.error("getLatest failed with error: " + error + ", message: " + errorMessage);
4155
+ } else if ("blocked".equals(kind)) {
4156
+ logger.info("Update check blocked with error: " + error);
4157
+ } else {
4158
+ logger.info("No new version available");
4159
+ }
1598
4160
  } else if (jsRes.has("version")) {
1599
4161
  String newVersion = jsRes.getString("version");
1600
4162
  String currentVersion = String.valueOf(CapacitorUpdaterPlugin.this.implementation.getCurrentBundle());
@@ -1629,6 +4191,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
1629
4191
  logger.info("semaphoreReady countDown");
1630
4192
  this.semaphoreDown();
1631
4193
  logger.info("semaphoreReady countDown done");
4194
+ this.clearIncomingPreviewTransition();
4195
+ this.hidePreviewTransitionLoader("notify-app-ready");
1632
4196
  final JSObject ret = new JSObject();
1633
4197
  ret.put("bundle", InternalUtils.mapToJSObject(bundle.toJSONMap()));
1634
4198
  call.resolve(ret);
@@ -1676,6 +4240,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
1676
4240
  }
1677
4241
 
1678
4242
  private Boolean _isAutoUpdateEnabled() {
4243
+ if (this.isPreviewSessionStateActive()) {
4244
+ return false;
4245
+ }
1679
4246
  final CapConfig config = CapConfig.loadDefault(this.getActivity());
1680
4247
  String serverUrl = config.getServerUrl();
1681
4248
  if (serverUrl != null && !serverUrl.isEmpty()) {
@@ -1716,11 +4283,27 @@ public class CapacitorUpdaterPlugin extends Plugin {
1716
4283
  }
1717
4284
 
1718
4285
  private void checkAppReady() {
4286
+ this.checkAppReady(this.resolveAppReadyCheckTimeoutMs());
4287
+ }
4288
+
4289
+ synchronized boolean shouldInterruptAppReadyCheck(final Thread existingCheck, final Thread currentThread) {
4290
+ return existingCheck != null && existingCheck != currentThread;
4291
+ }
4292
+
4293
+ synchronized void clearAppReadyCheckIfCurrent(final Thread expectedThread) {
4294
+ if (this.appReadyCheck == expectedThread) {
4295
+ this.appReadyCheck = null;
4296
+ }
4297
+ }
4298
+
4299
+ private void checkAppReady(final long waitTimeMs) {
1719
4300
  try {
1720
- if (this.appReadyCheck != null) {
1721
- this.appReadyCheck.interrupt();
4301
+ final Thread currentThread = Thread.currentThread();
4302
+ final Thread existingCheck = this.appReadyCheck;
4303
+ if (this.shouldInterruptAppReadyCheck(existingCheck, currentThread)) {
4304
+ existingCheck.interrupt();
1722
4305
  }
1723
- this.appReadyCheck = startNewThread(new DeferredNotifyAppReadyCheck());
4306
+ this.appReadyCheck = startNewThread(new DeferredNotifyAppReadyCheck(waitTimeMs));
1724
4307
  } catch (final Exception e) {
1725
4308
  logger.error("Failed to start " + DeferredNotifyAppReadyCheck.class.getName() + " " + e.getMessage());
1726
4309
  }
@@ -1735,6 +4318,35 @@ public class CapacitorUpdaterPlugin extends Plugin {
1735
4318
  }
1736
4319
  }
1737
4320
 
4321
+ static String normalizedUpdateResponseKind(final String kind) {
4322
+ if ("up_to_date".equals(kind) || "blocked".equals(kind) || "failed".equals(kind)) {
4323
+ return kind;
4324
+ }
4325
+ return "failed";
4326
+ }
4327
+
4328
+ private String getUpdateResponseKind(final String kind) {
4329
+ return normalizedUpdateResponseKind(kind);
4330
+ }
4331
+
4332
+ private void notifyUpdateCheckResult(
4333
+ final String kind,
4334
+ final String error,
4335
+ final String message,
4336
+ final int statusCode,
4337
+ final String version,
4338
+ final BundleInfo current
4339
+ ) {
4340
+ JSObject ret = new JSObject();
4341
+ ret.put("kind", kind);
4342
+ ret.put("error", error);
4343
+ ret.put("message", message);
4344
+ ret.put("statusCode", statusCode);
4345
+ ret.put("version", version);
4346
+ ret.put("bundle", InternalUtils.mapToJSObject(current.toJSONMap()));
4347
+ this.notifyListeners("updateCheckResult", ret);
4348
+ }
4349
+
1738
4350
  private void ensureBridgeSet() {
1739
4351
  if (this.bridge != null && this.bridge.getWebView() != null) {
1740
4352
  logger.setBridge(this.bridge);
@@ -1772,11 +4384,36 @@ public class CapacitorUpdaterPlugin extends Plugin {
1772
4384
  String latestVersionName,
1773
4385
  BundleInfo current,
1774
4386
  Boolean error,
1775
- Boolean isDirectUpdate,
4387
+ Boolean plannedDirectUpdate,
1776
4388
  String failureAction,
1777
4389
  String failureEvent,
1778
4390
  boolean shouldSendStats
1779
4391
  ) {
4392
+ endBackGroundTaskWithNotif(
4393
+ msg,
4394
+ latestVersionName,
4395
+ current,
4396
+ error,
4397
+ plannedDirectUpdate,
4398
+ failureAction,
4399
+ failureEvent,
4400
+ shouldSendStats,
4401
+ true
4402
+ );
4403
+ }
4404
+
4405
+ private void endBackGroundTaskWithNotif(
4406
+ String msg,
4407
+ String latestVersionName,
4408
+ BundleInfo current,
4409
+ Boolean error,
4410
+ Boolean plannedDirectUpdate,
4411
+ String failureAction,
4412
+ String failureEvent,
4413
+ boolean shouldSendStats,
4414
+ boolean shouldNotifyNoNeedUpdate
4415
+ ) {
4416
+ this.consumeOnLaunchDirectUpdateAttempt(Boolean.TRUE.equals(plannedDirectUpdate));
1780
4417
  if (error) {
1781
4418
  logger.info(
1782
4419
  "endBackGroundTaskWithNotif error: " +
@@ -1793,15 +4430,22 @@ public class CapacitorUpdaterPlugin extends Plugin {
1793
4430
  ret.put("version", latestVersionName);
1794
4431
  this.notifyListeners(failureEvent, ret);
1795
4432
  }
1796
- final JSObject ret = new JSObject();
1797
- ret.put("bundle", InternalUtils.mapToJSObject(current.toJSONMap()));
1798
- this.notifyListeners("noNeedUpdate", ret);
1799
- this.sendReadyToJs(current, msg, isDirectUpdate);
4433
+ if (shouldNotifyNoNeedUpdate) {
4434
+ final JSObject ret = new JSObject();
4435
+ ret.put("bundle", InternalUtils.mapToJSObject(current.toJSONMap()));
4436
+ this.notifyListeners("noNeedUpdate", ret);
4437
+ }
4438
+ this.sendReadyToJs(current, msg, plannedDirectUpdate);
1800
4439
  this.backgroundDownloadTask = null;
1801
4440
  this.downloadStartTimeMs = 0;
1802
4441
  logger.info("endBackGroundTaskWithNotif " + msg);
1803
4442
  }
1804
4443
 
4444
+ private void clearBackgroundDownloadState() {
4445
+ this.backgroundDownloadTask = null;
4446
+ this.downloadStartTimeMs = 0;
4447
+ }
4448
+
1805
4449
  private boolean isDownloadStuckOrTimedOut() {
1806
4450
  if (this.backgroundDownloadTask == null || !this.backgroundDownloadTask.isAlive()) {
1807
4451
  return false;
@@ -1828,46 +4472,69 @@ public class CapacitorUpdaterPlugin extends Plugin {
1828
4472
  }
1829
4473
 
1830
4474
  private Thread backgroundDownload() {
4475
+ if (this.shouldBlockAutoUpdateForPreviewSession()) {
4476
+ return null;
4477
+ }
1831
4478
  final boolean plannedDirectUpdate = this.shouldUseDirectUpdate();
1832
4479
  final boolean initialDirectUpdateAllowed = this.isDirectUpdateCurrentlyAllowed(plannedDirectUpdate);
1833
- this.implementation.directUpdate = initialDirectUpdateAllowed;
1834
4480
  final String messageUpdate = initialDirectUpdateAllowed
1835
4481
  ? "Update will occur now."
1836
- : "Update will occur next time app moves to background.";
4482
+ : this.shouldAutoSetNextBundle()
4483
+ ? "Update will occur next time app moves to background."
4484
+ : "Update will be downloaded and made available.";
1837
4485
  Thread newTask = startNewThread(() -> {
1838
4486
  // Wait for cleanup to complete before starting download
1839
4487
  waitForCleanupIfNeeded();
4488
+ if (CapacitorUpdaterPlugin.this.shouldBlockAutoUpdateForPreviewSession()) {
4489
+ CapacitorUpdaterPlugin.this.clearBackgroundDownloadState();
4490
+ return;
4491
+ }
1840
4492
  logger.info("Check for update via: " + CapacitorUpdaterPlugin.this.updateUrl);
1841
4493
  try {
1842
4494
  CapacitorUpdaterPlugin.this.implementation.getLatest(CapacitorUpdaterPlugin.this.updateUrl, null, (res) -> {
4495
+ if (CapacitorUpdaterPlugin.this.shouldBlockAutoUpdateForPreviewSession()) {
4496
+ CapacitorUpdaterPlugin.this.clearBackgroundDownloadState();
4497
+ return;
4498
+ }
1843
4499
  JSObject jsRes = InternalUtils.mapToJSObject(res);
1844
4500
  final BundleInfo current = CapacitorUpdaterPlugin.this.implementation.getCurrentBundle();
1845
4501
 
1846
4502
  // Handle network errors and other failures first
1847
- if (jsRes.has("error")) {
1848
- String error = jsRes.getString("error");
4503
+ if (jsRes.has("error") || jsRes.has("kind")) {
4504
+ String error = jsRes.has("error") ? jsRes.getString("error") : "";
1849
4505
  String errorMessage = jsRes.has("message") ? jsRes.getString("message") : "server did not provide a message";
1850
4506
  int statusCode = jsRes.has("statusCode") ? jsRes.optInt("statusCode", 0) : 0;
1851
- boolean responseIsOk = statusCode >= 200 && statusCode < 300;
1852
-
1853
- logger.error(
1854
- "getLatest failed with error: " + error + ", message: " + errorMessage + ", statusCode: " + statusCode
1855
- );
4507
+ String kind = CapacitorUpdaterPlugin.this.getUpdateResponseKind(jsRes.has("kind") ? jsRes.getString("kind") : null);
1856
4508
  String latestVersion = jsRes.has("version") ? jsRes.getString("version") : current.getVersionName();
4509
+ CapacitorUpdaterPlugin.this.notifyUpdateCheckResult(kind, error, errorMessage, statusCode, latestVersion, current);
4510
+ CapacitorUpdaterPlugin.this.notifyBreakingEventsIfNeeded(
4511
+ jsRes,
4512
+ jsRes.has("version") ? jsRes.getString("version") : ""
4513
+ );
4514
+
4515
+ if ("up_to_date".equals(kind)) {
4516
+ logger.info("No new version available");
4517
+ } else if ("blocked".equals(kind)) {
4518
+ logger.info("Update check blocked with error: " + error);
4519
+ } else {
4520
+ logger.error(
4521
+ "getLatest failed with error: " + error + ", message: " + errorMessage + ", statusCode: " + statusCode
4522
+ );
4523
+ }
1857
4524
 
4525
+ boolean isFailure = "failed".equals(kind);
1858
4526
  CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
1859
4527
  errorMessage,
1860
4528
  latestVersion,
1861
4529
  current,
1862
- true,
4530
+ isFailure,
1863
4531
  plannedDirectUpdate,
1864
4532
  "download_fail",
1865
4533
  "downloadFailed",
1866
- !responseIsOk
4534
+ isFailure
1867
4535
  );
1868
4536
  return;
1869
4537
  }
1870
-
1871
4538
  try {
1872
4539
  final String latestVersionName = jsRes.getString("version");
1873
4540
 
@@ -1878,7 +4545,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
1878
4545
  );
1879
4546
  if (directUpdateAllowedNow) {
1880
4547
  logger.info("Direct update to builtin version");
1881
- this._reset(false);
4548
+ this._reset(false, false);
1882
4549
  CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
1883
4550
  "Updated to builtin version",
1884
4551
  latestVersionName,
@@ -1886,7 +4553,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
1886
4553
  false,
1887
4554
  true
1888
4555
  );
1889
- } else {
4556
+ } else if (CapacitorUpdaterPlugin.this.shouldAutoSetNextBundle()) {
1890
4557
  if (plannedDirectUpdate && !directUpdateAllowedNow) {
1891
4558
  logger.info(
1892
4559
  "Direct update skipped because splashscreen timeout occurred. Update will be applied later."
@@ -1898,13 +4565,37 @@ public class CapacitorUpdaterPlugin extends Plugin {
1898
4565
  "Next update will be to builtin version",
1899
4566
  latestVersionName,
1900
4567
  current,
1901
- false
4568
+ false,
4569
+ plannedDirectUpdate
4570
+ );
4571
+ } else {
4572
+ logger.info("autoUpdate is set to onlyDownload, builtin version will not be set as next bundle");
4573
+ final boolean builtinUpdateAvailable = !current.isBuiltin();
4574
+ if (builtinUpdateAvailable) {
4575
+ final JSObject ret = new JSObject();
4576
+ final BundleInfo builtinBundle = CapacitorUpdaterPlugin.this.implementation.getBundleInfo(
4577
+ BundleInfo.ID_BUILTIN
4578
+ );
4579
+ ret.put("bundle", InternalUtils.mapToJSObject(builtinBundle.toJSONMap()));
4580
+ CapacitorUpdaterPlugin.this.notifyListeners("updateAvailable", ret);
4581
+ }
4582
+ CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
4583
+ "Latest version is builtin, autoUpdate onlyDownload",
4584
+ latestVersionName,
4585
+ current,
4586
+ false,
4587
+ plannedDirectUpdate,
4588
+ "download_fail",
4589
+ "downloadFailed",
4590
+ true,
4591
+ !builtinUpdateAvailable
1902
4592
  );
1903
4593
  }
1904
4594
  return;
1905
4595
  }
1906
4596
 
1907
4597
  if (!jsRes.has("url") || !CapacitorUpdaterPlugin.this.isValidURL(jsRes.getString("url"))) {
4598
+ CapacitorUpdaterPlugin.this.notifyBreakingEventsIfNeeded(jsRes, latestVersionName);
1908
4599
  logger.error("Error no url or wrong format");
1909
4600
  CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
1910
4601
  "Error no url or wrong format",
@@ -1934,7 +4625,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
1934
4625
  );
1935
4626
  return;
1936
4627
  }
1937
- if (latest.isDownloaded()) {
4628
+ if (latest.isDownloaded() && BundleStatus.DOWNLOADING != latest.getStatus()) {
1938
4629
  logger.info("Latest bundle already exists and download is NOT required. " + messageUpdate);
1939
4630
  final boolean directUpdateAllowedNow = CapacitorUpdaterPlugin.this.isDirectUpdateCurrentlyAllowed(
1940
4631
  plannedDirectUpdate
@@ -1955,16 +4646,27 @@ public class CapacitorUpdaterPlugin extends Plugin {
1955
4646
  );
1956
4647
  return;
1957
4648
  }
1958
- CapacitorUpdaterPlugin.this.implementation.set(latest);
1959
- CapacitorUpdaterPlugin.this._reload();
1960
- CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
1961
- "Update installed",
1962
- latestVersionName,
1963
- latest,
1964
- false,
1965
- true
1966
- );
1967
- } else {
4649
+ if (
4650
+ CapacitorUpdaterPlugin.this.implementation.set(latest) && CapacitorUpdaterPlugin.this._reload()
4651
+ ) {
4652
+ CapacitorUpdaterPlugin.this.notifyBundleSet(latest);
4653
+ CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
4654
+ "Update installed",
4655
+ latestVersionName,
4656
+ latest,
4657
+ false,
4658
+ true
4659
+ );
4660
+ } else {
4661
+ CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
4662
+ "Update install failed",
4663
+ latestVersionName,
4664
+ latest,
4665
+ true,
4666
+ true
4667
+ );
4668
+ }
4669
+ } else if (CapacitorUpdaterPlugin.this.shouldAutoSetNextBundle()) {
1968
4670
  if (plannedDirectUpdate && !directUpdateAllowedNow) {
1969
4671
  logger.info(
1970
4672
  "Direct update skipped because splashscreen timeout occurred. Update will install on next background."
@@ -1976,6 +4678,21 @@ public class CapacitorUpdaterPlugin extends Plugin {
1976
4678
  "update downloaded, will install next background",
1977
4679
  latestVersionName,
1978
4680
  latest,
4681
+ false,
4682
+ plannedDirectUpdate
4683
+ );
4684
+ } else {
4685
+ logger.info("autoUpdate is set to onlyDownload, downloaded update will not be set as next bundle");
4686
+ CapacitorUpdaterPlugin.this.notifyListeners("updateAvailable", ret);
4687
+ CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
4688
+ "update downloaded, autoUpdate onlyDownload",
4689
+ latestVersionName,
4690
+ current,
4691
+ false,
4692
+ plannedDirectUpdate,
4693
+ "download_fail",
4694
+ "downloadFailed",
4695
+ true,
1979
4696
  false
1980
4697
  );
1981
4698
  }
@@ -1993,8 +4710,20 @@ public class CapacitorUpdaterPlugin extends Plugin {
1993
4710
  }
1994
4711
  }
1995
4712
  }
4713
+ final boolean retryingInFlightDownload =
4714
+ latest != null &&
4715
+ BundleStatus.DOWNLOADING == latest.getStatus() &&
4716
+ CapacitorUpdaterPlugin.this.isVersionDownloadInProgress(latest.getVersionName());
4717
+ CapacitorUpdaterPlugin.this.consumeOnLaunchDirectUpdateAttempt(plannedDirectUpdate);
4718
+ CapacitorUpdaterPlugin.this.implementation.directUpdate = retryingInFlightDownload
4719
+ ? Boolean.TRUE.equals(CapacitorUpdaterPlugin.this.implementation.directUpdate) || initialDirectUpdateAllowed
4720
+ : initialDirectUpdateAllowed;
1996
4721
  startNewThread(() -> {
1997
4722
  try {
4723
+ if (CapacitorUpdaterPlugin.this.shouldBlockAutoUpdateForPreviewSession()) {
4724
+ CapacitorUpdaterPlugin.this.clearBackgroundDownloadState();
4725
+ return;
4726
+ }
1998
4727
  logger.info(
1999
4728
  "New bundle: " +
2000
4729
  latestVersionName +
@@ -2016,7 +4745,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
2016
4745
  latestVersionName,
2017
4746
  sessionKey,
2018
4747
  checksum,
2019
- manifest
4748
+ manifest,
4749
+ CapacitorUpdaterPlugin.this.shouldAutoSetNextBundle()
2020
4750
  );
2021
4751
  } else {
2022
4752
  // Handle single file download (existing code)
@@ -2025,7 +4755,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
2025
4755
  latestVersionName,
2026
4756
  sessionKey,
2027
4757
  checksum,
2028
- null
4758
+ null,
4759
+ CapacitorUpdaterPlugin.this.shouldAutoSetNextBundle()
2029
4760
  );
2030
4761
  }
2031
4762
  } catch (final Exception e) {
@@ -2041,7 +4772,13 @@ public class CapacitorUpdaterPlugin extends Plugin {
2041
4772
  });
2042
4773
  } else {
2043
4774
  logger.info("No need to update, " + current.getId() + " is the latest bundle.");
2044
- CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif("No need to update", latestVersionName, current, false);
4775
+ CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
4776
+ "No need to update",
4777
+ latestVersionName,
4778
+ current,
4779
+ false,
4780
+ plannedDirectUpdate
4781
+ );
2045
4782
  }
2046
4783
  } catch (final Exception e) {
2047
4784
  logger.error("error in update check " + e.getMessage());
@@ -2073,6 +4810,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
2073
4810
 
2074
4811
  private void installNext() {
2075
4812
  try {
4813
+ if (this.shouldBlockAutoUpdateForPreviewSession()) {
4814
+ return;
4815
+ }
2076
4816
  String delayUpdatePreferences = prefs.getString(DelayUpdateUtils.DELAY_CONDITION_PREFERENCES, "[]");
2077
4817
  ArrayList<DelayCondition> delayConditionList = delayUpdateUtils.parseDelayConditions(delayUpdatePreferences);
2078
4818
  if (!delayConditionList.isEmpty()) {
@@ -2085,15 +4825,18 @@ public class CapacitorUpdaterPlugin extends Plugin {
2085
4825
  if (next != null && !next.isErrorStatus() && !next.getId().equals(current.getId())) {
2086
4826
  // There is a next bundle waiting for activation
2087
4827
  logger.debug("Next bundle is: " + next.getVersionName());
2088
- if (this.implementation.set(next) && this._reload()) {
2089
- logger.info("Updated to bundle: " + next.getVersionName());
2090
- this.implementation.setNextBundle(null);
2091
- } else {
2092
- logger.error("Update to bundle: " + next.getVersionName() + " Failed!");
2093
- }
4828
+ startNewThread(() -> {
4829
+ if (this.implementation.set(next) && this._reload()) {
4830
+ logger.info("Updated to bundle: " + next.getVersionName());
4831
+ this.notifyBundleSet(next);
4832
+ this.implementation.setNextBundle(null);
4833
+ } else {
4834
+ logger.error("Update to bundle: " + next.getVersionName() + " Failed!");
4835
+ }
4836
+ });
2094
4837
  }
2095
4838
  } catch (final Exception e) {
2096
- logger.error("Error during onActivityStopped " + e.getMessage());
4839
+ logger.error("Error during installNext " + e);
2097
4840
  }
2098
4841
  }
2099
4842
 
@@ -2105,6 +4848,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
2105
4848
  logger.info("Built-in bundle is active. We skip the check for notifyAppReady.");
2106
4849
  return;
2107
4850
  }
4851
+ if (this.isPreviewSessionStateActive()) {
4852
+ logger.info("Preview session is active. We skip the check for notifyAppReady.");
4853
+ return;
4854
+ }
2108
4855
  logger.debug("Current bundle is: " + current);
2109
4856
 
2110
4857
  if (BundleStatus.SUCCESS != current.getStatus()) {
@@ -2116,7 +4863,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2116
4863
  this.notifyListeners("updateFailed", ret);
2117
4864
  this.implementation.sendStats("update_fail", current.getVersionName());
2118
4865
  this.implementation.setError(current);
2119
- this._reset(true);
4866
+ this.performReset(true, false, true);
2120
4867
  if (CapacitorUpdaterPlugin.this.autoDeleteFailed && !current.isBuiltin()) {
2121
4868
  logger.info("Deleting failing bundle: " + current.getVersionName());
2122
4869
  try {
@@ -2135,14 +4882,22 @@ public class CapacitorUpdaterPlugin extends Plugin {
2135
4882
 
2136
4883
  private class DeferredNotifyAppReadyCheck implements Runnable {
2137
4884
 
4885
+ private final long waitTimeMs;
4886
+
4887
+ DeferredNotifyAppReadyCheck(final long waitTimeMs) {
4888
+ this.waitTimeMs = waitTimeMs;
4889
+ }
4890
+
2138
4891
  @Override
2139
4892
  public void run() {
4893
+ final Thread currentThread = Thread.currentThread();
2140
4894
  try {
2141
- logger.info("Wait for " + CapacitorUpdaterPlugin.this.appReadyTimeout + "ms, then check for notifyAppReady");
2142
- Thread.sleep(CapacitorUpdaterPlugin.this.appReadyTimeout);
4895
+ logger.info("Wait for " + this.waitTimeMs + "ms, then check for notifyAppReady");
4896
+ Thread.sleep(this.waitTimeMs);
2143
4897
  CapacitorUpdaterPlugin.this.checkRevert();
2144
- CapacitorUpdaterPlugin.this.appReadyCheck = null;
4898
+ CapacitorUpdaterPlugin.this.clearAppReadyCheckIfCurrent(currentThread);
2145
4899
  } catch (final InterruptedException e) {
4900
+ CapacitorUpdaterPlugin.this.clearAppReadyCheckIfCurrent(currentThread);
2146
4901
  logger.info(DeferredNotifyAppReadyCheck.class.getName() + " was interrupted.");
2147
4902
  }
2148
4903
  }
@@ -2271,6 +5026,36 @@ public class CapacitorUpdaterPlugin extends Plugin {
2271
5026
  }
2272
5027
  }
2273
5028
 
5029
+ @Override
5030
+ protected void handleOnNewIntent(Intent intent) {
5031
+ super.handleOnNewIntent(intent);
5032
+ if (
5033
+ intent == null ||
5034
+ !Intent.ACTION_VIEW.equals(intent.getAction()) ||
5035
+ intent.getData() == null ||
5036
+ !Boolean.TRUE.equals(this.previewSessionEnabled) ||
5037
+ !isPreviewDeepLink(intent.getData()) ||
5038
+ Boolean.TRUE.equals(this.isLeavingPreviewForIncomingLink)
5039
+ ) {
5040
+ return;
5041
+ }
5042
+
5043
+ this.isLeavingPreviewForIncomingLink = true;
5044
+ this.showPreviewTransitionLoader("incoming-preview-deeplink");
5045
+ if (getActivity() != null) {
5046
+ getActivity().setIntent(intent);
5047
+ }
5048
+ logger.info("Preview deeplink received while preview session is active; restoring fallback before routing");
5049
+ startNewThread(() -> {
5050
+ final boolean didLeave = this.leavePreviewSessionForIncomingPreviewLink();
5051
+ if (!didLeave) {
5052
+ logger.error("Could not leave preview session before routing incoming preview deeplink");
5053
+ this.isLeavingPreviewForIncomingLink = false;
5054
+ this.hidePreviewTransitionLoader("incoming-preview-deeplink-failed");
5055
+ }
5056
+ });
5057
+ }
5058
+
2274
5059
  @Override
2275
5060
  public void handleOnStart() {
2276
5061
  try {
@@ -2286,15 +5071,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2286
5071
  isPreviousMainActivity = true;
2287
5072
  }
2288
5073
 
2289
- // Initialize shake menu if enabled and activity is BridgeActivity
2290
- if (shakeMenuEnabled && getActivity() instanceof com.getcapacitor.BridgeActivity && shakeMenu == null) {
2291
- try {
2292
- shakeMenu = new ShakeMenu(this, (com.getcapacitor.BridgeActivity) getActivity(), logger);
2293
- logger.info("Shake menu initialized");
2294
- } catch (Exception e) {
2295
- logger.error("Failed to initialize shake menu: " + e.getMessage());
2296
- }
2297
- }
5074
+ this.syncShakeMenuLifecycle();
2298
5075
  } catch (Exception e) {
2299
5076
  logger.error("Failed to run handleOnStart: " + e.getMessage());
2300
5077
  }
@@ -2326,6 +5103,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2326
5103
  backgroundTask.interrupt();
2327
5104
  }
2328
5105
  this.implementation.activity = getActivity();
5106
+ this.syncShakeMenuLifecycle();
2329
5107
  } catch (Exception e) {
2330
5108
  logger.error("Failed to run handleOnResume: " + e.getMessage());
2331
5109
  }
@@ -2350,25 +5128,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
2350
5128
  }
2351
5129
 
2352
5130
  this.shakeMenuEnabled = enabled;
2353
- logger.info("Shake menu " + (enabled ? "enabled" : "disabled"));
2354
-
2355
- // Manage shake menu instance based on enabled state
2356
- if (enabled && getActivity() instanceof com.getcapacitor.BridgeActivity && shakeMenu == null) {
2357
- try {
2358
- shakeMenu = new ShakeMenu(this, (com.getcapacitor.BridgeActivity) getActivity(), logger);
2359
- logger.info("Shake menu initialized");
2360
- } catch (Exception e) {
2361
- logger.error("Failed to initialize shake menu: " + e.getMessage());
2362
- }
2363
- } else if (!enabled && shakeMenu != null) {
2364
- try {
2365
- shakeMenu.stop();
2366
- shakeMenu = null;
2367
- logger.info("Shake menu stopped");
2368
- } catch (Exception e) {
2369
- logger.error("Failed to stop shake menu: " + e.getMessage());
2370
- }
2371
- }
5131
+ logger.info("Shake menu " + (enabled ? "enabled" : "disabled") + " with " + this.shakeMenuGesture + " gesture");
5132
+ this.syncShakeMenuLifecycle();
2372
5133
 
2373
5134
  call.resolve();
2374
5135
  }
@@ -2378,6 +5139,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2378
5139
  try {
2379
5140
  final JSObject ret = new JSObject();
2380
5141
  ret.put("enabled", this.shakeMenuEnabled);
5142
+ ret.put("gesture", this.shakeMenuGesture);
2381
5143
  call.resolve(ret);
2382
5144
  } catch (final Exception e) {
2383
5145
  logger.error("Could not get shake menu status " + e.getMessage());
@@ -2396,6 +5158,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2396
5158
 
2397
5159
  this.shakeChannelSelectorEnabled = enabled;
2398
5160
  logger.info("Shake channel selector " + (enabled ? "enabled" : "disabled"));
5161
+ this.syncShakeMenuLifecycle();
2399
5162
  call.resolve();
2400
5163
  }
2401
5164
 
@@ -2436,7 +5199,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
2436
5199
  call.reject("setAppId called without appId");
2437
5200
  return;
2438
5201
  }
2439
- this.implementation.appId = appId;
5202
+ this.setActiveAppId(appId);
2440
5203
  call.resolve();
2441
5204
  }
2442
5205
 
@@ -2492,9 +5255,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
2492
5255
 
2493
5256
  JSObject result = new JSObject();
2494
5257
  try {
2495
- PackageInfo pInfo = getContext().getPackageManager().getPackageInfo(getContext().getPackageName(), 0);
5258
+ PackageInfo pInfo = getCurrentPackageInfo();
2496
5259
  result.put("currentVersionName", pInfo.versionName);
2497
- result.put("currentVersionCode", String.valueOf(pInfo.versionCode));
5260
+ result.put("currentVersionCode", getVersionCode(pInfo));
2498
5261
  } catch (PackageManager.NameNotFoundException e) {
2499
5262
  result.put("currentVersionName", "0.0.0");
2500
5263
  result.put("currentVersionCode", "0");
@@ -2774,6 +5537,11 @@ public class CapacitorUpdaterPlugin extends Plugin {
2774
5537
  logger.error("Failed to clean up AppLifecycleObserver: " + e.getMessage());
2775
5538
  }
2776
5539
  }
5540
+
5541
+ if (webViewStatsListener != null && bridge != null) {
5542
+ bridge.removeWebViewListener(webViewStatsListener);
5543
+ webViewStatsListener = null;
5544
+ }
2777
5545
  } catch (Exception e) {
2778
5546
  logger.error("Failed to run handleOnDestroy: " + e.getMessage());
2779
5547
  }