@capgo/capacitor-updater 7.50.2 → 7.51.16

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