@capgo/capacitor-updater 7.45.10 → 7.50.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/Package.swift +1 -1
  2. package/README.md +510 -92
  3. package/android/src/main/java/ee/forgr/capacitor_updater/AndroidAppExitReporter.java +92 -0
  4. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +3192 -777
  5. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +798 -299
  6. package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +45 -30
  7. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +46 -28
  8. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +2 -0
  9. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +3 -3
  10. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +359 -25
  11. package/android/src/main/java/ee/forgr/capacitor_updater/ThreeFingerPinchDetector.java +323 -0
  12. package/dist/docs.json +1283 -169
  13. package/dist/esm/definitions.d.ts +621 -44
  14. package/dist/esm/definitions.js.map +1 -1
  15. package/dist/esm/web.d.ts +11 -1
  16. package/dist/esm/web.js +59 -1
  17. package/dist/esm/web.js.map +1 -1
  18. package/dist/plugin.cjs.js +59 -1
  19. package/dist/plugin.cjs.js.map +1 -1
  20. package/dist/plugin.js +59 -1
  21. package/dist/plugin.js.map +1 -1
  22. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +0 -1
  23. package/ios/Sources/CapacitorUpdaterPlugin/AppHealthTracker.swift +82 -0
  24. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +0 -16
  25. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +2 -2
  26. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +78 -2
  27. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +2116 -223
  28. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +997 -332
  29. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +0 -1
  30. package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +78 -1
  31. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +418 -36
  32. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +276 -0
  33. package/package.json +15 -3
@@ -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,6 +983,7 @@ 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();
@@ -762,6 +993,73 @@ public class CapgoUpdater {
762
993
  return bundlePath != null && !bundlePath.trim().isEmpty() && !isBuiltin && !hasStoredBundleInfo;
763
994
  }
764
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
+
765
1063
  private boolean hasStoredBundleInfo(final String id) {
766
1064
  return (
767
1065
  id != null &&
@@ -941,6 +1239,17 @@ public class CapgoUpdater {
941
1239
  logger.debug("Bundle ID: " + id);
942
1240
  return false;
943
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
+ }
944
1253
  final BundleInfo next = this.getNextBundle();
945
1254
  if (next != null && !next.isDeleted() && !next.isErrorStatus() && next.getId().equals(id)) {
946
1255
  logger.error("Cannot delete the next bundle");
@@ -1013,12 +1322,10 @@ public class CapgoUpdater {
1013
1322
  }
1014
1323
 
1015
1324
  void restoreResetState(final ResetState state) {
1016
- final String currentBundlePath = state.currentBundlePath == null || state.currentBundlePath.trim().isEmpty()
1017
- ? "public"
1018
- : state.currentBundlePath;
1019
- final String fallbackBundleId = state.fallbackBundleId == null || state.fallbackBundleId.isEmpty()
1020
- ? BundleInfo.ID_BUILTIN
1021
- : state.fallbackBundleId;
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;
1022
1329
 
1023
1330
  this.editor.putString(this.CAP_SERVER_PATH, currentBundlePath);
1024
1331
  this.editor.putString(FALLBACK_VERSION, fallbackBundleId);
@@ -1081,6 +1388,21 @@ public class CapgoUpdater {
1081
1388
  return true;
1082
1389
  }
1083
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
+
1084
1406
  void finalizePendingReload(final BundleInfo bundle, final String previousBundleName) {
1085
1407
  if (bundle == null || bundle.isBuiltin()) {
1086
1408
  return;
@@ -1088,7 +1410,16 @@ public class CapgoUpdater {
1088
1410
  this.sendStats("set", bundle.getVersionName(), previousBundleName);
1089
1411
  }
1090
1412
 
1413
+ @Deprecated
1091
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) {
1092
1423
  final BundleInfo currentBundle = this.getCurrentBundle();
1093
1424
  if (!currentBundle.isBuiltin() && !this.bundleExists(currentBundle.getId())) {
1094
1425
  logger.info("Folder at bundle path does not exist. Triggering reset.");
@@ -1099,7 +1430,36 @@ public class CapgoUpdater {
1099
1430
  if (shouldResetForForeignBundle(bundlePath, currentBundle.isBuiltin(), this.hasStoredBundleInfo(currentBundle.getId()))) {
1100
1431
  logger.info("Current bundle id is not one of the bundle ids stored by this plugin. Triggering reset.");
1101
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();
1451
+ }
1452
+ }
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", "");
1102
1461
  }
1462
+ return previousNativeBuildVersion == null ? "" : previousNativeBuildVersion;
1103
1463
  }
1104
1464
 
1105
1465
  public void reset() {
@@ -1109,12 +1469,20 @@ public class CapgoUpdater {
1109
1469
  public void setSuccess(final BundleInfo bundle, Boolean autoDeletePrevious) {
1110
1470
  this.setBundleStatus(bundle.getId(), BundleStatus.SUCCESS);
1111
1471
  final BundleInfo fallback = this.getFallbackBundle();
1472
+ final BundleInfo previewFallback = this.getPreviewFallbackBundle();
1473
+ final boolean fallbackIsPreviewFallback = previewFallback != null && previewFallback.getId().equals(fallback.getId());
1112
1474
  logger.debug("Fallback bundle is: " + fallback);
1113
1475
  logger.info("Version successfully loaded: " + bundle.getVersionName());
1114
1476
  // Only attempt to delete when the fallback is a different bundle than the
1115
1477
  // currently loaded one. Otherwise we spam logs with "Cannot delete <id>"
1116
1478
  // because delete() protects the current bundle from removal.
1117
- 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
+ ) {
1118
1486
  final Boolean res = this.delete(fallback.getId());
1119
1487
  if (res) {
1120
1488
  logger.info("Deleted previous bundle: " + fallback.getVersionName());
@@ -1137,10 +1505,14 @@ public class CapgoUpdater {
1137
1505
  }
1138
1506
 
1139
1507
  private JSONObject createInfoObject() throws JSONException {
1508
+ return this.createInfoObject(null);
1509
+ }
1510
+
1511
+ private JSONObject createInfoObject(final String appIdOverride) throws JSONException {
1140
1512
  JSONObject json = new JSONObject();
1141
1513
  json.put("platform", "android");
1142
1514
  json.put("device_id", this.deviceID);
1143
- json.put("app_id", this.appId);
1515
+ json.put("app_id", appIdOverride == null || appIdOverride.trim().isEmpty() ? this.appId : appIdOverride);
1144
1516
  json.put("custom_id", this.customId);
1145
1517
  json.put("version_build", this.versionBuild);
1146
1518
  json.put("version_code", this.versionCode);
@@ -1149,6 +1521,7 @@ public class CapgoUpdater {
1149
1521
  json.put("plugin_version", this.pluginVersion);
1150
1522
  json.put("is_emulator", this.isEmulator());
1151
1523
  json.put("is_prod", this.isProd());
1524
+ json.put("install_source", this.getInstallSource());
1152
1525
  json.put("defaultChannel", this.defaultChannel);
1153
1526
 
1154
1527
  // Add encryption key ID if encryption is enabled (use cached value)
@@ -1166,7 +1539,7 @@ public class CapgoUpdater {
1166
1539
  if (response.code() == 429) {
1167
1540
  // Send a statistic about the rate limit BEFORE setting the flag
1168
1541
  // Only send once to prevent infinite loop if the stat request itself gets rate limited
1169
- if (!rateLimitExceeded && !rateLimitStatisticSent) {
1542
+ if (!this.previewSession && !rateLimitExceeded && !rateLimitStatisticSent) {
1170
1543
  rateLimitStatisticSent = true;
1171
1544
  sendRateLimitStatistic();
1172
1545
  }
@@ -1220,113 +1593,115 @@ public class CapgoUpdater {
1220
1593
 
1221
1594
  Request request = new Request.Builder().url(url).post(body).build();
1222
1595
 
1223
- DownloadService.sharedClient
1224
- .newCall(request)
1225
- .enqueue(
1226
- new okhttp3.Callback() {
1227
- @Override
1228
- public void onFailure(@NonNull Call call, @NonNull IOException e) {
1229
- Map<String, Object> retError = new HashMap<>();
1230
- retError.put("message", "Request failed: " + e.getMessage());
1231
- retError.put("error", "network_error");
1232
- retError.put("kind", "failed");
1233
- callback.callback(retError);
1234
- }
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
+ }
1235
1606
 
1236
- @Override
1237
- public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
1238
- try (ResponseBody responseBody = response.body()) {
1239
- final int statusCode = response.code();
1240
- final String responseData = responseBody != null ? responseBody.string() : "";
1241
- JSONObject jsonResponse = null;
1242
- if (!responseData.isEmpty()) {
1243
- try {
1244
- jsonResponse = new JSONObject(responseData);
1245
- } catch (JSONException ignored) {
1246
- // Non-JSON responses are handled as response or parse errors below.
1247
- }
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.
1248
1618
  }
1619
+ }
1249
1620
 
1250
- if (jsonResponse != null && (jsonResponse.has("error") || jsonResponse.has("kind"))) {
1251
- if (statusCode == 429) {
1252
- checkAndHandleRateLimitResponse(response);
1253
- }
1254
- Map<String, Object> retError = new HashMap<>();
1255
- if (jsonResponse.has("error") && !jsonResponse.isNull("error")) {
1256
- retError.put("error", jsonResponse.getString("error"));
1257
- }
1258
- if (jsonResponse.has("kind") && !jsonResponse.isNull("kind")) {
1259
- retError.put("kind", jsonResponse.getString("kind"));
1260
- }
1261
- if (jsonResponse.has("message") && !jsonResponse.isNull("message")) {
1262
- retError.put("message", jsonResponse.getString("message"));
1263
- } else {
1264
- retError.put("message", "server did not provide a message");
1265
- }
1266
- if (jsonResponse.has("version") && !jsonResponse.isNull("version")) {
1267
- retError.put("version", jsonResponse.getString("version"));
1268
- }
1269
- retError.put("statusCode", statusCode);
1270
- callback.callback(retError);
1271
- return;
1621
+ if (jsonResponse != null && (jsonResponse.has("error") || jsonResponse.has("kind"))) {
1622
+ if (statusCode == 429) {
1623
+ checkAndHandleRateLimitResponse(response);
1272
1624
  }
1273
-
1274
- // Check for 429 rate limit
1275
- if (checkAndHandleRateLimitResponse(response)) {
1276
- Map<String, Object> retError = new HashMap<>();
1277
- retError.put("message", "Rate limit exceeded");
1278
- retError.put("error", "rate_limit_exceeded");
1279
- retError.put("kind", "failed");
1280
- retError.put("statusCode", statusCode);
1281
- callback.callback(retError);
1282
- return;
1625
+ Map<String, Object> retError = new HashMap<>();
1626
+ if (jsonResponse.has("error") && !jsonResponse.isNull("error")) {
1627
+ retError.put("error", jsonResponse.getString("error"));
1283
1628
  }
1284
-
1285
- if (!response.isSuccessful()) {
1286
- Map<String, Object> retError = new HashMap<>();
1287
- retError.put("message", "Server error: " + response.code());
1288
- retError.put("error", "response_error");
1289
- retError.put("kind", "failed");
1290
- retError.put("statusCode", statusCode);
1291
- callback.callback(retError);
1292
- return;
1629
+ if (jsonResponse.has("kind") && !jsonResponse.isNull("kind")) {
1630
+ retError.put("kind", jsonResponse.getString("kind"));
1293
1631
  }
1294
-
1295
- if (jsonResponse == null) {
1296
- throw new JSONException("Response is not a JSON object");
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"));
1297
1639
  }
1640
+ retError.put("statusCode", statusCode);
1641
+ callback.callback(retError);
1642
+ return;
1643
+ }
1298
1644
 
1299
- Map<String, Object> ret = new HashMap<>();
1300
- 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
+ }
1301
1655
 
1302
- Iterator<String> keys = jsonResponse.keys();
1303
- while (keys.hasNext()) {
1304
- String key = keys.next();
1305
- if (jsonResponse.has(key)) {
1306
- if ("session_key".equals(key)) {
1307
- ret.put("sessionKey", jsonResponse.get(key));
1308
- } else {
1309
- ret.put(key, jsonResponse.get(key));
1310
- }
1311
- }
1312
- }
1313
- callback.callback(ret);
1314
- } catch (JSONException e) {
1656
+ if (!response.isSuccessful()) {
1315
1657
  Map<String, Object> retError = new HashMap<>();
1316
- retError.put("message", "JSON parse error: " + e.getMessage());
1317
- retError.put("error", "parse_error");
1658
+ retError.put("message", "Server error: " + response.code());
1659
+ retError.put("error", "response_error");
1318
1660
  retError.put("kind", "failed");
1661
+ retError.put("statusCode", statusCode);
1319
1662
  callback.callback(retError);
1663
+ return;
1664
+ }
1665
+
1666
+ if (jsonResponse == null) {
1667
+ throw new JSONException("Response is not a JSON object");
1320
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);
1321
1691
  }
1322
1692
  }
1323
- );
1693
+ }
1694
+ );
1324
1695
  }
1325
1696
 
1326
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) {
1327
1702
  JSONObject json;
1328
1703
  try {
1329
- json = this.createInfoObject();
1704
+ json = this.createInfoObject(appIdOverride);
1330
1705
  if (channel != null && json != null) {
1331
1706
  json.put("defaultChannel", channel);
1332
1707
  }
@@ -1340,7 +1715,9 @@ public class CapgoUpdater {
1340
1715
  return;
1341
1716
  }
1342
1717
 
1343
- logger.info("Auto-update parameters: " + json);
1718
+ if (logger != null) {
1719
+ logger.info("Auto-update parameters: " + json);
1720
+ }
1344
1721
 
1345
1722
  makeJsonRequest(updateUrl, json, callback);
1346
1723
  }
@@ -1369,6 +1746,17 @@ public class CapgoUpdater {
1369
1746
  final String defaultChannelKey,
1370
1747
  final boolean allowSetDefaultChannel,
1371
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
1372
1760
  ) {
1373
1761
  // Check if setting defaultChannel is allowed
1374
1762
  if (!allowSetDefaultChannel) {
@@ -1421,6 +1809,7 @@ public class CapgoUpdater {
1421
1809
  // Clear persisted defaultChannel and revert to config value
1422
1810
  editor.remove(defaultChannelKey);
1423
1811
  editor.apply();
1812
+ this.defaultChannel = configDefaultChannel;
1424
1813
  logger.info("Public channel requested, channel override removed");
1425
1814
  callback.callback(res);
1426
1815
  } else {
@@ -1435,6 +1824,10 @@ public class CapgoUpdater {
1435
1824
  }
1436
1825
 
1437
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) {
1438
1831
  // Check if rate limit was exceeded
1439
1832
  if (rateLimitExceeded) {
1440
1833
  logger.debug("Skipping getChannel due to rate limit (429). Requests will resume after app restart.");
@@ -1472,88 +1865,117 @@ public class CapgoUpdater {
1472
1865
  .put(RequestBody.create(json.toString(), MediaType.get("application/json")))
1473
1866
  .build();
1474
1867
 
1475
- DownloadService.sharedClient
1476
- .newCall(request)
1477
- .enqueue(
1478
- new okhttp3.Callback() {
1479
- @Override
1480
- public void onFailure(@NonNull Call call, @NonNull IOException e) {
1481
- Map<String, Object> retError = new HashMap<>();
1482
- retError.put("message", "Request failed: " + e.getMessage());
1483
- retError.put("error", "network_error");
1484
- callback.callback(retError);
1485
- }
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
+ }
1877
+
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
+ }
1486
1889
 
1487
- @Override
1488
- public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
1489
- try (ResponseBody responseBody = response.body()) {
1490
- // Check for 429 rate limit
1491
- if (checkAndHandleRateLimitResponse(response)) {
1890
+ if (response.code() == 400) {
1891
+ if (responseBody == null) {
1492
1892
  Map<String, Object> retError = new HashMap<>();
1493
- retError.put("message", "Rate limit exceeded");
1494
- retError.put("error", "rate_limit_exceeded");
1893
+ retError.put("message", "Empty response body");
1894
+ retError.put("error", "no_response_body");
1495
1895
  callback.callback(retError);
1496
1896
  return;
1497
1897
  }
1498
-
1499
- if (response.code() == 400) {
1500
- assert responseBody != null;
1501
- String data = responseBody.string();
1502
- if (data.contains("channel_not_found") && !defaultChannel.isEmpty()) {
1503
- Map<String, Object> ret = new HashMap<>();
1504
- ret.put("channel", defaultChannel);
1505
- ret.put("status", "default");
1506
- logger.info("Channel get to \"" + ret);
1507
- callback.callback(ret);
1508
- return;
1509
- }
1510
- }
1511
-
1512
- if (!response.isSuccessful()) {
1513
- Map<String, Object> retError = new HashMap<>();
1514
- retError.put("message", "Server error: " + response.code());
1515
- retError.put("error", "response_error");
1516
- 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);
1517
1905
  return;
1518
1906
  }
1907
+ }
1519
1908
 
1520
- assert responseBody != null;
1521
- String responseData = responseBody.string();
1522
- 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
+ }
1523
1916
 
1524
- // Check for server-side errors first
1525
- if (jsonResponse.has("error")) {
1526
- Map<String, Object> retError = new HashMap<>();
1527
- retError.put("error", jsonResponse.getString("error"));
1528
- if (jsonResponse.has("message")) {
1529
- retError.put("message", jsonResponse.getString("message"));
1530
- } else {
1531
- retError.put("message", "server did not provide a message");
1532
- }
1533
- callback.callback(retError);
1534
- 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");
1535
1935
  }
1936
+ callback.callback(retError);
1937
+ return;
1938
+ }
1536
1939
 
1537
- Map<String, Object> ret = new HashMap<>();
1940
+ Map<String, Object> ret = new HashMap<>();
1538
1941
 
1539
- Iterator<String> keys = jsonResponse.keys();
1540
- while (keys.hasNext()) {
1541
- String key = keys.next();
1542
- if (jsonResponse.has(key)) {
1543
- ret.put(key, jsonResponse.get(key));
1544
- }
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));
1545
1947
  }
1546
- logger.info("Channel get to \"" + ret);
1547
- callback.callback(ret);
1548
- } catch (JSONException e) {
1549
- Map<String, Object> retError = new HashMap<>();
1550
- retError.put("message", "JSON parse error: " + e.getMessage());
1551
- retError.put("error", "parse_error");
1552
- callback.callback(retError);
1553
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);
1554
1957
  }
1555
1958
  }
1556
- );
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);
1557
1979
  }
1558
1980
 
1559
1981
  public void listChannels(final Callback callback) {
@@ -1608,94 +2030,106 @@ public class CapgoUpdater {
1608
2030
 
1609
2031
  Request request = new Request.Builder().url(urlBuilder.build()).get().build();
1610
2032
 
1611
- DownloadService.sharedClient
1612
- .newCall(request)
1613
- .enqueue(
1614
- new okhttp3.Callback() {
1615
- @Override
1616
- public void onFailure(@NonNull Call call, @NonNull IOException e) {
1617
- Map<String, Object> retError = new HashMap<>();
1618
- retError.put("message", "Request failed: " + e.getMessage());
1619
- retError.put("error", "network_error");
1620
- callback.callback(retError);
1621
- }
1622
-
1623
- @Override
1624
- public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
1625
- try (ResponseBody responseBody = response.body()) {
1626
- // Check for 429 rate limit
1627
- if (checkAndHandleRateLimitResponse(response)) {
1628
- Map<String, Object> retError = new HashMap<>();
1629
- retError.put("message", "Rate limit exceeded");
1630
- retError.put("error", "rate_limit_exceeded");
1631
- callback.callback(retError);
1632
- return;
1633
- }
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
+ }
1634
2042
 
1635
- if (!response.isSuccessful()) {
1636
- Map<String, Object> retError = new HashMap<>();
1637
- retError.put("message", "Server error: " + response.code());
1638
- retError.put("error", "response_error");
1639
- callback.callback(retError);
1640
- return;
1641
- }
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
+ }
1642
2054
 
1643
- assert responseBody != null;
1644
- 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
+ }
1645
2062
 
1646
- try {
1647
- 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();
1648
2071
 
1649
- try {
1650
- // Try to parse as direct array first
1651
- JSONArray channelsJson = new JSONArray(data);
1652
- List<Map<String, Object>> channelsList = new ArrayList<>();
1653
-
1654
- for (int i = 0; i < channelsJson.length(); i++) {
1655
- JSONObject channelJson = channelsJson.getJSONObject(i);
1656
- Map<String, Object> channel = new HashMap<>();
1657
- channel.put("id", channelJson.optString("id", ""));
1658
- channel.put("name", channelJson.optString("name", ""));
1659
- channel.put("public", channelJson.optBoolean("public", false));
1660
- channel.put("allow_self_set", channelJson.optBoolean("allow_self_set", false));
1661
- channelsList.add(channel);
1662
- }
2072
+ try {
2073
+ Map<String, Object> ret = parseListChannelsResponse(data);
1663
2074
 
1664
- // Wrap in channels object for JS API
1665
- ret.put("channels", channelsList);
1666
-
1667
- logger.info("Channels listed successfully");
1668
- callback.callback(ret);
1669
- } catch (JSONException arrayException) {
1670
- // If not an array, try to parse as error object
1671
- try {
1672
- JSONObject json = new JSONObject(data);
1673
- if (json.has("error")) {
1674
- Map<String, Object> retError = new HashMap<>();
1675
- retError.put("error", json.getString("error"));
1676
- if (json.has("message")) {
1677
- retError.put("message", json.getString("message"));
1678
- } else {
1679
- retError.put("message", "server did not provide a message");
1680
- }
1681
- callback.callback(retError);
1682
- return;
1683
- }
1684
- } catch (JSONException objException) {
1685
- // If neither array nor object, throw parse error
1686
- 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");
1687
2088
  }
2089
+ callback.callback(retError);
2090
+ return;
1688
2091
  }
1689
- } catch (JSONException e) {
1690
2092
  Map<String, Object> retError = new HashMap<>();
1691
- 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());
1692
2102
  retError.put("error", "parse_error");
1693
2103
  callback.callback(retError);
1694
2104
  }
1695
2105
  }
1696
2106
  }
1697
2107
  }
1698
- );
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;
1699
2133
  }
1700
2134
 
1701
2135
  public void sendStats(final String action) {
@@ -1707,6 +2141,27 @@ public class CapgoUpdater {
1707
2141
  }
1708
2142
 
1709
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
+
1710
2165
  // Check if rate limit was exceeded
1711
2166
  if (rateLimitExceeded) {
1712
2167
  logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.");
@@ -1725,13 +2180,16 @@ public class CapgoUpdater {
1725
2180
  json.put("old_version_name", oldVersionName);
1726
2181
  json.put("action", action);
1727
2182
  json.put("timestamp", System.currentTimeMillis());
2183
+ if (metadata != null && !metadata.isEmpty()) {
2184
+ json.put("metadata", new JSONObject(metadata));
2185
+ }
1728
2186
  } catch (JSONException e) {
1729
2187
  logger.error("Error preparing stats");
1730
2188
  logger.debug("JSONException: " + e.getMessage());
1731
2189
  return;
1732
2190
  }
1733
2191
 
1734
- statsQueue.add(json);
2192
+ statsQueue.add(new QueuedStatsEvent(json, onSent));
1735
2193
  ensureStatsTimerStarted();
1736
2194
  }
1737
2195
 
@@ -1758,7 +2216,7 @@ public class CapgoUpdater {
1758
2216
  }
1759
2217
 
1760
2218
  // Copy and clear the queue atomically using synchronized block
1761
- List<JSONObject> eventsToSend;
2219
+ List<QueuedStatsEvent> eventsToSend;
1762
2220
  synchronized (statsQueue) {
1763
2221
  if (statsQueue.isEmpty()) {
1764
2222
  return;
@@ -1768,8 +2226,8 @@ public class CapgoUpdater {
1768
2226
  }
1769
2227
 
1770
2228
  JSONArray jsonArray = new JSONArray();
1771
- for (JSONObject event : eventsToSend) {
1772
- jsonArray.put(event);
2229
+ for (QueuedStatsEvent queuedEvent : eventsToSend) {
2230
+ jsonArray.put(queuedEvent.event);
1773
2231
  }
1774
2232
 
1775
2233
  Request request = new Request.Builder()
@@ -1778,35 +2236,51 @@ public class CapgoUpdater {
1778
2236
  .build();
1779
2237
 
1780
2238
  final int eventCount = eventsToSend.size();
1781
- DownloadService.sharedClient
1782
- .newCall(request)
1783
- .enqueue(
1784
- new okhttp3.Callback() {
1785
- @Override
1786
- public void onFailure(@NonNull Call call, @NonNull IOException e) {
1787
- logger.error("Failed to send stats batch");
1788
- logger.debug("Error: " + e.getMessage());
1789
- }
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
+ }
1790
2246
 
1791
- @Override
1792
- public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
1793
- try (ResponseBody responseBody = response.body()) {
1794
- // Check for 429 rate limit
1795
- if (checkAndHandleRateLimitResponse(response)) {
1796
- return;
1797
- }
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
+ }
1798
2254
 
1799
- if (response.isSuccessful()) {
1800
- logger.info("Stats batch sent successfully");
1801
- logger.debug("Sent " + eventCount + " events");
1802
- } else {
1803
- logger.error("Error sending stats batch");
1804
- logger.debug("Response code: " + response.code());
1805
- }
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());
1806
2262
  }
1807
2263
  }
1808
2264
  }
1809
- );
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
+ }
1810
2284
  }
1811
2285
 
1812
2286
  public BundleInfo getBundleInfo(final String id) {
@@ -1926,6 +2400,31 @@ public class CapgoUpdater {
1926
2400
  return this.getBundleInfo(id);
1927
2401
  }
1928
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
+
1929
2428
  public boolean setNextBundle(final String next) {
1930
2429
  BundleInfo bundleToNotify = null;
1931
2430
  if (next == null) {