@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
@@ -9,6 +9,7 @@ package ee.forgr.capacitor_updater;
9
9
  import android.app.Activity;
10
10
  import android.content.Context;
11
11
  import android.content.SharedPreferences;
12
+ import android.content.pm.PackageManager;
12
13
  import android.os.Build;
13
14
  import androidx.annotation.NonNull;
14
15
  import androidx.lifecycle.LifecycleOwner;
@@ -18,12 +19,15 @@ import androidx.work.WorkManager;
18
19
  import com.google.common.util.concurrent.Futures;
19
20
  import com.google.common.util.concurrent.ListenableFuture;
20
21
  import java.io.BufferedInputStream;
22
+ import java.io.BufferedReader;
21
23
  import java.io.File;
22
24
  import java.io.FileInputStream;
23
25
  import java.io.FileNotFoundException;
24
26
  import java.io.FileOutputStream;
25
27
  import java.io.FilenameFilter;
26
28
  import java.io.IOException;
29
+ import java.io.InputStreamReader;
30
+ import java.nio.charset.StandardCharsets;
27
31
  import java.security.SecureRandom;
28
32
  import java.util.ArrayList;
29
33
  import java.util.Date;
@@ -60,8 +64,11 @@ public class CapgoUpdater {
60
64
 
61
65
  private static final String FALLBACK_VERSION = "pastVersion";
62
66
  private static final String NEXT_VERSION = "nextVersion";
67
+ private static final String PREVIEW_FALLBACK_VERSION = "previewFallbackVersion";
63
68
  private static final String bundleDirectory = "versions";
64
69
  private static final String TEMP_UNZIP_PREFIX = "capgo_unzip_";
70
+ private static final String CAPACITOR_CONFIG_ASSET = "capacitor.config.json";
71
+ private static final String BACKGROUND_RUNNER_CONFIG_KEY = "BackgroundRunner";
65
72
 
66
73
  public static final String TAG = "Capacitor-updater";
67
74
  public SharedPreferences.Editor editor;
@@ -81,6 +88,7 @@ public class CapgoUpdater {
81
88
  public String channelUrl = "";
82
89
  public String defaultChannel = "";
83
90
  public String appId = "";
91
+ public volatile boolean previewSession = false;
84
92
  public String publicKey = "";
85
93
  public String deviceID = "";
86
94
  public int timeout = 20000;
@@ -95,11 +103,22 @@ public class CapgoUpdater {
95
103
  private static volatile boolean rateLimitStatisticSent = false;
96
104
 
97
105
  // Stats batching - queue events and send max once per second
98
- private final List<JSONObject> statsQueue = new CopyOnWriteArrayList<>();
106
+ private final List<QueuedStatsEvent> statsQueue = new CopyOnWriteArrayList<>();
99
107
  private final ScheduledExecutorService statsScheduler = Executors.newSingleThreadScheduledExecutor();
100
108
  private ScheduledFuture<?> statsFlushTask = null;
101
109
  private static final long STATS_FLUSH_INTERVAL_MS = 1000;
102
110
 
111
+ private static final class QueuedStatsEvent {
112
+
113
+ private final JSONObject event;
114
+ private final Runnable onSent;
115
+
116
+ private QueuedStatsEvent(final JSONObject event, final Runnable onSent) {
117
+ this.event = event;
118
+ this.onSent = onSent;
119
+ }
120
+ }
121
+
103
122
  private final Map<String, CompletableFuture<BundleInfo>> downloadFutures = new ConcurrentHashMap<>();
104
123
  private final ExecutorService io = Executors.newSingleThreadExecutor();
105
124
 
@@ -123,25 +142,78 @@ public class CapgoUpdater {
123
142
  }
124
143
  }
125
144
 
145
+ static String installSourceForInstallerPackage(final String installerPackageName) {
146
+ if (installerPackageName == null || installerPackageName.trim().isEmpty()) {
147
+ return "";
148
+ }
149
+
150
+ switch (installerPackageName) {
151
+ case "com.android.vending":
152
+ // Android exposes the Google Play installer package, but not whether the app came from production, alpha, beta, or internal testing.
153
+ return "google_play";
154
+ case "com.amazon.venezia":
155
+ return "amazon_appstore";
156
+ case "com.sec.android.app.samsungapps":
157
+ return "samsung_galaxy_store";
158
+ case "com.huawei.appmarket":
159
+ return "huawei_appgallery";
160
+ default:
161
+ return "";
162
+ }
163
+ }
164
+
165
+ @SuppressWarnings("deprecation")
166
+ private String getInstallSource() {
167
+ if (activity == null) {
168
+ return "";
169
+ }
170
+
171
+ try {
172
+ PackageManager packageManager = activity.getPackageManager();
173
+ String packageName = activity.getPackageName();
174
+ String installerPackageName;
175
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
176
+ android.content.pm.InstallSourceInfo installSourceInfo = packageManager.getInstallSourceInfo(packageName);
177
+ installerPackageName = installSourceInfo.getInstallingPackageName();
178
+ if (installerPackageName == null || installerPackageName.trim().isEmpty()) {
179
+ installerPackageName = installSourceInfo.getInitiatingPackageName();
180
+ }
181
+ } else {
182
+ installerPackageName = packageManager.getInstallerPackageName(packageName);
183
+ }
184
+ return installSourceForInstallerPackage(installerPackageName);
185
+ } catch (Exception e) {
186
+ return "";
187
+ }
188
+ }
189
+
126
190
  private boolean isEmulator() {
191
+ final String brand = String.valueOf(Build.BRAND);
192
+ final String device = String.valueOf(Build.DEVICE);
193
+ final String fingerprint = String.valueOf(Build.FINGERPRINT);
194
+ final String hardware = String.valueOf(Build.HARDWARE);
195
+ final String model = String.valueOf(Build.MODEL);
196
+ final String manufacturer = String.valueOf(Build.MANUFACTURER);
197
+ final String product = String.valueOf(Build.PRODUCT);
198
+
127
199
  return (
128
- (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) ||
129
- Build.FINGERPRINT.startsWith("generic") ||
130
- Build.FINGERPRINT.startsWith("unknown") ||
131
- Build.HARDWARE.contains("goldfish") ||
132
- Build.HARDWARE.contains("ranchu") ||
133
- Build.MODEL.contains("google_sdk") ||
134
- Build.MODEL.contains("Emulator") ||
135
- Build.MODEL.contains("Android SDK built for x86") ||
136
- Build.MANUFACTURER.contains("Genymotion") ||
137
- Build.PRODUCT.contains("sdk_google") ||
138
- Build.PRODUCT.contains("google_sdk") ||
139
- Build.PRODUCT.contains("sdk") ||
140
- Build.PRODUCT.contains("sdk_x86") ||
141
- Build.PRODUCT.contains("sdk_gphone64_arm64") ||
142
- Build.PRODUCT.contains("vbox86p") ||
143
- Build.PRODUCT.contains("emulator") ||
144
- Build.PRODUCT.contains("simulator")
200
+ (brand.startsWith("generic") && device.startsWith("generic")) ||
201
+ fingerprint.startsWith("generic") ||
202
+ fingerprint.startsWith("unknown") ||
203
+ hardware.contains("goldfish") ||
204
+ hardware.contains("ranchu") ||
205
+ model.contains("google_sdk") ||
206
+ model.contains("Emulator") ||
207
+ model.contains("Android SDK built for x86") ||
208
+ manufacturer.contains("Genymotion") ||
209
+ product.contains("sdk_google") ||
210
+ product.contains("google_sdk") ||
211
+ product.contains("sdk") ||
212
+ product.contains("sdk_x86") ||
213
+ product.contains("sdk_gphone64_arm64") ||
214
+ product.contains("vbox86p") ||
215
+ product.contains("emulator") ||
216
+ product.contains("simulator")
145
217
  );
146
218
  }
147
219
 
@@ -183,6 +255,30 @@ public class CapgoUpdater {
183
255
  this.cachedKeyId = CryptoCipher.calcKeyId(publicKey);
184
256
  }
185
257
 
258
+ static File resolvePathInsideDirectory(final File baseDirectory, final String relativePath) throws IOException {
259
+ if (relativePath == null || relativePath.isEmpty()) {
260
+ throw new IOException("Invalid empty path");
261
+ }
262
+ if (relativePath.contains("\\") || relativePath.indexOf('\0') >= 0) {
263
+ throw new IOException("Invalid path separator");
264
+ }
265
+ if (new File(relativePath).isAbsolute()) {
266
+ throw new IOException("Absolute paths are not allowed");
267
+ }
268
+
269
+ final File canonicalBase = baseDirectory.getCanonicalFile();
270
+ final File canonicalTarget = new File(canonicalBase, relativePath).getCanonicalFile();
271
+ final String basePath = canonicalBase.getPath();
272
+ final String targetPath = canonicalTarget.getPath();
273
+ final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
274
+
275
+ if (!targetPath.equals(basePath) && !targetPath.startsWith(normalizedBasePath)) {
276
+ throw new IOException("Path escapes base directory: " + relativePath);
277
+ }
278
+
279
+ return canonicalTarget;
280
+ }
281
+
186
282
  public String getKeyId() {
187
283
  return this.cachedKeyId;
188
284
  }
@@ -203,23 +299,21 @@ public class CapgoUpdater {
203
299
 
204
300
  ZipEntry entry;
205
301
  while ((entry = zis.getNextEntry()) != null) {
206
- if (entry.getName().contains("\\")) {
207
- logger.error("Unzip failed: Windows path not supported");
208
- logger.debug("Invalid path: " + entry.getName());
209
- this.sendStats("windows_path_fail");
302
+ final File file;
303
+ try {
304
+ file = resolvePathInsideDirectory(targetDirectory, entry.getName());
305
+ } catch (IOException e) {
306
+ if (entry.getName().contains("\\")) {
307
+ logger.error("Unzip failed: Windows path not supported");
308
+ logger.debug("Invalid path: " + entry.getName());
309
+ this.sendStats("windows_path_fail");
310
+ } else {
311
+ this.sendStats("canonical_path_fail");
312
+ }
313
+ throw e;
210
314
  }
211
- final File file = new File(targetDirectory, entry.getName());
212
- final String canonicalPath = file.getCanonicalPath();
213
- final String canonicalDir = targetDirectory.getCanonicalPath();
214
315
  final File dir = entry.isDirectory() ? file : file.getParentFile();
215
316
 
216
- if (!canonicalPath.startsWith(canonicalDir)) {
217
- this.sendStats("canonical_path_fail");
218
- throw new FileNotFoundException(
219
- "SecurityException, Failed to ensure directory is the start path : " + canonicalDir + " of " + canonicalPath
220
- );
221
- }
222
-
223
317
  assert dir != null;
224
318
  if (!dir.isDirectory() && !dir.mkdirs()) {
225
319
  this.sendStats("directory_path_fail");
@@ -345,7 +439,138 @@ public class CapgoUpdater {
345
439
  }
346
440
  }
347
441
 
348
- private void observeWorkProgress(Context context, String id) {
442
+ private boolean verifyChecksum(final File file, final String expectedHash) {
443
+ if (expectedHash == null || expectedHash.isEmpty() || file == null || !file.exists()) {
444
+ return false;
445
+ }
446
+ final String actualHash = CryptoCipher.calcChecksum(file);
447
+ return expectedHash.equalsIgnoreCase(actualHash);
448
+ }
449
+
450
+ private String resolveManifestFileHash(final JSONObject entry, final String sessionKey) {
451
+ String fileHash = entry.optString("file_hash", "");
452
+ if (fileHash.isEmpty()) {
453
+ return "";
454
+ }
455
+ if (this.publicKey != null && !this.publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
456
+ try {
457
+ fileHash = CryptoCipher.decryptChecksum(fileHash, this.publicKey);
458
+ } catch (Exception e) {
459
+ logger.error("Checksum decryption failed while checking missing manifest files");
460
+ logger.debug("File: " + entry.optString("file_name", "unknown") + ", Error: " + e.getMessage());
461
+ return "";
462
+ }
463
+ }
464
+ return fileHash;
465
+ }
466
+
467
+ private boolean isManifestEntryAvailableLocally(final JSONObject entry, final String sessionKey) {
468
+ final String fileName = entry.optString("file_name", "");
469
+ final String fileHash = resolveManifestFileHash(entry, sessionKey);
470
+ if (fileName.isEmpty() || fileHash.isEmpty() || this.activity == null) {
471
+ return false;
472
+ }
473
+
474
+ final File builtinFile = new File(this.activity.getFilesDir(), "public/" + fileName);
475
+ if (verifyChecksum(builtinFile, fileHash)) {
476
+ return true;
477
+ }
478
+
479
+ final boolean isBrotli = fileName.endsWith(".br");
480
+ final String fileNameWithoutPath = new File(fileName).getName();
481
+ final String cacheBaseName = isBrotli ? fileNameWithoutPath.substring(0, fileNameWithoutPath.length() - 3) : fileNameWithoutPath;
482
+ final File cacheFolder = new File(this.activity.getCacheDir(), "capgo_downloads");
483
+ final File cacheFile = new File(cacheFolder, fileHash + "_" + cacheBaseName);
484
+ if (verifyChecksum(cacheFile, fileHash)) {
485
+ return true;
486
+ }
487
+
488
+ if (isBrotli) {
489
+ final File legacyCacheFile = new File(cacheFolder, fileHash + "_" + fileNameWithoutPath);
490
+ return verifyChecksum(legacyCacheFile, fileHash);
491
+ }
492
+
493
+ return false;
494
+ }
495
+
496
+ public JSONArray getMissingBundleFiles(final JSONArray manifest, final String sessionKey) throws JSONException {
497
+ final JSONArray missing = new JSONArray();
498
+ for (int i = 0; i < manifest.length(); i++) {
499
+ final JSONObject entry = manifest.getJSONObject(i);
500
+ if (!isManifestEntryAvailableLocally(entry, sessionKey)) {
501
+ missing.put(entry);
502
+ }
503
+ }
504
+ return missing;
505
+ }
506
+
507
+ public JSONObject missingBundleFilesResult(final JSONArray manifest, final String sessionKey) throws JSONException {
508
+ final JSONArray missing = getMissingBundleFiles(manifest, sessionKey);
509
+ final JSONObject ret = new JSONObject();
510
+ ret.put("missing", missing);
511
+ ret.put("total", manifest.length());
512
+ ret.put("missingCount", missing.length());
513
+ ret.put("reusableCount", manifest.length() - missing.length());
514
+ return ret;
515
+ }
516
+
517
+ private String manifestSizeUrl(final String updateUrl) {
518
+ HttpUrl parsed = HttpUrl.parse(updateUrl);
519
+ if (parsed == null) {
520
+ return updateUrl;
521
+ }
522
+ return parsed.newBuilder().addPathSegment("manifest_size").query(null).build().toString();
523
+ }
524
+
525
+ private JSONObject unavailableBundleSizeResult(final JSONArray manifest, final String error) throws JSONException {
526
+ final JSONObject ret = new JSONObject();
527
+ final JSONArray files = new JSONArray();
528
+ for (int i = 0; i < manifest.length(); i++) {
529
+ final JSONObject entry = new JSONObject(manifest.getJSONObject(i).toString());
530
+ entry.put("error", error);
531
+ files.put(entry);
532
+ }
533
+ ret.put("totalSize", 0);
534
+ ret.put("knownFiles", 0);
535
+ ret.put("unknownFiles", manifest.length());
536
+ ret.put("files", files);
537
+ return ret;
538
+ }
539
+
540
+ public JSONObject getBundleDownloadSize(final String updateUrl, final String version, final JSONArray manifest) throws JSONException {
541
+ if (manifest.length() == 0) {
542
+ final JSONObject ret = new JSONObject();
543
+ ret.put("totalSize", 0);
544
+ ret.put("knownFiles", 0);
545
+ ret.put("unknownFiles", 0);
546
+ ret.put("files", new JSONArray());
547
+ return ret;
548
+ }
549
+
550
+ final JSONObject json = this.createInfoObject();
551
+ json.put("version", version != null ? version : "");
552
+ json.put("manifest", manifest);
553
+
554
+ Request request = new Request.Builder()
555
+ .url(manifestSizeUrl(updateUrl))
556
+ .post(RequestBody.create(json.toString(), MediaType.get("application/json; charset=utf-8")))
557
+ .build();
558
+
559
+ try (Response response = DownloadService.sharedClient.newCall(request).execute()) {
560
+ final ResponseBody responseBody = response.body();
561
+ final String responseData = responseBody != null ? responseBody.string() : "";
562
+ if (!response.isSuccessful() || responseData.isEmpty()) {
563
+ return unavailableBundleSizeResult(manifest, "response_error");
564
+ }
565
+ return new JSONObject(responseData);
566
+ } catch (IOException e) {
567
+ logger.error("Error getting bundle download size");
568
+ logger.debug("Error: " + e.getMessage());
569
+ return unavailableBundleSizeResult(manifest, "response_error");
570
+ }
571
+ }
572
+
573
+ private void observeWorkProgress(Context context, String id, boolean setNext) {
349
574
  if (!(context instanceof LifecycleOwner)) {
350
575
  logger.error("Context is not a LifecycleOwner, cannot observe work progress");
351
576
  return;
@@ -375,7 +600,7 @@ public class CapgoUpdater {
375
600
  boolean isManifest = outputData.getBoolean(DownloadService.IS_MANIFEST, false);
376
601
 
377
602
  io.execute(() -> {
378
- boolean success = finishDownload(id, dest, version, sessionKey, checksum, true, isManifest);
603
+ boolean success = finishDownload(id, dest, version, sessionKey, checksum, setNext, isManifest);
379
604
  BundleInfo resultBundle;
380
605
  if (!success) {
381
606
  logger.error("Finish download failed");
@@ -454,13 +679,14 @@ public class CapgoUpdater {
454
679
  final String version,
455
680
  final String sessionKey,
456
681
  final String checksum,
457
- final JSONArray manifest
682
+ final JSONArray manifest,
683
+ final boolean setNext
458
684
  ) {
459
685
  if (this.activity == null) {
460
686
  logger.error("Activity is null, cannot observe work progress");
461
687
  return;
462
688
  }
463
- observeWorkProgress(this.activity, id);
689
+ observeWorkProgress(this.activity, id, setNext);
464
690
 
465
691
  DownloadWorkerManager.enqueueDownload(
466
692
  this.activity,
@@ -477,6 +703,7 @@ public class CapgoUpdater {
477
703
  this.appId,
478
704
  this.pluginVersion,
479
705
  this.isProd(),
706
+ this.getInstallSource(),
480
707
  this.statsUrl,
481
708
  this.deviceID,
482
709
  this.versionBuild,
@@ -578,11 +805,15 @@ public class CapgoUpdater {
578
805
  CapgoUpdater.this.notifyListeners("updateAvailable", ret);
579
806
  logger.info("setNext: " + setNext);
580
807
  if (setNext) {
581
- logger.info("directUpdate: " + this.directUpdate);
582
- if (this.directUpdate) {
808
+ if (this.previewSession) {
809
+ logger.info("Preview session is active, skipping automatic install of downloaded bundle");
810
+ this.directUpdate = false;
811
+ } else if (this.directUpdate) {
812
+ logger.info("directUpdate: " + this.directUpdate);
583
813
  CapgoUpdater.this.directUpdateFinish(next);
584
814
  this.directUpdate = false;
585
815
  } else {
816
+ logger.info("directUpdate: " + this.directUpdate);
586
817
  this.setNextBundle(next.getId());
587
818
  }
588
819
  }
@@ -752,17 +983,110 @@ public class CapgoUpdater {
752
983
  }
753
984
 
754
985
  private void setCurrentBundle(final File bundle) {
986
+ this.cancelBackgroundRunnerWorkBeforeBundleSwitch();
755
987
  this.editor.putString(this.CAP_SERVER_PATH, bundle.getPath());
756
988
  logger.info("Current bundle set to: " + bundle);
757
989
  this.editor.commit();
758
990
  }
759
991
 
992
+ static boolean shouldResetForForeignBundle(final String bundlePath, final boolean isBuiltin, final boolean hasStoredBundleInfo) {
993
+ return bundlePath != null && !bundlePath.trim().isEmpty() && !isBuiltin && !hasStoredBundleInfo;
994
+ }
995
+
996
+ static String getBackgroundRunnerLabelFromConfig(final String configJson) {
997
+ if (configJson == null || configJson.trim().isEmpty()) {
998
+ return null;
999
+ }
1000
+
1001
+ try {
1002
+ final JSONObject config = new JSONObject(configJson);
1003
+ final JSONObject plugins = config.optJSONObject("plugins");
1004
+ if (plugins == null) {
1005
+ return null;
1006
+ }
1007
+
1008
+ final JSONObject backgroundRunner = plugins.optJSONObject(BACKGROUND_RUNNER_CONFIG_KEY);
1009
+ if (backgroundRunner == null) {
1010
+ return null;
1011
+ }
1012
+
1013
+ final String label = backgroundRunner.optString("label", "").trim();
1014
+ return label.isEmpty() ? null : label;
1015
+ } catch (JSONException ignored) {
1016
+ return null;
1017
+ }
1018
+ }
1019
+
1020
+ private String readAssetAsString(final String assetPath) throws IOException {
1021
+ final StringBuilder buffer = new StringBuilder();
1022
+ try (
1023
+ final BufferedReader reader = new BufferedReader(
1024
+ new InputStreamReader(this.activity.getAssets().open(assetPath), StandardCharsets.UTF_8)
1025
+ )
1026
+ ) {
1027
+ String line;
1028
+ while ((line = reader.readLine()) != null) {
1029
+ buffer.append(line).append('\n');
1030
+ }
1031
+ }
1032
+ return buffer.toString();
1033
+ }
1034
+
1035
+ private void cancelBackgroundRunnerWorkBeforeBundleSwitch() {
1036
+ if (this.activity == null) {
1037
+ return;
1038
+ }
1039
+
1040
+ final String label;
1041
+ try {
1042
+ label = getBackgroundRunnerLabelFromConfig(this.readAssetAsString(CAPACITOR_CONFIG_ASSET));
1043
+ } catch (IOException ignored) {
1044
+ return;
1045
+ }
1046
+
1047
+ if (label == null) {
1048
+ return;
1049
+ }
1050
+
1051
+ try {
1052
+ final WorkManager workManager = WorkManager.getInstance(this.activity.getApplicationContext());
1053
+ workManager.cancelUniqueWork(label);
1054
+ workManager.cancelAllWorkByTag(label);
1055
+ logger.info("Cancelled Background Runner work before bundle switch.");
1056
+ logger.debug("Background Runner label: " + label);
1057
+ } catch (Exception e) {
1058
+ logger.warn("Failed to cancel Background Runner work before bundle switch.");
1059
+ logger.debug("Background Runner cancellation error: " + e.getMessage());
1060
+ }
1061
+ }
1062
+
1063
+ private boolean hasStoredBundleInfo(final String id) {
1064
+ return (
1065
+ id != null &&
1066
+ !id.isEmpty() &&
1067
+ !BundleInfo.ID_BUILTIN.equals(id) &&
1068
+ !BundleInfo.VERSION_UNKNOWN.equals(id) &&
1069
+ this.prefs.contains(id + INFO_SUFFIX)
1070
+ );
1071
+ }
1072
+
760
1073
  public void downloadBackground(
761
1074
  final String url,
762
1075
  final String version,
763
1076
  final String sessionKey,
764
1077
  final String checksum,
765
1078
  final JSONArray manifest
1079
+ ) {
1080
+ downloadBackground(url, version, sessionKey, checksum, manifest, true);
1081
+ }
1082
+
1083
+ public void downloadBackground(
1084
+ final String url,
1085
+ final String version,
1086
+ final String sessionKey,
1087
+ final String checksum,
1088
+ final JSONArray manifest,
1089
+ final boolean setNext
766
1090
  ) {
767
1091
  final String id = this.randomString();
768
1092
 
@@ -784,7 +1108,7 @@ public class CapgoUpdater {
784
1108
  this.notifyDownload(id, 0);
785
1109
  this.notifyDownload(id, 5);
786
1110
 
787
- this.download(id, url, this.randomString(), version, sessionKey, checksum, manifest);
1111
+ this.download(id, url, this.randomString(), version, sessionKey, checksum, manifest, setNext);
788
1112
  }
789
1113
 
790
1114
  public BundleInfo download(final String url, final String version, final String sessionKey, final String checksum) throws IOException {
@@ -806,7 +1130,7 @@ public class CapgoUpdater {
806
1130
  downloadFutures.put(id, downloadFuture);
807
1131
 
808
1132
  // Start the download
809
- this.download(id, url, dest, version, sessionKey, checksum, null);
1133
+ this.download(id, url, dest, version, sessionKey, checksum, null, false);
810
1134
 
811
1135
  // Wait for completion without timeout
812
1136
  try {
@@ -858,7 +1182,7 @@ public class CapgoUpdater {
858
1182
  downloadFutures.put(id, downloadFuture);
859
1183
 
860
1184
  // Start the download
861
- this.download(id, url, dest, version, sessionKey, checksum, manifest);
1185
+ this.download(id, url, dest, version, sessionKey, checksum, manifest, false);
862
1186
 
863
1187
  // Wait for completion without timeout
864
1188
  try {
@@ -915,6 +1239,17 @@ public class CapgoUpdater {
915
1239
  logger.debug("Bundle ID: " + id);
916
1240
  return false;
917
1241
  }
1242
+ final BundleInfo previewFallback = this.getPreviewFallbackBundle();
1243
+ if (
1244
+ previewFallback != null &&
1245
+ !previewFallback.isDeleted() &&
1246
+ !previewFallback.isErrorStatus() &&
1247
+ previewFallback.getId().equals(id)
1248
+ ) {
1249
+ logger.error("Cannot delete the preview fallback bundle");
1250
+ logger.debug("Bundle ID: " + id);
1251
+ return false;
1252
+ }
918
1253
  final BundleInfo next = this.getNextBundle();
919
1254
  if (next != null && !next.isDeleted() && !next.isErrorStatus() && next.getId().equals(id)) {
920
1255
  logger.error("Cannot delete the next bundle");
@@ -965,6 +1300,62 @@ public class CapgoUpdater {
965
1300
  return (bundle.isDirectory() && bundle.exists() && new File(bundle.getPath(), "/index.html").exists() && !bundleInfo.isDeleted());
966
1301
  }
967
1302
 
1303
+ static final class ResetState {
1304
+
1305
+ final String currentBundlePath;
1306
+ final String fallbackBundleId;
1307
+ final String nextBundleId;
1308
+
1309
+ ResetState(final String currentBundlePath, final String fallbackBundleId, final String nextBundleId) {
1310
+ this.currentBundlePath = currentBundlePath;
1311
+ this.fallbackBundleId = fallbackBundleId;
1312
+ this.nextBundleId = nextBundleId;
1313
+ }
1314
+ }
1315
+
1316
+ ResetState captureResetState() {
1317
+ return new ResetState(
1318
+ this.getCurrentBundlePath(),
1319
+ this.prefs.getString(FALLBACK_VERSION, BundleInfo.ID_BUILTIN),
1320
+ this.prefs.getString(NEXT_VERSION, null)
1321
+ );
1322
+ }
1323
+
1324
+ void restoreResetState(final ResetState state) {
1325
+ final String currentBundlePath =
1326
+ state.currentBundlePath == null || state.currentBundlePath.trim().isEmpty() ? "public" : state.currentBundlePath;
1327
+ final String fallbackBundleId =
1328
+ state.fallbackBundleId == null || state.fallbackBundleId.isEmpty() ? BundleInfo.ID_BUILTIN : state.fallbackBundleId;
1329
+
1330
+ this.editor.putString(this.CAP_SERVER_PATH, currentBundlePath);
1331
+ this.editor.putString(FALLBACK_VERSION, fallbackBundleId);
1332
+ if (state.nextBundleId == null || state.nextBundleId.isEmpty()) {
1333
+ this.editor.remove(NEXT_VERSION);
1334
+ } else {
1335
+ this.editor.putString(NEXT_VERSION, state.nextBundleId);
1336
+ }
1337
+ this.editor.commit();
1338
+ }
1339
+
1340
+ void prepareResetStateForTransition() {
1341
+ this.setCurrentBundle(new File("public"));
1342
+ this.setFallbackBundle(null);
1343
+ this.setNextBundle(null);
1344
+ }
1345
+
1346
+ void finalizeResetTransition(final String previousBundleName, final boolean internal) {
1347
+ if (this.activity != null) {
1348
+ DownloadWorkerManager.cancelAllDownloads(this.activity);
1349
+ }
1350
+ if (!internal) {
1351
+ this.sendStats("reset", this.getCurrentBundle().getVersionName(), previousBundleName);
1352
+ }
1353
+ }
1354
+
1355
+ boolean canSet(final BundleInfo bundle) {
1356
+ return bundle != null && (bundle.isBuiltin() || this.bundleExists(bundle.getId()));
1357
+ }
1358
+
968
1359
  public Boolean set(final BundleInfo bundle) {
969
1360
  return this.set(bundle.getId());
970
1361
  }
@@ -989,14 +1380,88 @@ public class CapgoUpdater {
989
1380
  return false;
990
1381
  }
991
1382
 
1383
+ boolean stagePendingReload(final BundleInfo bundle) {
1384
+ if (bundle == null || bundle.isBuiltin() || !this.bundleExists(bundle.getId())) {
1385
+ return false;
1386
+ }
1387
+ this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
1388
+ return true;
1389
+ }
1390
+
1391
+ boolean stagePreviewFallbackReload(final BundleInfo bundle) {
1392
+ if (bundle == null || bundle.isErrorStatus()) {
1393
+ return false;
1394
+ }
1395
+ if (bundle.isBuiltin()) {
1396
+ this.setCurrentBundle(new File("public"));
1397
+ return true;
1398
+ }
1399
+ if (!this.bundleExists(bundle.getId())) {
1400
+ return false;
1401
+ }
1402
+ this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
1403
+ return true;
1404
+ }
1405
+
1406
+ void finalizePendingReload(final BundleInfo bundle, final String previousBundleName) {
1407
+ if (bundle == null || bundle.isBuiltin()) {
1408
+ return;
1409
+ }
1410
+ this.sendStats("set", bundle.getVersionName(), previousBundleName);
1411
+ }
1412
+
1413
+ @Deprecated
992
1414
  public void autoReset() {
1415
+ this.autoReset(this.versionCode == null ? "" : this.versionCode);
1416
+ }
1417
+
1418
+ public void autoReset(final String currentNativeBuildVersion) {
1419
+ this.autoReset(currentNativeBuildVersion, true);
1420
+ }
1421
+
1422
+ public void autoReset(final String currentNativeBuildVersion, final boolean resetWhenNativeVersionChanged) {
993
1423
  final BundleInfo currentBundle = this.getCurrentBundle();
994
1424
  if (!currentBundle.isBuiltin() && !this.bundleExists(currentBundle.getId())) {
995
1425
  logger.info("Folder at bundle path does not exist. Triggering reset.");
996
1426
  this.reset();
1427
+ return;
1428
+ }
1429
+ String bundlePath = this.prefs.getString(this.CAP_SERVER_PATH, null);
1430
+ if (shouldResetForForeignBundle(bundlePath, currentBundle.isBuiltin(), this.hasStoredBundleInfo(currentBundle.getId()))) {
1431
+ logger.info("Current bundle id is not one of the bundle ids stored by this plugin. Triggering reset.");
1432
+ this.reset();
1433
+ return;
1434
+ }
1435
+ final String previousNativeBuildVersion = this.getStoredNativeBuildVersion();
1436
+ if (
1437
+ resetWhenNativeVersionChanged &&
1438
+ !previousNativeBuildVersion.isEmpty() &&
1439
+ currentNativeBuildVersion != null &&
1440
+ !currentNativeBuildVersion.isEmpty() &&
1441
+ !Objects.equals(previousNativeBuildVersion, currentNativeBuildVersion)
1442
+ ) {
1443
+ logger.info(
1444
+ "Stored native build version " +
1445
+ previousNativeBuildVersion +
1446
+ " does not match current native build version " +
1447
+ currentNativeBuildVersion +
1448
+ ". Triggering reset."
1449
+ );
1450
+ this.reset();
997
1451
  }
998
1452
  }
999
1453
 
1454
+ private String getStoredNativeBuildVersion() {
1455
+ if (this.prefs == null) {
1456
+ return "";
1457
+ }
1458
+ String previousNativeBuildVersion = this.prefs.getString("LatestNativeBuildVersion", "");
1459
+ if (previousNativeBuildVersion == null || previousNativeBuildVersion.isEmpty()) {
1460
+ previousNativeBuildVersion = this.prefs.getString("LatestVersionNative", "");
1461
+ }
1462
+ return previousNativeBuildVersion == null ? "" : previousNativeBuildVersion;
1463
+ }
1464
+
1000
1465
  public void reset() {
1001
1466
  this.reset(false);
1002
1467
  }
@@ -1004,12 +1469,20 @@ public class CapgoUpdater {
1004
1469
  public void setSuccess(final BundleInfo bundle, Boolean autoDeletePrevious) {
1005
1470
  this.setBundleStatus(bundle.getId(), BundleStatus.SUCCESS);
1006
1471
  final BundleInfo fallback = this.getFallbackBundle();
1472
+ final BundleInfo previewFallback = this.getPreviewFallbackBundle();
1473
+ final boolean fallbackIsPreviewFallback = previewFallback != null && previewFallback.getId().equals(fallback.getId());
1007
1474
  logger.debug("Fallback bundle is: " + fallback);
1008
1475
  logger.info("Version successfully loaded: " + bundle.getVersionName());
1009
1476
  // Only attempt to delete when the fallback is a different bundle than the
1010
1477
  // currently loaded one. Otherwise we spam logs with "Cannot delete <id>"
1011
1478
  // because delete() protects the current bundle from removal.
1012
- if (autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != null && !fallback.getId().equals(bundle.getId())) {
1479
+ if (
1480
+ autoDeletePrevious &&
1481
+ !fallback.isBuiltin() &&
1482
+ fallback.getId() != null &&
1483
+ !fallback.getId().equals(bundle.getId()) &&
1484
+ !fallbackIsPreviewFallback
1485
+ ) {
1013
1486
  final Boolean res = this.delete(fallback.getId());
1014
1487
  if (res) {
1015
1488
  logger.info("Deleted previous bundle: " + fallback.getVersionName());
@@ -1026,24 +1499,20 @@ public class CapgoUpdater {
1026
1499
 
1027
1500
  public void reset(final boolean internal) {
1028
1501
  logger.debug("reset: " + internal);
1029
- var currentBundleName = this.getCurrentBundle().getVersionName();
1030
- this.setCurrentBundle(new File("public"));
1031
- this.setFallbackBundle(null);
1032
- this.setNextBundle(null);
1033
- // Cancel any ongoing downloads
1034
- if (this.activity != null) {
1035
- DownloadWorkerManager.cancelAllDownloads(this.activity);
1036
- }
1037
- if (!internal) {
1038
- this.sendStats("reset", this.getCurrentBundle().getVersionName(), currentBundleName);
1039
- }
1502
+ final String currentBundleName = this.getCurrentBundle().getVersionName();
1503
+ this.prepareResetStateForTransition();
1504
+ this.finalizeResetTransition(currentBundleName, internal);
1040
1505
  }
1041
1506
 
1042
1507
  private JSONObject createInfoObject() throws JSONException {
1508
+ return this.createInfoObject(null);
1509
+ }
1510
+
1511
+ private JSONObject createInfoObject(final String appIdOverride) throws JSONException {
1043
1512
  JSONObject json = new JSONObject();
1044
1513
  json.put("platform", "android");
1045
1514
  json.put("device_id", this.deviceID);
1046
- json.put("app_id", this.appId);
1515
+ json.put("app_id", appIdOverride == null || appIdOverride.trim().isEmpty() ? this.appId : appIdOverride);
1047
1516
  json.put("custom_id", this.customId);
1048
1517
  json.put("version_build", this.versionBuild);
1049
1518
  json.put("version_code", this.versionCode);
@@ -1052,6 +1521,7 @@ public class CapgoUpdater {
1052
1521
  json.put("plugin_version", this.pluginVersion);
1053
1522
  json.put("is_emulator", this.isEmulator());
1054
1523
  json.put("is_prod", this.isProd());
1524
+ json.put("install_source", this.getInstallSource());
1055
1525
  json.put("defaultChannel", this.defaultChannel);
1056
1526
 
1057
1527
  // Add encryption key ID if encryption is enabled (use cached value)
@@ -1069,7 +1539,7 @@ public class CapgoUpdater {
1069
1539
  if (response.code() == 429) {
1070
1540
  // Send a statistic about the rate limit BEFORE setting the flag
1071
1541
  // Only send once to prevent infinite loop if the stat request itself gets rate limited
1072
- if (!rateLimitExceeded && !rateLimitStatisticSent) {
1542
+ if (!this.previewSession && !rateLimitExceeded && !rateLimitStatisticSent) {
1073
1543
  rateLimitStatisticSent = true;
1074
1544
  sendRateLimitStatistic();
1075
1545
  }
@@ -1123,89 +1593,115 @@ public class CapgoUpdater {
1123
1593
 
1124
1594
  Request request = new Request.Builder().url(url).post(body).build();
1125
1595
 
1126
- DownloadService.sharedClient
1127
- .newCall(request)
1128
- .enqueue(
1129
- new okhttp3.Callback() {
1130
- @Override
1131
- public void onFailure(@NonNull Call call, @NonNull IOException e) {
1132
- Map<String, Object> retError = new HashMap<>();
1133
- retError.put("message", "Request failed: " + e.getMessage());
1134
- retError.put("error", "network_error");
1135
- callback.callback(retError);
1136
- }
1596
+ DownloadService.sharedClient.newCall(request).enqueue(
1597
+ new okhttp3.Callback() {
1598
+ @Override
1599
+ public void onFailure(@NonNull Call call, @NonNull IOException e) {
1600
+ Map<String, Object> retError = new HashMap<>();
1601
+ retError.put("message", "Request failed: " + e.getMessage());
1602
+ retError.put("error", "network_error");
1603
+ retError.put("kind", "failed");
1604
+ callback.callback(retError);
1605
+ }
1137
1606
 
1138
- @Override
1139
- public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
1140
- try (ResponseBody responseBody = response.body()) {
1141
- final int statusCode = response.code();
1142
- // Check for 429 rate limit
1143
- if (checkAndHandleRateLimitResponse(response)) {
1144
- Map<String, Object> retError = new HashMap<>();
1145
- retError.put("message", "Rate limit exceeded");
1146
- retError.put("error", "rate_limit_exceeded");
1147
- retError.put("statusCode", statusCode);
1148
- callback.callback(retError);
1149
- return;
1607
+ @Override
1608
+ public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
1609
+ try (ResponseBody responseBody = response.body()) {
1610
+ final int statusCode = response.code();
1611
+ final String responseData = responseBody != null ? responseBody.string() : "";
1612
+ JSONObject jsonResponse = null;
1613
+ if (!responseData.isEmpty()) {
1614
+ try {
1615
+ jsonResponse = new JSONObject(responseData);
1616
+ } catch (JSONException ignored) {
1617
+ // Non-JSON responses are handled as response or parse errors below.
1150
1618
  }
1619
+ }
1151
1620
 
1152
- if (!response.isSuccessful()) {
1153
- Map<String, Object> retError = new HashMap<>();
1154
- retError.put("message", "Server error: " + response.code());
1155
- retError.put("error", "response_error");
1156
- retError.put("statusCode", statusCode);
1157
- callback.callback(retError);
1158
- return;
1621
+ if (jsonResponse != null && (jsonResponse.has("error") || jsonResponse.has("kind"))) {
1622
+ if (statusCode == 429) {
1623
+ checkAndHandleRateLimitResponse(response);
1159
1624
  }
1160
-
1161
- assert responseBody != null;
1162
- String responseData = responseBody.string();
1163
- JSONObject jsonResponse = new JSONObject(responseData);
1164
-
1165
- // Check for server-side errors first
1166
- if (jsonResponse.has("error")) {
1167
- Map<String, Object> retError = new HashMap<>();
1625
+ Map<String, Object> retError = new HashMap<>();
1626
+ if (jsonResponse.has("error") && !jsonResponse.isNull("error")) {
1168
1627
  retError.put("error", jsonResponse.getString("error"));
1169
- if (jsonResponse.has("message")) {
1170
- retError.put("message", jsonResponse.getString("message"));
1171
- } else {
1172
- retError.put("message", "server did not provide a message");
1173
- }
1174
- retError.put("statusCode", statusCode);
1175
- callback.callback(retError);
1176
- return;
1177
1628
  }
1629
+ if (jsonResponse.has("kind") && !jsonResponse.isNull("kind")) {
1630
+ retError.put("kind", jsonResponse.getString("kind"));
1631
+ }
1632
+ if (jsonResponse.has("message") && !jsonResponse.isNull("message")) {
1633
+ retError.put("message", jsonResponse.getString("message"));
1634
+ } else {
1635
+ retError.put("message", "server did not provide a message");
1636
+ }
1637
+ if (jsonResponse.has("version") && !jsonResponse.isNull("version")) {
1638
+ retError.put("version", jsonResponse.getString("version"));
1639
+ }
1640
+ retError.put("statusCode", statusCode);
1641
+ callback.callback(retError);
1642
+ return;
1643
+ }
1178
1644
 
1179
- Map<String, Object> ret = new HashMap<>();
1180
- ret.put("statusCode", statusCode);
1645
+ // Check for 429 rate limit
1646
+ if (checkAndHandleRateLimitResponse(response)) {
1647
+ Map<String, Object> retError = new HashMap<>();
1648
+ retError.put("message", "Rate limit exceeded");
1649
+ retError.put("error", "rate_limit_exceeded");
1650
+ retError.put("kind", "failed");
1651
+ retError.put("statusCode", statusCode);
1652
+ callback.callback(retError);
1653
+ return;
1654
+ }
1181
1655
 
1182
- Iterator<String> keys = jsonResponse.keys();
1183
- while (keys.hasNext()) {
1184
- String key = keys.next();
1185
- if (jsonResponse.has(key)) {
1186
- if ("session_key".equals(key)) {
1187
- ret.put("sessionKey", jsonResponse.get(key));
1188
- } else {
1189
- ret.put(key, jsonResponse.get(key));
1190
- }
1191
- }
1192
- }
1193
- callback.callback(ret);
1194
- } catch (JSONException e) {
1656
+ if (!response.isSuccessful()) {
1195
1657
  Map<String, Object> retError = new HashMap<>();
1196
- retError.put("message", "JSON parse error: " + e.getMessage());
1197
- retError.put("error", "parse_error");
1658
+ retError.put("message", "Server error: " + response.code());
1659
+ retError.put("error", "response_error");
1660
+ retError.put("kind", "failed");
1661
+ retError.put("statusCode", statusCode);
1198
1662
  callback.callback(retError);
1663
+ return;
1664
+ }
1665
+
1666
+ if (jsonResponse == null) {
1667
+ throw new JSONException("Response is not a JSON object");
1668
+ }
1669
+
1670
+ Map<String, Object> ret = new HashMap<>();
1671
+ ret.put("statusCode", statusCode);
1672
+
1673
+ Iterator<String> keys = jsonResponse.keys();
1674
+ while (keys.hasNext()) {
1675
+ String key = keys.next();
1676
+ if (jsonResponse.has(key)) {
1677
+ if ("session_key".equals(key)) {
1678
+ ret.put("sessionKey", jsonResponse.get(key));
1679
+ } else {
1680
+ ret.put(key, jsonResponse.get(key));
1681
+ }
1682
+ }
1199
1683
  }
1684
+ callback.callback(ret);
1685
+ } catch (JSONException e) {
1686
+ Map<String, Object> retError = new HashMap<>();
1687
+ retError.put("message", "JSON parse error: " + e.getMessage());
1688
+ retError.put("error", "parse_error");
1689
+ retError.put("kind", "failed");
1690
+ callback.callback(retError);
1200
1691
  }
1201
1692
  }
1202
- );
1693
+ }
1694
+ );
1203
1695
  }
1204
1696
 
1205
1697
  public void getLatest(final String updateUrl, final String channel, final Callback callback) {
1698
+ this.getLatest(updateUrl, channel, null, callback);
1699
+ }
1700
+
1701
+ public void getLatest(final String updateUrl, final String channel, final String appIdOverride, final Callback callback) {
1206
1702
  JSONObject json;
1207
1703
  try {
1208
- json = this.createInfoObject();
1704
+ json = this.createInfoObject(appIdOverride);
1209
1705
  if (channel != null && json != null) {
1210
1706
  json.put("defaultChannel", channel);
1211
1707
  }
@@ -1219,7 +1715,9 @@ public class CapgoUpdater {
1219
1715
  return;
1220
1716
  }
1221
1717
 
1222
- logger.info("Auto-update parameters: " + json);
1718
+ if (logger != null) {
1719
+ logger.info("Auto-update parameters: " + json);
1720
+ }
1223
1721
 
1224
1722
  makeJsonRequest(updateUrl, json, callback);
1225
1723
  }
@@ -1248,6 +1746,17 @@ public class CapgoUpdater {
1248
1746
  final String defaultChannelKey,
1249
1747
  final boolean allowSetDefaultChannel,
1250
1748
  final Callback callback
1749
+ ) {
1750
+ this.setChannel(channel, editor, defaultChannelKey, allowSetDefaultChannel, "", callback);
1751
+ }
1752
+
1753
+ public void setChannel(
1754
+ final String channel,
1755
+ final SharedPreferences.Editor editor,
1756
+ final String defaultChannelKey,
1757
+ final boolean allowSetDefaultChannel,
1758
+ final String configDefaultChannel,
1759
+ final Callback callback
1251
1760
  ) {
1252
1761
  // Check if setting defaultChannel is allowed
1253
1762
  if (!allowSetDefaultChannel) {
@@ -1300,6 +1809,7 @@ public class CapgoUpdater {
1300
1809
  // Clear persisted defaultChannel and revert to config value
1301
1810
  editor.remove(defaultChannelKey);
1302
1811
  editor.apply();
1812
+ this.defaultChannel = configDefaultChannel;
1303
1813
  logger.info("Public channel requested, channel override removed");
1304
1814
  callback.callback(res);
1305
1815
  } else {
@@ -1314,6 +1824,10 @@ public class CapgoUpdater {
1314
1824
  }
1315
1825
 
1316
1826
  public void getChannel(final Callback callback) {
1827
+ this.getChannel(callback, null, null);
1828
+ }
1829
+
1830
+ public void getChannel(final Callback callback, final SharedPreferences.Editor editor, final String defaultChannelKey) {
1317
1831
  // Check if rate limit was exceeded
1318
1832
  if (rateLimitExceeded) {
1319
1833
  logger.debug("Skipping getChannel due to rate limit (429). Requests will resume after app restart.");
@@ -1351,88 +1865,117 @@ public class CapgoUpdater {
1351
1865
  .put(RequestBody.create(json.toString(), MediaType.get("application/json")))
1352
1866
  .build();
1353
1867
 
1354
- DownloadService.sharedClient
1355
- .newCall(request)
1356
- .enqueue(
1357
- new okhttp3.Callback() {
1358
- @Override
1359
- public void onFailure(@NonNull Call call, @NonNull IOException e) {
1360
- Map<String, Object> retError = new HashMap<>();
1361
- retError.put("message", "Request failed: " + e.getMessage());
1362
- retError.put("error", "network_error");
1363
- callback.callback(retError);
1364
- }
1868
+ DownloadService.sharedClient.newCall(request).enqueue(
1869
+ new okhttp3.Callback() {
1870
+ @Override
1871
+ public void onFailure(@NonNull Call call, @NonNull IOException e) {
1872
+ Map<String, Object> retError = new HashMap<>();
1873
+ retError.put("message", "Request failed: " + e.getMessage());
1874
+ retError.put("error", "network_error");
1875
+ callback.callback(retError);
1876
+ }
1365
1877
 
1366
- @Override
1367
- public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
1368
- try (ResponseBody responseBody = response.body()) {
1369
- // Check for 429 rate limit
1370
- if (checkAndHandleRateLimitResponse(response)) {
1878
+ @Override
1879
+ public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
1880
+ try (ResponseBody responseBody = response.body()) {
1881
+ // Check for 429 rate limit
1882
+ if (checkAndHandleRateLimitResponse(response)) {
1883
+ Map<String, Object> retError = new HashMap<>();
1884
+ retError.put("message", "Rate limit exceeded");
1885
+ retError.put("error", "rate_limit_exceeded");
1886
+ callback.callback(retError);
1887
+ return;
1888
+ }
1889
+
1890
+ if (response.code() == 400) {
1891
+ if (responseBody == null) {
1371
1892
  Map<String, Object> retError = new HashMap<>();
1372
- retError.put("message", "Rate limit exceeded");
1373
- retError.put("error", "rate_limit_exceeded");
1893
+ retError.put("message", "Empty response body");
1894
+ retError.put("error", "no_response_body");
1374
1895
  callback.callback(retError);
1375
1896
  return;
1376
1897
  }
1377
-
1378
- if (response.code() == 400) {
1379
- assert responseBody != null;
1380
- String data = responseBody.string();
1381
- if (data.contains("channel_not_found") && !defaultChannel.isEmpty()) {
1382
- Map<String, Object> ret = new HashMap<>();
1383
- ret.put("channel", defaultChannel);
1384
- ret.put("status", "default");
1385
- logger.info("Channel get to \"" + ret);
1386
- callback.callback(ret);
1387
- return;
1388
- }
1389
- }
1390
-
1391
- if (!response.isSuccessful()) {
1392
- Map<String, Object> retError = new HashMap<>();
1393
- retError.put("message", "Server error: " + response.code());
1394
- retError.put("error", "response_error");
1395
- callback.callback(retError);
1898
+ String data = responseBody.string();
1899
+ if (data.contains("channel_not_found") && !defaultChannel.isEmpty()) {
1900
+ Map<String, Object> ret = new HashMap<>();
1901
+ ret.put("channel", defaultChannel);
1902
+ ret.put("status", "default");
1903
+ logger.info("Channel get to \"" + ret);
1904
+ callback.callback(ret);
1396
1905
  return;
1397
1906
  }
1907
+ }
1398
1908
 
1399
- assert responseBody != null;
1400
- String responseData = responseBody.string();
1401
- JSONObject jsonResponse = new JSONObject(responseData);
1909
+ if (!response.isSuccessful()) {
1910
+ Map<String, Object> retError = new HashMap<>();
1911
+ retError.put("message", "Server error: " + response.code());
1912
+ retError.put("error", "response_error");
1913
+ callback.callback(retError);
1914
+ return;
1915
+ }
1402
1916
 
1403
- // Check for server-side errors first
1404
- if (jsonResponse.has("error")) {
1405
- Map<String, Object> retError = new HashMap<>();
1406
- retError.put("error", jsonResponse.getString("error"));
1407
- if (jsonResponse.has("message")) {
1408
- retError.put("message", jsonResponse.getString("message"));
1409
- } else {
1410
- retError.put("message", "server did not provide a message");
1411
- }
1412
- callback.callback(retError);
1413
- return;
1917
+ if (responseBody == null) {
1918
+ Map<String, Object> retError = new HashMap<>();
1919
+ retError.put("message", "Empty response body");
1920
+ retError.put("error", "no_response_body");
1921
+ callback.callback(retError);
1922
+ return;
1923
+ }
1924
+ String responseData = responseBody.string();
1925
+ JSONObject jsonResponse = new JSONObject(responseData);
1926
+
1927
+ // Check for server-side errors first
1928
+ if (jsonResponse.has("error")) {
1929
+ Map<String, Object> retError = new HashMap<>();
1930
+ retError.put("error", jsonResponse.getString("error"));
1931
+ if (jsonResponse.has("message")) {
1932
+ retError.put("message", jsonResponse.getString("message"));
1933
+ } else {
1934
+ retError.put("message", "server did not provide a message");
1414
1935
  }
1936
+ callback.callback(retError);
1937
+ return;
1938
+ }
1415
1939
 
1416
- Map<String, Object> ret = new HashMap<>();
1940
+ Map<String, Object> ret = new HashMap<>();
1417
1941
 
1418
- Iterator<String> keys = jsonResponse.keys();
1419
- while (keys.hasNext()) {
1420
- String key = keys.next();
1421
- if (jsonResponse.has(key)) {
1422
- ret.put(key, jsonResponse.get(key));
1423
- }
1942
+ Iterator<String> keys = jsonResponse.keys();
1943
+ while (keys.hasNext()) {
1944
+ String key = keys.next();
1945
+ if (jsonResponse.has(key)) {
1946
+ ret.put(key, jsonResponse.get(key));
1424
1947
  }
1425
- logger.info("Channel get to \"" + ret);
1426
- callback.callback(ret);
1427
- } catch (JSONException e) {
1428
- Map<String, Object> retError = new HashMap<>();
1429
- retError.put("message", "JSON parse error: " + e.getMessage());
1430
- retError.put("error", "parse_error");
1431
- callback.callback(retError);
1432
1948
  }
1949
+ persistDefaultChannelFromResponse(ret.get("channel"), editor, defaultChannelKey);
1950
+ logger.info("Channel get to \"" + ret);
1951
+ callback.callback(ret);
1952
+ } catch (JSONException e) {
1953
+ Map<String, Object> retError = new HashMap<>();
1954
+ retError.put("message", "JSON parse error: " + e.getMessage());
1955
+ retError.put("error", "parse_error");
1956
+ callback.callback(retError);
1433
1957
  }
1434
1958
  }
1435
- );
1959
+ }
1960
+ );
1961
+ }
1962
+
1963
+ void persistDefaultChannelFromResponse(final Object channel, final SharedPreferences.Editor editor, final String defaultChannelKey) {
1964
+ if (!(channel instanceof String)) {
1965
+ return;
1966
+ }
1967
+
1968
+ final String channelName = ((String) channel).trim();
1969
+ if (channelName.isEmpty() || BundleInfo.ID_BUILTIN.equals(channelName)) {
1970
+ return;
1971
+ }
1972
+
1973
+ this.defaultChannel = channelName;
1974
+ if (editor != null && defaultChannelKey != null && !defaultChannelKey.isEmpty()) {
1975
+ editor.putString(defaultChannelKey, channelName);
1976
+ editor.apply();
1977
+ }
1978
+ logger.info("defaultChannel synchronized from getChannel(): " + channelName);
1436
1979
  }
1437
1980
 
1438
1981
  public void listChannels(final Callback callback) {
@@ -1487,94 +2030,106 @@ public class CapgoUpdater {
1487
2030
 
1488
2031
  Request request = new Request.Builder().url(urlBuilder.build()).get().build();
1489
2032
 
1490
- DownloadService.sharedClient
1491
- .newCall(request)
1492
- .enqueue(
1493
- new okhttp3.Callback() {
1494
- @Override
1495
- public void onFailure(@NonNull Call call, @NonNull IOException e) {
1496
- Map<String, Object> retError = new HashMap<>();
1497
- retError.put("message", "Request failed: " + e.getMessage());
1498
- retError.put("error", "network_error");
1499
- callback.callback(retError);
1500
- }
1501
-
1502
- @Override
1503
- public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
1504
- try (ResponseBody responseBody = response.body()) {
1505
- // Check for 429 rate limit
1506
- if (checkAndHandleRateLimitResponse(response)) {
1507
- Map<String, Object> retError = new HashMap<>();
1508
- retError.put("message", "Rate limit exceeded");
1509
- retError.put("error", "rate_limit_exceeded");
1510
- callback.callback(retError);
1511
- return;
1512
- }
2033
+ DownloadService.sharedClient.newCall(request).enqueue(
2034
+ new okhttp3.Callback() {
2035
+ @Override
2036
+ public void onFailure(@NonNull Call call, @NonNull IOException e) {
2037
+ Map<String, Object> retError = new HashMap<>();
2038
+ retError.put("message", "Request failed: " + e.getMessage());
2039
+ retError.put("error", "network_error");
2040
+ callback.callback(retError);
2041
+ }
1513
2042
 
1514
- if (!response.isSuccessful()) {
1515
- Map<String, Object> retError = new HashMap<>();
1516
- retError.put("message", "Server error: " + response.code());
1517
- retError.put("error", "response_error");
1518
- callback.callback(retError);
1519
- return;
1520
- }
2043
+ @Override
2044
+ public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
2045
+ try (ResponseBody responseBody = response.body()) {
2046
+ // Check for 429 rate limit
2047
+ if (checkAndHandleRateLimitResponse(response)) {
2048
+ Map<String, Object> retError = new HashMap<>();
2049
+ retError.put("message", "Rate limit exceeded");
2050
+ retError.put("error", "rate_limit_exceeded");
2051
+ callback.callback(retError);
2052
+ return;
2053
+ }
1521
2054
 
1522
- assert responseBody != null;
1523
- String data = responseBody.string();
2055
+ if (!response.isSuccessful()) {
2056
+ Map<String, Object> retError = new HashMap<>();
2057
+ retError.put("message", "Server error: " + response.code());
2058
+ retError.put("error", "response_error");
2059
+ callback.callback(retError);
2060
+ return;
2061
+ }
1524
2062
 
1525
- try {
1526
- Map<String, Object> ret = new HashMap<>();
2063
+ if (responseBody == null) {
2064
+ Map<String, Object> retError = new HashMap<>();
2065
+ retError.put("message", "Empty response body");
2066
+ retError.put("error", "no_response_body");
2067
+ callback.callback(retError);
2068
+ return;
2069
+ }
2070
+ String data = responseBody.string();
1527
2071
 
1528
- try {
1529
- // Try to parse as direct array first
1530
- JSONArray channelsJson = new JSONArray(data);
1531
- List<Map<String, Object>> channelsList = new ArrayList<>();
1532
-
1533
- for (int i = 0; i < channelsJson.length(); i++) {
1534
- JSONObject channelJson = channelsJson.getJSONObject(i);
1535
- Map<String, Object> channel = new HashMap<>();
1536
- channel.put("id", channelJson.optString("id", ""));
1537
- channel.put("name", channelJson.optString("name", ""));
1538
- channel.put("public", channelJson.optBoolean("public", false));
1539
- channel.put("allow_self_set", channelJson.optBoolean("allow_self_set", false));
1540
- channelsList.add(channel);
1541
- }
2072
+ try {
2073
+ Map<String, Object> ret = parseListChannelsResponse(data);
1542
2074
 
1543
- // Wrap in channels object for JS API
1544
- ret.put("channels", channelsList);
1545
-
1546
- logger.info("Channels listed successfully");
1547
- callback.callback(ret);
1548
- } catch (JSONException arrayException) {
1549
- // If not an array, try to parse as error object
1550
- try {
1551
- JSONObject json = new JSONObject(data);
1552
- if (json.has("error")) {
1553
- Map<String, Object> retError = new HashMap<>();
1554
- retError.put("error", json.getString("error"));
1555
- if (json.has("message")) {
1556
- retError.put("message", json.getString("message"));
1557
- } else {
1558
- retError.put("message", "server did not provide a message");
1559
- }
1560
- callback.callback(retError);
1561
- return;
1562
- }
1563
- } catch (JSONException objException) {
1564
- // If neither array nor object, throw parse error
1565
- throw arrayException;
2075
+ logger.info("Channels listed successfully");
2076
+ callback.callback(ret);
2077
+ } catch (JSONException arrayException) {
2078
+ // If not an array, try to parse as error object
2079
+ try {
2080
+ JSONObject json = new JSONObject(data);
2081
+ if (json.has("error")) {
2082
+ Map<String, Object> retError = new HashMap<>();
2083
+ retError.put("error", json.getString("error"));
2084
+ if (json.has("message")) {
2085
+ retError.put("message", json.getString("message"));
2086
+ } else {
2087
+ retError.put("message", "server did not provide a message");
1566
2088
  }
2089
+ callback.callback(retError);
2090
+ return;
1567
2091
  }
1568
- } catch (JSONException e) {
1569
2092
  Map<String, Object> retError = new HashMap<>();
1570
- retError.put("message", "JSON parse error: " + e.getMessage());
2093
+ retError.put("message", "Unexpected channels response format");
2094
+ retError.put("error", "parse_error");
2095
+ callback.callback(retError);
2096
+ return;
2097
+ } catch (JSONException objException) {
2098
+ // If neither array nor object, throw parse error
2099
+ arrayException.addSuppressed(objException);
2100
+ Map<String, Object> retError = new HashMap<>();
2101
+ retError.put("message", "JSON parse error: " + arrayException.getMessage());
1571
2102
  retError.put("error", "parse_error");
1572
2103
  callback.callback(retError);
1573
2104
  }
1574
2105
  }
1575
2106
  }
1576
2107
  }
1577
- );
2108
+ }
2109
+ );
2110
+ }
2111
+
2112
+ static Map<String, Object> parseListChannelsResponse(final String data) throws JSONException {
2113
+ JSONArray channelsJson = new JSONArray(data);
2114
+ List<Map<String, Object>> channelsList = new ArrayList<>();
2115
+
2116
+ for (int i = 0; i < channelsJson.length(); i++) {
2117
+ JSONObject channelJson = channelsJson.getJSONObject(i);
2118
+ Object channelId = channelJson.get("id");
2119
+ if (!(channelId instanceof Number)) {
2120
+ throw new JSONException("Channel id must be a number");
2121
+ }
2122
+ Map<String, Object> channel = new HashMap<>();
2123
+ channel.put("id", channelId);
2124
+ channel.put("name", channelJson.optString("name", ""));
2125
+ channel.put("public", channelJson.optBoolean("public", false));
2126
+ channel.put("allow_self_set", channelJson.optBoolean("allow_self_set", false));
2127
+ channelsList.add(channel);
2128
+ }
2129
+
2130
+ Map<String, Object> ret = new HashMap<>();
2131
+ ret.put("channels", channelsList);
2132
+ return ret;
1578
2133
  }
1579
2134
 
1580
2135
  public void sendStats(final String action) {
@@ -1586,6 +2141,27 @@ public class CapgoUpdater {
1586
2141
  }
1587
2142
 
1588
2143
  public void sendStats(final String action, final String versionName, final String oldVersionName) {
2144
+ this.sendStats(action, versionName, oldVersionName, null);
2145
+ }
2146
+
2147
+ public void sendStats(final String action, final String versionName, final String oldVersionName, final Map<String, String> metadata) {
2148
+ this.sendStats(action, versionName, oldVersionName, metadata, null);
2149
+ }
2150
+
2151
+ public void sendStats(
2152
+ final String action,
2153
+ final String versionName,
2154
+ final String oldVersionName,
2155
+ final Map<String, String> metadata,
2156
+ final Runnable onSent
2157
+ ) {
2158
+ if (this.previewSession) {
2159
+ if (logger != null) {
2160
+ logger.debug("Skipping sendStats during preview session.");
2161
+ }
2162
+ return;
2163
+ }
2164
+
1589
2165
  // Check if rate limit was exceeded
1590
2166
  if (rateLimitExceeded) {
1591
2167
  logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.");
@@ -1604,13 +2180,16 @@ public class CapgoUpdater {
1604
2180
  json.put("old_version_name", oldVersionName);
1605
2181
  json.put("action", action);
1606
2182
  json.put("timestamp", System.currentTimeMillis());
2183
+ if (metadata != null && !metadata.isEmpty()) {
2184
+ json.put("metadata", new JSONObject(metadata));
2185
+ }
1607
2186
  } catch (JSONException e) {
1608
2187
  logger.error("Error preparing stats");
1609
2188
  logger.debug("JSONException: " + e.getMessage());
1610
2189
  return;
1611
2190
  }
1612
2191
 
1613
- statsQueue.add(json);
2192
+ statsQueue.add(new QueuedStatsEvent(json, onSent));
1614
2193
  ensureStatsTimerStarted();
1615
2194
  }
1616
2195
 
@@ -1637,7 +2216,7 @@ public class CapgoUpdater {
1637
2216
  }
1638
2217
 
1639
2218
  // Copy and clear the queue atomically using synchronized block
1640
- List<JSONObject> eventsToSend;
2219
+ List<QueuedStatsEvent> eventsToSend;
1641
2220
  synchronized (statsQueue) {
1642
2221
  if (statsQueue.isEmpty()) {
1643
2222
  return;
@@ -1647,8 +2226,8 @@ public class CapgoUpdater {
1647
2226
  }
1648
2227
 
1649
2228
  JSONArray jsonArray = new JSONArray();
1650
- for (JSONObject event : eventsToSend) {
1651
- jsonArray.put(event);
2229
+ for (QueuedStatsEvent queuedEvent : eventsToSend) {
2230
+ jsonArray.put(queuedEvent.event);
1652
2231
  }
1653
2232
 
1654
2233
  Request request = new Request.Builder()
@@ -1657,35 +2236,51 @@ public class CapgoUpdater {
1657
2236
  .build();
1658
2237
 
1659
2238
  final int eventCount = eventsToSend.size();
1660
- DownloadService.sharedClient
1661
- .newCall(request)
1662
- .enqueue(
1663
- new okhttp3.Callback() {
1664
- @Override
1665
- public void onFailure(@NonNull Call call, @NonNull IOException e) {
1666
- logger.error("Failed to send stats batch");
1667
- logger.debug("Error: " + e.getMessage());
1668
- }
2239
+ DownloadService.sharedClient.newCall(request).enqueue(
2240
+ new okhttp3.Callback() {
2241
+ @Override
2242
+ public void onFailure(@NonNull Call call, @NonNull IOException e) {
2243
+ logger.error("Failed to send stats batch");
2244
+ logger.debug("Error: " + e.getMessage());
2245
+ }
1669
2246
 
1670
- @Override
1671
- public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
1672
- try (ResponseBody responseBody = response.body()) {
1673
- // Check for 429 rate limit
1674
- if (checkAndHandleRateLimitResponse(response)) {
1675
- return;
1676
- }
2247
+ @Override
2248
+ public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
2249
+ try (ResponseBody responseBody = response.body()) {
2250
+ // Check for 429 rate limit
2251
+ if (checkAndHandleRateLimitResponse(response)) {
2252
+ return;
2253
+ }
1677
2254
 
1678
- if (response.isSuccessful()) {
1679
- logger.info("Stats batch sent successfully");
1680
- logger.debug("Sent " + eventCount + " events");
1681
- } else {
1682
- logger.error("Error sending stats batch");
1683
- logger.debug("Response code: " + response.code());
1684
- }
2255
+ if (response.isSuccessful()) {
2256
+ logger.info("Stats batch sent successfully");
2257
+ logger.debug("Sent " + eventCount + " events");
2258
+ runStatsCallbacks(eventsToSend);
2259
+ } else {
2260
+ logger.error("Error sending stats batch");
2261
+ logger.debug("Response code: " + response.code());
1685
2262
  }
1686
2263
  }
1687
2264
  }
1688
- );
2265
+ }
2266
+ );
2267
+ }
2268
+
2269
+ private void runStatsCallbacks(final List<QueuedStatsEvent> sentEvents) {
2270
+ for (final QueuedStatsEvent sentEvent : sentEvents) {
2271
+ if (sentEvent.onSent == null) {
2272
+ continue;
2273
+ }
2274
+
2275
+ try {
2276
+ sentEvent.onSent.run();
2277
+ } catch (Exception e) {
2278
+ if (logger != null) {
2279
+ logger.error("Error running stats sent callback");
2280
+ logger.debug("Error: " + e.getMessage());
2281
+ }
2282
+ }
2283
+ }
1689
2284
  }
1690
2285
 
1691
2286
  public BundleInfo getBundleInfo(final String id) {
@@ -1695,7 +2290,13 @@ public class CapgoUpdater {
1695
2290
  }
1696
2291
  BundleInfo result;
1697
2292
  if (BundleInfo.ID_BUILTIN.equals(trueId)) {
1698
- result = new BundleInfo(trueId, null, BundleStatus.SUCCESS, "", "");
2293
+ result = new BundleInfo(
2294
+ trueId,
2295
+ this.versionBuild == null || this.versionBuild.isEmpty() ? null : this.versionBuild,
2296
+ BundleStatus.SUCCESS,
2297
+ "",
2298
+ ""
2299
+ );
1699
2300
  } else if (BundleInfo.VERSION_UNKNOWN.equals(trueId)) {
1700
2301
  result = new BundleInfo(trueId, null, BundleStatus.ERROR, "", "");
1701
2302
  } else {
@@ -1799,7 +2400,33 @@ public class CapgoUpdater {
1799
2400
  return this.getBundleInfo(id);
1800
2401
  }
1801
2402
 
2403
+ public BundleInfo getPreviewFallbackBundle() {
2404
+ final String id = this.prefs.getString(PREVIEW_FALLBACK_VERSION, null);
2405
+ if (id == null) return null;
2406
+ final BundleInfo bundle = this.getBundleInfo(id);
2407
+ if (bundle.isErrorStatus() || (!bundle.isBuiltin() && !this.bundleExists(id))) {
2408
+ this.setPreviewFallbackBundle(null);
2409
+ return null;
2410
+ }
2411
+ return bundle;
2412
+ }
2413
+
2414
+ public boolean setPreviewFallbackBundle(final String fallback) {
2415
+ if (fallback == null) {
2416
+ this.editor.remove(PREVIEW_FALLBACK_VERSION);
2417
+ } else {
2418
+ final BundleInfo newBundle = this.getBundleInfo(fallback);
2419
+ if (newBundle.isErrorStatus() || (!newBundle.isBuiltin() && !this.bundleExists(fallback))) {
2420
+ return false;
2421
+ }
2422
+ this.editor.putString(PREVIEW_FALLBACK_VERSION, fallback);
2423
+ }
2424
+ this.editor.commit();
2425
+ return true;
2426
+ }
2427
+
1802
2428
  public boolean setNextBundle(final String next) {
2429
+ BundleInfo bundleToNotify = null;
1803
2430
  if (next == null) {
1804
2431
  this.editor.remove(NEXT_VERSION);
1805
2432
  } else {
@@ -1809,8 +2436,15 @@ public class CapgoUpdater {
1809
2436
  }
1810
2437
  this.editor.putString(NEXT_VERSION, next);
1811
2438
  this.setBundleStatus(next, BundleStatus.PENDING);
2439
+ bundleToNotify = newBundle;
1812
2440
  }
1813
2441
  this.editor.commit();
2442
+ if (bundleToNotify != null) {
2443
+ this.sendStats("set_next", bundleToNotify.getVersionName(), this.getCurrentBundle().getVersionName());
2444
+ final Map<String, Object> payload = new HashMap<>();
2445
+ payload.put("bundle", bundleToNotify.toJSONMap());
2446
+ this.notifyListeners("setNext", payload);
2447
+ }
1814
2448
  return true;
1815
2449
  }
1816
2450