@capgo/capacitor-updater 5.50.1 → 5.51.15

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 (28) hide show
  1. package/CapgoCapacitorUpdater.podspec +1 -1
  2. package/Package.swift +3 -2
  3. package/README.md +53 -48
  4. package/android/build.gradle +1 -0
  5. package/android/src/main/java/ee/forgr/capacitor_updater/AppLifecycleObserver.java +29 -2
  6. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +7 -3
  7. package/android/src/main/java/ee/forgr/capacitor_updater/BundleStatus.java +1 -0
  8. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +476 -151
  9. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1354 -412
  10. package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +102 -31
  11. package/android/src/main/java/ee/forgr/capacitor_updater/DataManager.java +23 -7
  12. package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +2 -2
  13. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +540 -224
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +103 -3
  15. package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +1 -1
  16. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +131 -145
  17. package/dist/docs.json +32 -8
  18. package/dist/esm/definitions.d.ts +41 -17
  19. package/dist/esm/definitions.js.map +1 -1
  20. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +124 -0
  21. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +9 -1
  22. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +3 -0
  23. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +787 -92
  24. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1014 -268
  25. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +49 -31
  26. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +44 -20
  27. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +28 -0
  28. package/package.json +12 -7
@@ -13,7 +13,14 @@ import android.content.pm.PackageManager;
13
13
  import android.os.Build;
14
14
  import androidx.annotation.NonNull;
15
15
  import androidx.lifecycle.LifecycleOwner;
16
+ import androidx.work.Constraints;
16
17
  import androidx.work.Data;
18
+ import androidx.work.ExistingPeriodicWorkPolicy;
19
+ import androidx.work.ExistingWorkPolicy;
20
+ import androidx.work.ListenableWorker;
21
+ import androidx.work.NetworkType;
22
+ import androidx.work.OneTimeWorkRequest;
23
+ import androidx.work.PeriodicWorkRequest;
17
24
  import androidx.work.WorkInfo;
18
25
  import androidx.work.WorkManager;
19
26
  import com.google.common.util.concurrent.Futures;
@@ -32,7 +39,9 @@ import java.security.SecureRandom;
32
39
  import java.util.ArrayList;
33
40
  import java.util.Date;
34
41
  import java.util.HashMap;
42
+ import java.util.HashSet;
35
43
  import java.util.Iterator;
44
+ import java.util.LinkedHashSet;
36
45
  import java.util.List;
37
46
  import java.util.Map;
38
47
  import java.util.Objects;
@@ -45,6 +54,7 @@ import java.util.concurrent.Executors;
45
54
  import java.util.concurrent.ScheduledExecutorService;
46
55
  import java.util.concurrent.ScheduledFuture;
47
56
  import java.util.concurrent.TimeUnit;
57
+ import java.util.concurrent.atomic.AtomicBoolean;
48
58
  import java.util.zip.ZipEntry;
49
59
  import java.util.zip.ZipInputStream;
50
60
  import okhttp3.*;
@@ -65,16 +75,24 @@ public class CapgoUpdater {
65
75
  private static final String FALLBACK_VERSION = "pastVersion";
66
76
  private static final String NEXT_VERSION = "nextVersion";
67
77
  private static final String PREVIEW_FALLBACK_VERSION = "previewFallbackVersion";
78
+ private static final String PENDING_DELETE_IDS = "pendingDeleteIds";
68
79
  private static final String bundleDirectory = "versions";
69
80
  private static final String TEMP_UNZIP_PREFIX = "capgo_unzip_";
81
+ private static final long DELETE_PACE_MS = 75L;
82
+ private final Object deleteLock = new Object();
70
83
  private static final String CAPACITOR_CONFIG_ASSET = "capacitor.config.json";
71
84
  private static final String BACKGROUND_RUNNER_CONFIG_KEY = "BackgroundRunner";
85
+ private static final String BACKGROUND_RUNNER_WORKER_CLASS = "io.ionic.backgroundrunner.plugin.RunnerWorker";
72
86
 
73
87
  public static final String TAG = "Capacitor-updater";
74
88
  public SharedPreferences.Editor editor;
89
+
90
+ /** Optional gate run before any download touches disk (e.g. wait for launch cleanup). */
91
+ public Runnable downloadGate = null;
75
92
  public SharedPreferences prefs;
76
93
 
77
94
  public File documentsDir;
95
+ public File noBackupDir;
78
96
  public Boolean directUpdate = false;
79
97
  public Activity activity;
80
98
  public String pluginVersion = "";
@@ -96,17 +114,31 @@ public class CapgoUpdater {
96
114
  // Cached key ID calculated once from publicKey
97
115
  private String cachedKeyId = "";
98
116
 
99
- // Flag to track if we received a 429 response - stops requests until app restart
100
- private static volatile boolean rateLimitExceeded = false;
117
+ // Temporary 429 block until this epoch ms (Retry-After / rateLimitResetAt). No sticky latch.
118
+ // Guarded by rateLimitStateLock so concurrent 429s cannot shorten the window or mix metadata.
119
+ private static final Object rateLimitStateLock = new Object();
120
+ private static long rateLimitBlockedUntilMs = 0L;
121
+ private static String rateLimitBlockedError = "too_many_requests";
122
+ private static String rateLimitBlockedMessage = "Too many requests";
123
+
124
+ // Flag to track if we've already sent the rate limit statistic - prevents infinite loop.
125
+ // Released again when the send fails, so a later 429 can retry it.
126
+ private static boolean rateLimitStatisticSent = false;
101
127
 
102
- // Flag to track if we've already sent the rate limit statistic - prevents infinite loop
103
- private static volatile boolean rateLimitStatisticSent = false;
128
+ // Upper bound for a client-side 429 block, so a bogus Retry-After cannot block the app for days.
129
+ private static final long MAX_RATE_LIMIT_WINDOW_MS = 24 * 60 * 60 * 1000L;
104
130
 
105
131
  // Stats batching - queue events and send max once per second
106
132
  private final List<QueuedStatsEvent> statsQueue = new CopyOnWriteArrayList<>();
133
+ private final List<QueuedStatsEvent> statsInFlight = new ArrayList<>();
134
+ private final Object pendingStatsPersistLock = new Object();
107
135
  private final ScheduledExecutorService statsScheduler = Executors.newSingleThreadScheduledExecutor();
108
136
  private ScheduledFuture<?> statsFlushTask = null;
137
+ private final AtomicBoolean statsFlushInFlight = new AtomicBoolean(false);
138
+ private final AtomicBoolean statsStopped = new AtomicBoolean(false);
109
139
  private static final long STATS_FLUSH_INTERVAL_MS = 1000;
140
+ private static final String PENDING_STATS_FILE = "capgo_pending_stats.json";
141
+ private static final int MAX_PENDING_STATS = 200;
110
142
 
111
143
  private static final class QueuedStatsEvent {
112
144
 
@@ -128,7 +160,7 @@ public class CapgoUpdater {
128
160
 
129
161
  private final FilenameFilter filter = (f, name) -> {
130
162
  // ignore directories generated by mac os x
131
- return (!name.startsWith("__MACOSX") && !name.startsWith(".") && !name.startsWith(".DS_Store"));
163
+ return !name.startsWith("__MACOSX") && !name.startsWith(".") && !name.startsWith(".DS_Store");
132
164
  };
133
165
 
134
166
  private boolean isProd() {
@@ -283,14 +315,17 @@ public class CapgoUpdater {
283
315
  return this.cachedKeyId;
284
316
  }
285
317
 
286
- private File unzip(final String id, final File zipFile, final String dest) throws IOException {
318
+ File unzip(final String id, final File zipFile, final String dest) throws IOException {
319
+ return unzip(id, zipFile, dest, CryptoCipher.ioBufferBytes());
320
+ }
321
+
322
+ File unzip(final String id, final File zipFile, final String dest, final int bufferSize) throws IOException {
287
323
  final File targetDirectory = new File(this.documentsDir, dest);
288
324
  try (
289
325
  final BufferedInputStream bis = new BufferedInputStream(new FileInputStream(zipFile));
290
326
  final ZipInputStream zis = new ZipInputStream(bis)
291
327
  ) {
292
328
  int count;
293
- final int bufferSize = 8192;
294
329
  final byte[] buffer = new byte[bufferSize];
295
330
  final long lengthTotal = zipFile.length();
296
331
  long lengthRead = bufferSize;
@@ -370,7 +405,7 @@ public class CapgoUpdater {
370
405
  io.execute(() -> cacheBundleFiles(id));
371
406
  }
372
407
 
373
- private void cacheBundleFiles(final String id) {
408
+ void cacheBundleFiles(final String id) {
374
409
  if (this.activity == null) {
375
410
  logger.debug("Skip delta cache population: activity is null");
376
411
  return;
@@ -392,20 +427,32 @@ public class CapgoUpdater {
392
427
  return;
393
428
  }
394
429
 
430
+ final File builtinFolder = new File(this.activity.getFilesDir(), "public");
431
+
395
432
  final List<File> files = new ArrayList<>();
396
433
  collectFiles(bundleDir, files);
434
+ final int bundlePrefixLength = bundleDir.getAbsolutePath().length() + 1;
397
435
  for (File file : files) {
398
436
  final String checksum = CryptoCipher.calcChecksum(file);
399
437
  if (checksum.isEmpty()) {
400
438
  continue;
401
439
  }
440
+
441
+ // Builtin is already a permanent reuse source (see isManifestEntryAvailableLocally),
442
+ // so there's no need to also duplicate a byte-identical file into the delta cache.
443
+ final String relativePath = file.getAbsolutePath().substring(bundlePrefixLength);
444
+ final File builtinFile = new File(builtinFolder, relativePath);
445
+ if (verifyChecksum(builtinFile, checksum)) {
446
+ continue;
447
+ }
448
+
402
449
  final String cacheName = checksum + "_" + file.getName();
403
450
  final File cacheFile = new File(cacheDir, cacheName);
404
451
  if (cacheFile.exists()) {
405
452
  continue;
406
453
  }
407
454
  try {
408
- copyFile(file, cacheFile);
455
+ copyFileAtomically(file, cacheFile);
409
456
  } catch (IOException e) {
410
457
  logger.debug("Delta cache copy failed: " + file.getPath());
411
458
  }
@@ -431,7 +478,7 @@ public class CapgoUpdater {
431
478
 
432
479
  private void copyFile(final File source, final File dest) throws IOException {
433
480
  try (final FileInputStream input = new FileInputStream(source); final FileOutputStream output = new FileOutputStream(dest)) {
434
- final byte[] buffer = new byte[1024 * 1024];
481
+ final byte[] buffer = new byte[CryptoCipher.copyBufferBytes()];
435
482
  int length;
436
483
  while ((length = input.read(buffer)) != -1) {
437
484
  output.write(buffer, 0, length);
@@ -471,28 +518,74 @@ public class CapgoUpdater {
471
518
  return false;
472
519
  }
473
520
 
474
- final File builtinFile = new File(this.activity.getFilesDir(), "public/" + fileName);
475
- if (verifyChecksum(builtinFile, fileHash)) {
521
+ if (DownloadService.builtinAssetMatches(this.activity.getAssets(), fileName, fileHash)) {
476
522
  return true;
477
523
  }
478
524
 
525
+ try {
526
+ final File builtinFile = DownloadService.resolveManifestBuiltinFile(new File(this.activity.getFilesDir(), "public"), fileName);
527
+ if (verifyChecksum(builtinFile, fileHash)) {
528
+ return true;
529
+ }
530
+ } catch (IOException ignored) {
531
+ // Invalid path; fall through to cache lookup.
532
+ }
533
+
479
534
  final boolean isBrotli = fileName.endsWith(".br");
480
535
  final String fileNameWithoutPath = new File(fileName).getName();
481
536
  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
- }
537
+ if (isSafeCacheHash(fileHash)) {
538
+ final File cacheFolder = new File(this.activity.getCacheDir(), "capgo_downloads");
539
+ final File cacheFile = new File(cacheFolder, fileHash + "_" + cacheBaseName);
540
+ // Cache files are named `{hash}_{filename}` and were checksum-verified
541
+ // when written. Re-hashing every hit re-reads the whole bundle and
542
+ // OOMs/janks low-RAM devices during getMissing / delta apply.
543
+ if (isReusableCacheFile(cacheFile, fileHash)) {
544
+ return true;
545
+ }
487
546
 
488
- if (isBrotli) {
489
- final File legacyCacheFile = new File(cacheFolder, fileHash + "_" + fileNameWithoutPath);
490
- return verifyChecksum(legacyCacheFile, fileHash);
547
+ if (isBrotli) {
548
+ final File legacyCacheFile = new File(cacheFolder, fileHash + "_" + fileNameWithoutPath);
549
+ return isReusableCacheFile(legacyCacheFile, fileHash);
550
+ }
491
551
  }
492
552
 
493
553
  return false;
494
554
  }
495
555
 
556
+ static final String EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
557
+
558
+ static boolean isSafeCacheHash(final String hash) {
559
+ if (hash == null) {
560
+ return false;
561
+ }
562
+ final int len = hash.length();
563
+ if (len != 64 && len != 8) {
564
+ return false;
565
+ }
566
+ for (int i = 0; i < len; i++) {
567
+ final char c = hash.charAt(i);
568
+ if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) {
569
+ return false;
570
+ }
571
+ }
572
+ return true;
573
+ }
574
+
575
+ // SHA-256 hash-named cache files were verified when written. Existence is
576
+ // enough for non-empty files; empty files are reused only for the empty SHA-256.
577
+ // CRC32 (8 hex) is too collision-prone to trust without a re-read.
578
+ static boolean isReusableCacheFile(final File file, final String expectedHash) {
579
+ if (file == null || !file.isFile() || !isSafeCacheHash(expectedHash) || expectedHash.length() != 64) {
580
+ return false;
581
+ }
582
+ final long length = file.length();
583
+ if (length > 0) {
584
+ return true;
585
+ }
586
+ return length == 0 && EMPTY_SHA256.equalsIgnoreCase(expectedHash);
587
+ }
588
+
496
589
  public JSONArray getMissingBundleFiles(final JSONArray manifest, final String sessionKey) throws JSONException {
497
590
  final JSONArray missing = new JSONArray();
498
591
  for (int i = 0; i < manifest.length(); i++) {
@@ -656,6 +749,9 @@ public class CapgoUpdater {
656
749
  if ("low_mem_fail".equals(error)) {
657
750
  sendStats("low_mem_fail", failedVersion);
658
751
  }
752
+ if ("insufficient_disk_space".equals(error)) {
753
+ sendStats("insufficient_disk_space", failedVersion);
754
+ }
659
755
  ret.put("error", error != null ? error : "download_fail");
660
756
  sendStats("download_fail", failedVersion);
661
757
  notifyListeners("downloadFailed", ret);
@@ -667,6 +763,13 @@ public class CapgoUpdater {
667
763
  }
668
764
  });
669
765
  break;
766
+ case CANCELLED:
767
+ DataManager.getInstance().clearManifest(id);
768
+ CompletableFuture<BundleInfo> cancelledFuture = downloadFutures.remove(id);
769
+ if (cancelledFuture != null) {
770
+ cancelledFuture.cancel(true);
771
+ }
772
+ break;
670
773
  }
671
774
  });
672
775
  });
@@ -688,6 +791,10 @@ public class CapgoUpdater {
688
791
  }
689
792
  observeWorkProgress(this.activity, id, setNext);
690
793
 
794
+ if (manifest != null) {
795
+ DataManager.getInstance().setManifest(id, manifest);
796
+ }
797
+
691
798
  DownloadWorkerManager.enqueueDownload(
692
799
  this.activity,
693
800
  url,
@@ -712,10 +819,6 @@ public class CapgoUpdater {
712
819
  this.customId,
713
820
  this.defaultChannel
714
821
  );
715
-
716
- if (manifest != null) {
717
- DataManager.getInstance().setManifest(manifest);
718
- }
719
822
  }
720
823
 
721
824
  public Boolean finishDownload(
@@ -916,6 +1019,11 @@ public class CapgoUpdater {
916
1019
 
917
1020
  try {
918
1021
  this.deleteDirectory(entry, threadToCheck);
1022
+ if (entry.exists()) {
1023
+ logger.error("Orphan bundle directory still present after delete");
1024
+ logger.debug("Bundle ID: " + id);
1025
+ continue;
1026
+ }
919
1027
  this.removeBundleInfo(id);
920
1028
  logger.info("Deleted orphan bundle directory");
921
1029
  logger.debug("Bundle ID: " + id);
@@ -927,6 +1035,43 @@ public class CapgoUpdater {
927
1035
  }
928
1036
  }
929
1037
 
1038
+ public Set<String> allowedBundleIdsForCleanup() {
1039
+ final Set<String> allowedIds = new HashSet<>();
1040
+ for (final BundleInfo info : this.list(true)) {
1041
+ if (info == null || info.getId() == null || info.getId().isEmpty()) {
1042
+ continue;
1043
+ }
1044
+ // DELETED tombstones must not protect leftover folders.
1045
+ // DELETING stays protected so drainPendingDeletes owns the removal.
1046
+ if (info.isDeleted()) {
1047
+ continue;
1048
+ }
1049
+ allowedIds.add(info.getId());
1050
+ }
1051
+ final String currentId = this.getCurrentBundleId();
1052
+ if (currentId != null && !currentId.isEmpty()) {
1053
+ allowedIds.add(currentId);
1054
+ }
1055
+ final BundleInfo fallback = this.getFallbackBundle();
1056
+ if (fallback != null && fallback.getId() != null && !fallback.getId().isEmpty() && !fallback.isDeleting()) {
1057
+ allowedIds.add(fallback.getId());
1058
+ }
1059
+ final BundleInfo next = this.getNextBundle();
1060
+ if (next != null && next.getId() != null && !next.getId().isEmpty() && !next.isDeleting()) {
1061
+ allowedIds.add(next.getId());
1062
+ }
1063
+ final BundleInfo previewFallback = this.getPreviewFallbackBundle();
1064
+ if (
1065
+ previewFallback != null &&
1066
+ previewFallback.getId() != null &&
1067
+ !previewFallback.getId().isEmpty() &&
1068
+ !previewFallback.isDeleting()
1069
+ ) {
1070
+ allowedIds.add(previewFallback.getId());
1071
+ }
1072
+ return allowedIds;
1073
+ }
1074
+
930
1075
  public void cleanupOrphanedTempFolders(final Thread threadToCheck) {
931
1076
  if (this.documentsDir == null) {
932
1077
  logger.warn("Documents directory is null, skipping temp folder cleanup");
@@ -983,7 +1128,7 @@ public class CapgoUpdater {
983
1128
  }
984
1129
 
985
1130
  private void setCurrentBundle(final File bundle) {
986
- this.cancelBackgroundRunnerWorkBeforeBundleSwitch();
1131
+ this.resetBackgroundRunnerWorkForBundleSwitch(bundle);
987
1132
  this.editor.putString(this.CAP_SERVER_PATH, bundle.getPath());
988
1133
  logger.info("Current bundle set to: " + bundle);
989
1134
  this.editor.commit();
@@ -993,7 +1138,33 @@ public class CapgoUpdater {
993
1138
  return bundlePath != null && !bundlePath.trim().isEmpty() && !isBuiltin && !hasStoredBundleInfo;
994
1139
  }
995
1140
 
996
- static String getBackgroundRunnerLabelFromConfig(final String configJson) {
1141
+ static final class BackgroundRunnerWorkConfig {
1142
+
1143
+ final String label;
1144
+ final String src;
1145
+ final String event;
1146
+ final boolean autoStart;
1147
+ final boolean repeat;
1148
+ final int interval;
1149
+
1150
+ BackgroundRunnerWorkConfig(
1151
+ final String label,
1152
+ final String src,
1153
+ final String event,
1154
+ final boolean autoStart,
1155
+ final boolean repeat,
1156
+ final int interval
1157
+ ) {
1158
+ this.label = label;
1159
+ this.src = src;
1160
+ this.event = event;
1161
+ this.autoStart = autoStart;
1162
+ this.repeat = repeat;
1163
+ this.interval = interval;
1164
+ }
1165
+ }
1166
+
1167
+ static BackgroundRunnerWorkConfig getBackgroundRunnerWorkConfigFromConfig(final String configJson) {
997
1168
  if (configJson == null || configJson.trim().isEmpty()) {
998
1169
  return null;
999
1170
  }
@@ -1011,12 +1182,30 @@ public class CapgoUpdater {
1011
1182
  }
1012
1183
 
1013
1184
  final String label = backgroundRunner.optString("label", "").trim();
1014
- return label.isEmpty() ? null : label;
1185
+ if (label.isEmpty()) {
1186
+ return null;
1187
+ }
1188
+
1189
+ final String src = backgroundRunner.optString("src", "").trim();
1190
+ final String event = backgroundRunner.optString("event", "").trim();
1191
+ return new BackgroundRunnerWorkConfig(
1192
+ label,
1193
+ src,
1194
+ event,
1195
+ backgroundRunner.optBoolean("autoStart", false),
1196
+ backgroundRunner.optBoolean("repeat", false),
1197
+ backgroundRunner.optInt("interval", 0)
1198
+ );
1015
1199
  } catch (JSONException ignored) {
1016
1200
  return null;
1017
1201
  }
1018
1202
  }
1019
1203
 
1204
+ static String getBackgroundRunnerLabelFromConfig(final String configJson) {
1205
+ final BackgroundRunnerWorkConfig config = getBackgroundRunnerWorkConfigFromConfig(configJson);
1206
+ return config == null ? null : config.label;
1207
+ }
1208
+
1020
1209
  private String readAssetAsString(final String assetPath) throws IOException {
1021
1210
  final StringBuilder buffer = new StringBuilder();
1022
1211
  try (
@@ -1032,32 +1221,131 @@ public class CapgoUpdater {
1032
1221
  return buffer.toString();
1033
1222
  }
1034
1223
 
1035
- private void cancelBackgroundRunnerWorkBeforeBundleSwitch() {
1224
+ private void copyFileAtomically(final File source, final File dest) throws IOException {
1225
+ final File parent = dest.getParentFile();
1226
+ if (parent != null && !parent.exists() && !parent.mkdirs()) {
1227
+ throw new IOException("Failed to create parent directory: " + parent.getAbsolutePath());
1228
+ }
1229
+
1230
+ final File tempFile = File.createTempFile("capgo-", ".tmp", parent);
1231
+ try {
1232
+ try (
1233
+ final FileInputStream input = new FileInputStream(source);
1234
+ final FileOutputStream output = new FileOutputStream(tempFile)
1235
+ ) {
1236
+ final byte[] buffer = new byte[CryptoCipher.copyBufferBytes()];
1237
+ int length;
1238
+ while ((length = input.read(buffer)) != -1) {
1239
+ output.write(buffer, 0, length);
1240
+ }
1241
+ }
1242
+ CryptoCipher.replaceFile(tempFile, dest);
1243
+ } finally {
1244
+ if (tempFile.exists()) {
1245
+ tempFile.delete();
1246
+ }
1247
+ }
1248
+ }
1249
+
1250
+ private void syncBackgroundRunnerScriptFromBundle(final File bundle, final BackgroundRunnerWorkConfig config) {
1251
+ if (this.activity == null || bundle == null || config == null || config.src == null || config.src.isEmpty()) {
1252
+ return;
1253
+ }
1254
+
1255
+ if (bundle.getPath().endsWith("/public") || "public".equals(bundle.getName())) {
1256
+ return;
1257
+ }
1258
+
1259
+ try {
1260
+ final File source = resolvePathInsideDirectory(bundle, config.src);
1261
+ if (!source.isFile()) {
1262
+ return;
1263
+ }
1264
+
1265
+ final File publicDir = new File(this.activity.getFilesDir(), "public");
1266
+ final File dest = resolvePathInsideDirectory(publicDir, config.src);
1267
+ this.copyFileAtomically(source, dest);
1268
+ logger.info("Synced Background Runner script into native public storage before bundle switch.");
1269
+ logger.debug("Background Runner script path: " + dest.getAbsolutePath());
1270
+ } catch (Exception e) {
1271
+ logger.debug("Background Runner script sync skipped: " + e.getMessage());
1272
+ }
1273
+ }
1274
+
1275
+ private void resetBackgroundRunnerWorkForBundleSwitch(final File bundle) {
1036
1276
  if (this.activity == null) {
1037
1277
  return;
1038
1278
  }
1039
1279
 
1040
- final String label;
1280
+ final BackgroundRunnerWorkConfig config;
1041
1281
  try {
1042
- label = getBackgroundRunnerLabelFromConfig(this.readAssetAsString(CAPACITOR_CONFIG_ASSET));
1282
+ config = getBackgroundRunnerWorkConfigFromConfig(this.readAssetAsString(CAPACITOR_CONFIG_ASSET));
1043
1283
  } catch (IOException ignored) {
1044
1284
  return;
1045
1285
  }
1046
1286
 
1047
- if (label == null) {
1287
+ if (config == null) {
1048
1288
  return;
1049
1289
  }
1050
1290
 
1051
1291
  try {
1052
1292
  final WorkManager workManager = WorkManager.getInstance(this.activity.getApplicationContext());
1053
- workManager.cancelUniqueWork(label);
1054
- workManager.cancelAllWorkByTag(label);
1293
+ workManager.cancelUniqueWork(config.label);
1294
+ workManager.cancelAllWorkByTag(config.label);
1055
1295
  logger.info("Cancelled Background Runner work before bundle switch.");
1056
- logger.debug("Background Runner label: " + label);
1296
+ logger.debug("Background Runner label: " + config.label);
1057
1297
  } catch (Exception e) {
1058
1298
  logger.warn("Failed to cancel Background Runner work before bundle switch.");
1059
1299
  logger.debug("Background Runner cancellation error: " + e.getMessage());
1060
1300
  }
1301
+
1302
+ this.syncBackgroundRunnerScriptFromBundle(bundle, config);
1303
+ this.rescheduleBackgroundRunnerWork(config);
1304
+ }
1305
+
1306
+ private void rescheduleBackgroundRunnerWork(final BackgroundRunnerWorkConfig config) {
1307
+ if (!config.autoStart || config.interval <= 0 || config.src.isEmpty()) {
1308
+ return;
1309
+ }
1310
+
1311
+ try {
1312
+ @SuppressWarnings("unchecked")
1313
+ final Class<? extends ListenableWorker> workerClass = (Class<? extends ListenableWorker>) Class.forName(
1314
+ BACKGROUND_RUNNER_WORKER_CLASS
1315
+ );
1316
+ final Data data = new Data.Builder()
1317
+ .putString("label", config.label)
1318
+ .putString("src", config.src)
1319
+ .putString("event", config.event)
1320
+ .build();
1321
+ final Constraints constraints = new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build();
1322
+ final WorkManager workManager = WorkManager.getInstance(this.activity.getApplicationContext());
1323
+
1324
+ if (!config.repeat) {
1325
+ final OneTimeWorkRequest work = new OneTimeWorkRequest.Builder(workerClass)
1326
+ .setInitialDelay(config.interval, TimeUnit.MINUTES)
1327
+ .setInputData(data)
1328
+ .addTag(config.label)
1329
+ .setConstraints(constraints)
1330
+ .build();
1331
+ workManager.enqueueUniqueWork(config.label, ExistingWorkPolicy.REPLACE, work);
1332
+ } else {
1333
+ final PeriodicWorkRequest work = new PeriodicWorkRequest.Builder(workerClass, config.interval, TimeUnit.MINUTES)
1334
+ .setInitialDelay(config.interval, TimeUnit.MINUTES)
1335
+ .setInputData(data)
1336
+ .addTag(config.label)
1337
+ .setConstraints(constraints)
1338
+ .build();
1339
+ workManager.enqueueUniquePeriodicWork(config.label, ExistingPeriodicWorkPolicy.UPDATE, work);
1340
+ }
1341
+
1342
+ logger.info("Rescheduled Background Runner work after bundle switch.");
1343
+ } catch (ClassNotFoundException ignored) {
1344
+ logger.debug("Background Runner plugin not installed, skipping reschedule.");
1345
+ } catch (Exception e) {
1346
+ logger.warn("Failed to reschedule Background Runner work after bundle switch.");
1347
+ logger.debug("Background Runner reschedule error: " + e.getMessage());
1348
+ }
1061
1349
  }
1062
1350
 
1063
1351
  private boolean hasStoredBundleInfo(final String id) {
@@ -1070,6 +1358,27 @@ public class CapgoUpdater {
1070
1358
  );
1071
1359
  }
1072
1360
 
1361
+ private void runDownloadGate() throws IOException {
1362
+ if (this.downloadGate == null) {
1363
+ return;
1364
+ }
1365
+ try {
1366
+ this.downloadGate.run();
1367
+ } catch (final RuntimeException e) {
1368
+ throw new IOException(e.getMessage() == null ? "Download gate failed" : e.getMessage(), e);
1369
+ }
1370
+ }
1371
+
1372
+ private boolean runDownloadGateQuiet() {
1373
+ try {
1374
+ this.runDownloadGate();
1375
+ return true;
1376
+ } catch (final IOException e) {
1377
+ logger.error("Download blocked by cleanup gate: " + e.getMessage());
1378
+ return false;
1379
+ }
1380
+ }
1381
+
1073
1382
  public void downloadBackground(
1074
1383
  final String url,
1075
1384
  final String version,
@@ -1088,6 +1397,9 @@ public class CapgoUpdater {
1088
1397
  final JSONArray manifest,
1089
1398
  final boolean setNext
1090
1399
  ) {
1400
+ if (!this.runDownloadGateQuiet()) {
1401
+ return;
1402
+ }
1091
1403
  final String id = this.randomString();
1092
1404
 
1093
1405
  // Check if version is already downloading, but allow retry if previous download failed
@@ -1096,7 +1408,10 @@ public class CapgoUpdater {
1096
1408
  BundleInfo existingBundle = this.getBundleInfoByName(version);
1097
1409
  if (existingBundle != null && existingBundle.isErrorStatus()) {
1098
1410
  // Cancel the failed download and allow retry
1099
- DownloadWorkerManager.cancelVersionDownload(this.activity, version);
1411
+ if (!DownloadWorkerManager.cancelVersionDownloadAndAwait(this.activity, version)) {
1412
+ logger.error("Failed to cancel previous download before retry");
1413
+ return;
1414
+ }
1100
1415
  logger.info("Retrying failed download for version: " + version);
1101
1416
  } else {
1102
1417
  logger.info("Version already downloading: " + version);
@@ -1112,11 +1427,14 @@ public class CapgoUpdater {
1112
1427
  }
1113
1428
 
1114
1429
  public BundleInfo download(final String url, final String version, final String sessionKey, final String checksum) throws IOException {
1430
+ this.runDownloadGate();
1115
1431
  // Check for existing bundle with same version and clean up if in error state
1116
1432
  BundleInfo existingBundle = this.getBundleInfoByName(version);
1117
- if (existingBundle != null && (existingBundle.isErrorStatus() || existingBundle.isDeleted())) {
1433
+ if (existingBundle != null && (existingBundle.isErrorStatus() || existingBundle.isDeleted() || existingBundle.isDeleting())) {
1118
1434
  logger.info("Found existing failed bundle for version " + version + ", deleting before retry");
1119
- this.delete(existingBundle.getId(), true);
1435
+ if (!Boolean.TRUE.equals(this.delete(existingBundle.getId(), true))) {
1436
+ throw new IOException("Failed to delete existing bundle before retry");
1437
+ }
1120
1438
  }
1121
1439
 
1122
1440
  final String id = this.randomString();
@@ -1160,15 +1478,18 @@ public class CapgoUpdater {
1160
1478
  final String checksum,
1161
1479
  final JSONArray manifest
1162
1480
  ) throws IOException {
1481
+ this.runDownloadGate();
1163
1482
  if (manifest == null) {
1164
1483
  return download(url, version, sessionKey, checksum);
1165
1484
  }
1166
1485
 
1167
1486
  // Check for existing bundle with same version and clean up if in error state
1168
1487
  BundleInfo existingBundle = this.getBundleInfoByName(version);
1169
- if (existingBundle != null && (existingBundle.isErrorStatus() || existingBundle.isDeleted())) {
1488
+ if (existingBundle != null && (existingBundle.isErrorStatus() || existingBundle.isDeleted() || existingBundle.isDeleting())) {
1170
1489
  logger.info("Found existing failed bundle for version " + version + ", deleting before retry");
1171
- this.delete(existingBundle.getId(), true);
1490
+ if (!Boolean.TRUE.equals(this.delete(existingBundle.getId(), true))) {
1491
+ throw new IOException("Failed to delete existing bundle before retry");
1492
+ }
1172
1493
  }
1173
1494
 
1174
1495
  final String id = this.randomString();
@@ -1233,51 +1554,96 @@ public class CapgoUpdater {
1233
1554
  }
1234
1555
 
1235
1556
  public Boolean delete(final String id, final Boolean removeInfo) throws IOException {
1236
- final BundleInfo deleted = this.getBundleInfo(id);
1237
- if (deleted.isBuiltin() || this.getCurrentBundleId().equals(id)) {
1238
- logger.error("Cannot delete current or builtin bundle");
1239
- logger.debug("Bundle ID: " + id);
1240
- return false;
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
- }
1253
- final BundleInfo next = this.getNextBundle();
1254
- if (next != null && !next.isDeleted() && !next.isErrorStatus() && next.getId().equals(id)) {
1255
- logger.error("Cannot delete the next bundle");
1256
- logger.debug("Bundle ID: " + id);
1257
- return false;
1258
- }
1259
- // Cancel download for this version if active
1260
- if (this.activity != null) {
1261
- DownloadWorkerManager.cancelVersionDownload(this.activity, deleted.getVersionName());
1262
- }
1263
- final File bundle = new File(this.documentsDir, bundleDirectory + "/" + id);
1264
- if (bundle.exists()) {
1265
- this.deleteDirectory(bundle);
1266
- if (!removeInfo) {
1267
- this.saveBundleInfo(id, deleted.setStatus(BundleStatus.DELETED));
1557
+ return this.delete(id, removeInfo, true);
1558
+ }
1559
+
1560
+ public Boolean delete(final String id, final Boolean removeInfo, final boolean cancelActiveDownload) throws IOException {
1561
+ synchronized (this.deleteLock) {
1562
+ final BundleInfo deleted = this.getBundleInfo(id);
1563
+ if (deleted.isBuiltin() || this.getCurrentBundleId().equals(id)) {
1564
+ logger.error("Cannot delete current or builtin bundle");
1565
+ logger.debug("Bundle ID: " + id);
1566
+ return false;
1567
+ }
1568
+ final BundleInfo previewFallback = this.getPreviewFallbackBundle();
1569
+ if (
1570
+ previewFallback != null &&
1571
+ !previewFallback.isDeleted() &&
1572
+ !previewFallback.isErrorStatus() &&
1573
+ !previewFallback.isDeleting() &&
1574
+ previewFallback.getId().equals(id)
1575
+ ) {
1576
+ logger.error("Cannot delete the preview fallback bundle");
1577
+ logger.debug("Bundle ID: " + id);
1578
+ return false;
1579
+ }
1580
+ final BundleInfo next = this.getNextBundle();
1581
+ if (next != null && !next.isDeleted() && !next.isErrorStatus() && !next.isDeleting() && next.getId().equals(id)) {
1582
+ logger.error("Cannot delete the next bundle");
1583
+ logger.debug("Bundle ID: " + id);
1584
+ return false;
1585
+ }
1586
+
1587
+ final File bundle = this.getBundleDirectory(id);
1588
+ final boolean hadRegistry = this.hasStoredBundleInfo(id);
1589
+ final boolean hadFolder = bundle.exists();
1590
+ if (!hadRegistry && !hadFolder) {
1591
+ logger.error("Cannot delete unknown bundle");
1592
+ logger.debug("Bundle ID: " + id);
1593
+ return false;
1594
+ }
1595
+
1596
+ // Persist DELETING before touching disk so kill/OOM can resume on next launch.
1597
+ if (!deleted.isDeleting()) {
1598
+ if (!this.saveBundleInfo(id, deleted.setStatus(BundleStatus.DELETING))) {
1599
+ logger.error("Failed to persist DELETING marker, aborting disk delete");
1600
+ logger.debug("Bundle ID: " + id);
1601
+ return false;
1602
+ }
1603
+ }
1604
+
1605
+ // Cancel download for this version if active
1606
+ if (cancelActiveDownload && this.activity != null) {
1607
+ if (!DownloadWorkerManager.cancelVersionDownloadAndAwait(this.activity, deleted.getVersionName())) {
1608
+ logger.error("Failed to cancel active download before delete");
1609
+ return false;
1610
+ }
1611
+ }
1612
+
1613
+ if (bundle.exists()) {
1614
+ try {
1615
+ this.deleteDirectory(bundle);
1616
+ } catch (final IOException e) {
1617
+ logger.error("Failed to delete bundle folder, will retry later");
1618
+ logger.debug("Bundle ID: " + id + ", Error: " + e.getMessage());
1619
+ return false;
1620
+ }
1621
+ }
1622
+
1623
+ // Only drop registry after the folder is confirmed gone.
1624
+ if (bundle.exists()) {
1625
+ logger.error("Bundle folder still present after delete, will retry later");
1626
+ logger.debug("Bundle ID: " + id);
1627
+ return false;
1628
+ }
1629
+
1630
+ final boolean finalized;
1631
+ if (Boolean.FALSE.equals(removeInfo)) {
1632
+ finalized = this.saveBundleInfo(id, deleted.setStatus(BundleStatus.DELETED));
1268
1633
  } else {
1269
- this.removeBundleInfo(id);
1634
+ finalized = this.saveBundleInfo(id, null);
1635
+ }
1636
+ if (!finalized) {
1637
+ logger.error("Failed to finalize delete registry update, will retry later");
1638
+ logger.debug("Bundle ID: " + id);
1639
+ return false;
1270
1640
  }
1641
+ this.sendStats("delete", deleted.getVersionName());
1642
+ this.dequeuePendingDelete(id);
1643
+ logger.info("Bundle deleted and confirmed gone");
1644
+ logger.debug("Bundle ID: " + id);
1271
1645
  return true;
1272
1646
  }
1273
- logger.info("Bundle not found on disk");
1274
- logger.debug("Version: " + deleted.getVersionName());
1275
- // perhaps we did not find the bundle in the files, but if the user requested a delete, we delete
1276
- if (removeInfo) {
1277
- this.removeBundleInfo(id);
1278
- }
1279
- this.sendStats("delete", deleted.getVersionName());
1280
- return false;
1281
1647
  }
1282
1648
 
1283
1649
  public Boolean delete(final String id) {
@@ -1290,6 +1656,82 @@ public class CapgoUpdater {
1290
1656
  }
1291
1657
  }
1292
1658
 
1659
+ /**
1660
+ * Resume incomplete deletes one-by-one. Safe across app kill / OOM because
1661
+ * delete() marks DELETING before disk work and only clears registry after confirm.
1662
+ */
1663
+ public void drainPendingDeletes() {
1664
+ final LinkedHashSet<String> pendingIds = new LinkedHashSet<>();
1665
+ for (final BundleInfo info : this.list(true)) {
1666
+ if (info != null && info.isDeleting() && info.getId() != null && !info.getId().isEmpty()) {
1667
+ pendingIds.add(info.getId());
1668
+ }
1669
+ }
1670
+ pendingIds.addAll(this.getPendingDeleteIds());
1671
+ for (final String id : pendingIds) {
1672
+ try {
1673
+ logger.info("Resuming pending delete for bundle: " + id);
1674
+ if (Boolean.TRUE.equals(this.delete(id, true))) {
1675
+ this.dequeuePendingDelete(id);
1676
+ }
1677
+ } catch (final Exception e) {
1678
+ logger.error("Pending delete failed, will retry next launch");
1679
+ logger.debug("Bundle ID: " + id + ", Error: " + e.getMessage());
1680
+ }
1681
+ try {
1682
+ Thread.sleep(DELETE_PACE_MS);
1683
+ } catch (final InterruptedException ie) {
1684
+ Thread.currentThread().interrupt();
1685
+ return;
1686
+ }
1687
+ }
1688
+ }
1689
+
1690
+ private Set<String> getPendingDeleteIds() {
1691
+ final Set<String> ids = new LinkedHashSet<>();
1692
+ if (this.prefs == null) {
1693
+ return ids;
1694
+ }
1695
+ final String raw = this.prefs.getString(PENDING_DELETE_IDS, "");
1696
+ if (raw == null || raw.isEmpty()) {
1697
+ return ids;
1698
+ }
1699
+ for (final String part : raw.split(",")) {
1700
+ if (part != null && !part.isEmpty()) {
1701
+ ids.add(part);
1702
+ }
1703
+ }
1704
+ return ids;
1705
+ }
1706
+
1707
+ private void enqueuePendingDelete(final String id) {
1708
+ if (id == null || id.isEmpty() || this.editor == null || this.prefs == null) {
1709
+ return;
1710
+ }
1711
+ final Set<String> ids = this.getPendingDeleteIds();
1712
+ if (!ids.add(id)) {
1713
+ return;
1714
+ }
1715
+ this.editor.putString(PENDING_DELETE_IDS, String.join(",", ids));
1716
+ this.editor.commit();
1717
+ }
1718
+
1719
+ private void dequeuePendingDelete(final String id) {
1720
+ if (id == null || id.isEmpty() || this.editor == null || this.prefs == null) {
1721
+ return;
1722
+ }
1723
+ final Set<String> ids = this.getPendingDeleteIds();
1724
+ if (!ids.remove(id)) {
1725
+ return;
1726
+ }
1727
+ if (ids.isEmpty()) {
1728
+ this.editor.remove(PENDING_DELETE_IDS);
1729
+ } else {
1730
+ this.editor.putString(PENDING_DELETE_IDS, String.join(",", ids));
1731
+ }
1732
+ this.editor.commit();
1733
+ }
1734
+
1293
1735
  private File getBundleDirectory(final String id) {
1294
1736
  return new File(this.documentsDir, bundleDirectory + "/" + id);
1295
1737
  }
@@ -1297,7 +1739,13 @@ public class CapgoUpdater {
1297
1739
  private boolean bundleExists(final String id) {
1298
1740
  final File bundle = this.getBundleDirectory(id);
1299
1741
  final BundleInfo bundleInfo = this.getBundleInfo(id);
1300
- return (bundle.isDirectory() && bundle.exists() && new File(bundle.getPath(), "/index.html").exists() && !bundleInfo.isDeleted());
1742
+ return (
1743
+ bundle.isDirectory() &&
1744
+ bundle.exists() &&
1745
+ new File(bundle.getPath(), "/index.html").exists() &&
1746
+ !bundleInfo.isDeleted() &&
1747
+ !bundleInfo.isDeleting()
1748
+ );
1301
1749
  }
1302
1750
 
1303
1751
  static final class ResetState {
@@ -1322,10 +1770,12 @@ public class CapgoUpdater {
1322
1770
  }
1323
1771
 
1324
1772
  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;
1773
+ final String currentBundlePath = state.currentBundlePath == null || state.currentBundlePath.trim().isEmpty()
1774
+ ? "public"
1775
+ : state.currentBundlePath;
1776
+ final String fallbackBundleId = state.fallbackBundleId == null || state.fallbackBundleId.isEmpty()
1777
+ ? BundleInfo.ID_BUILTIN
1778
+ : state.fallbackBundleId;
1329
1779
 
1330
1780
  this.editor.putString(this.CAP_SERVER_PATH, currentBundlePath);
1331
1781
  this.editor.putString(FALLBACK_VERSION, fallbackBundleId);
@@ -1476,21 +1926,54 @@ public class CapgoUpdater {
1476
1926
  // Only attempt to delete when the fallback is a different bundle than the
1477
1927
  // currently loaded one. Otherwise we spam logs with "Cannot delete <id>"
1478
1928
  // because delete() protects the current bundle from removal.
1479
- if (
1480
- autoDeletePrevious &&
1929
+ final String previousFallbackId = fallback.getId();
1930
+ final String previousFallbackVersion = fallback.getVersionName();
1931
+ final BundleInfo nextBundle = this.getNextBundle();
1932
+ final boolean previousIsNext =
1933
+ nextBundle != null &&
1934
+ previousFallbackId != null &&
1935
+ previousFallbackId.equals(nextBundle.getId()) &&
1936
+ !nextBundle.isDeleted() &&
1937
+ !nextBundle.isErrorStatus() &&
1938
+ !nextBundle.isDeleting();
1939
+ final boolean shouldDeletePrevious =
1940
+ Boolean.TRUE.equals(autoDeletePrevious) &&
1481
1941
  !fallback.isBuiltin() &&
1482
- fallback.getId() != null &&
1483
- !fallback.getId().equals(bundle.getId()) &&
1484
- !fallbackIsPreviewFallback
1485
- ) {
1486
- final Boolean res = this.delete(fallback.getId());
1487
- if (res) {
1488
- logger.info("Deleted previous bundle: " + fallback.getVersionName());
1489
- } else {
1490
- logger.debug("Skip deleting previous bundle (same as current or protected): " + fallback.getId());
1942
+ previousFallbackId != null &&
1943
+ !previousFallbackId.equals(bundle.getId()) &&
1944
+ !fallbackIsPreviewFallback &&
1945
+ !previousIsNext;
1946
+ if (shouldDeletePrevious) {
1947
+ if (!this.saveBundleInfo(previousFallbackId, fallback.setStatus(BundleStatus.DELETING))) {
1948
+ logger.error("Failed to persist DELETING for previous bundle; queueing durable retry");
1949
+ logger.debug("Bundle ID: " + previousFallbackId);
1950
+ this.enqueuePendingDelete(previousFallbackId);
1491
1951
  }
1492
1952
  }
1953
+ boolean deletePreviousAsync = shouldDeletePrevious;
1493
1954
  this.setFallbackBundle(bundle);
1955
+ if (deletePreviousAsync) {
1956
+ final String asyncPreviousFallbackId = previousFallbackId;
1957
+ final String asyncPreviousFallbackVersion = previousFallbackVersion;
1958
+ io.execute(() -> {
1959
+ if (this.activity != null) {
1960
+ if (!DownloadWorkerManager.cancelVersionDownloadAndAwait(this.activity, asyncPreviousFallbackVersion)) {
1961
+ logger.error("Failed to cancel previous version download before delete");
1962
+ return;
1963
+ }
1964
+ }
1965
+ try {
1966
+ final Boolean res = this.delete(asyncPreviousFallbackId, true, false);
1967
+ if (Boolean.TRUE.equals(res)) {
1968
+ logger.info("Deleted previous bundle: " + asyncPreviousFallbackVersion);
1969
+ } else {
1970
+ logger.debug("Previous bundle delete incomplete, will retry: " + asyncPreviousFallbackId);
1971
+ }
1972
+ } catch (final IOException e) {
1973
+ logger.error("Failed to delete previous bundle: " + asyncPreviousFallbackId + " " + e.getMessage());
1974
+ }
1975
+ });
1976
+ }
1494
1977
  }
1495
1978
 
1496
1979
  public void setError(final BundleInfo bundle) {
@@ -1532,56 +2015,229 @@ public class CapgoUpdater {
1532
2015
  return json;
1533
2016
  }
1534
2017
 
1535
- /**
1536
- * Check if a 429 (Too Many Requests) response was received and set the flag
1537
- */
1538
- private boolean checkAndHandleRateLimitResponse(Response response) {
1539
- if (response.code() == 429) {
1540
- // Send a statistic about the rate limit BEFORE setting the flag
1541
- // Only send once to prevent infinite loop if the stat request itself gets rate limited
1542
- if (!this.previewSession && !rateLimitExceeded && !rateLimitStatisticSent) {
1543
- rateLimitStatisticSent = true;
1544
- sendRateLimitStatistic();
1545
- }
1546
- rateLimitExceeded = true;
1547
- logger.warn("Rate limit exceeded (429). Stopping all stats and channel requests until app restart.");
1548
- return true;
2018
+ private static final class RemoteBlockResult {
2019
+
2020
+ final boolean blocked;
2021
+ final String error;
2022
+ final String message;
2023
+
2024
+ RemoteBlockResult(final boolean blocked, final String error, final String message) {
2025
+ this.blocked = blocked;
2026
+ this.error = error;
2027
+ this.message = message;
1549
2028
  }
1550
- return false;
1551
2029
  }
1552
2030
 
1553
2031
  /**
1554
- * Send a synchronous statistic about rate limiting
2032
+ * Handle HTTP 429 responses by honouring Retry-After / rateLimitResetAt.
2033
+ * All 429s use the same temporary client block — no sticky latch until restart.
1555
2034
  */
1556
- private void sendRateLimitStatistic() {
1557
- String statsUrl = this.statsUrl;
1558
- if (statsUrl == null || statsUrl.isEmpty()) {
1559
- return;
2035
+ private RemoteBlockResult checkAndHandleRateLimitResponse(Response response, String responseData) {
2036
+ if (response == null || response.code() != 429) {
2037
+ return new RemoteBlockResult(false, "", "");
2038
+ }
2039
+
2040
+ final String parsedError = parseRemoteError(responseData);
2041
+ final String parsedMessage = parseRemoteMessage(responseData);
2042
+ final String errorCode = parsedError.isEmpty() ? "too_many_requests" : parsedError;
2043
+ final String message = parsedMessage.isEmpty() ? "Too many requests" : parsedMessage;
2044
+
2045
+ final long retryUntilMs = resolveRateLimitBlockedUntilMs(response, responseData);
2046
+ synchronized (rateLimitStateLock) {
2047
+ if (retryUntilMs > rateLimitBlockedUntilMs) {
2048
+ rateLimitBlockedUntilMs = retryUntilMs;
2049
+ rateLimitBlockedError = errorCode;
2050
+ rateLimitBlockedMessage = message;
2051
+ } else if (rateLimitBlockedUntilMs <= 0L) {
2052
+ rateLimitBlockedError = errorCode;
2053
+ rateLimitBlockedMessage = message;
2054
+ }
1560
2055
  }
1561
2056
 
1562
- try {
1563
- BundleInfo current = this.getCurrentBundle();
1564
- JSONObject json = this.createInfoObject();
1565
- json.put("version_name", current.getVersionName());
1566
- json.put("old_version_name", "");
1567
- json.put("action", "rate_limit_reached");
2057
+ // Claim last, and only when there is somewhere to send it, so a 429 burst with no
2058
+ // stats URL does not claim and release the latch once per response.
2059
+ if ("too_many_requests".equals(errorCode) && !this.previewSession && this.hasStatsUrl() && claimRateLimitStatistic()) {
2060
+ sendRateLimitStatistic();
2061
+ }
1568
2062
 
1569
- Request request = new Request.Builder()
1570
- .url(statsUrl)
1571
- .post(RequestBody.create(json.toString(), MediaType.get("application/json")))
1572
- .build();
2063
+ final long nowMs = System.currentTimeMillis();
2064
+ final long retryAfter = Math.max(0L, (Math.max(retryUntilMs, nowMs) - nowMs + 999L) / 1000L);
2065
+ logger.warn("Received 429 (" + errorCode + "). Honouring Retry-After: " + retryAfter + "s.");
2066
+ return new RemoteBlockResult(true, errorCode, message);
2067
+ }
1573
2068
 
1574
- // Send synchronously to ensure it goes out before the flag is set
1575
- // User-Agent header is automatically added by DownloadService.sharedClient interceptor
1576
- try (Response response = DownloadService.sharedClient.newCall(request).execute()) {
1577
- if (response.isSuccessful()) {
1578
- logger.info("Rate limit statistic sent");
1579
- } else {
1580
- logger.error("Error sending rate limit statistic");
1581
- logger.debug("Response code: " + response.code());
2069
+ private String parseRemoteError(final String responseData) {
2070
+ if (responseData == null || responseData.isEmpty()) {
2071
+ return "";
2072
+ }
2073
+ try {
2074
+ final JSONObject json = new JSONObject(responseData);
2075
+ return json.optString("error", "");
2076
+ } catch (JSONException ignored) {
2077
+ return "";
2078
+ }
2079
+ }
2080
+
2081
+ private String parseRemoteMessage(final String responseData) {
2082
+ if (responseData == null || responseData.isEmpty()) {
2083
+ return "";
2084
+ }
2085
+ try {
2086
+ final JSONObject json = new JSONObject(responseData);
2087
+ return json.optString("message", "");
2088
+ } catch (JSONException ignored) {
2089
+ return "";
2090
+ }
2091
+ }
2092
+
2093
+ private long resolveRateLimitBlockedUntilMs(final Response response, final String responseData) {
2094
+ final long nowMs = System.currentTimeMillis();
2095
+ final double candidate = rawRateLimitDeadlineMs(response, responseData, nowMs);
2096
+ // NaN and past deadlines mean "no client-side block"; anything further out is capped.
2097
+ if (!(candidate > nowMs)) {
2098
+ return 0L;
2099
+ }
2100
+ return (long) Math.min(candidate, (double) nowMs + MAX_RATE_LIMIT_WINDOW_MS);
2101
+ }
2102
+
2103
+ private double rawRateLimitDeadlineMs(final Response response, final String responseData, final long nowMs) {
2104
+ final String header = response.header("Retry-After");
2105
+ if (header != null) {
2106
+ try {
2107
+ final double seconds = Double.parseDouble(header.trim());
2108
+ if (seconds >= 0) {
2109
+ return nowMs + seconds * 1000d;
2110
+ }
2111
+ } catch (NumberFormatException ignored) {
2112
+ // Fall through to body fields
2113
+ }
2114
+ }
2115
+
2116
+ if (responseData != null && !responseData.isEmpty()) {
2117
+ try {
2118
+ final JSONObject json = new JSONObject(responseData);
2119
+ final JSONObject moreInfo = json.optJSONObject("moreInfo");
2120
+ if (moreInfo != null && moreInfo.has("retryAfterSeconds")) {
2121
+ final double retryAfter = moreInfo.getDouble("retryAfterSeconds");
2122
+ if (retryAfter >= 0) {
2123
+ return nowMs + retryAfter * 1000d;
2124
+ }
2125
+ } else if (json.has("retryAfterSeconds")) {
2126
+ final double retryAfter = json.getDouble("retryAfterSeconds");
2127
+ if (retryAfter >= 0) {
2128
+ return nowMs + retryAfter * 1000d;
2129
+ }
2130
+ }
2131
+ if (moreInfo != null && moreInfo.has("rateLimitResetAt")) {
2132
+ return moreInfo.getDouble("rateLimitResetAt");
2133
+ } else if (json.has("rateLimitResetAt")) {
2134
+ return json.getDouble("rateLimitResetAt");
1582
2135
  }
2136
+ } catch (JSONException ignored) {
2137
+ // No retry hint
2138
+ }
2139
+ }
2140
+
2141
+ // No retry hint — do not hold a client-side block; allow immediate retry to the worker
2142
+ return 0d;
2143
+ }
2144
+
2145
+ private static boolean claimRateLimitStatistic() {
2146
+ synchronized (rateLimitStateLock) {
2147
+ if (rateLimitStatisticSent) {
2148
+ return false;
2149
+ }
2150
+ rateLimitStatisticSent = true;
2151
+ return true;
2152
+ }
2153
+ }
2154
+
2155
+ /**
2156
+ * Give the claim back when the statistic never made it out, so a later 429 can retry it.
2157
+ */
2158
+ private static void releaseRateLimitStatisticClaim() {
2159
+ synchronized (rateLimitStateLock) {
2160
+ rateLimitStatisticSent = false;
2161
+ }
2162
+ }
2163
+
2164
+ private boolean hasStatsUrl() {
2165
+ final String url = this.statsUrl;
2166
+ return url != null && !url.isEmpty();
2167
+ }
2168
+
2169
+ private boolean isRemoteBlocked() {
2170
+ synchronized (rateLimitStateLock) {
2171
+ if (rateLimitBlockedUntilMs <= 0L) {
2172
+ return false;
2173
+ }
2174
+ if (System.currentTimeMillis() >= rateLimitBlockedUntilMs) {
2175
+ rateLimitBlockedUntilMs = 0L;
2176
+ return false;
1583
2177
  }
2178
+ return true;
2179
+ }
2180
+ }
2181
+
2182
+ private RemoteBlockResult remoteBlockedClientError() {
2183
+ synchronized (rateLimitStateLock) {
2184
+ return new RemoteBlockResult(true, rateLimitBlockedError, rateLimitBlockedMessage);
2185
+ }
2186
+ }
2187
+
2188
+ /**
2189
+ * Send a statistic about rate limiting.
2190
+ * Dispatched through OkHttp so no caller thread waits on the request.
2191
+ */
2192
+ private void sendRateLimitStatistic() {
2193
+ String statsUrl = this.statsUrl;
2194
+ if (statsUrl == null || statsUrl.isEmpty()) {
2195
+ // The URL was cleared after the claim was taken; nothing went out, so hand it back.
2196
+ releaseRateLimitStatisticClaim();
2197
+ return;
2198
+ }
2199
+
2200
+ try {
2201
+ BundleInfo current = this.getCurrentBundle();
2202
+ JSONObject json = this.createInfoObject();
2203
+ json.put("version_name", current.getVersionName());
2204
+ json.put("old_version_name", "");
2205
+ json.put("action", "rate_limit_reached");
2206
+
2207
+ Request request = new Request.Builder()
2208
+ .url(statsUrl)
2209
+ .post(RequestBody.create(json.toString(), MediaType.get("application/json")))
2210
+ .build();
2211
+
2212
+ // User-Agent header is automatically added by DownloadService.sharedClient interceptor
2213
+ DownloadService.sharedClient
2214
+ .newCall(request)
2215
+ .enqueue(
2216
+ new okhttp3.Callback() {
2217
+ @Override
2218
+ public void onFailure(@NonNull Call call, @NonNull IOException e) {
2219
+ releaseRateLimitStatisticClaim();
2220
+ logger.error("Failed to send rate limit statistic");
2221
+ logger.debug("Error: " + e.getMessage());
2222
+ }
2223
+
2224
+ @Override
2225
+ public void onResponse(@NonNull Call call, @NonNull Response response) {
2226
+ // The body is unused here; closing the Response closes it.
2227
+ try (response) {
2228
+ if (response.isSuccessful()) {
2229
+ logger.info("Rate limit statistic sent");
2230
+ } else {
2231
+ releaseRateLimitStatisticClaim();
2232
+ logger.error("Error sending rate limit statistic");
2233
+ logger.debug("Response code: " + response.code());
2234
+ }
2235
+ }
2236
+ }
2237
+ }
2238
+ );
1584
2239
  } catch (final Exception e) {
2240
+ releaseRateLimitStatisticClaim();
1585
2241
  logger.error("Failed to send rate limit statistic");
1586
2242
  logger.debug("Error: " + e.getMessage());
1587
2243
  }
@@ -1593,105 +2249,130 @@ public class CapgoUpdater {
1593
2249
 
1594
2250
  Request request = new Request.Builder().url(url).post(body).build();
1595
2251
 
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
- }
2252
+ DownloadService.sharedClient
2253
+ .newCall(request)
2254
+ .enqueue(
2255
+ new okhttp3.Callback() {
2256
+ @Override
2257
+ public void onFailure(@NonNull Call call, @NonNull IOException e) {
2258
+ Map<String, Object> retError = new HashMap<>();
2259
+ retError.put("message", "Request failed: " + e.getMessage());
2260
+ retError.put("error", "network_error");
2261
+ retError.put("kind", "failed");
2262
+ callback.callback(retError);
2263
+ }
1606
2264
 
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.
2265
+ @Override
2266
+ public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
2267
+ try (ResponseBody responseBody = response.body()) {
2268
+ final int statusCode = response.code();
2269
+ final String responseData = responseBody != null ? responseBody.string() : "";
2270
+ JSONObject jsonResponse = null;
2271
+ if (!responseData.isEmpty()) {
2272
+ try {
2273
+ jsonResponse = new JSONObject(responseData);
2274
+ } catch (JSONException ignored) {
2275
+ // Non-JSON responses are handled as response or parse errors below.
2276
+ }
1618
2277
  }
1619
- }
1620
2278
 
1621
- if (jsonResponse != null && (jsonResponse.has("error") || jsonResponse.has("kind"))) {
1622
- if (statusCode == 429) {
1623
- checkAndHandleRateLimitResponse(response);
1624
- }
1625
- Map<String, Object> retError = new HashMap<>();
1626
- if (jsonResponse.has("error") && !jsonResponse.isNull("error")) {
1627
- retError.put("error", jsonResponse.getString("error"));
2279
+ if (jsonResponse != null && (jsonResponse.has("error") || jsonResponse.has("kind"))) {
2280
+ if (statusCode == 429) {
2281
+ final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
2282
+ Map<String, Object> retError = new HashMap<>();
2283
+ retError.put(
2284
+ "error",
2285
+ rateLimit.error.isEmpty() ? jsonResponse.optString("error", "too_many_requests") : rateLimit.error
2286
+ );
2287
+ retError.put(
2288
+ "message",
2289
+ rateLimit.message.isEmpty()
2290
+ ? jsonResponse.optString("message", "Too many requests")
2291
+ : rateLimit.message
2292
+ );
2293
+ if (jsonResponse.has("kind") && !jsonResponse.isNull("kind")) {
2294
+ retError.put("kind", jsonResponse.getString("kind"));
2295
+ } else {
2296
+ retError.put("kind", "failed");
2297
+ }
2298
+ if (jsonResponse.has("version") && !jsonResponse.isNull("version")) {
2299
+ retError.put("version", jsonResponse.getString("version"));
2300
+ }
2301
+ retError.put("statusCode", statusCode);
2302
+ callback.callback(retError);
2303
+ return;
2304
+ }
2305
+ Map<String, Object> retError = new HashMap<>();
2306
+ if (jsonResponse.has("error") && !jsonResponse.isNull("error")) {
2307
+ retError.put("error", jsonResponse.getString("error"));
2308
+ }
2309
+ if (jsonResponse.has("kind") && !jsonResponse.isNull("kind")) {
2310
+ retError.put("kind", jsonResponse.getString("kind"));
2311
+ }
2312
+ if (jsonResponse.has("message") && !jsonResponse.isNull("message")) {
2313
+ retError.put("message", jsonResponse.getString("message"));
2314
+ } else {
2315
+ retError.put("message", "server did not provide a message");
2316
+ }
2317
+ if (jsonResponse.has("version") && !jsonResponse.isNull("version")) {
2318
+ retError.put("version", jsonResponse.getString("version"));
2319
+ }
2320
+ retError.put("statusCode", statusCode);
2321
+ callback.callback(retError);
2322
+ return;
1628
2323
  }
1629
- if (jsonResponse.has("kind") && !jsonResponse.isNull("kind")) {
1630
- retError.put("kind", jsonResponse.getString("kind"));
2324
+
2325
+ // Check for 429 rate limit without JSON body
2326
+ final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
2327
+ if (rateLimit.blocked) {
2328
+ Map<String, Object> retError = new HashMap<>();
2329
+ retError.put("message", rateLimit.message);
2330
+ retError.put("error", rateLimit.error);
2331
+ retError.put("kind", "failed");
2332
+ retError.put("statusCode", statusCode);
2333
+ callback.callback(retError);
2334
+ return;
1631
2335
  }
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");
2336
+
2337
+ if (!response.isSuccessful()) {
2338
+ Map<String, Object> retError = new HashMap<>();
2339
+ retError.put("message", "Server error: " + response.code());
2340
+ retError.put("error", "response_error");
2341
+ retError.put("kind", "failed");
2342
+ retError.put("statusCode", statusCode);
2343
+ callback.callback(retError);
2344
+ return;
1636
2345
  }
1637
- if (jsonResponse.has("version") && !jsonResponse.isNull("version")) {
1638
- retError.put("version", jsonResponse.getString("version"));
2346
+
2347
+ if (jsonResponse == null) {
2348
+ throw new JSONException("Response is not a JSON object");
1639
2349
  }
1640
- retError.put("statusCode", statusCode);
1641
- callback.callback(retError);
1642
- return;
1643
- }
1644
2350
 
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
- }
2351
+ Map<String, Object> ret = new HashMap<>();
2352
+ ret.put("statusCode", statusCode);
1655
2353
 
1656
- if (!response.isSuccessful()) {
2354
+ Iterator<String> keys = jsonResponse.keys();
2355
+ while (keys.hasNext()) {
2356
+ String key = keys.next();
2357
+ if (jsonResponse.has(key)) {
2358
+ if ("session_key".equals(key)) {
2359
+ ret.put("sessionKey", jsonResponse.get(key));
2360
+ } else {
2361
+ ret.put(key, jsonResponse.get(key));
2362
+ }
2363
+ }
2364
+ }
2365
+ callback.callback(ret);
2366
+ } catch (JSONException e) {
1657
2367
  Map<String, Object> retError = new HashMap<>();
1658
- retError.put("message", "Server error: " + response.code());
1659
- retError.put("error", "response_error");
2368
+ retError.put("message", "JSON parse error: " + e.getMessage());
2369
+ retError.put("error", "parse_error");
1660
2370
  retError.put("kind", "failed");
1661
- retError.put("statusCode", statusCode);
1662
2371
  callback.callback(retError);
1663
- return;
1664
2372
  }
1665
-
1666
- if (jsonResponse == null) {
1667
- throw new JSONException("Response is not a JSON object");
1668
- }
1669
-
1670
- Map<String, Object> ret = new HashMap<>();
1671
- ret.put("statusCode", statusCode);
1672
-
1673
- Iterator<String> keys = jsonResponse.keys();
1674
- while (keys.hasNext()) {
1675
- String key = keys.next();
1676
- if (jsonResponse.has(key)) {
1677
- if ("session_key".equals(key)) {
1678
- ret.put("sessionKey", jsonResponse.get(key));
1679
- } else {
1680
- ret.put(key, jsonResponse.get(key));
1681
- }
1682
- }
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);
1691
2373
  }
1692
2374
  }
1693
- }
1694
- );
2375
+ );
1695
2376
  }
1696
2377
 
1697
2378
  public void getLatest(final String updateUrl, final String channel, final Callback callback) {
@@ -1699,6 +2380,16 @@ public class CapgoUpdater {
1699
2380
  }
1700
2381
 
1701
2382
  public void getLatest(final String updateUrl, final String channel, final String appIdOverride, final Callback callback) {
2383
+ if (isRemoteBlocked()) {
2384
+ final RemoteBlockResult blocked = remoteBlockedClientError();
2385
+ logger.debug("Skipping getLatest due to remote block (" + blocked.error + ").");
2386
+ final Map<String, Object> retError = new HashMap<>();
2387
+ retError.put("message", blocked.message);
2388
+ retError.put("error", blocked.error);
2389
+ retError.put("kind", "failed");
2390
+ callback.callback(retError);
2391
+ return;
2392
+ }
1702
2393
  JSONObject json;
1703
2394
  try {
1704
2395
  json = this.createInfoObject(appIdOverride);
@@ -1768,12 +2459,12 @@ public class CapgoUpdater {
1768
2459
  return;
1769
2460
  }
1770
2461
 
1771
- // Check if rate limit was exceeded
1772
- if (rateLimitExceeded) {
1773
- logger.debug("Skipping setChannel due to rate limit (429). Requests will resume after app restart.");
2462
+ if (isRemoteBlocked()) {
2463
+ final RemoteBlockResult blocked = remoteBlockedClientError();
2464
+ logger.debug("Skipping setChannel due to remote block (" + blocked.error + ").");
1774
2465
  final Map<String, Object> retError = new HashMap<>();
1775
- retError.put("message", "Rate limit exceeded");
1776
- retError.put("error", "rate_limit_exceeded");
2466
+ retError.put("message", blocked.message);
2467
+ retError.put("error", blocked.error);
1777
2468
  callback.callback(retError);
1778
2469
  return;
1779
2470
  }
@@ -1813,7 +2504,6 @@ public class CapgoUpdater {
1813
2504
  logger.info("Public channel requested, channel override removed");
1814
2505
  callback.callback(res);
1815
2506
  } else {
1816
- // Success - persist defaultChannel
1817
2507
  this.defaultChannel = channel;
1818
2508
  editor.putString(defaultChannelKey, channel);
1819
2509
  editor.apply();
@@ -1828,12 +2518,12 @@ public class CapgoUpdater {
1828
2518
  }
1829
2519
 
1830
2520
  public void getChannel(final Callback callback, final SharedPreferences.Editor editor, final String defaultChannelKey) {
1831
- // Check if rate limit was exceeded
1832
- if (rateLimitExceeded) {
1833
- logger.debug("Skipping getChannel due to rate limit (429). Requests will resume after app restart.");
2521
+ if (isRemoteBlocked()) {
2522
+ final RemoteBlockResult blocked = remoteBlockedClientError();
2523
+ logger.debug("Skipping getChannel due to remote block (" + blocked.error + ").");
1834
2524
  final Map<String, Object> retError = new HashMap<>();
1835
- retError.put("message", "Rate limit exceeded");
1836
- retError.put("error", "rate_limit_exceeded");
2525
+ retError.put("message", blocked.message);
2526
+ retError.put("error", blocked.error);
1837
2527
  callback.callback(retError);
1838
2528
  return;
1839
2529
  }
@@ -1865,99 +2555,93 @@ public class CapgoUpdater {
1865
2555
  .put(RequestBody.create(json.toString(), MediaType.get("application/json")))
1866
2556
  .build();
1867
2557
 
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
- }
2558
+ DownloadService.sharedClient
2559
+ .newCall(request)
2560
+ .enqueue(
2561
+ new okhttp3.Callback() {
2562
+ @Override
2563
+ public void onFailure(@NonNull Call call, @NonNull IOException e) {
2564
+ Map<String, Object> retError = new HashMap<>();
2565
+ retError.put("message", "Request failed: " + e.getMessage());
2566
+ retError.put("error", "network_error");
2567
+ callback.callback(retError);
2568
+ }
1877
2569
 
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
- }
2570
+ @Override
2571
+ public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
2572
+ try (ResponseBody responseBody = response.body()) {
2573
+ final String responseData = responseBody != null ? responseBody.string() : "";
2574
+ final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
2575
+ if (rateLimit.blocked) {
2576
+ Map<String, Object> retError = new HashMap<>();
2577
+ retError.put("message", rateLimit.message);
2578
+ retError.put("error", rateLimit.error);
2579
+ callback.callback(retError);
2580
+ return;
2581
+ }
2582
+
2583
+ if (response.code() == 400) {
2584
+ if (responseData.contains("channel_not_found") && !defaultChannel.isEmpty()) {
2585
+ Map<String, Object> ret = new HashMap<>();
2586
+ ret.put("channel", defaultChannel);
2587
+ ret.put("status", "default");
2588
+ logger.info("Channel get to \"" + ret);
2589
+ callback.callback(ret);
2590
+ return;
2591
+ }
2592
+ }
1889
2593
 
1890
- if (response.code() == 400) {
1891
- if (responseBody == null) {
2594
+ if (!response.isSuccessful()) {
2595
+ Map<String, Object> retError = new HashMap<>();
2596
+ retError.put("message", "Server error: " + response.code());
2597
+ retError.put("error", "response_error");
2598
+ callback.callback(retError);
2599
+ return;
2600
+ }
2601
+
2602
+ if (responseData.isEmpty()) {
1892
2603
  Map<String, Object> retError = new HashMap<>();
1893
2604
  retError.put("message", "Empty response body");
1894
2605
  retError.put("error", "no_response_body");
1895
2606
  callback.callback(retError);
1896
2607
  return;
1897
2608
  }
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);
2609
+ JSONObject jsonResponse = new JSONObject(responseData);
2610
+
2611
+ // Check for server-side errors first
2612
+ if (jsonResponse.has("error")) {
2613
+ Map<String, Object> retError = new HashMap<>();
2614
+ retError.put("error", jsonResponse.getString("error"));
2615
+ if (jsonResponse.has("message")) {
2616
+ retError.put("message", jsonResponse.getString("message"));
2617
+ } else {
2618
+ retError.put("message", "server did not provide a message");
2619
+ }
2620
+ callback.callback(retError);
1905
2621
  return;
1906
2622
  }
1907
- }
1908
2623
 
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
- }
1916
-
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);
2624
+ Map<String, Object> ret = new HashMap<>();
1926
2625
 
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");
2626
+ Iterator<String> keys = jsonResponse.keys();
2627
+ while (keys.hasNext()) {
2628
+ String key = keys.next();
2629
+ if (jsonResponse.has(key)) {
2630
+ ret.put(key, jsonResponse.get(key));
2631
+ }
1935
2632
  }
2633
+ persistDefaultChannelFromResponse(ret.get("channel"), editor, defaultChannelKey);
2634
+ logger.info("Channel get to \"" + ret);
2635
+ callback.callback(ret);
2636
+ } catch (JSONException e) {
2637
+ Map<String, Object> retError = new HashMap<>();
2638
+ retError.put("message", "JSON parse error: " + e.getMessage());
2639
+ retError.put("error", "parse_error");
1936
2640
  callback.callback(retError);
1937
- return;
1938
2641
  }
1939
-
1940
- Map<String, Object> ret = new HashMap<>();
1941
-
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));
1947
- }
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);
1957
2642
  }
1958
2643
  }
1959
- }
1960
- );
2644
+ );
1961
2645
  }
1962
2646
 
1963
2647
  void persistDefaultChannelFromResponse(final Object channel, final SharedPreferences.Editor editor, final String defaultChannelKey) {
@@ -1979,12 +2663,12 @@ public class CapgoUpdater {
1979
2663
  }
1980
2664
 
1981
2665
  public void listChannels(final Callback callback) {
1982
- // Check if rate limit was exceeded
1983
- if (rateLimitExceeded) {
1984
- logger.debug("Skipping listChannels due to rate limit (429). Requests will resume after app restart.");
2666
+ if (isRemoteBlocked()) {
2667
+ final RemoteBlockResult blocked = remoteBlockedClientError();
2668
+ logger.debug("Skipping listChannels due to remote block (" + blocked.error + ").");
1985
2669
  final Map<String, Object> retError = new HashMap<>();
1986
- retError.put("message", "Rate limit exceeded");
1987
- retError.put("error", "rate_limit_exceeded");
2670
+ retError.put("message", blocked.message);
2671
+ retError.put("error", blocked.error);
1988
2672
  callback.callback(retError);
1989
2673
  return;
1990
2674
  }
@@ -2030,83 +2714,85 @@ public class CapgoUpdater {
2030
2714
 
2031
2715
  Request request = new Request.Builder().url(urlBuilder.build()).get().build();
2032
2716
 
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
- }
2042
-
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
- }
2717
+ DownloadService.sharedClient
2718
+ .newCall(request)
2719
+ .enqueue(
2720
+ new okhttp3.Callback() {
2721
+ @Override
2722
+ public void onFailure(@NonNull Call call, @NonNull IOException e) {
2723
+ Map<String, Object> retError = new HashMap<>();
2724
+ retError.put("message", "Request failed: " + e.getMessage());
2725
+ retError.put("error", "network_error");
2726
+ callback.callback(retError);
2727
+ }
2054
2728
 
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
- }
2729
+ @Override
2730
+ public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
2731
+ try (ResponseBody responseBody = response.body()) {
2732
+ final String data = responseBody != null ? responseBody.string() : "";
2733
+ final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, data);
2734
+ if (rateLimit.blocked) {
2735
+ Map<String, Object> retError = new HashMap<>();
2736
+ retError.put("message", rateLimit.message);
2737
+ retError.put("error", rateLimit.error);
2738
+ callback.callback(retError);
2739
+ return;
2740
+ }
2062
2741
 
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();
2742
+ if (!response.isSuccessful()) {
2743
+ Map<String, Object> retError = new HashMap<>();
2744
+ retError.put("message", "Server error: " + response.code());
2745
+ retError.put("error", "response_error");
2746
+ callback.callback(retError);
2747
+ return;
2748
+ }
2071
2749
 
2072
- try {
2073
- Map<String, Object> ret = parseListChannelsResponse(data);
2750
+ if (data.isEmpty()) {
2751
+ Map<String, Object> retError = new HashMap<>();
2752
+ retError.put("message", "Empty response body");
2753
+ retError.put("error", "no_response_body");
2754
+ callback.callback(retError);
2755
+ return;
2756
+ }
2074
2757
 
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
2758
  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");
2759
+ Map<String, Object> ret = parseListChannelsResponse(data);
2760
+
2761
+ logger.info("Channels listed successfully");
2762
+ callback.callback(ret);
2763
+ } catch (JSONException arrayException) {
2764
+ // If not an array, try to parse as error object
2765
+ try {
2766
+ JSONObject json = new JSONObject(data);
2767
+ if (json.has("error")) {
2768
+ Map<String, Object> retError = new HashMap<>();
2769
+ retError.put("error", json.getString("error"));
2770
+ if (json.has("message")) {
2771
+ retError.put("message", json.getString("message"));
2772
+ } else {
2773
+ retError.put("message", "server did not provide a message");
2774
+ }
2775
+ callback.callback(retError);
2776
+ return;
2088
2777
  }
2778
+ Map<String, Object> retError = new HashMap<>();
2779
+ retError.put("message", "Unexpected channels response format");
2780
+ retError.put("error", "parse_error");
2089
2781
  callback.callback(retError);
2090
2782
  return;
2783
+ } catch (JSONException objException) {
2784
+ // If neither array nor object, throw parse error
2785
+ arrayException.addSuppressed(objException);
2786
+ Map<String, Object> retError = new HashMap<>();
2787
+ retError.put("message", "JSON parse error: " + arrayException.getMessage());
2788
+ retError.put("error", "parse_error");
2789
+ callback.callback(retError);
2091
2790
  }
2092
- Map<String, Object> retError = new HashMap<>();
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());
2102
- retError.put("error", "parse_error");
2103
- callback.callback(retError);
2104
2791
  }
2105
2792
  }
2106
2793
  }
2107
2794
  }
2108
- }
2109
- );
2795
+ );
2110
2796
  }
2111
2797
 
2112
2798
  static Map<String, Object> parseListChannelsResponse(final String data) throws JSONException {
@@ -2155,6 +2841,10 @@ public class CapgoUpdater {
2155
2841
  final Map<String, String> metadata,
2156
2842
  final Runnable onSent
2157
2843
  ) {
2844
+ if (statsStopped.get()) {
2845
+ return;
2846
+ }
2847
+
2158
2848
  if (this.previewSession) {
2159
2849
  if (logger != null) {
2160
2850
  logger.debug("Skipping sendStats during preview session.");
@@ -2162,12 +2852,6 @@ public class CapgoUpdater {
2162
2852
  return;
2163
2853
  }
2164
2854
 
2165
- // Check if rate limit was exceeded
2166
- if (rateLimitExceeded) {
2167
- logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.");
2168
- return;
2169
- }
2170
-
2171
2855
  String statsUrl = this.statsUrl;
2172
2856
  if (statsUrl == null || statsUrl.isEmpty()) {
2173
2857
  return;
@@ -2184,16 +2868,190 @@ public class CapgoUpdater {
2184
2868
  json.put("metadata", new JSONObject(metadata));
2185
2869
  }
2186
2870
  } catch (JSONException e) {
2187
- logger.error("Error preparing stats");
2188
- logger.debug("JSONException: " + e.getMessage());
2871
+ if (logger != null) {
2872
+ logger.error("Error preparing stats");
2873
+ logger.debug("JSONException: " + e.getMessage());
2874
+ }
2189
2875
  return;
2190
2876
  }
2191
2877
 
2192
- statsQueue.add(new QueuedStatsEvent(json, onSent));
2878
+ synchronized (statsQueue) {
2879
+ if (statsStopped.get()) {
2880
+ return;
2881
+ }
2882
+ while (statsQueue.size() >= MAX_PENDING_STATS) {
2883
+ statsQueue.remove(0);
2884
+ }
2885
+ statsQueue.add(new QueuedStatsEvent(json, onSent));
2886
+ }
2193
2887
  ensureStatsTimerStarted();
2194
2888
  }
2195
2889
 
2890
+ public void restorePendingStats() {
2891
+ File file = pendingStatsFile();
2892
+ if (file == null) {
2893
+ return;
2894
+ }
2895
+ File backup = new File(file.getAbsolutePath() + ".bak");
2896
+ if (!file.exists() && backup.exists() && !backup.renameTo(file)) {
2897
+ if (logger != null) {
2898
+ logger.error("Failed to restore stats backup");
2899
+ }
2900
+ return;
2901
+ }
2902
+ if (!file.exists()) {
2903
+ return;
2904
+ }
2905
+ try {
2906
+ String raw = readFileUtf8(file);
2907
+ JSONArray arr = new JSONArray(raw);
2908
+ synchronized (statsQueue) {
2909
+ for (int i = 0; i < arr.length(); i++) {
2910
+ if (statsQueue.size() >= MAX_PENDING_STATS) {
2911
+ break;
2912
+ }
2913
+ statsQueue.add(new QueuedStatsEvent(arr.getJSONObject(i), null));
2914
+ }
2915
+ }
2916
+ if (backup.exists() && !backup.delete()) {
2917
+ if (logger != null) {
2918
+ logger.error("Failed to delete stats backup");
2919
+ }
2920
+ }
2921
+ if (!statsQueue.isEmpty()) {
2922
+ if (logger != null) {
2923
+ logger.info("Restored " + statsQueue.size() + " pending stats events");
2924
+ }
2925
+ ensureStatsTimerStarted();
2926
+ }
2927
+ } catch (Exception e) {
2928
+ if (logger != null) {
2929
+ logger.error("Failed to restore pending stats");
2930
+ logger.debug("Error: " + e.getMessage());
2931
+ }
2932
+ }
2933
+ }
2934
+
2935
+ int pendingStatsCount() {
2936
+ return statsQueue.size();
2937
+ }
2938
+
2939
+ public void persistPendingStats() {
2940
+ persistStatsQueue();
2941
+ }
2942
+
2943
+ private File pendingStatsFile() {
2944
+ final File dir = this.noBackupDir != null ? this.noBackupDir : this.documentsDir;
2945
+ if (dir == null) {
2946
+ return null;
2947
+ }
2948
+ return new File(dir, PENDING_STATS_FILE);
2949
+ }
2950
+
2951
+ private void persistStatsQueue() {
2952
+ persistStatsQueue(false);
2953
+ }
2954
+
2955
+ private void persistStatsQueue(final boolean force) {
2956
+ File file = pendingStatsFile();
2957
+ if (file == null) {
2958
+ return;
2959
+ }
2960
+ synchronized (pendingStatsPersistLock) {
2961
+ if (statsStopped.get() && !force) {
2962
+ return;
2963
+ }
2964
+ JSONArray arr = new JSONArray();
2965
+ synchronized (statsQueue) {
2966
+ final List<QueuedStatsEvent> combined = new ArrayList<>(statsInFlight.size() + statsQueue.size());
2967
+ combined.addAll(statsInFlight);
2968
+ combined.addAll(statsQueue);
2969
+ final int start = Math.max(0, combined.size() - MAX_PENDING_STATS);
2970
+ for (int i = start; i < combined.size(); i++) {
2971
+ arr.put(combined.get(i).event);
2972
+ }
2973
+ }
2974
+ try {
2975
+ if (arr.length() == 0) {
2976
+ writeFileAtomically(file, "[]".getBytes(StandardCharsets.UTF_8));
2977
+ File backup = new File(file.getAbsolutePath() + ".bak");
2978
+ if (backup.exists() && !backup.delete()) {
2979
+ if (logger != null) {
2980
+ logger.error("Failed to delete empty stats backup");
2981
+ }
2982
+ }
2983
+ return;
2984
+ }
2985
+ writeFileAtomically(file, arr.toString().getBytes(StandardCharsets.UTF_8));
2986
+ } catch (Exception e) {
2987
+ if (logger != null) {
2988
+ logger.error("Failed to persist stats queue");
2989
+ logger.debug("Error: " + e.getMessage());
2990
+ }
2991
+ }
2992
+ }
2993
+ }
2994
+
2995
+ private static String readFileUtf8(final File file) throws IOException {
2996
+ final long length = file.length();
2997
+ final byte[] buf = new byte[length > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) length];
2998
+ try (FileInputStream in = new FileInputStream(file)) {
2999
+ int offset = 0;
3000
+ while (offset < buf.length) {
3001
+ final int read = in.read(buf, offset, buf.length - offset);
3002
+ if (read < 0) {
3003
+ break;
3004
+ }
3005
+ offset += read;
3006
+ }
3007
+ return new String(buf, 0, offset, StandardCharsets.UTF_8);
3008
+ }
3009
+ }
3010
+
3011
+ private void writeFileAtomically(final File file, final byte[] bytes) throws IOException {
3012
+ final File tmp = new File(file.getAbsolutePath() + ".tmp");
3013
+ File backup = null;
3014
+ try {
3015
+ try (FileOutputStream out = new FileOutputStream(tmp)) {
3016
+ out.write(bytes);
3017
+ out.flush();
3018
+ }
3019
+ if (tmp.renameTo(file)) {
3020
+ return;
3021
+ }
3022
+ if (file.exists()) {
3023
+ backup = new File(file.getAbsolutePath() + ".bak");
3024
+ if (backup.exists() && !backup.delete()) {
3025
+ throw new IOException("Failed to replace " + file.getAbsolutePath());
3026
+ }
3027
+ if (!file.renameTo(backup)) {
3028
+ throw new IOException("Failed to replace " + file.getAbsolutePath());
3029
+ }
3030
+ }
3031
+ if (tmp.renameTo(file)) {
3032
+ if (backup != null && backup.exists() && !backup.delete()) {
3033
+ if (logger != null) {
3034
+ logger.error("Failed to delete stats backup");
3035
+ }
3036
+ }
3037
+ backup = null;
3038
+ return;
3039
+ }
3040
+ throw new IOException("Failed to persist " + file.getAbsolutePath());
3041
+ } finally {
3042
+ if (backup != null && !file.exists()) {
3043
+ backup.renameTo(file);
3044
+ }
3045
+ if (tmp.exists() && !tmp.delete()) {
3046
+ tmp.deleteOnExit();
3047
+ }
3048
+ }
3049
+ }
3050
+
2196
3051
  private synchronized void ensureStatsTimerStarted() {
3052
+ if (statsStopped.get()) {
3053
+ return;
3054
+ }
2197
3055
  if (statsFlushTask == null || statsFlushTask.isCancelled() || statsFlushTask.isDone()) {
2198
3056
  statsFlushTask = statsScheduler.scheduleAtFixedRate(
2199
3057
  this::flushStatsQueue,
@@ -2205,25 +3063,45 @@ public class CapgoUpdater {
2205
3063
  }
2206
3064
 
2207
3065
  private void flushStatsQueue() {
3066
+ if (statsStopped.get()) {
3067
+ return;
3068
+ }
2208
3069
  if (statsQueue.isEmpty()) {
2209
3070
  return;
2210
3071
  }
2211
3072
 
3073
+ // While Retry-After is active, keep stats queued and skip the network call.
3074
+ if (isRemoteBlocked()) {
3075
+ logger.debug("Deferring stats flush until Retry-After expires.");
3076
+ return;
3077
+ }
3078
+
2212
3079
  String statsUrl = this.statsUrl;
2213
3080
  if (statsUrl == null || statsUrl.isEmpty()) {
2214
- statsQueue.clear();
3081
+ synchronized (statsQueue) {
3082
+ statsQueue.clear();
3083
+ statsInFlight.clear();
3084
+ }
3085
+ persistStatsQueue();
3086
+ return;
3087
+ }
3088
+
3089
+ if (!statsFlushInFlight.compareAndSet(false, true)) {
2215
3090
  return;
2216
3091
  }
2217
3092
 
2218
- // Copy and clear the queue atomically using synchronized block
2219
- List<QueuedStatsEvent> eventsToSend;
3093
+ final List<QueuedStatsEvent> eventsToSend;
2220
3094
  synchronized (statsQueue) {
2221
3095
  if (statsQueue.isEmpty()) {
3096
+ statsFlushInFlight.set(false);
2222
3097
  return;
2223
3098
  }
2224
3099
  eventsToSend = new ArrayList<>(statsQueue);
2225
3100
  statsQueue.clear();
3101
+ statsInFlight.clear();
3102
+ statsInFlight.addAll(eventsToSend);
2226
3103
  }
3104
+ persistStatsQueue();
2227
3105
 
2228
3106
  JSONArray jsonArray = new JSONArray();
2229
3107
  for (QueuedStatsEvent queuedEvent : eventsToSend) {
@@ -2236,34 +3114,97 @@ public class CapgoUpdater {
2236
3114
  .build();
2237
3115
 
2238
3116
  final int eventCount = eventsToSend.size();
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
- }
2246
-
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)) {
3117
+ DownloadService.sharedClient
3118
+ .newCall(request)
3119
+ .enqueue(
3120
+ new okhttp3.Callback() {
3121
+ @Override
3122
+ public void onFailure(@NonNull Call call, @NonNull IOException e) {
3123
+ if (abandonStoppedStatsFlush()) {
2252
3124
  return;
2253
3125
  }
3126
+ requeueStatsEvents(eventsToSend);
3127
+ if (logger != null) {
3128
+ logger.error("Failed to send stats batch");
3129
+ logger.debug("Error: " + e.getMessage());
3130
+ }
3131
+ statsFlushInFlight.set(false);
3132
+ }
3133
+
3134
+ @Override
3135
+ public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
3136
+ try (ResponseBody responseBody = response.body()) {
3137
+ if (abandonStoppedStatsFlush()) {
3138
+ return;
3139
+ }
3140
+ final String responseData = responseBody != null ? responseBody.string() : "";
3141
+ if (checkAndHandleRateLimitResponse(response, responseData).blocked) {
3142
+ requeueStatsEvents(eventsToSend);
3143
+ return;
3144
+ }
2254
3145
 
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());
3146
+ if (response.isSuccessful()) {
3147
+ synchronized (statsQueue) {
3148
+ statsInFlight.clear();
3149
+ }
3150
+ persistStatsQueue();
3151
+ if (logger != null) {
3152
+ logger.info("Stats batch sent successfully");
3153
+ logger.debug("Sent " + eventCount + " events");
3154
+ }
3155
+ runStatsCallbacks(eventsToSend);
3156
+ } else if (isTransientStatsFailure(response.code())) {
3157
+ requeueStatsEvents(eventsToSend);
3158
+ if (logger != null) {
3159
+ logger.error("Error sending stats batch");
3160
+ logger.debug("Retrying later, response code: " + response.code());
3161
+ }
3162
+ } else {
3163
+ synchronized (statsQueue) {
3164
+ statsInFlight.clear();
3165
+ }
3166
+ persistStatsQueue();
3167
+ if (logger != null) {
3168
+ logger.error("Dropping stats batch after permanent error");
3169
+ logger.debug("Response code: " + response.code());
3170
+ }
3171
+ }
3172
+ } finally {
3173
+ statsFlushInFlight.set(false);
2262
3174
  }
2263
3175
  }
2264
3176
  }
3177
+ );
3178
+ }
3179
+
3180
+ private boolean abandonStoppedStatsFlush() {
3181
+ if (!statsStopped.get()) {
3182
+ return false;
3183
+ }
3184
+ statsFlushInFlight.set(false);
3185
+ return true;
3186
+ }
3187
+
3188
+ /**
3189
+ * Only 429, request timeout and 5xx are worth retrying; other 4xx are permanent rejections.
3190
+ */
3191
+ private static boolean isTransientStatsFailure(final int statusCode) {
3192
+ return statusCode == 429 || statusCode == 408 || statusCode >= 500;
3193
+ }
3194
+
3195
+ private void requeueStatsEvents(final List<QueuedStatsEvent> events) {
3196
+ if (statsStopped.get() || events == null || events.isEmpty()) {
3197
+ return;
3198
+ }
3199
+ synchronized (statsQueue) {
3200
+ statsInFlight.clear();
3201
+ statsQueue.addAll(0, events);
3202
+ while (statsQueue.size() > MAX_PENDING_STATS) {
3203
+ statsQueue.remove(0);
2265
3204
  }
2266
- );
3205
+ }
3206
+ persistStatsQueue();
3207
+ ensureStatsTimerStarted();
2267
3208
  }
2268
3209
 
2269
3210
  private void runStatsCallbacks(final List<QueuedStatsEvent> sentEvents) {
@@ -2333,10 +3274,10 @@ public class CapgoUpdater {
2333
3274
  this.saveBundleInfo(id, null);
2334
3275
  }
2335
3276
 
2336
- public void saveBundleInfo(final String id, final BundleInfo info) {
3277
+ public boolean saveBundleInfo(final String id, final BundleInfo info) {
2337
3278
  if (id == null || (info != null && (info.isBuiltin() || info.isUnknown()))) {
2338
3279
  logger.debug("Not saving info for bundle: [" + id + "] " + info);
2339
- return;
3280
+ return false;
2340
3281
  }
2341
3282
 
2342
3283
  if (info == null) {
@@ -2348,7 +3289,7 @@ public class CapgoUpdater {
2348
3289
  logger.debug("Storing info for bundle [" + id + "] " + update.getClass().getName() + " -> " + jsonString);
2349
3290
  this.editor.putString(id + INFO_SUFFIX, jsonString);
2350
3291
  }
2351
- this.editor.commit();
3292
+ return this.editor.commit();
2352
3293
  }
2353
3294
 
2354
3295
  private void setBundleStatus(final String id, final BundleStatus status) {
@@ -2453,14 +3394,15 @@ public class CapgoUpdater {
2453
3394
  * Should be called when the plugin is destroyed to prevent resource leaks.
2454
3395
  */
2455
3396
  public void shutdown() {
3397
+ statsStopped.set(true);
2456
3398
  // Cancel the scheduled task
2457
3399
  if (statsFlushTask != null) {
2458
3400
  statsFlushTask.cancel(false);
2459
3401
  statsFlushTask = null;
2460
3402
  }
2461
3403
 
2462
- // Flush any remaining stats before shutdown
2463
- flushStatsQueue();
3404
+ // Write once, then ignore later callbacks so they cannot delete a newer instance's file.
3405
+ persistStatsQueue(true);
2464
3406
 
2465
3407
  // Shutdown the scheduler
2466
3408
  statsScheduler.shutdown();