@capgo/capacitor-updater 8.51.4 → 8.51.6

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.
@@ -54,6 +54,7 @@ import java.util.concurrent.Executors;
54
54
  import java.util.concurrent.ScheduledExecutorService;
55
55
  import java.util.concurrent.ScheduledFuture;
56
56
  import java.util.concurrent.TimeUnit;
57
+ import java.util.concurrent.atomic.AtomicBoolean;
57
58
  import java.util.zip.ZipEntry;
58
59
  import java.util.zip.ZipInputStream;
59
60
  import okhttp3.*;
@@ -91,6 +92,7 @@ public class CapgoUpdater {
91
92
  public SharedPreferences prefs;
92
93
 
93
94
  public File documentsDir;
95
+ public File noBackupDir;
94
96
  public Boolean directUpdate = false;
95
97
  public Activity activity;
96
98
  public String pluginVersion = "";
@@ -112,17 +114,31 @@ public class CapgoUpdater {
112
114
  // Cached key ID calculated once from publicKey
113
115
  private String cachedKeyId = "";
114
116
 
115
- // Flag to track if we received a 429 response - stops requests until app restart
116
- 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";
117
123
 
118
- // Flag to track if we've already sent the rate limit statistic - prevents infinite loop
119
- private static volatile boolean rateLimitStatisticSent = false;
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;
127
+
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;
120
130
 
121
131
  // Stats batching - queue events and send max once per second
122
132
  private final List<QueuedStatsEvent> statsQueue = new CopyOnWriteArrayList<>();
133
+ private final List<QueuedStatsEvent> statsInFlight = new ArrayList<>();
134
+ private final Object pendingStatsPersistLock = new Object();
123
135
  private final ScheduledExecutorService statsScheduler = Executors.newSingleThreadScheduledExecutor();
124
136
  private ScheduledFuture<?> statsFlushTask = null;
137
+ private final AtomicBoolean statsFlushInFlight = new AtomicBoolean(false);
138
+ private final AtomicBoolean statsStopped = new AtomicBoolean(false);
125
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;
126
142
 
127
143
  private static final class QueuedStatsEvent {
128
144
 
@@ -386,7 +402,7 @@ public class CapgoUpdater {
386
402
  io.execute(() -> cacheBundleFiles(id));
387
403
  }
388
404
 
389
- private void cacheBundleFiles(final String id) {
405
+ void cacheBundleFiles(final String id) {
390
406
  if (this.activity == null) {
391
407
  logger.debug("Skip delta cache population: activity is null");
392
408
  return;
@@ -408,13 +424,25 @@ public class CapgoUpdater {
408
424
  return;
409
425
  }
410
426
 
427
+ final File builtinFolder = new File(this.activity.getFilesDir(), "public");
428
+
411
429
  final List<File> files = new ArrayList<>();
412
430
  collectFiles(bundleDir, files);
431
+ final int bundlePrefixLength = bundleDir.getAbsolutePath().length() + 1;
413
432
  for (File file : files) {
414
433
  final String checksum = CryptoCipher.calcChecksum(file);
415
434
  if (checksum.isEmpty()) {
416
435
  continue;
417
436
  }
437
+
438
+ // Builtin is already a permanent reuse source (see isManifestEntryAvailableLocally),
439
+ // so there's no need to also duplicate a byte-identical file into the delta cache.
440
+ final String relativePath = file.getAbsolutePath().substring(bundlePrefixLength);
441
+ final File builtinFile = new File(builtinFolder, relativePath);
442
+ if (verifyChecksum(builtinFile, checksum)) {
443
+ continue;
444
+ }
445
+
418
446
  final String cacheName = checksum + "_" + file.getName();
419
447
  final File cacheFile = new File(cacheDir, cacheName);
420
448
  if (cacheFile.exists()) {
@@ -1913,30 +1941,185 @@ public class CapgoUpdater {
1913
1941
  return json;
1914
1942
  }
1915
1943
 
1944
+ private static final class RemoteBlockResult {
1945
+
1946
+ final boolean blocked;
1947
+ final String error;
1948
+ final String message;
1949
+
1950
+ RemoteBlockResult(final boolean blocked, final String error, final String message) {
1951
+ this.blocked = blocked;
1952
+ this.error = error;
1953
+ this.message = message;
1954
+ }
1955
+ }
1956
+
1957
+ /**
1958
+ * Handle HTTP 429 responses by honouring Retry-After / rateLimitResetAt.
1959
+ * All 429s use the same temporary client block — no sticky latch until restart.
1960
+ */
1961
+ private RemoteBlockResult checkAndHandleRateLimitResponse(Response response, String responseData) {
1962
+ if (response == null || response.code() != 429) {
1963
+ return new RemoteBlockResult(false, "", "");
1964
+ }
1965
+
1966
+ final String parsedError = parseRemoteError(responseData);
1967
+ final String parsedMessage = parseRemoteMessage(responseData);
1968
+ final String errorCode = parsedError.isEmpty() ? "too_many_requests" : parsedError;
1969
+ final String message = parsedMessage.isEmpty() ? "Too many requests" : parsedMessage;
1970
+
1971
+ final long retryUntilMs = resolveRateLimitBlockedUntilMs(response, responseData);
1972
+ synchronized (rateLimitStateLock) {
1973
+ if (retryUntilMs > rateLimitBlockedUntilMs) {
1974
+ rateLimitBlockedUntilMs = retryUntilMs;
1975
+ rateLimitBlockedError = errorCode;
1976
+ rateLimitBlockedMessage = message;
1977
+ } else if (rateLimitBlockedUntilMs <= 0L) {
1978
+ rateLimitBlockedError = errorCode;
1979
+ rateLimitBlockedMessage = message;
1980
+ }
1981
+ }
1982
+
1983
+ // Claim last, and only when there is somewhere to send it, so a 429 burst with no
1984
+ // stats URL does not claim and release the latch once per response.
1985
+ if ("too_many_requests".equals(errorCode) && !this.previewSession && this.hasStatsUrl() && claimRateLimitStatistic()) {
1986
+ sendRateLimitStatistic();
1987
+ }
1988
+
1989
+ final long nowMs = System.currentTimeMillis();
1990
+ final long retryAfter = Math.max(0L, (Math.max(retryUntilMs, nowMs) - nowMs + 999L) / 1000L);
1991
+ logger.warn("Received 429 (" + errorCode + "). Honouring Retry-After: " + retryAfter + "s.");
1992
+ return new RemoteBlockResult(true, errorCode, message);
1993
+ }
1994
+
1995
+ private String parseRemoteError(final String responseData) {
1996
+ if (responseData == null || responseData.isEmpty()) {
1997
+ return "";
1998
+ }
1999
+ try {
2000
+ final JSONObject json = new JSONObject(responseData);
2001
+ return json.optString("error", "");
2002
+ } catch (JSONException ignored) {
2003
+ return "";
2004
+ }
2005
+ }
2006
+
2007
+ private String parseRemoteMessage(final String responseData) {
2008
+ if (responseData == null || responseData.isEmpty()) {
2009
+ return "";
2010
+ }
2011
+ try {
2012
+ final JSONObject json = new JSONObject(responseData);
2013
+ return json.optString("message", "");
2014
+ } catch (JSONException ignored) {
2015
+ return "";
2016
+ }
2017
+ }
2018
+
2019
+ private long resolveRateLimitBlockedUntilMs(final Response response, final String responseData) {
2020
+ final long nowMs = System.currentTimeMillis();
2021
+ final double candidate = rawRateLimitDeadlineMs(response, responseData, nowMs);
2022
+ // NaN and past deadlines mean "no client-side block"; anything further out is capped.
2023
+ if (!(candidate > nowMs)) {
2024
+ return 0L;
2025
+ }
2026
+ return (long) Math.min(candidate, (double) nowMs + MAX_RATE_LIMIT_WINDOW_MS);
2027
+ }
2028
+
2029
+ private double rawRateLimitDeadlineMs(final Response response, final String responseData, final long nowMs) {
2030
+ final String header = response.header("Retry-After");
2031
+ if (header != null) {
2032
+ try {
2033
+ final double seconds = Double.parseDouble(header.trim());
2034
+ if (seconds >= 0) {
2035
+ return nowMs + seconds * 1000d;
2036
+ }
2037
+ } catch (NumberFormatException ignored) {
2038
+ // Fall through to body fields
2039
+ }
2040
+ }
2041
+
2042
+ if (responseData != null && !responseData.isEmpty()) {
2043
+ try {
2044
+ final JSONObject json = new JSONObject(responseData);
2045
+ final JSONObject moreInfo = json.optJSONObject("moreInfo");
2046
+ if (moreInfo != null && moreInfo.has("retryAfterSeconds")) {
2047
+ final double retryAfter = moreInfo.getDouble("retryAfterSeconds");
2048
+ if (retryAfter >= 0) {
2049
+ return nowMs + retryAfter * 1000d;
2050
+ }
2051
+ } else if (json.has("retryAfterSeconds")) {
2052
+ final double retryAfter = json.getDouble("retryAfterSeconds");
2053
+ if (retryAfter >= 0) {
2054
+ return nowMs + retryAfter * 1000d;
2055
+ }
2056
+ }
2057
+ if (moreInfo != null && moreInfo.has("rateLimitResetAt")) {
2058
+ return moreInfo.getDouble("rateLimitResetAt");
2059
+ } else if (json.has("rateLimitResetAt")) {
2060
+ return json.getDouble("rateLimitResetAt");
2061
+ }
2062
+ } catch (JSONException ignored) {
2063
+ // No retry hint
2064
+ }
2065
+ }
2066
+
2067
+ // No retry hint — do not hold a client-side block; allow immediate retry to the worker
2068
+ return 0d;
2069
+ }
2070
+
2071
+ private static boolean claimRateLimitStatistic() {
2072
+ synchronized (rateLimitStateLock) {
2073
+ if (rateLimitStatisticSent) {
2074
+ return false;
2075
+ }
2076
+ rateLimitStatisticSent = true;
2077
+ return true;
2078
+ }
2079
+ }
2080
+
1916
2081
  /**
1917
- * Check if a 429 (Too Many Requests) response was received and set the flag
2082
+ * Give the claim back when the statistic never made it out, so a later 429 can retry it.
1918
2083
  */
1919
- private boolean checkAndHandleRateLimitResponse(Response response) {
1920
- if (response.code() == 429) {
1921
- // Send a statistic about the rate limit BEFORE setting the flag
1922
- // Only send once to prevent infinite loop if the stat request itself gets rate limited
1923
- if (!this.previewSession && !rateLimitExceeded && !rateLimitStatisticSent) {
1924
- rateLimitStatisticSent = true;
1925
- sendRateLimitStatistic();
1926
- }
1927
- rateLimitExceeded = true;
1928
- logger.warn("Rate limit exceeded (429). Stopping all stats and channel requests until app restart.");
2084
+ private static void releaseRateLimitStatisticClaim() {
2085
+ synchronized (rateLimitStateLock) {
2086
+ rateLimitStatisticSent = false;
2087
+ }
2088
+ }
2089
+
2090
+ private boolean hasStatsUrl() {
2091
+ final String url = this.statsUrl;
2092
+ return url != null && !url.isEmpty();
2093
+ }
2094
+
2095
+ private boolean isRemoteBlocked() {
2096
+ synchronized (rateLimitStateLock) {
2097
+ if (rateLimitBlockedUntilMs <= 0L) {
2098
+ return false;
2099
+ }
2100
+ if (System.currentTimeMillis() >= rateLimitBlockedUntilMs) {
2101
+ rateLimitBlockedUntilMs = 0L;
2102
+ return false;
2103
+ }
1929
2104
  return true;
1930
2105
  }
1931
- return false;
2106
+ }
2107
+
2108
+ private RemoteBlockResult remoteBlockedClientError() {
2109
+ synchronized (rateLimitStateLock) {
2110
+ return new RemoteBlockResult(true, rateLimitBlockedError, rateLimitBlockedMessage);
2111
+ }
1932
2112
  }
1933
2113
 
1934
2114
  /**
1935
- * Send a synchronous statistic about rate limiting
2115
+ * Send a statistic about rate limiting.
2116
+ * Dispatched through OkHttp so no caller thread waits on the request.
1936
2117
  */
1937
2118
  private void sendRateLimitStatistic() {
1938
2119
  String statsUrl = this.statsUrl;
1939
2120
  if (statsUrl == null || statsUrl.isEmpty()) {
2121
+ // The URL was cleared after the claim was taken; nothing went out, so hand it back.
2122
+ releaseRateLimitStatisticClaim();
1940
2123
  return;
1941
2124
  }
1942
2125
 
@@ -1952,17 +2135,33 @@ public class CapgoUpdater {
1952
2135
  .post(RequestBody.create(json.toString(), MediaType.get("application/json")))
1953
2136
  .build();
1954
2137
 
1955
- // Send synchronously to ensure it goes out before the flag is set
1956
2138
  // User-Agent header is automatically added by DownloadService.sharedClient interceptor
1957
- try (Response response = DownloadService.sharedClient.newCall(request).execute()) {
1958
- if (response.isSuccessful()) {
1959
- logger.info("Rate limit statistic sent");
1960
- } else {
1961
- logger.error("Error sending rate limit statistic");
1962
- logger.debug("Response code: " + response.code());
2139
+ DownloadService.sharedClient.newCall(request).enqueue(
2140
+ new okhttp3.Callback() {
2141
+ @Override
2142
+ public void onFailure(@NonNull Call call, @NonNull IOException e) {
2143
+ releaseRateLimitStatisticClaim();
2144
+ logger.error("Failed to send rate limit statistic");
2145
+ logger.debug("Error: " + e.getMessage());
2146
+ }
2147
+
2148
+ @Override
2149
+ public void onResponse(@NonNull Call call, @NonNull Response response) {
2150
+ // The body is unused here; closing the Response closes it.
2151
+ try (response) {
2152
+ if (response.isSuccessful()) {
2153
+ logger.info("Rate limit statistic sent");
2154
+ } else {
2155
+ releaseRateLimitStatisticClaim();
2156
+ logger.error("Error sending rate limit statistic");
2157
+ logger.debug("Response code: " + response.code());
2158
+ }
2159
+ }
2160
+ }
1963
2161
  }
1964
- }
2162
+ );
1965
2163
  } catch (final Exception e) {
2164
+ releaseRateLimitStatisticClaim();
1966
2165
  logger.error("Failed to send rate limit statistic");
1967
2166
  logger.debug("Error: " + e.getMessage());
1968
2167
  }
@@ -2001,7 +2200,27 @@ public class CapgoUpdater {
2001
2200
 
2002
2201
  if (jsonResponse != null && (jsonResponse.has("error") || jsonResponse.has("kind"))) {
2003
2202
  if (statusCode == 429) {
2004
- checkAndHandleRateLimitResponse(response);
2203
+ final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
2204
+ Map<String, Object> retError = new HashMap<>();
2205
+ retError.put(
2206
+ "error",
2207
+ rateLimit.error.isEmpty() ? jsonResponse.optString("error", "too_many_requests") : rateLimit.error
2208
+ );
2209
+ retError.put(
2210
+ "message",
2211
+ rateLimit.message.isEmpty() ? jsonResponse.optString("message", "Too many requests") : rateLimit.message
2212
+ );
2213
+ if (jsonResponse.has("kind") && !jsonResponse.isNull("kind")) {
2214
+ retError.put("kind", jsonResponse.getString("kind"));
2215
+ } else {
2216
+ retError.put("kind", "failed");
2217
+ }
2218
+ if (jsonResponse.has("version") && !jsonResponse.isNull("version")) {
2219
+ retError.put("version", jsonResponse.getString("version"));
2220
+ }
2221
+ retError.put("statusCode", statusCode);
2222
+ callback.callback(retError);
2223
+ return;
2005
2224
  }
2006
2225
  Map<String, Object> retError = new HashMap<>();
2007
2226
  if (jsonResponse.has("error") && !jsonResponse.isNull("error")) {
@@ -2023,11 +2242,12 @@ public class CapgoUpdater {
2023
2242
  return;
2024
2243
  }
2025
2244
 
2026
- // Check for 429 rate limit
2027
- if (checkAndHandleRateLimitResponse(response)) {
2245
+ // Check for 429 rate limit without JSON body
2246
+ final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
2247
+ if (rateLimit.blocked) {
2028
2248
  Map<String, Object> retError = new HashMap<>();
2029
- retError.put("message", "Rate limit exceeded");
2030
- retError.put("error", "rate_limit_exceeded");
2249
+ retError.put("message", rateLimit.message);
2250
+ retError.put("error", rateLimit.error);
2031
2251
  retError.put("kind", "failed");
2032
2252
  retError.put("statusCode", statusCode);
2033
2253
  callback.callback(retError);
@@ -2080,6 +2300,16 @@ public class CapgoUpdater {
2080
2300
  }
2081
2301
 
2082
2302
  public void getLatest(final String updateUrl, final String channel, final String appIdOverride, final Callback callback) {
2303
+ if (isRemoteBlocked()) {
2304
+ final RemoteBlockResult blocked = remoteBlockedClientError();
2305
+ logger.debug("Skipping getLatest due to remote block (" + blocked.error + ").");
2306
+ final Map<String, Object> retError = new HashMap<>();
2307
+ retError.put("message", blocked.message);
2308
+ retError.put("error", blocked.error);
2309
+ retError.put("kind", "failed");
2310
+ callback.callback(retError);
2311
+ return;
2312
+ }
2083
2313
  JSONObject json;
2084
2314
  try {
2085
2315
  json = this.createInfoObject(appIdOverride);
@@ -2149,12 +2379,12 @@ public class CapgoUpdater {
2149
2379
  return;
2150
2380
  }
2151
2381
 
2152
- // Check if rate limit was exceeded
2153
- if (rateLimitExceeded) {
2154
- logger.debug("Skipping setChannel due to rate limit (429). Requests will resume after app restart.");
2382
+ if (isRemoteBlocked()) {
2383
+ final RemoteBlockResult blocked = remoteBlockedClientError();
2384
+ logger.debug("Skipping setChannel due to remote block (" + blocked.error + ").");
2155
2385
  final Map<String, Object> retError = new HashMap<>();
2156
- retError.put("message", "Rate limit exceeded");
2157
- retError.put("error", "rate_limit_exceeded");
2386
+ retError.put("message", blocked.message);
2387
+ retError.put("error", blocked.error);
2158
2388
  callback.callback(retError);
2159
2389
  return;
2160
2390
  }
@@ -2208,12 +2438,12 @@ public class CapgoUpdater {
2208
2438
  }
2209
2439
 
2210
2440
  public void getChannel(final Callback callback, final SharedPreferences.Editor editor, final String defaultChannelKey) {
2211
- // Check if rate limit was exceeded
2212
- if (rateLimitExceeded) {
2213
- logger.debug("Skipping getChannel due to rate limit (429). Requests will resume after app restart.");
2441
+ if (isRemoteBlocked()) {
2442
+ final RemoteBlockResult blocked = remoteBlockedClientError();
2443
+ logger.debug("Skipping getChannel due to remote block (" + blocked.error + ").");
2214
2444
  final Map<String, Object> retError = new HashMap<>();
2215
- retError.put("message", "Rate limit exceeded");
2216
- retError.put("error", "rate_limit_exceeded");
2445
+ retError.put("message", blocked.message);
2446
+ retError.put("error", blocked.error);
2217
2447
  callback.callback(retError);
2218
2448
  return;
2219
2449
  }
@@ -2258,25 +2488,18 @@ public class CapgoUpdater {
2258
2488
  @Override
2259
2489
  public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
2260
2490
  try (ResponseBody responseBody = response.body()) {
2261
- // Check for 429 rate limit
2262
- if (checkAndHandleRateLimitResponse(response)) {
2491
+ final String responseData = responseBody != null ? responseBody.string() : "";
2492
+ final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
2493
+ if (rateLimit.blocked) {
2263
2494
  Map<String, Object> retError = new HashMap<>();
2264
- retError.put("message", "Rate limit exceeded");
2265
- retError.put("error", "rate_limit_exceeded");
2495
+ retError.put("message", rateLimit.message);
2496
+ retError.put("error", rateLimit.error);
2266
2497
  callback.callback(retError);
2267
2498
  return;
2268
2499
  }
2269
2500
 
2270
2501
  if (response.code() == 400) {
2271
- if (responseBody == null) {
2272
- Map<String, Object> retError = new HashMap<>();
2273
- retError.put("message", "Empty response body");
2274
- retError.put("error", "no_response_body");
2275
- callback.callback(retError);
2276
- return;
2277
- }
2278
- String data = responseBody.string();
2279
- if (data.contains("channel_not_found") && !defaultChannel.isEmpty()) {
2502
+ if (responseData.contains("channel_not_found") && !defaultChannel.isEmpty()) {
2280
2503
  Map<String, Object> ret = new HashMap<>();
2281
2504
  ret.put("channel", defaultChannel);
2282
2505
  ret.put("status", "default");
@@ -2294,14 +2517,13 @@ public class CapgoUpdater {
2294
2517
  return;
2295
2518
  }
2296
2519
 
2297
- if (responseBody == null) {
2520
+ if (responseData.isEmpty()) {
2298
2521
  Map<String, Object> retError = new HashMap<>();
2299
2522
  retError.put("message", "Empty response body");
2300
2523
  retError.put("error", "no_response_body");
2301
2524
  callback.callback(retError);
2302
2525
  return;
2303
2526
  }
2304
- String responseData = responseBody.string();
2305
2527
  JSONObject jsonResponse = new JSONObject(responseData);
2306
2528
 
2307
2529
  // Check for server-side errors first
@@ -2359,12 +2581,12 @@ public class CapgoUpdater {
2359
2581
  }
2360
2582
 
2361
2583
  public void listChannels(final Callback callback) {
2362
- // Check if rate limit was exceeded
2363
- if (rateLimitExceeded) {
2364
- logger.debug("Skipping listChannels due to rate limit (429). Requests will resume after app restart.");
2584
+ if (isRemoteBlocked()) {
2585
+ final RemoteBlockResult blocked = remoteBlockedClientError();
2586
+ logger.debug("Skipping listChannels due to remote block (" + blocked.error + ").");
2365
2587
  final Map<String, Object> retError = new HashMap<>();
2366
- retError.put("message", "Rate limit exceeded");
2367
- retError.put("error", "rate_limit_exceeded");
2588
+ retError.put("message", blocked.message);
2589
+ retError.put("error", blocked.error);
2368
2590
  callback.callback(retError);
2369
2591
  return;
2370
2592
  }
@@ -2423,11 +2645,12 @@ public class CapgoUpdater {
2423
2645
  @Override
2424
2646
  public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
2425
2647
  try (ResponseBody responseBody = response.body()) {
2426
- // Check for 429 rate limit
2427
- if (checkAndHandleRateLimitResponse(response)) {
2648
+ final String data = responseBody != null ? responseBody.string() : "";
2649
+ final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, data);
2650
+ if (rateLimit.blocked) {
2428
2651
  Map<String, Object> retError = new HashMap<>();
2429
- retError.put("message", "Rate limit exceeded");
2430
- retError.put("error", "rate_limit_exceeded");
2652
+ retError.put("message", rateLimit.message);
2653
+ retError.put("error", rateLimit.error);
2431
2654
  callback.callback(retError);
2432
2655
  return;
2433
2656
  }
@@ -2440,14 +2663,13 @@ public class CapgoUpdater {
2440
2663
  return;
2441
2664
  }
2442
2665
 
2443
- if (responseBody == null) {
2666
+ if (data.isEmpty()) {
2444
2667
  Map<String, Object> retError = new HashMap<>();
2445
2668
  retError.put("message", "Empty response body");
2446
2669
  retError.put("error", "no_response_body");
2447
2670
  callback.callback(retError);
2448
2671
  return;
2449
2672
  }
2450
- String data = responseBody.string();
2451
2673
 
2452
2674
  try {
2453
2675
  Map<String, Object> ret = parseListChannelsResponse(data);
@@ -2535,6 +2757,10 @@ public class CapgoUpdater {
2535
2757
  final Map<String, String> metadata,
2536
2758
  final Runnable onSent
2537
2759
  ) {
2760
+ if (statsStopped.get()) {
2761
+ return;
2762
+ }
2763
+
2538
2764
  if (this.previewSession) {
2539
2765
  if (logger != null) {
2540
2766
  logger.debug("Skipping sendStats during preview session.");
@@ -2542,12 +2768,6 @@ public class CapgoUpdater {
2542
2768
  return;
2543
2769
  }
2544
2770
 
2545
- // Check if rate limit was exceeded
2546
- if (rateLimitExceeded) {
2547
- logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.");
2548
- return;
2549
- }
2550
-
2551
2771
  String statsUrl = this.statsUrl;
2552
2772
  if (statsUrl == null || statsUrl.isEmpty()) {
2553
2773
  return;
@@ -2564,16 +2784,150 @@ public class CapgoUpdater {
2564
2784
  json.put("metadata", new JSONObject(metadata));
2565
2785
  }
2566
2786
  } catch (JSONException e) {
2567
- logger.error("Error preparing stats");
2568
- logger.debug("JSONException: " + e.getMessage());
2787
+ if (logger != null) {
2788
+ logger.error("Error preparing stats");
2789
+ logger.debug("JSONException: " + e.getMessage());
2790
+ }
2569
2791
  return;
2570
2792
  }
2571
2793
 
2572
- statsQueue.add(new QueuedStatsEvent(json, onSent));
2794
+ synchronized (statsQueue) {
2795
+ if (statsStopped.get()) {
2796
+ return;
2797
+ }
2798
+ while (statsQueue.size() >= MAX_PENDING_STATS) {
2799
+ statsQueue.remove(0);
2800
+ }
2801
+ statsQueue.add(new QueuedStatsEvent(json, onSent));
2802
+ }
2573
2803
  ensureStatsTimerStarted();
2574
2804
  }
2575
2805
 
2806
+ public void restorePendingStats() {
2807
+ File file = pendingStatsFile();
2808
+ if (file == null || !file.exists()) {
2809
+ return;
2810
+ }
2811
+ try {
2812
+ String raw = readFileUtf8(file);
2813
+ JSONArray arr = new JSONArray(raw);
2814
+ synchronized (statsQueue) {
2815
+ for (int i = 0; i < arr.length(); i++) {
2816
+ if (statsQueue.size() >= MAX_PENDING_STATS) {
2817
+ break;
2818
+ }
2819
+ statsQueue.add(new QueuedStatsEvent(arr.getJSONObject(i), null));
2820
+ }
2821
+ }
2822
+ if (!statsQueue.isEmpty()) {
2823
+ if (logger != null) {
2824
+ logger.info("Restored " + statsQueue.size() + " pending stats events");
2825
+ }
2826
+ ensureStatsTimerStarted();
2827
+ }
2828
+ } catch (Exception e) {
2829
+ if (logger != null) {
2830
+ logger.error("Failed to restore pending stats");
2831
+ logger.debug("Error: " + e.getMessage());
2832
+ }
2833
+ }
2834
+ }
2835
+
2836
+ int pendingStatsCount() {
2837
+ return statsQueue.size();
2838
+ }
2839
+
2840
+ public void persistPendingStats() {
2841
+ persistStatsQueue();
2842
+ }
2843
+
2844
+ private File pendingStatsFile() {
2845
+ final File dir = this.noBackupDir != null ? this.noBackupDir : this.documentsDir;
2846
+ if (dir == null) {
2847
+ return null;
2848
+ }
2849
+ return new File(dir, PENDING_STATS_FILE);
2850
+ }
2851
+
2852
+ private void persistStatsQueue() {
2853
+ persistStatsQueue(false);
2854
+ }
2855
+
2856
+ private void persistStatsQueue(final boolean force) {
2857
+ File file = pendingStatsFile();
2858
+ if (file == null) {
2859
+ return;
2860
+ }
2861
+ synchronized (pendingStatsPersistLock) {
2862
+ if (statsStopped.get() && !force) {
2863
+ return;
2864
+ }
2865
+ JSONArray arr = new JSONArray();
2866
+ synchronized (statsQueue) {
2867
+ final List<QueuedStatsEvent> combined = new ArrayList<>(statsInFlight.size() + statsQueue.size());
2868
+ combined.addAll(statsInFlight);
2869
+ combined.addAll(statsQueue);
2870
+ final int start = Math.max(0, combined.size() - MAX_PENDING_STATS);
2871
+ for (int i = start; i < combined.size(); i++) {
2872
+ arr.put(combined.get(i).event);
2873
+ }
2874
+ }
2875
+ try {
2876
+ if (arr.length() == 0) {
2877
+ if (file.exists() && !file.delete()) {
2878
+ if (logger != null) {
2879
+ logger.error("Failed to delete empty stats queue file");
2880
+ }
2881
+ }
2882
+ return;
2883
+ }
2884
+ writeFileAtomically(file, arr.toString().getBytes(StandardCharsets.UTF_8));
2885
+ } catch (Exception e) {
2886
+ if (logger != null) {
2887
+ logger.error("Failed to persist stats queue");
2888
+ logger.debug("Error: " + e.getMessage());
2889
+ }
2890
+ }
2891
+ }
2892
+ }
2893
+
2894
+ private static String readFileUtf8(final File file) throws IOException {
2895
+ final long length = file.length();
2896
+ final byte[] buf = new byte[length > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) length];
2897
+ try (FileInputStream in = new FileInputStream(file)) {
2898
+ int offset = 0;
2899
+ while (offset < buf.length) {
2900
+ final int read = in.read(buf, offset, buf.length - offset);
2901
+ if (read < 0) {
2902
+ break;
2903
+ }
2904
+ offset += read;
2905
+ }
2906
+ return new String(buf, 0, offset, StandardCharsets.UTF_8);
2907
+ }
2908
+ }
2909
+
2910
+ private static void writeFileAtomically(final File file, final byte[] bytes) throws IOException {
2911
+ final File tmp = new File(file.getAbsolutePath() + ".tmp");
2912
+ try (FileOutputStream out = new FileOutputStream(tmp)) {
2913
+ out.write(bytes);
2914
+ out.flush();
2915
+ }
2916
+ if (tmp.renameTo(file)) {
2917
+ return;
2918
+ }
2919
+ if (file.exists() && !file.delete()) {
2920
+ throw new IOException("Failed to replace " + file.getAbsolutePath());
2921
+ }
2922
+ if (!tmp.renameTo(file)) {
2923
+ throw new IOException("Failed to persist " + file.getAbsolutePath());
2924
+ }
2925
+ }
2926
+
2576
2927
  private synchronized void ensureStatsTimerStarted() {
2928
+ if (statsStopped.get()) {
2929
+ return;
2930
+ }
2577
2931
  if (statsFlushTask == null || statsFlushTask.isCancelled() || statsFlushTask.isDone()) {
2578
2932
  statsFlushTask = statsScheduler.scheduleAtFixedRate(
2579
2933
  this::flushStatsQueue,
@@ -2585,25 +2939,45 @@ public class CapgoUpdater {
2585
2939
  }
2586
2940
 
2587
2941
  private void flushStatsQueue() {
2942
+ if (statsStopped.get()) {
2943
+ return;
2944
+ }
2588
2945
  if (statsQueue.isEmpty()) {
2589
2946
  return;
2590
2947
  }
2591
2948
 
2949
+ // While Retry-After is active, keep stats queued and skip the network call.
2950
+ if (isRemoteBlocked()) {
2951
+ logger.debug("Deferring stats flush until Retry-After expires.");
2952
+ return;
2953
+ }
2954
+
2592
2955
  String statsUrl = this.statsUrl;
2593
2956
  if (statsUrl == null || statsUrl.isEmpty()) {
2594
- statsQueue.clear();
2957
+ synchronized (statsQueue) {
2958
+ statsQueue.clear();
2959
+ statsInFlight.clear();
2960
+ }
2961
+ persistStatsQueue();
2962
+ return;
2963
+ }
2964
+
2965
+ if (!statsFlushInFlight.compareAndSet(false, true)) {
2595
2966
  return;
2596
2967
  }
2597
2968
 
2598
- // Copy and clear the queue atomically using synchronized block
2599
- List<QueuedStatsEvent> eventsToSend;
2969
+ final List<QueuedStatsEvent> eventsToSend;
2600
2970
  synchronized (statsQueue) {
2601
2971
  if (statsQueue.isEmpty()) {
2972
+ statsFlushInFlight.set(false);
2602
2973
  return;
2603
2974
  }
2604
2975
  eventsToSend = new ArrayList<>(statsQueue);
2605
2976
  statsQueue.clear();
2977
+ statsInFlight.clear();
2978
+ statsInFlight.addAll(eventsToSend);
2606
2979
  }
2980
+ persistStatsQueue();
2607
2981
 
2608
2982
  JSONArray jsonArray = new JSONArray();
2609
2983
  for (QueuedStatsEvent queuedEvent : eventsToSend) {
@@ -2620,32 +2994,93 @@ public class CapgoUpdater {
2620
2994
  new okhttp3.Callback() {
2621
2995
  @Override
2622
2996
  public void onFailure(@NonNull Call call, @NonNull IOException e) {
2623
- logger.error("Failed to send stats batch");
2624
- logger.debug("Error: " + e.getMessage());
2997
+ if (abandonStoppedStatsFlush()) {
2998
+ return;
2999
+ }
3000
+ requeueStatsEvents(eventsToSend);
3001
+ if (logger != null) {
3002
+ logger.error("Failed to send stats batch");
3003
+ logger.debug("Error: " + e.getMessage());
3004
+ }
3005
+ statsFlushInFlight.set(false);
2625
3006
  }
2626
3007
 
2627
3008
  @Override
2628
3009
  public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
2629
3010
  try (ResponseBody responseBody = response.body()) {
2630
- // Check for 429 rate limit
2631
- if (checkAndHandleRateLimitResponse(response)) {
3011
+ if (abandonStoppedStatsFlush()) {
3012
+ return;
3013
+ }
3014
+ final String responseData = responseBody != null ? responseBody.string() : "";
3015
+ if (checkAndHandleRateLimitResponse(response, responseData).blocked) {
3016
+ requeueStatsEvents(eventsToSend);
2632
3017
  return;
2633
3018
  }
2634
3019
 
2635
3020
  if (response.isSuccessful()) {
2636
- logger.info("Stats batch sent successfully");
2637
- logger.debug("Sent " + eventCount + " events");
3021
+ synchronized (statsQueue) {
3022
+ statsInFlight.clear();
3023
+ }
3024
+ persistStatsQueue();
3025
+ if (logger != null) {
3026
+ logger.info("Stats batch sent successfully");
3027
+ logger.debug("Sent " + eventCount + " events");
3028
+ }
2638
3029
  runStatsCallbacks(eventsToSend);
3030
+ } else if (isTransientStatsFailure(response.code())) {
3031
+ requeueStatsEvents(eventsToSend);
3032
+ if (logger != null) {
3033
+ logger.error("Error sending stats batch");
3034
+ logger.debug("Retrying later, response code: " + response.code());
3035
+ }
2639
3036
  } else {
2640
- logger.error("Error sending stats batch");
2641
- logger.debug("Response code: " + response.code());
3037
+ synchronized (statsQueue) {
3038
+ statsInFlight.clear();
3039
+ }
3040
+ persistStatsQueue();
3041
+ if (logger != null) {
3042
+ logger.error("Dropping stats batch after permanent error");
3043
+ logger.debug("Response code: " + response.code());
3044
+ }
2642
3045
  }
3046
+ } finally {
3047
+ statsFlushInFlight.set(false);
2643
3048
  }
2644
3049
  }
2645
3050
  }
2646
3051
  );
2647
3052
  }
2648
3053
 
3054
+ private boolean abandonStoppedStatsFlush() {
3055
+ if (!statsStopped.get()) {
3056
+ return false;
3057
+ }
3058
+ statsFlushInFlight.set(false);
3059
+ return true;
3060
+ }
3061
+
3062
+ /**
3063
+ * Only 429, request timeout and 5xx are worth retrying; other 4xx are permanent rejections.
3064
+ */
3065
+ private static boolean isTransientStatsFailure(final int statusCode) {
3066
+ return statusCode == 429 || statusCode == 408 || statusCode >= 500;
3067
+ }
3068
+
3069
+ private void requeueStatsEvents(final List<QueuedStatsEvent> events) {
3070
+ if (statsStopped.get() || events == null || events.isEmpty()) {
3071
+ return;
3072
+ }
3073
+ synchronized (statsQueue) {
3074
+ statsInFlight.clear();
3075
+ statsQueue.addAll(0, events);
3076
+ while (statsQueue.size() > MAX_PENDING_STATS) {
3077
+ statsQueue.remove(0);
3078
+ }
3079
+ }
3080
+ persistStatsQueue();
3081
+ ensureStatsTimerStarted();
3082
+ }
3083
+
2649
3084
  private void runStatsCallbacks(final List<QueuedStatsEvent> sentEvents) {
2650
3085
  for (final QueuedStatsEvent sentEvent : sentEvents) {
2651
3086
  if (sentEvent.onSent == null) {
@@ -2833,14 +3268,15 @@ public class CapgoUpdater {
2833
3268
  * Should be called when the plugin is destroyed to prevent resource leaks.
2834
3269
  */
2835
3270
  public void shutdown() {
3271
+ statsStopped.set(true);
2836
3272
  // Cancel the scheduled task
2837
3273
  if (statsFlushTask != null) {
2838
3274
  statsFlushTask.cancel(false);
2839
3275
  statsFlushTask = null;
2840
3276
  }
2841
3277
 
2842
- // Flush any remaining stats before shutdown
2843
- flushStatsQueue();
3278
+ // Write once, then ignore later callbacks so they cannot delete a newer instance's file.
3279
+ persistStatsQueue(true);
2844
3280
 
2845
3281
  // Shutdown the scheduler
2846
3282
  statsScheduler.shutdown();