@capgo/capacitor-updater 6.43.5 → 6.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 (34) hide show
  1. package/Package.swift +1 -1
  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 -354
  6. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +905 -283
  7. package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +45 -30
  8. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +75 -32
  9. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +2 -0
  10. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +3 -3
  11. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +401 -27
  12. package/android/src/main/java/ee/forgr/capacitor_updater/ThreeFingerPinchDetector.java +323 -0
  13. package/dist/docs.json +1590 -196
  14. package/dist/esm/definitions.d.ts +755 -66
  15. package/dist/esm/definitions.js.map +1 -1
  16. package/dist/esm/web.d.ts +11 -1
  17. package/dist/esm/web.js +59 -1
  18. package/dist/esm/web.js.map +1 -1
  19. package/dist/plugin.cjs.js +59 -1
  20. package/dist/plugin.cjs.js.map +1 -1
  21. package/dist/plugin.js +59 -1
  22. package/dist/plugin.js.map +1 -1
  23. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +0 -1
  24. package/ios/Sources/CapacitorUpdaterPlugin/AppHealthTracker.swift +82 -0
  25. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +0 -16
  26. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +2 -2
  27. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +78 -2
  28. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +2732 -416
  29. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1191 -363
  30. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +0 -1
  31. package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +80 -1
  32. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +438 -39
  33. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +276 -0
  34. package/package.json +18 -3
@@ -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,6 +439,137 @@ public class CapgoUpdater {
345
439
  }
346
440
  }
347
441
 
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
+
348
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");
@@ -478,6 +703,7 @@ public class CapgoUpdater {
478
703
  this.appId,
479
704
  this.pluginVersion,
480
705
  this.isProd(),
706
+ this.getInstallSource(),
481
707
  this.statsUrl,
482
708
  this.deviceID,
483
709
  this.versionBuild,
@@ -579,11 +805,15 @@ public class CapgoUpdater {
579
805
  CapgoUpdater.this.notifyListeners("updateAvailable", ret);
580
806
  logger.info("setNext: " + setNext);
581
807
  if (setNext) {
582
- logger.info("directUpdate: " + this.directUpdate);
583
- 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);
584
813
  CapgoUpdater.this.directUpdateFinish(next);
585
814
  this.directUpdate = false;
586
815
  } else {
816
+ logger.info("directUpdate: " + this.directUpdate);
587
817
  this.setNextBundle(next.getId());
588
818
  }
589
819
  }
@@ -753,11 +983,93 @@ public class CapgoUpdater {
753
983
  }
754
984
 
755
985
  private void setCurrentBundle(final File bundle) {
986
+ this.cancelBackgroundRunnerWorkBeforeBundleSwitch();
756
987
  this.editor.putString(this.CAP_SERVER_PATH, bundle.getPath());
757
988
  logger.info("Current bundle set to: " + bundle);
758
989
  this.editor.commit();
759
990
  }
760
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
+
761
1073
  public void downloadBackground(
762
1074
  final String url,
763
1075
  final String version,
@@ -927,6 +1239,17 @@ public class CapgoUpdater {
927
1239
  logger.debug("Bundle ID: " + id);
928
1240
  return false;
929
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
+ }
930
1253
  final BundleInfo next = this.getNextBundle();
931
1254
  if (next != null && !next.isDeleted() && !next.isErrorStatus() && next.getId().equals(id)) {
932
1255
  logger.error("Cannot delete the next bundle");
@@ -977,6 +1300,62 @@ public class CapgoUpdater {
977
1300
  return (bundle.isDirectory() && bundle.exists() && new File(bundle.getPath(), "/index.html").exists() && !bundleInfo.isDeleted());
978
1301
  }
979
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
+
980
1359
  public Boolean set(final BundleInfo bundle) {
981
1360
  return this.set(bundle.getId());
982
1361
  }
@@ -1001,14 +1380,88 @@ public class CapgoUpdater {
1001
1380
  return false;
1002
1381
  }
1003
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
1004
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) {
1005
1423
  final BundleInfo currentBundle = this.getCurrentBundle();
1006
1424
  if (!currentBundle.isBuiltin() && !this.bundleExists(currentBundle.getId())) {
1007
1425
  logger.info("Folder at bundle path does not exist. Triggering reset.");
1008
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();
1009
1451
  }
1010
1452
  }
1011
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
+
1012
1465
  public void reset() {
1013
1466
  this.reset(false);
1014
1467
  }
@@ -1016,12 +1469,20 @@ public class CapgoUpdater {
1016
1469
  public void setSuccess(final BundleInfo bundle, Boolean autoDeletePrevious) {
1017
1470
  this.setBundleStatus(bundle.getId(), BundleStatus.SUCCESS);
1018
1471
  final BundleInfo fallback = this.getFallbackBundle();
1472
+ final BundleInfo previewFallback = this.getPreviewFallbackBundle();
1473
+ final boolean fallbackIsPreviewFallback = previewFallback != null && previewFallback.getId().equals(fallback.getId());
1019
1474
  logger.debug("Fallback bundle is: " + fallback);
1020
1475
  logger.info("Version successfully loaded: " + bundle.getVersionName());
1021
1476
  // Only attempt to delete when the fallback is a different bundle than the
1022
1477
  // currently loaded one. Otherwise we spam logs with "Cannot delete <id>"
1023
1478
  // because delete() protects the current bundle from removal.
1024
- 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
+ ) {
1025
1486
  final Boolean res = this.delete(fallback.getId());
1026
1487
  if (res) {
1027
1488
  logger.info("Deleted previous bundle: " + fallback.getVersionName());
@@ -1038,24 +1499,20 @@ public class CapgoUpdater {
1038
1499
 
1039
1500
  public void reset(final boolean internal) {
1040
1501
  logger.debug("reset: " + internal);
1041
- var currentBundleName = this.getCurrentBundle().getVersionName();
1042
- this.setCurrentBundle(new File("public"));
1043
- this.setFallbackBundle(null);
1044
- this.setNextBundle(null);
1045
- // Cancel any ongoing downloads
1046
- if (this.activity != null) {
1047
- DownloadWorkerManager.cancelAllDownloads(this.activity);
1048
- }
1049
- if (!internal) {
1050
- this.sendStats("reset", this.getCurrentBundle().getVersionName(), currentBundleName);
1051
- }
1502
+ final String currentBundleName = this.getCurrentBundle().getVersionName();
1503
+ this.prepareResetStateForTransition();
1504
+ this.finalizeResetTransition(currentBundleName, internal);
1052
1505
  }
1053
1506
 
1054
1507
  private JSONObject createInfoObject() throws JSONException {
1508
+ return this.createInfoObject(null);
1509
+ }
1510
+
1511
+ private JSONObject createInfoObject(final String appIdOverride) throws JSONException {
1055
1512
  JSONObject json = new JSONObject();
1056
1513
  json.put("platform", "android");
1057
1514
  json.put("device_id", this.deviceID);
1058
- json.put("app_id", this.appId);
1515
+ json.put("app_id", appIdOverride == null || appIdOverride.trim().isEmpty() ? this.appId : appIdOverride);
1059
1516
  json.put("custom_id", this.customId);
1060
1517
  json.put("version_build", this.versionBuild);
1061
1518
  json.put("version_code", this.versionCode);
@@ -1064,6 +1521,7 @@ public class CapgoUpdater {
1064
1521
  json.put("plugin_version", this.pluginVersion);
1065
1522
  json.put("is_emulator", this.isEmulator());
1066
1523
  json.put("is_prod", this.isProd());
1524
+ json.put("install_source", this.getInstallSource());
1067
1525
  json.put("defaultChannel", this.defaultChannel);
1068
1526
 
1069
1527
  // Add encryption key ID if encryption is enabled (use cached value)
@@ -1081,7 +1539,7 @@ public class CapgoUpdater {
1081
1539
  if (response.code() == 429) {
1082
1540
  // Send a statistic about the rate limit BEFORE setting the flag
1083
1541
  // Only send once to prevent infinite loop if the stat request itself gets rate limited
1084
- if (!rateLimitExceeded && !rateLimitStatisticSent) {
1542
+ if (!this.previewSession && !rateLimitExceeded && !rateLimitStatisticSent) {
1085
1543
  rateLimitStatisticSent = true;
1086
1544
  sendRateLimitStatistic();
1087
1545
  }
@@ -1135,89 +1593,115 @@ public class CapgoUpdater {
1135
1593
 
1136
1594
  Request request = new Request.Builder().url(url).post(body).build();
1137
1595
 
1138
- DownloadService.sharedClient
1139
- .newCall(request)
1140
- .enqueue(
1141
- new okhttp3.Callback() {
1142
- @Override
1143
- public void onFailure(@NonNull Call call, @NonNull IOException e) {
1144
- Map<String, Object> retError = new HashMap<>();
1145
- retError.put("message", "Request failed: " + e.getMessage());
1146
- retError.put("error", "network_error");
1147
- callback.callback(retError);
1148
- }
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
+ }
1149
1606
 
1150
- @Override
1151
- public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
1152
- try (ResponseBody responseBody = response.body()) {
1153
- final int statusCode = response.code();
1154
- // Check for 429 rate limit
1155
- if (checkAndHandleRateLimitResponse(response)) {
1156
- Map<String, Object> retError = new HashMap<>();
1157
- retError.put("message", "Rate limit exceeded");
1158
- retError.put("error", "rate_limit_exceeded");
1159
- retError.put("statusCode", statusCode);
1160
- callback.callback(retError);
1161
- 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.
1162
1618
  }
1619
+ }
1163
1620
 
1164
- if (!response.isSuccessful()) {
1165
- Map<String, Object> retError = new HashMap<>();
1166
- retError.put("message", "Server error: " + response.code());
1167
- retError.put("error", "response_error");
1168
- retError.put("statusCode", statusCode);
1169
- callback.callback(retError);
1170
- return;
1621
+ if (jsonResponse != null && (jsonResponse.has("error") || jsonResponse.has("kind"))) {
1622
+ if (statusCode == 429) {
1623
+ checkAndHandleRateLimitResponse(response);
1171
1624
  }
1172
-
1173
- assert responseBody != null;
1174
- String responseData = responseBody.string();
1175
- JSONObject jsonResponse = new JSONObject(responseData);
1176
-
1177
- // Check for server-side errors first
1178
- if (jsonResponse.has("error")) {
1179
- Map<String, Object> retError = new HashMap<>();
1625
+ Map<String, Object> retError = new HashMap<>();
1626
+ if (jsonResponse.has("error") && !jsonResponse.isNull("error")) {
1180
1627
  retError.put("error", jsonResponse.getString("error"));
1181
- if (jsonResponse.has("message")) {
1182
- retError.put("message", jsonResponse.getString("message"));
1183
- } else {
1184
- retError.put("message", "server did not provide a message");
1185
- }
1186
- retError.put("statusCode", statusCode);
1187
- callback.callback(retError);
1188
- return;
1189
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
+ }
1190
1644
 
1191
- Map<String, Object> ret = new HashMap<>();
1192
- 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
+ }
1193
1655
 
1194
- Iterator<String> keys = jsonResponse.keys();
1195
- while (keys.hasNext()) {
1196
- String key = keys.next();
1197
- if (jsonResponse.has(key)) {
1198
- if ("session_key".equals(key)) {
1199
- ret.put("sessionKey", jsonResponse.get(key));
1200
- } else {
1201
- ret.put(key, jsonResponse.get(key));
1202
- }
1203
- }
1204
- }
1205
- callback.callback(ret);
1206
- } catch (JSONException e) {
1656
+ if (!response.isSuccessful()) {
1207
1657
  Map<String, Object> retError = new HashMap<>();
1208
- retError.put("message", "JSON parse error: " + e.getMessage());
1209
- 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);
1210
1662
  callback.callback(retError);
1663
+ return;
1664
+ }
1665
+
1666
+ if (jsonResponse == null) {
1667
+ throw new JSONException("Response is not a JSON object");
1211
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
+ }
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);
1212
1691
  }
1213
1692
  }
1214
- );
1693
+ }
1694
+ );
1215
1695
  }
1216
1696
 
1217
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) {
1218
1702
  JSONObject json;
1219
1703
  try {
1220
- json = this.createInfoObject();
1704
+ json = this.createInfoObject(appIdOverride);
1221
1705
  if (channel != null && json != null) {
1222
1706
  json.put("defaultChannel", channel);
1223
1707
  }
@@ -1231,7 +1715,9 @@ public class CapgoUpdater {
1231
1715
  return;
1232
1716
  }
1233
1717
 
1234
- logger.info("Auto-update parameters: " + json);
1718
+ if (logger != null) {
1719
+ logger.info("Auto-update parameters: " + json);
1720
+ }
1235
1721
 
1236
1722
  makeJsonRequest(updateUrl, json, callback);
1237
1723
  }
@@ -1260,6 +1746,17 @@ public class CapgoUpdater {
1260
1746
  final String defaultChannelKey,
1261
1747
  final boolean allowSetDefaultChannel,
1262
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
1263
1760
  ) {
1264
1761
  // Check if setting defaultChannel is allowed
1265
1762
  if (!allowSetDefaultChannel) {
@@ -1312,6 +1809,7 @@ public class CapgoUpdater {
1312
1809
  // Clear persisted defaultChannel and revert to config value
1313
1810
  editor.remove(defaultChannelKey);
1314
1811
  editor.apply();
1812
+ this.defaultChannel = configDefaultChannel;
1315
1813
  logger.info("Public channel requested, channel override removed");
1316
1814
  callback.callback(res);
1317
1815
  } else {
@@ -1326,6 +1824,10 @@ public class CapgoUpdater {
1326
1824
  }
1327
1825
 
1328
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) {
1329
1831
  // Check if rate limit was exceeded
1330
1832
  if (rateLimitExceeded) {
1331
1833
  logger.debug("Skipping getChannel due to rate limit (429). Requests will resume after app restart.");
@@ -1363,88 +1865,117 @@ public class CapgoUpdater {
1363
1865
  .put(RequestBody.create(json.toString(), MediaType.get("application/json")))
1364
1866
  .build();
1365
1867
 
1366
- DownloadService.sharedClient
1367
- .newCall(request)
1368
- .enqueue(
1369
- new okhttp3.Callback() {
1370
- @Override
1371
- public void onFailure(@NonNull Call call, @NonNull IOException e) {
1372
- Map<String, Object> retError = new HashMap<>();
1373
- retError.put("message", "Request failed: " + e.getMessage());
1374
- retError.put("error", "network_error");
1375
- callback.callback(retError);
1376
- }
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
+ }
1377
1877
 
1378
- @Override
1379
- public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
1380
- try (ResponseBody responseBody = response.body()) {
1381
- // Check for 429 rate limit
1382
- 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) {
1383
1892
  Map<String, Object> retError = new HashMap<>();
1384
- retError.put("message", "Rate limit exceeded");
1385
- retError.put("error", "rate_limit_exceeded");
1893
+ retError.put("message", "Empty response body");
1894
+ retError.put("error", "no_response_body");
1386
1895
  callback.callback(retError);
1387
1896
  return;
1388
1897
  }
1389
-
1390
- if (response.code() == 400) {
1391
- assert responseBody != null;
1392
- String data = responseBody.string();
1393
- if (data.contains("channel_not_found") && !defaultChannel.isEmpty()) {
1394
- Map<String, Object> ret = new HashMap<>();
1395
- ret.put("channel", defaultChannel);
1396
- ret.put("status", "default");
1397
- logger.info("Channel get to \"" + ret);
1398
- callback.callback(ret);
1399
- return;
1400
- }
1401
- }
1402
-
1403
- if (!response.isSuccessful()) {
1404
- Map<String, Object> retError = new HashMap<>();
1405
- retError.put("message", "Server error: " + response.code());
1406
- retError.put("error", "response_error");
1407
- 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);
1408
1905
  return;
1409
1906
  }
1907
+ }
1410
1908
 
1411
- assert responseBody != null;
1412
- String responseData = responseBody.string();
1413
- 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
+ }
1414
1916
 
1415
- // Check for server-side errors first
1416
- if (jsonResponse.has("error")) {
1417
- Map<String, Object> retError = new HashMap<>();
1418
- retError.put("error", jsonResponse.getString("error"));
1419
- if (jsonResponse.has("message")) {
1420
- retError.put("message", jsonResponse.getString("message"));
1421
- } else {
1422
- retError.put("message", "server did not provide a message");
1423
- }
1424
- callback.callback(retError);
1425
- 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");
1426
1935
  }
1936
+ callback.callback(retError);
1937
+ return;
1938
+ }
1427
1939
 
1428
- Map<String, Object> ret = new HashMap<>();
1940
+ Map<String, Object> ret = new HashMap<>();
1429
1941
 
1430
- Iterator<String> keys = jsonResponse.keys();
1431
- while (keys.hasNext()) {
1432
- String key = keys.next();
1433
- if (jsonResponse.has(key)) {
1434
- ret.put(key, jsonResponse.get(key));
1435
- }
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));
1436
1947
  }
1437
- logger.info("Channel get to \"" + ret);
1438
- callback.callback(ret);
1439
- } catch (JSONException e) {
1440
- Map<String, Object> retError = new HashMap<>();
1441
- retError.put("message", "JSON parse error: " + e.getMessage());
1442
- retError.put("error", "parse_error");
1443
- callback.callback(retError);
1444
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);
1445
1957
  }
1446
1958
  }
1447
- );
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);
1448
1979
  }
1449
1980
 
1450
1981
  public void listChannels(final Callback callback) {
@@ -1499,94 +2030,106 @@ public class CapgoUpdater {
1499
2030
 
1500
2031
  Request request = new Request.Builder().url(urlBuilder.build()).get().build();
1501
2032
 
1502
- DownloadService.sharedClient
1503
- .newCall(request)
1504
- .enqueue(
1505
- new okhttp3.Callback() {
1506
- @Override
1507
- public void onFailure(@NonNull Call call, @NonNull IOException e) {
1508
- Map<String, Object> retError = new HashMap<>();
1509
- retError.put("message", "Request failed: " + e.getMessage());
1510
- retError.put("error", "network_error");
1511
- callback.callback(retError);
1512
- }
1513
-
1514
- @Override
1515
- public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
1516
- try (ResponseBody responseBody = response.body()) {
1517
- // Check for 429 rate limit
1518
- if (checkAndHandleRateLimitResponse(response)) {
1519
- Map<String, Object> retError = new HashMap<>();
1520
- retError.put("message", "Rate limit exceeded");
1521
- retError.put("error", "rate_limit_exceeded");
1522
- callback.callback(retError);
1523
- return;
1524
- }
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
+ }
1525
2042
 
1526
- if (!response.isSuccessful()) {
1527
- Map<String, Object> retError = new HashMap<>();
1528
- retError.put("message", "Server error: " + response.code());
1529
- retError.put("error", "response_error");
1530
- callback.callback(retError);
1531
- return;
1532
- }
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
+ }
1533
2054
 
1534
- assert responseBody != null;
1535
- 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
+ }
1536
2062
 
1537
- try {
1538
- 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();
1539
2071
 
1540
- try {
1541
- // Try to parse as direct array first
1542
- JSONArray channelsJson = new JSONArray(data);
1543
- List<Map<String, Object>> channelsList = new ArrayList<>();
1544
-
1545
- for (int i = 0; i < channelsJson.length(); i++) {
1546
- JSONObject channelJson = channelsJson.getJSONObject(i);
1547
- Map<String, Object> channel = new HashMap<>();
1548
- channel.put("id", channelJson.optString("id", ""));
1549
- channel.put("name", channelJson.optString("name", ""));
1550
- channel.put("public", channelJson.optBoolean("public", false));
1551
- channel.put("allow_self_set", channelJson.optBoolean("allow_self_set", false));
1552
- channelsList.add(channel);
1553
- }
2072
+ try {
2073
+ Map<String, Object> ret = parseListChannelsResponse(data);
1554
2074
 
1555
- // Wrap in channels object for JS API
1556
- ret.put("channels", channelsList);
1557
-
1558
- logger.info("Channels listed successfully");
1559
- callback.callback(ret);
1560
- } catch (JSONException arrayException) {
1561
- // If not an array, try to parse as error object
1562
- try {
1563
- JSONObject json = new JSONObject(data);
1564
- if (json.has("error")) {
1565
- Map<String, Object> retError = new HashMap<>();
1566
- retError.put("error", json.getString("error"));
1567
- if (json.has("message")) {
1568
- retError.put("message", json.getString("message"));
1569
- } else {
1570
- retError.put("message", "server did not provide a message");
1571
- }
1572
- callback.callback(retError);
1573
- return;
1574
- }
1575
- } catch (JSONException objException) {
1576
- // If neither array nor object, throw parse error
1577
- 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");
1578
2088
  }
2089
+ callback.callback(retError);
2090
+ return;
1579
2091
  }
1580
- } catch (JSONException e) {
1581
2092
  Map<String, Object> retError = new HashMap<>();
1582
- 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());
1583
2102
  retError.put("error", "parse_error");
1584
2103
  callback.callback(retError);
1585
2104
  }
1586
2105
  }
1587
2106
  }
1588
2107
  }
1589
- );
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;
1590
2133
  }
1591
2134
 
1592
2135
  public void sendStats(final String action) {
@@ -1598,6 +2141,27 @@ public class CapgoUpdater {
1598
2141
  }
1599
2142
 
1600
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
+
1601
2165
  // Check if rate limit was exceeded
1602
2166
  if (rateLimitExceeded) {
1603
2167
  logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.");
@@ -1616,13 +2180,16 @@ public class CapgoUpdater {
1616
2180
  json.put("old_version_name", oldVersionName);
1617
2181
  json.put("action", action);
1618
2182
  json.put("timestamp", System.currentTimeMillis());
2183
+ if (metadata != null && !metadata.isEmpty()) {
2184
+ json.put("metadata", new JSONObject(metadata));
2185
+ }
1619
2186
  } catch (JSONException e) {
1620
2187
  logger.error("Error preparing stats");
1621
2188
  logger.debug("JSONException: " + e.getMessage());
1622
2189
  return;
1623
2190
  }
1624
2191
 
1625
- statsQueue.add(json);
2192
+ statsQueue.add(new QueuedStatsEvent(json, onSent));
1626
2193
  ensureStatsTimerStarted();
1627
2194
  }
1628
2195
 
@@ -1649,7 +2216,7 @@ public class CapgoUpdater {
1649
2216
  }
1650
2217
 
1651
2218
  // Copy and clear the queue atomically using synchronized block
1652
- List<JSONObject> eventsToSend;
2219
+ List<QueuedStatsEvent> eventsToSend;
1653
2220
  synchronized (statsQueue) {
1654
2221
  if (statsQueue.isEmpty()) {
1655
2222
  return;
@@ -1659,8 +2226,8 @@ public class CapgoUpdater {
1659
2226
  }
1660
2227
 
1661
2228
  JSONArray jsonArray = new JSONArray();
1662
- for (JSONObject event : eventsToSend) {
1663
- jsonArray.put(event);
2229
+ for (QueuedStatsEvent queuedEvent : eventsToSend) {
2230
+ jsonArray.put(queuedEvent.event);
1664
2231
  }
1665
2232
 
1666
2233
  Request request = new Request.Builder()
@@ -1669,35 +2236,51 @@ public class CapgoUpdater {
1669
2236
  .build();
1670
2237
 
1671
2238
  final int eventCount = eventsToSend.size();
1672
- DownloadService.sharedClient
1673
- .newCall(request)
1674
- .enqueue(
1675
- new okhttp3.Callback() {
1676
- @Override
1677
- public void onFailure(@NonNull Call call, @NonNull IOException e) {
1678
- logger.error("Failed to send stats batch");
1679
- logger.debug("Error: " + e.getMessage());
1680
- }
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
+ }
1681
2246
 
1682
- @Override
1683
- public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
1684
- try (ResponseBody responseBody = response.body()) {
1685
- // Check for 429 rate limit
1686
- if (checkAndHandleRateLimitResponse(response)) {
1687
- return;
1688
- }
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
+ }
1689
2254
 
1690
- if (response.isSuccessful()) {
1691
- logger.info("Stats batch sent successfully");
1692
- logger.debug("Sent " + eventCount + " events");
1693
- } else {
1694
- logger.error("Error sending stats batch");
1695
- logger.debug("Response code: " + response.code());
1696
- }
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());
1697
2262
  }
1698
2263
  }
1699
2264
  }
1700
- );
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
+ }
1701
2284
  }
1702
2285
 
1703
2286
  public BundleInfo getBundleInfo(final String id) {
@@ -1707,7 +2290,13 @@ public class CapgoUpdater {
1707
2290
  }
1708
2291
  BundleInfo result;
1709
2292
  if (BundleInfo.ID_BUILTIN.equals(trueId)) {
1710
- 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
+ );
1711
2300
  } else if (BundleInfo.VERSION_UNKNOWN.equals(trueId)) {
1712
2301
  result = new BundleInfo(trueId, null, BundleStatus.ERROR, "", "");
1713
2302
  } else {
@@ -1811,7 +2400,33 @@ public class CapgoUpdater {
1811
2400
  return this.getBundleInfo(id);
1812
2401
  }
1813
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
+
1814
2428
  public boolean setNextBundle(final String next) {
2429
+ BundleInfo bundleToNotify = null;
1815
2430
  if (next == null) {
1816
2431
  this.editor.remove(NEXT_VERSION);
1817
2432
  } else {
@@ -1821,8 +2436,15 @@ public class CapgoUpdater {
1821
2436
  }
1822
2437
  this.editor.putString(NEXT_VERSION, next);
1823
2438
  this.setBundleStatus(next, BundleStatus.PENDING);
2439
+ bundleToNotify = newBundle;
1824
2440
  }
1825
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
+ }
1826
2448
  return true;
1827
2449
  }
1828
2450