@capgo/capacitor-updater 7.50.2 → 7.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 (26) hide show
  1. package/Package.swift +2 -1
  2. package/README.md +53 -48
  3. package/android/src/main/java/ee/forgr/capacitor_updater/AppLifecycleObserver.java +29 -2
  4. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +7 -3
  5. package/android/src/main/java/ee/forgr/capacitor_updater/BundleStatus.java +1 -0
  6. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +427 -116
  7. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1108 -180
  8. package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +102 -31
  9. package/android/src/main/java/ee/forgr/capacitor_updater/DataManager.java +23 -7
  10. package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +2 -2
  11. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +513 -201
  12. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +103 -3
  13. package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +1 -1
  14. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +115 -133
  15. package/dist/docs.json +32 -8
  16. package/dist/esm/definitions.d.ts +41 -17
  17. package/dist/esm/definitions.js.map +1 -1
  18. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +124 -0
  19. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +9 -1
  20. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +3 -0
  21. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +789 -92
  22. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1014 -268
  23. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +49 -31
  24. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +44 -20
  25. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +28 -0
  26. 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);
1270
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;
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 {
@@ -1476,21 +1924,54 @@ public class CapgoUpdater {
1476
1924
  // Only attempt to delete when the fallback is a different bundle than the
1477
1925
  // currently loaded one. Otherwise we spam logs with "Cannot delete <id>"
1478
1926
  // because delete() protects the current bundle from removal.
1479
- if (
1480
- autoDeletePrevious &&
1927
+ final String previousFallbackId = fallback.getId();
1928
+ final String previousFallbackVersion = fallback.getVersionName();
1929
+ final BundleInfo nextBundle = this.getNextBundle();
1930
+ final boolean previousIsNext =
1931
+ nextBundle != null &&
1932
+ previousFallbackId != null &&
1933
+ previousFallbackId.equals(nextBundle.getId()) &&
1934
+ !nextBundle.isDeleted() &&
1935
+ !nextBundle.isErrorStatus() &&
1936
+ !nextBundle.isDeleting();
1937
+ final boolean shouldDeletePrevious =
1938
+ Boolean.TRUE.equals(autoDeletePrevious) &&
1481
1939
  !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());
1940
+ previousFallbackId != null &&
1941
+ !previousFallbackId.equals(bundle.getId()) &&
1942
+ !fallbackIsPreviewFallback &&
1943
+ !previousIsNext;
1944
+ if (shouldDeletePrevious) {
1945
+ if (!this.saveBundleInfo(previousFallbackId, fallback.setStatus(BundleStatus.DELETING))) {
1946
+ logger.error("Failed to persist DELETING for previous bundle; queueing durable retry");
1947
+ logger.debug("Bundle ID: " + previousFallbackId);
1948
+ this.enqueuePendingDelete(previousFallbackId);
1491
1949
  }
1492
1950
  }
1951
+ boolean deletePreviousAsync = shouldDeletePrevious;
1493
1952
  this.setFallbackBundle(bundle);
1953
+ if (deletePreviousAsync) {
1954
+ final String asyncPreviousFallbackId = previousFallbackId;
1955
+ final String asyncPreviousFallbackVersion = previousFallbackVersion;
1956
+ io.execute(() -> {
1957
+ if (this.activity != null) {
1958
+ if (!DownloadWorkerManager.cancelVersionDownloadAndAwait(this.activity, asyncPreviousFallbackVersion)) {
1959
+ logger.error("Failed to cancel previous version download before delete");
1960
+ return;
1961
+ }
1962
+ }
1963
+ try {
1964
+ final Boolean res = this.delete(asyncPreviousFallbackId, true, false);
1965
+ if (Boolean.TRUE.equals(res)) {
1966
+ logger.info("Deleted previous bundle: " + asyncPreviousFallbackVersion);
1967
+ } else {
1968
+ logger.debug("Previous bundle delete incomplete, will retry: " + asyncPreviousFallbackId);
1969
+ }
1970
+ } catch (final IOException e) {
1971
+ logger.error("Failed to delete previous bundle: " + asyncPreviousFallbackId + " " + e.getMessage());
1972
+ }
1973
+ });
1974
+ }
1494
1975
  }
1495
1976
 
1496
1977
  public void setError(final BundleInfo bundle) {
@@ -1532,30 +2013,185 @@ public class CapgoUpdater {
1532
2013
  return json;
1533
2014
  }
1534
2015
 
2016
+ private static final class RemoteBlockResult {
2017
+
2018
+ final boolean blocked;
2019
+ final String error;
2020
+ final String message;
2021
+
2022
+ RemoteBlockResult(final boolean blocked, final String error, final String message) {
2023
+ this.blocked = blocked;
2024
+ this.error = error;
2025
+ this.message = message;
2026
+ }
2027
+ }
2028
+
1535
2029
  /**
1536
- * Check if a 429 (Too Many Requests) response was received and set the flag
2030
+ * Handle HTTP 429 responses by honouring Retry-After / rateLimitResetAt.
2031
+ * All 429s use the same temporary client block — no sticky latch until restart.
1537
2032
  */
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.");
2033
+ private RemoteBlockResult checkAndHandleRateLimitResponse(Response response, String responseData) {
2034
+ if (response == null || response.code() != 429) {
2035
+ return new RemoteBlockResult(false, "", "");
2036
+ }
2037
+
2038
+ final String parsedError = parseRemoteError(responseData);
2039
+ final String parsedMessage = parseRemoteMessage(responseData);
2040
+ final String errorCode = parsedError.isEmpty() ? "too_many_requests" : parsedError;
2041
+ final String message = parsedMessage.isEmpty() ? "Too many requests" : parsedMessage;
2042
+
2043
+ final long retryUntilMs = resolveRateLimitBlockedUntilMs(response, responseData);
2044
+ synchronized (rateLimitStateLock) {
2045
+ if (retryUntilMs > rateLimitBlockedUntilMs) {
2046
+ rateLimitBlockedUntilMs = retryUntilMs;
2047
+ rateLimitBlockedError = errorCode;
2048
+ rateLimitBlockedMessage = message;
2049
+ } else if (rateLimitBlockedUntilMs <= 0L) {
2050
+ rateLimitBlockedError = errorCode;
2051
+ rateLimitBlockedMessage = message;
2052
+ }
2053
+ }
2054
+
2055
+ // Claim last, and only when there is somewhere to send it, so a 429 burst with no
2056
+ // stats URL does not claim and release the latch once per response.
2057
+ if ("too_many_requests".equals(errorCode) && !this.previewSession && this.hasStatsUrl() && claimRateLimitStatistic()) {
2058
+ sendRateLimitStatistic();
2059
+ }
2060
+
2061
+ final long nowMs = System.currentTimeMillis();
2062
+ final long retryAfter = Math.max(0L, (Math.max(retryUntilMs, nowMs) - nowMs + 999L) / 1000L);
2063
+ logger.warn("Received 429 (" + errorCode + "). Honouring Retry-After: " + retryAfter + "s.");
2064
+ return new RemoteBlockResult(true, errorCode, message);
2065
+ }
2066
+
2067
+ private String parseRemoteError(final String responseData) {
2068
+ if (responseData == null || responseData.isEmpty()) {
2069
+ return "";
2070
+ }
2071
+ try {
2072
+ final JSONObject json = new JSONObject(responseData);
2073
+ return json.optString("error", "");
2074
+ } catch (JSONException ignored) {
2075
+ return "";
2076
+ }
2077
+ }
2078
+
2079
+ private String parseRemoteMessage(final String responseData) {
2080
+ if (responseData == null || responseData.isEmpty()) {
2081
+ return "";
2082
+ }
2083
+ try {
2084
+ final JSONObject json = new JSONObject(responseData);
2085
+ return json.optString("message", "");
2086
+ } catch (JSONException ignored) {
2087
+ return "";
2088
+ }
2089
+ }
2090
+
2091
+ private long resolveRateLimitBlockedUntilMs(final Response response, final String responseData) {
2092
+ final long nowMs = System.currentTimeMillis();
2093
+ final double candidate = rawRateLimitDeadlineMs(response, responseData, nowMs);
2094
+ // NaN and past deadlines mean "no client-side block"; anything further out is capped.
2095
+ if (!(candidate > nowMs)) {
2096
+ return 0L;
2097
+ }
2098
+ return (long) Math.min(candidate, (double) nowMs + MAX_RATE_LIMIT_WINDOW_MS);
2099
+ }
2100
+
2101
+ private double rawRateLimitDeadlineMs(final Response response, final String responseData, final long nowMs) {
2102
+ final String header = response.header("Retry-After");
2103
+ if (header != null) {
2104
+ try {
2105
+ final double seconds = Double.parseDouble(header.trim());
2106
+ if (seconds >= 0) {
2107
+ return nowMs + seconds * 1000d;
2108
+ }
2109
+ } catch (NumberFormatException ignored) {
2110
+ // Fall through to body fields
2111
+ }
2112
+ }
2113
+
2114
+ if (responseData != null && !responseData.isEmpty()) {
2115
+ try {
2116
+ final JSONObject json = new JSONObject(responseData);
2117
+ final JSONObject moreInfo = json.optJSONObject("moreInfo");
2118
+ if (moreInfo != null && moreInfo.has("retryAfterSeconds")) {
2119
+ final double retryAfter = moreInfo.getDouble("retryAfterSeconds");
2120
+ if (retryAfter >= 0) {
2121
+ return nowMs + retryAfter * 1000d;
2122
+ }
2123
+ } else if (json.has("retryAfterSeconds")) {
2124
+ final double retryAfter = json.getDouble("retryAfterSeconds");
2125
+ if (retryAfter >= 0) {
2126
+ return nowMs + retryAfter * 1000d;
2127
+ }
2128
+ }
2129
+ if (moreInfo != null && moreInfo.has("rateLimitResetAt")) {
2130
+ return moreInfo.getDouble("rateLimitResetAt");
2131
+ } else if (json.has("rateLimitResetAt")) {
2132
+ return json.getDouble("rateLimitResetAt");
2133
+ }
2134
+ } catch (JSONException ignored) {
2135
+ // No retry hint
2136
+ }
2137
+ }
2138
+
2139
+ // No retry hint — do not hold a client-side block; allow immediate retry to the worker
2140
+ return 0d;
2141
+ }
2142
+
2143
+ private static boolean claimRateLimitStatistic() {
2144
+ synchronized (rateLimitStateLock) {
2145
+ if (rateLimitStatisticSent) {
2146
+ return false;
2147
+ }
2148
+ rateLimitStatisticSent = true;
1548
2149
  return true;
1549
2150
  }
1550
- return false;
1551
2151
  }
1552
2152
 
1553
2153
  /**
1554
- * Send a synchronous statistic about rate limiting
2154
+ * Give the claim back when the statistic never made it out, so a later 429 can retry it.
2155
+ */
2156
+ private static void releaseRateLimitStatisticClaim() {
2157
+ synchronized (rateLimitStateLock) {
2158
+ rateLimitStatisticSent = false;
2159
+ }
2160
+ }
2161
+
2162
+ private boolean hasStatsUrl() {
2163
+ final String url = this.statsUrl;
2164
+ return url != null && !url.isEmpty();
2165
+ }
2166
+
2167
+ private boolean isRemoteBlocked() {
2168
+ synchronized (rateLimitStateLock) {
2169
+ if (rateLimitBlockedUntilMs <= 0L) {
2170
+ return false;
2171
+ }
2172
+ if (System.currentTimeMillis() >= rateLimitBlockedUntilMs) {
2173
+ rateLimitBlockedUntilMs = 0L;
2174
+ return false;
2175
+ }
2176
+ return true;
2177
+ }
2178
+ }
2179
+
2180
+ private RemoteBlockResult remoteBlockedClientError() {
2181
+ synchronized (rateLimitStateLock) {
2182
+ return new RemoteBlockResult(true, rateLimitBlockedError, rateLimitBlockedMessage);
2183
+ }
2184
+ }
2185
+
2186
+ /**
2187
+ * Send a statistic about rate limiting.
2188
+ * Dispatched through OkHttp so no caller thread waits on the request.
1555
2189
  */
1556
2190
  private void sendRateLimitStatistic() {
1557
2191
  String statsUrl = this.statsUrl;
1558
2192
  if (statsUrl == null || statsUrl.isEmpty()) {
2193
+ // The URL was cleared after the claim was taken; nothing went out, so hand it back.
2194
+ releaseRateLimitStatisticClaim();
1559
2195
  return;
1560
2196
  }
1561
2197
 
@@ -1571,17 +2207,33 @@ public class CapgoUpdater {
1571
2207
  .post(RequestBody.create(json.toString(), MediaType.get("application/json")))
1572
2208
  .build();
1573
2209
 
1574
- // Send synchronously to ensure it goes out before the flag is set
1575
2210
  // 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());
2211
+ DownloadService.sharedClient.newCall(request).enqueue(
2212
+ new okhttp3.Callback() {
2213
+ @Override
2214
+ public void onFailure(@NonNull Call call, @NonNull IOException e) {
2215
+ releaseRateLimitStatisticClaim();
2216
+ logger.error("Failed to send rate limit statistic");
2217
+ logger.debug("Error: " + e.getMessage());
2218
+ }
2219
+
2220
+ @Override
2221
+ public void onResponse(@NonNull Call call, @NonNull Response response) {
2222
+ // The body is unused here; closing the Response closes it.
2223
+ try (response) {
2224
+ if (response.isSuccessful()) {
2225
+ logger.info("Rate limit statistic sent");
2226
+ } else {
2227
+ releaseRateLimitStatisticClaim();
2228
+ logger.error("Error sending rate limit statistic");
2229
+ logger.debug("Response code: " + response.code());
2230
+ }
2231
+ }
2232
+ }
1582
2233
  }
1583
- }
2234
+ );
1584
2235
  } catch (final Exception e) {
2236
+ releaseRateLimitStatisticClaim();
1585
2237
  logger.error("Failed to send rate limit statistic");
1586
2238
  logger.debug("Error: " + e.getMessage());
1587
2239
  }
@@ -1620,7 +2272,27 @@ public class CapgoUpdater {
1620
2272
 
1621
2273
  if (jsonResponse != null && (jsonResponse.has("error") || jsonResponse.has("kind"))) {
1622
2274
  if (statusCode == 429) {
1623
- checkAndHandleRateLimitResponse(response);
2275
+ final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
2276
+ Map<String, Object> retError = new HashMap<>();
2277
+ retError.put(
2278
+ "error",
2279
+ rateLimit.error.isEmpty() ? jsonResponse.optString("error", "too_many_requests") : rateLimit.error
2280
+ );
2281
+ retError.put(
2282
+ "message",
2283
+ rateLimit.message.isEmpty() ? jsonResponse.optString("message", "Too many requests") : rateLimit.message
2284
+ );
2285
+ if (jsonResponse.has("kind") && !jsonResponse.isNull("kind")) {
2286
+ retError.put("kind", jsonResponse.getString("kind"));
2287
+ } else {
2288
+ retError.put("kind", "failed");
2289
+ }
2290
+ if (jsonResponse.has("version") && !jsonResponse.isNull("version")) {
2291
+ retError.put("version", jsonResponse.getString("version"));
2292
+ }
2293
+ retError.put("statusCode", statusCode);
2294
+ callback.callback(retError);
2295
+ return;
1624
2296
  }
1625
2297
  Map<String, Object> retError = new HashMap<>();
1626
2298
  if (jsonResponse.has("error") && !jsonResponse.isNull("error")) {
@@ -1642,11 +2314,12 @@ public class CapgoUpdater {
1642
2314
  return;
1643
2315
  }
1644
2316
 
1645
- // Check for 429 rate limit
1646
- if (checkAndHandleRateLimitResponse(response)) {
2317
+ // Check for 429 rate limit without JSON body
2318
+ final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
2319
+ if (rateLimit.blocked) {
1647
2320
  Map<String, Object> retError = new HashMap<>();
1648
- retError.put("message", "Rate limit exceeded");
1649
- retError.put("error", "rate_limit_exceeded");
2321
+ retError.put("message", rateLimit.message);
2322
+ retError.put("error", rateLimit.error);
1650
2323
  retError.put("kind", "failed");
1651
2324
  retError.put("statusCode", statusCode);
1652
2325
  callback.callback(retError);
@@ -1699,6 +2372,16 @@ public class CapgoUpdater {
1699
2372
  }
1700
2373
 
1701
2374
  public void getLatest(final String updateUrl, final String channel, final String appIdOverride, final Callback callback) {
2375
+ if (isRemoteBlocked()) {
2376
+ final RemoteBlockResult blocked = remoteBlockedClientError();
2377
+ logger.debug("Skipping getLatest due to remote block (" + blocked.error + ").");
2378
+ final Map<String, Object> retError = new HashMap<>();
2379
+ retError.put("message", blocked.message);
2380
+ retError.put("error", blocked.error);
2381
+ retError.put("kind", "failed");
2382
+ callback.callback(retError);
2383
+ return;
2384
+ }
1702
2385
  JSONObject json;
1703
2386
  try {
1704
2387
  json = this.createInfoObject(appIdOverride);
@@ -1768,12 +2451,12 @@ public class CapgoUpdater {
1768
2451
  return;
1769
2452
  }
1770
2453
 
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.");
2454
+ if (isRemoteBlocked()) {
2455
+ final RemoteBlockResult blocked = remoteBlockedClientError();
2456
+ logger.debug("Skipping setChannel due to remote block (" + blocked.error + ").");
1774
2457
  final Map<String, Object> retError = new HashMap<>();
1775
- retError.put("message", "Rate limit exceeded");
1776
- retError.put("error", "rate_limit_exceeded");
2458
+ retError.put("message", blocked.message);
2459
+ retError.put("error", blocked.error);
1777
2460
  callback.callback(retError);
1778
2461
  return;
1779
2462
  }
@@ -1813,7 +2496,6 @@ public class CapgoUpdater {
1813
2496
  logger.info("Public channel requested, channel override removed");
1814
2497
  callback.callback(res);
1815
2498
  } else {
1816
- // Success - persist defaultChannel
1817
2499
  this.defaultChannel = channel;
1818
2500
  editor.putString(defaultChannelKey, channel);
1819
2501
  editor.apply();
@@ -1828,12 +2510,12 @@ public class CapgoUpdater {
1828
2510
  }
1829
2511
 
1830
2512
  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.");
2513
+ if (isRemoteBlocked()) {
2514
+ final RemoteBlockResult blocked = remoteBlockedClientError();
2515
+ logger.debug("Skipping getChannel due to remote block (" + blocked.error + ").");
1834
2516
  final Map<String, Object> retError = new HashMap<>();
1835
- retError.put("message", "Rate limit exceeded");
1836
- retError.put("error", "rate_limit_exceeded");
2517
+ retError.put("message", blocked.message);
2518
+ retError.put("error", blocked.error);
1837
2519
  callback.callback(retError);
1838
2520
  return;
1839
2521
  }
@@ -1878,25 +2560,18 @@ public class CapgoUpdater {
1878
2560
  @Override
1879
2561
  public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
1880
2562
  try (ResponseBody responseBody = response.body()) {
1881
- // Check for 429 rate limit
1882
- if (checkAndHandleRateLimitResponse(response)) {
2563
+ final String responseData = responseBody != null ? responseBody.string() : "";
2564
+ final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
2565
+ if (rateLimit.blocked) {
1883
2566
  Map<String, Object> retError = new HashMap<>();
1884
- retError.put("message", "Rate limit exceeded");
1885
- retError.put("error", "rate_limit_exceeded");
2567
+ retError.put("message", rateLimit.message);
2568
+ retError.put("error", rateLimit.error);
1886
2569
  callback.callback(retError);
1887
2570
  return;
1888
2571
  }
1889
2572
 
1890
2573
  if (response.code() == 400) {
1891
- if (responseBody == null) {
1892
- Map<String, Object> retError = new HashMap<>();
1893
- retError.put("message", "Empty response body");
1894
- retError.put("error", "no_response_body");
1895
- callback.callback(retError);
1896
- return;
1897
- }
1898
- String data = responseBody.string();
1899
- if (data.contains("channel_not_found") && !defaultChannel.isEmpty()) {
2574
+ if (responseData.contains("channel_not_found") && !defaultChannel.isEmpty()) {
1900
2575
  Map<String, Object> ret = new HashMap<>();
1901
2576
  ret.put("channel", defaultChannel);
1902
2577
  ret.put("status", "default");
@@ -1914,14 +2589,13 @@ public class CapgoUpdater {
1914
2589
  return;
1915
2590
  }
1916
2591
 
1917
- if (responseBody == null) {
2592
+ if (responseData.isEmpty()) {
1918
2593
  Map<String, Object> retError = new HashMap<>();
1919
2594
  retError.put("message", "Empty response body");
1920
2595
  retError.put("error", "no_response_body");
1921
2596
  callback.callback(retError);
1922
2597
  return;
1923
2598
  }
1924
- String responseData = responseBody.string();
1925
2599
  JSONObject jsonResponse = new JSONObject(responseData);
1926
2600
 
1927
2601
  // Check for server-side errors first
@@ -1979,12 +2653,12 @@ public class CapgoUpdater {
1979
2653
  }
1980
2654
 
1981
2655
  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.");
2656
+ if (isRemoteBlocked()) {
2657
+ final RemoteBlockResult blocked = remoteBlockedClientError();
2658
+ logger.debug("Skipping listChannels due to remote block (" + blocked.error + ").");
1985
2659
  final Map<String, Object> retError = new HashMap<>();
1986
- retError.put("message", "Rate limit exceeded");
1987
- retError.put("error", "rate_limit_exceeded");
2660
+ retError.put("message", blocked.message);
2661
+ retError.put("error", blocked.error);
1988
2662
  callback.callback(retError);
1989
2663
  return;
1990
2664
  }
@@ -2043,11 +2717,12 @@ public class CapgoUpdater {
2043
2717
  @Override
2044
2718
  public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
2045
2719
  try (ResponseBody responseBody = response.body()) {
2046
- // Check for 429 rate limit
2047
- if (checkAndHandleRateLimitResponse(response)) {
2720
+ final String data = responseBody != null ? responseBody.string() : "";
2721
+ final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, data);
2722
+ if (rateLimit.blocked) {
2048
2723
  Map<String, Object> retError = new HashMap<>();
2049
- retError.put("message", "Rate limit exceeded");
2050
- retError.put("error", "rate_limit_exceeded");
2724
+ retError.put("message", rateLimit.message);
2725
+ retError.put("error", rateLimit.error);
2051
2726
  callback.callback(retError);
2052
2727
  return;
2053
2728
  }
@@ -2060,14 +2735,13 @@ public class CapgoUpdater {
2060
2735
  return;
2061
2736
  }
2062
2737
 
2063
- if (responseBody == null) {
2738
+ if (data.isEmpty()) {
2064
2739
  Map<String, Object> retError = new HashMap<>();
2065
2740
  retError.put("message", "Empty response body");
2066
2741
  retError.put("error", "no_response_body");
2067
2742
  callback.callback(retError);
2068
2743
  return;
2069
2744
  }
2070
- String data = responseBody.string();
2071
2745
 
2072
2746
  try {
2073
2747
  Map<String, Object> ret = parseListChannelsResponse(data);
@@ -2155,6 +2829,10 @@ public class CapgoUpdater {
2155
2829
  final Map<String, String> metadata,
2156
2830
  final Runnable onSent
2157
2831
  ) {
2832
+ if (statsStopped.get()) {
2833
+ return;
2834
+ }
2835
+
2158
2836
  if (this.previewSession) {
2159
2837
  if (logger != null) {
2160
2838
  logger.debug("Skipping sendStats during preview session.");
@@ -2162,12 +2840,6 @@ public class CapgoUpdater {
2162
2840
  return;
2163
2841
  }
2164
2842
 
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
2843
  String statsUrl = this.statsUrl;
2172
2844
  if (statsUrl == null || statsUrl.isEmpty()) {
2173
2845
  return;
@@ -2184,16 +2856,190 @@ public class CapgoUpdater {
2184
2856
  json.put("metadata", new JSONObject(metadata));
2185
2857
  }
2186
2858
  } catch (JSONException e) {
2187
- logger.error("Error preparing stats");
2188
- logger.debug("JSONException: " + e.getMessage());
2859
+ if (logger != null) {
2860
+ logger.error("Error preparing stats");
2861
+ logger.debug("JSONException: " + e.getMessage());
2862
+ }
2189
2863
  return;
2190
2864
  }
2191
2865
 
2192
- statsQueue.add(new QueuedStatsEvent(json, onSent));
2866
+ synchronized (statsQueue) {
2867
+ if (statsStopped.get()) {
2868
+ return;
2869
+ }
2870
+ while (statsQueue.size() >= MAX_PENDING_STATS) {
2871
+ statsQueue.remove(0);
2872
+ }
2873
+ statsQueue.add(new QueuedStatsEvent(json, onSent));
2874
+ }
2193
2875
  ensureStatsTimerStarted();
2194
2876
  }
2195
2877
 
2878
+ public void restorePendingStats() {
2879
+ File file = pendingStatsFile();
2880
+ if (file == null) {
2881
+ return;
2882
+ }
2883
+ File backup = new File(file.getAbsolutePath() + ".bak");
2884
+ if (!file.exists() && backup.exists() && !backup.renameTo(file)) {
2885
+ if (logger != null) {
2886
+ logger.error("Failed to restore stats backup");
2887
+ }
2888
+ return;
2889
+ }
2890
+ if (!file.exists()) {
2891
+ return;
2892
+ }
2893
+ try {
2894
+ String raw = readFileUtf8(file);
2895
+ JSONArray arr = new JSONArray(raw);
2896
+ synchronized (statsQueue) {
2897
+ for (int i = 0; i < arr.length(); i++) {
2898
+ if (statsQueue.size() >= MAX_PENDING_STATS) {
2899
+ break;
2900
+ }
2901
+ statsQueue.add(new QueuedStatsEvent(arr.getJSONObject(i), null));
2902
+ }
2903
+ }
2904
+ if (backup.exists() && !backup.delete()) {
2905
+ if (logger != null) {
2906
+ logger.error("Failed to delete stats backup");
2907
+ }
2908
+ }
2909
+ if (!statsQueue.isEmpty()) {
2910
+ if (logger != null) {
2911
+ logger.info("Restored " + statsQueue.size() + " pending stats events");
2912
+ }
2913
+ ensureStatsTimerStarted();
2914
+ }
2915
+ } catch (Exception e) {
2916
+ if (logger != null) {
2917
+ logger.error("Failed to restore pending stats");
2918
+ logger.debug("Error: " + e.getMessage());
2919
+ }
2920
+ }
2921
+ }
2922
+
2923
+ int pendingStatsCount() {
2924
+ return statsQueue.size();
2925
+ }
2926
+
2927
+ public void persistPendingStats() {
2928
+ persistStatsQueue();
2929
+ }
2930
+
2931
+ private File pendingStatsFile() {
2932
+ final File dir = this.noBackupDir != null ? this.noBackupDir : this.documentsDir;
2933
+ if (dir == null) {
2934
+ return null;
2935
+ }
2936
+ return new File(dir, PENDING_STATS_FILE);
2937
+ }
2938
+
2939
+ private void persistStatsQueue() {
2940
+ persistStatsQueue(false);
2941
+ }
2942
+
2943
+ private void persistStatsQueue(final boolean force) {
2944
+ File file = pendingStatsFile();
2945
+ if (file == null) {
2946
+ return;
2947
+ }
2948
+ synchronized (pendingStatsPersistLock) {
2949
+ if (statsStopped.get() && !force) {
2950
+ return;
2951
+ }
2952
+ JSONArray arr = new JSONArray();
2953
+ synchronized (statsQueue) {
2954
+ final List<QueuedStatsEvent> combined = new ArrayList<>(statsInFlight.size() + statsQueue.size());
2955
+ combined.addAll(statsInFlight);
2956
+ combined.addAll(statsQueue);
2957
+ final int start = Math.max(0, combined.size() - MAX_PENDING_STATS);
2958
+ for (int i = start; i < combined.size(); i++) {
2959
+ arr.put(combined.get(i).event);
2960
+ }
2961
+ }
2962
+ try {
2963
+ if (arr.length() == 0) {
2964
+ writeFileAtomically(file, "[]".getBytes(StandardCharsets.UTF_8));
2965
+ File backup = new File(file.getAbsolutePath() + ".bak");
2966
+ if (backup.exists() && !backup.delete()) {
2967
+ if (logger != null) {
2968
+ logger.error("Failed to delete empty stats backup");
2969
+ }
2970
+ }
2971
+ return;
2972
+ }
2973
+ writeFileAtomically(file, arr.toString().getBytes(StandardCharsets.UTF_8));
2974
+ } catch (Exception e) {
2975
+ if (logger != null) {
2976
+ logger.error("Failed to persist stats queue");
2977
+ logger.debug("Error: " + e.getMessage());
2978
+ }
2979
+ }
2980
+ }
2981
+ }
2982
+
2983
+ private static String readFileUtf8(final File file) throws IOException {
2984
+ final long length = file.length();
2985
+ final byte[] buf = new byte[length > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) length];
2986
+ try (FileInputStream in = new FileInputStream(file)) {
2987
+ int offset = 0;
2988
+ while (offset < buf.length) {
2989
+ final int read = in.read(buf, offset, buf.length - offset);
2990
+ if (read < 0) {
2991
+ break;
2992
+ }
2993
+ offset += read;
2994
+ }
2995
+ return new String(buf, 0, offset, StandardCharsets.UTF_8);
2996
+ }
2997
+ }
2998
+
2999
+ private void writeFileAtomically(final File file, final byte[] bytes) throws IOException {
3000
+ final File tmp = new File(file.getAbsolutePath() + ".tmp");
3001
+ File backup = null;
3002
+ try {
3003
+ try (FileOutputStream out = new FileOutputStream(tmp)) {
3004
+ out.write(bytes);
3005
+ out.flush();
3006
+ }
3007
+ if (tmp.renameTo(file)) {
3008
+ return;
3009
+ }
3010
+ if (file.exists()) {
3011
+ backup = new File(file.getAbsolutePath() + ".bak");
3012
+ if (backup.exists() && !backup.delete()) {
3013
+ throw new IOException("Failed to replace " + file.getAbsolutePath());
3014
+ }
3015
+ if (!file.renameTo(backup)) {
3016
+ throw new IOException("Failed to replace " + file.getAbsolutePath());
3017
+ }
3018
+ }
3019
+ if (tmp.renameTo(file)) {
3020
+ if (backup != null && backup.exists() && !backup.delete()) {
3021
+ if (logger != null) {
3022
+ logger.error("Failed to delete stats backup");
3023
+ }
3024
+ }
3025
+ backup = null;
3026
+ return;
3027
+ }
3028
+ throw new IOException("Failed to persist " + file.getAbsolutePath());
3029
+ } finally {
3030
+ if (backup != null && !file.exists()) {
3031
+ backup.renameTo(file);
3032
+ }
3033
+ if (tmp.exists() && !tmp.delete()) {
3034
+ tmp.deleteOnExit();
3035
+ }
3036
+ }
3037
+ }
3038
+
2196
3039
  private synchronized void ensureStatsTimerStarted() {
3040
+ if (statsStopped.get()) {
3041
+ return;
3042
+ }
2197
3043
  if (statsFlushTask == null || statsFlushTask.isCancelled() || statsFlushTask.isDone()) {
2198
3044
  statsFlushTask = statsScheduler.scheduleAtFixedRate(
2199
3045
  this::flushStatsQueue,
@@ -2205,25 +3051,45 @@ public class CapgoUpdater {
2205
3051
  }
2206
3052
 
2207
3053
  private void flushStatsQueue() {
3054
+ if (statsStopped.get()) {
3055
+ return;
3056
+ }
2208
3057
  if (statsQueue.isEmpty()) {
2209
3058
  return;
2210
3059
  }
2211
3060
 
3061
+ // While Retry-After is active, keep stats queued and skip the network call.
3062
+ if (isRemoteBlocked()) {
3063
+ logger.debug("Deferring stats flush until Retry-After expires.");
3064
+ return;
3065
+ }
3066
+
2212
3067
  String statsUrl = this.statsUrl;
2213
3068
  if (statsUrl == null || statsUrl.isEmpty()) {
2214
- statsQueue.clear();
3069
+ synchronized (statsQueue) {
3070
+ statsQueue.clear();
3071
+ statsInFlight.clear();
3072
+ }
3073
+ persistStatsQueue();
3074
+ return;
3075
+ }
3076
+
3077
+ if (!statsFlushInFlight.compareAndSet(false, true)) {
2215
3078
  return;
2216
3079
  }
2217
3080
 
2218
- // Copy and clear the queue atomically using synchronized block
2219
- List<QueuedStatsEvent> eventsToSend;
3081
+ final List<QueuedStatsEvent> eventsToSend;
2220
3082
  synchronized (statsQueue) {
2221
3083
  if (statsQueue.isEmpty()) {
3084
+ statsFlushInFlight.set(false);
2222
3085
  return;
2223
3086
  }
2224
3087
  eventsToSend = new ArrayList<>(statsQueue);
2225
3088
  statsQueue.clear();
3089
+ statsInFlight.clear();
3090
+ statsInFlight.addAll(eventsToSend);
2226
3091
  }
3092
+ persistStatsQueue();
2227
3093
 
2228
3094
  JSONArray jsonArray = new JSONArray();
2229
3095
  for (QueuedStatsEvent queuedEvent : eventsToSend) {
@@ -2240,32 +3106,93 @@ public class CapgoUpdater {
2240
3106
  new okhttp3.Callback() {
2241
3107
  @Override
2242
3108
  public void onFailure(@NonNull Call call, @NonNull IOException e) {
2243
- logger.error("Failed to send stats batch");
2244
- logger.debug("Error: " + e.getMessage());
3109
+ if (abandonStoppedStatsFlush()) {
3110
+ return;
3111
+ }
3112
+ requeueStatsEvents(eventsToSend);
3113
+ if (logger != null) {
3114
+ logger.error("Failed to send stats batch");
3115
+ logger.debug("Error: " + e.getMessage());
3116
+ }
3117
+ statsFlushInFlight.set(false);
2245
3118
  }
2246
3119
 
2247
3120
  @Override
2248
3121
  public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
2249
3122
  try (ResponseBody responseBody = response.body()) {
2250
- // Check for 429 rate limit
2251
- if (checkAndHandleRateLimitResponse(response)) {
3123
+ if (abandonStoppedStatsFlush()) {
3124
+ return;
3125
+ }
3126
+ final String responseData = responseBody != null ? responseBody.string() : "";
3127
+ if (checkAndHandleRateLimitResponse(response, responseData).blocked) {
3128
+ requeueStatsEvents(eventsToSend);
2252
3129
  return;
2253
3130
  }
2254
3131
 
2255
3132
  if (response.isSuccessful()) {
2256
- logger.info("Stats batch sent successfully");
2257
- logger.debug("Sent " + eventCount + " events");
3133
+ synchronized (statsQueue) {
3134
+ statsInFlight.clear();
3135
+ }
3136
+ persistStatsQueue();
3137
+ if (logger != null) {
3138
+ logger.info("Stats batch sent successfully");
3139
+ logger.debug("Sent " + eventCount + " events");
3140
+ }
2258
3141
  runStatsCallbacks(eventsToSend);
3142
+ } else if (isTransientStatsFailure(response.code())) {
3143
+ requeueStatsEvents(eventsToSend);
3144
+ if (logger != null) {
3145
+ logger.error("Error sending stats batch");
3146
+ logger.debug("Retrying later, response code: " + response.code());
3147
+ }
2259
3148
  } else {
2260
- logger.error("Error sending stats batch");
2261
- logger.debug("Response code: " + response.code());
3149
+ synchronized (statsQueue) {
3150
+ statsInFlight.clear();
3151
+ }
3152
+ persistStatsQueue();
3153
+ if (logger != null) {
3154
+ logger.error("Dropping stats batch after permanent error");
3155
+ logger.debug("Response code: " + response.code());
3156
+ }
2262
3157
  }
3158
+ } finally {
3159
+ statsFlushInFlight.set(false);
2263
3160
  }
2264
3161
  }
2265
3162
  }
2266
3163
  );
2267
3164
  }
2268
3165
 
3166
+ private boolean abandonStoppedStatsFlush() {
3167
+ if (!statsStopped.get()) {
3168
+ return false;
3169
+ }
3170
+ statsFlushInFlight.set(false);
3171
+ return true;
3172
+ }
3173
+
3174
+ /**
3175
+ * Only 429, request timeout and 5xx are worth retrying; other 4xx are permanent rejections.
3176
+ */
3177
+ private static boolean isTransientStatsFailure(final int statusCode) {
3178
+ return statusCode == 429 || statusCode == 408 || statusCode >= 500;
3179
+ }
3180
+
3181
+ private void requeueStatsEvents(final List<QueuedStatsEvent> events) {
3182
+ if (statsStopped.get() || events == null || events.isEmpty()) {
3183
+ return;
3184
+ }
3185
+ synchronized (statsQueue) {
3186
+ statsInFlight.clear();
3187
+ statsQueue.addAll(0, events);
3188
+ while (statsQueue.size() > MAX_PENDING_STATS) {
3189
+ statsQueue.remove(0);
3190
+ }
3191
+ }
3192
+ persistStatsQueue();
3193
+ ensureStatsTimerStarted();
3194
+ }
3195
+
2269
3196
  private void runStatsCallbacks(final List<QueuedStatsEvent> sentEvents) {
2270
3197
  for (final QueuedStatsEvent sentEvent : sentEvents) {
2271
3198
  if (sentEvent.onSent == null) {
@@ -2333,10 +3260,10 @@ public class CapgoUpdater {
2333
3260
  this.saveBundleInfo(id, null);
2334
3261
  }
2335
3262
 
2336
- public void saveBundleInfo(final String id, final BundleInfo info) {
3263
+ public boolean saveBundleInfo(final String id, final BundleInfo info) {
2337
3264
  if (id == null || (info != null && (info.isBuiltin() || info.isUnknown()))) {
2338
3265
  logger.debug("Not saving info for bundle: [" + id + "] " + info);
2339
- return;
3266
+ return false;
2340
3267
  }
2341
3268
 
2342
3269
  if (info == null) {
@@ -2348,7 +3275,7 @@ public class CapgoUpdater {
2348
3275
  logger.debug("Storing info for bundle [" + id + "] " + update.getClass().getName() + " -> " + jsonString);
2349
3276
  this.editor.putString(id + INFO_SUFFIX, jsonString);
2350
3277
  }
2351
- this.editor.commit();
3278
+ return this.editor.commit();
2352
3279
  }
2353
3280
 
2354
3281
  private void setBundleStatus(final String id, final BundleStatus status) {
@@ -2453,14 +3380,15 @@ public class CapgoUpdater {
2453
3380
  * Should be called when the plugin is destroyed to prevent resource leaks.
2454
3381
  */
2455
3382
  public void shutdown() {
3383
+ statsStopped.set(true);
2456
3384
  // Cancel the scheduled task
2457
3385
  if (statsFlushTask != null) {
2458
3386
  statsFlushTask.cancel(false);
2459
3387
  statsFlushTask = null;
2460
3388
  }
2461
3389
 
2462
- // Flush any remaining stats before shutdown
2463
- flushStatsQueue();
3390
+ // Write once, then ignore later callbacks so they cannot delete a newer instance's file.
3391
+ persistStatsQueue(true);
2464
3392
 
2465
3393
  // Shutdown the scheduler
2466
3394
  statsScheduler.shutdown();