@capgo/capacitor-updater 8.51.3 → 8.51.5

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.
@@ -50,7 +50,7 @@ repositories {
50
50
 
51
51
  dependencies {
52
52
  def work_version = "2.10.5"
53
- def lifecycle_version = "2.10.0"
53
+ def lifecycle_version = "2.11.0"
54
54
  implementation "androidx.work:work-runtime:$work_version"
55
55
  implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"
56
56
  implementation "com.google.android.gms:play-services-tasks:18.4.1"
@@ -146,7 +146,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
146
146
  static final int APPLICATION_EXIT_REASON_USER_REQUESTED = 10;
147
147
  static final int APPLICATION_EXIT_REASON_DEPENDENCY_DIED = 12;
148
148
 
149
- private final String pluginVersion = "8.51.3";
149
+ private final String pluginVersion = "8.51.5";
150
150
  private static final String DELAY_CONDITION_PREFERENCES = "";
151
151
 
152
152
  private SharedPreferences.Editor editor;
@@ -3990,7 +3990,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
3990
3990
 
3991
3991
  new AlertDialog.Builder(getActivity())
3992
3992
  .setTitle("Preview started")
3993
- .setMessage("Shake your device anytime to reload or leave the test app.")
3993
+ .setMessage("shake".equals(this.shakeMenuGesture) ? "Shake to open menu." : "Three-finger pinch to open menu.")
3994
3994
  .setPositiveButton("Got it", (dialog, which) -> dialog.dismiss())
3995
3995
  .show();
3996
3996
  } catch (final Exception e) {
@@ -112,11 +112,19 @@ public class CapgoUpdater {
112
112
  // Cached key ID calculated once from publicKey
113
113
  private String cachedKeyId = "";
114
114
 
115
- // Flag to track if we received a 429 response - stops requests until app restart
116
- 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";
117
121
 
118
- // Flag to track if we've already sent the rate limit statistic - prevents infinite loop
119
- private static volatile boolean rateLimitStatisticSent = false;
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;
125
+
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;
120
128
 
121
129
  // Stats batching - queue events and send max once per second
122
130
  private final List<QueuedStatsEvent> statsQueue = new CopyOnWriteArrayList<>();
@@ -386,7 +394,7 @@ public class CapgoUpdater {
386
394
  io.execute(() -> cacheBundleFiles(id));
387
395
  }
388
396
 
389
- private void cacheBundleFiles(final String id) {
397
+ void cacheBundleFiles(final String id) {
390
398
  if (this.activity == null) {
391
399
  logger.debug("Skip delta cache population: activity is null");
392
400
  return;
@@ -408,13 +416,25 @@ public class CapgoUpdater {
408
416
  return;
409
417
  }
410
418
 
419
+ final File builtinFolder = new File(this.activity.getFilesDir(), "public");
420
+
411
421
  final List<File> files = new ArrayList<>();
412
422
  collectFiles(bundleDir, files);
423
+ final int bundlePrefixLength = bundleDir.getAbsolutePath().length() + 1;
413
424
  for (File file : files) {
414
425
  final String checksum = CryptoCipher.calcChecksum(file);
415
426
  if (checksum.isEmpty()) {
416
427
  continue;
417
428
  }
429
+
430
+ // Builtin is already a permanent reuse source (see isManifestEntryAvailableLocally),
431
+ // so there's no need to also duplicate a byte-identical file into the delta cache.
432
+ final String relativePath = file.getAbsolutePath().substring(bundlePrefixLength);
433
+ final File builtinFile = new File(builtinFolder, relativePath);
434
+ if (verifyChecksum(builtinFile, checksum)) {
435
+ continue;
436
+ }
437
+
418
438
  final String cacheName = checksum + "_" + file.getName();
419
439
  final File cacheFile = new File(cacheDir, cacheName);
420
440
  if (cacheFile.exists()) {
@@ -1913,30 +1933,185 @@ public class CapgoUpdater {
1913
1933
  return json;
1914
1934
  }
1915
1935
 
1936
+ private static final class RemoteBlockResult {
1937
+
1938
+ final boolean blocked;
1939
+ final String error;
1940
+ final String message;
1941
+
1942
+ RemoteBlockResult(final boolean blocked, final String error, final String message) {
1943
+ this.blocked = blocked;
1944
+ this.error = error;
1945
+ this.message = message;
1946
+ }
1947
+ }
1948
+
1916
1949
  /**
1917
- * Check if a 429 (Too Many Requests) response was received and set the flag
1950
+ * Handle HTTP 429 responses by honouring Retry-After / rateLimitResetAt.
1951
+ * All 429s use the same temporary client block — no sticky latch until restart.
1918
1952
  */
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.");
1953
+ private RemoteBlockResult checkAndHandleRateLimitResponse(Response response, String responseData) {
1954
+ if (response == null || response.code() != 429) {
1955
+ return new RemoteBlockResult(false, "", "");
1956
+ }
1957
+
1958
+ final String parsedError = parseRemoteError(responseData);
1959
+ final String parsedMessage = parseRemoteMessage(responseData);
1960
+ final String errorCode = parsedError.isEmpty() ? "too_many_requests" : parsedError;
1961
+ final String message = parsedMessage.isEmpty() ? "Too many requests" : parsedMessage;
1962
+
1963
+ final long retryUntilMs = resolveRateLimitBlockedUntilMs(response, responseData);
1964
+ synchronized (rateLimitStateLock) {
1965
+ if (retryUntilMs > rateLimitBlockedUntilMs) {
1966
+ rateLimitBlockedUntilMs = retryUntilMs;
1967
+ rateLimitBlockedError = errorCode;
1968
+ rateLimitBlockedMessage = message;
1969
+ } else if (rateLimitBlockedUntilMs <= 0L) {
1970
+ rateLimitBlockedError = errorCode;
1971
+ rateLimitBlockedMessage = message;
1972
+ }
1973
+ }
1974
+
1975
+ // Claim last, and only when there is somewhere to send it, so a 429 burst with no
1976
+ // stats URL does not claim and release the latch once per response.
1977
+ if ("too_many_requests".equals(errorCode) && !this.previewSession && this.hasStatsUrl() && claimRateLimitStatistic()) {
1978
+ sendRateLimitStatistic();
1979
+ }
1980
+
1981
+ final long nowMs = System.currentTimeMillis();
1982
+ final long retryAfter = Math.max(0L, (Math.max(retryUntilMs, nowMs) - nowMs + 999L) / 1000L);
1983
+ logger.warn("Received 429 (" + errorCode + "). Honouring Retry-After: " + retryAfter + "s.");
1984
+ return new RemoteBlockResult(true, errorCode, message);
1985
+ }
1986
+
1987
+ private String parseRemoteError(final String responseData) {
1988
+ if (responseData == null || responseData.isEmpty()) {
1989
+ return "";
1990
+ }
1991
+ try {
1992
+ final JSONObject json = new JSONObject(responseData);
1993
+ return json.optString("error", "");
1994
+ } catch (JSONException ignored) {
1995
+ return "";
1996
+ }
1997
+ }
1998
+
1999
+ private String parseRemoteMessage(final String responseData) {
2000
+ if (responseData == null || responseData.isEmpty()) {
2001
+ return "";
2002
+ }
2003
+ try {
2004
+ final JSONObject json = new JSONObject(responseData);
2005
+ return json.optString("message", "");
2006
+ } catch (JSONException ignored) {
2007
+ return "";
2008
+ }
2009
+ }
2010
+
2011
+ private long resolveRateLimitBlockedUntilMs(final Response response, final String responseData) {
2012
+ final long nowMs = System.currentTimeMillis();
2013
+ final double candidate = rawRateLimitDeadlineMs(response, responseData, nowMs);
2014
+ // NaN and past deadlines mean "no client-side block"; anything further out is capped.
2015
+ if (!(candidate > nowMs)) {
2016
+ return 0L;
2017
+ }
2018
+ return (long) Math.min(candidate, (double) nowMs + MAX_RATE_LIMIT_WINDOW_MS);
2019
+ }
2020
+
2021
+ private double rawRateLimitDeadlineMs(final Response response, final String responseData, final long nowMs) {
2022
+ final String header = response.header("Retry-After");
2023
+ if (header != null) {
2024
+ try {
2025
+ final double seconds = Double.parseDouble(header.trim());
2026
+ if (seconds >= 0) {
2027
+ return nowMs + seconds * 1000d;
2028
+ }
2029
+ } catch (NumberFormatException ignored) {
2030
+ // Fall through to body fields
2031
+ }
2032
+ }
2033
+
2034
+ if (responseData != null && !responseData.isEmpty()) {
2035
+ try {
2036
+ final JSONObject json = new JSONObject(responseData);
2037
+ final JSONObject moreInfo = json.optJSONObject("moreInfo");
2038
+ if (moreInfo != null && moreInfo.has("retryAfterSeconds")) {
2039
+ final double retryAfter = moreInfo.getDouble("retryAfterSeconds");
2040
+ if (retryAfter >= 0) {
2041
+ return nowMs + retryAfter * 1000d;
2042
+ }
2043
+ } else if (json.has("retryAfterSeconds")) {
2044
+ final double retryAfter = json.getDouble("retryAfterSeconds");
2045
+ if (retryAfter >= 0) {
2046
+ return nowMs + retryAfter * 1000d;
2047
+ }
2048
+ }
2049
+ if (moreInfo != null && moreInfo.has("rateLimitResetAt")) {
2050
+ return moreInfo.getDouble("rateLimitResetAt");
2051
+ } else if (json.has("rateLimitResetAt")) {
2052
+ return json.getDouble("rateLimitResetAt");
2053
+ }
2054
+ } catch (JSONException ignored) {
2055
+ // No retry hint
2056
+ }
2057
+ }
2058
+
2059
+ // No retry hint — do not hold a client-side block; allow immediate retry to the worker
2060
+ return 0d;
2061
+ }
2062
+
2063
+ private static boolean claimRateLimitStatistic() {
2064
+ synchronized (rateLimitStateLock) {
2065
+ if (rateLimitStatisticSent) {
2066
+ return false;
2067
+ }
2068
+ rateLimitStatisticSent = true;
1929
2069
  return true;
1930
2070
  }
1931
- return false;
1932
2071
  }
1933
2072
 
1934
2073
  /**
1935
- * Send a synchronous statistic about rate limiting
2074
+ * Give the claim back when the statistic never made it out, so a later 429 can retry it.
2075
+ */
2076
+ private static void releaseRateLimitStatisticClaim() {
2077
+ synchronized (rateLimitStateLock) {
2078
+ rateLimitStatisticSent = false;
2079
+ }
2080
+ }
2081
+
2082
+ private boolean hasStatsUrl() {
2083
+ final String url = this.statsUrl;
2084
+ return url != null && !url.isEmpty();
2085
+ }
2086
+
2087
+ private boolean isRemoteBlocked() {
2088
+ synchronized (rateLimitStateLock) {
2089
+ if (rateLimitBlockedUntilMs <= 0L) {
2090
+ return false;
2091
+ }
2092
+ if (System.currentTimeMillis() >= rateLimitBlockedUntilMs) {
2093
+ rateLimitBlockedUntilMs = 0L;
2094
+ return false;
2095
+ }
2096
+ return true;
2097
+ }
2098
+ }
2099
+
2100
+ private RemoteBlockResult remoteBlockedClientError() {
2101
+ synchronized (rateLimitStateLock) {
2102
+ return new RemoteBlockResult(true, rateLimitBlockedError, rateLimitBlockedMessage);
2103
+ }
2104
+ }
2105
+
2106
+ /**
2107
+ * Send a statistic about rate limiting.
2108
+ * Dispatched through OkHttp so no caller thread waits on the request.
1936
2109
  */
1937
2110
  private void sendRateLimitStatistic() {
1938
2111
  String statsUrl = this.statsUrl;
1939
2112
  if (statsUrl == null || statsUrl.isEmpty()) {
2113
+ // The URL was cleared after the claim was taken; nothing went out, so hand it back.
2114
+ releaseRateLimitStatisticClaim();
1940
2115
  return;
1941
2116
  }
1942
2117
 
@@ -1952,17 +2127,33 @@ public class CapgoUpdater {
1952
2127
  .post(RequestBody.create(json.toString(), MediaType.get("application/json")))
1953
2128
  .build();
1954
2129
 
1955
- // Send synchronously to ensure it goes out before the flag is set
1956
2130
  // 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());
2131
+ DownloadService.sharedClient.newCall(request).enqueue(
2132
+ new okhttp3.Callback() {
2133
+ @Override
2134
+ public void onFailure(@NonNull Call call, @NonNull IOException e) {
2135
+ releaseRateLimitStatisticClaim();
2136
+ logger.error("Failed to send rate limit statistic");
2137
+ logger.debug("Error: " + e.getMessage());
2138
+ }
2139
+
2140
+ @Override
2141
+ public void onResponse(@NonNull Call call, @NonNull Response response) {
2142
+ // The body is unused here; closing the Response closes it.
2143
+ try (response) {
2144
+ if (response.isSuccessful()) {
2145
+ logger.info("Rate limit statistic sent");
2146
+ } else {
2147
+ releaseRateLimitStatisticClaim();
2148
+ logger.error("Error sending rate limit statistic");
2149
+ logger.debug("Response code: " + response.code());
2150
+ }
2151
+ }
2152
+ }
1963
2153
  }
1964
- }
2154
+ );
1965
2155
  } catch (final Exception e) {
2156
+ releaseRateLimitStatisticClaim();
1966
2157
  logger.error("Failed to send rate limit statistic");
1967
2158
  logger.debug("Error: " + e.getMessage());
1968
2159
  }
@@ -2001,7 +2192,27 @@ public class CapgoUpdater {
2001
2192
 
2002
2193
  if (jsonResponse != null && (jsonResponse.has("error") || jsonResponse.has("kind"))) {
2003
2194
  if (statusCode == 429) {
2004
- checkAndHandleRateLimitResponse(response);
2195
+ final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
2196
+ Map<String, Object> retError = new HashMap<>();
2197
+ retError.put(
2198
+ "error",
2199
+ rateLimit.error.isEmpty() ? jsonResponse.optString("error", "too_many_requests") : rateLimit.error
2200
+ );
2201
+ retError.put(
2202
+ "message",
2203
+ rateLimit.message.isEmpty() ? jsonResponse.optString("message", "Too many requests") : rateLimit.message
2204
+ );
2205
+ if (jsonResponse.has("kind") && !jsonResponse.isNull("kind")) {
2206
+ retError.put("kind", jsonResponse.getString("kind"));
2207
+ } else {
2208
+ retError.put("kind", "failed");
2209
+ }
2210
+ if (jsonResponse.has("version") && !jsonResponse.isNull("version")) {
2211
+ retError.put("version", jsonResponse.getString("version"));
2212
+ }
2213
+ retError.put("statusCode", statusCode);
2214
+ callback.callback(retError);
2215
+ return;
2005
2216
  }
2006
2217
  Map<String, Object> retError = new HashMap<>();
2007
2218
  if (jsonResponse.has("error") && !jsonResponse.isNull("error")) {
@@ -2023,11 +2234,12 @@ public class CapgoUpdater {
2023
2234
  return;
2024
2235
  }
2025
2236
 
2026
- // Check for 429 rate limit
2027
- if (checkAndHandleRateLimitResponse(response)) {
2237
+ // Check for 429 rate limit without JSON body
2238
+ final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
2239
+ if (rateLimit.blocked) {
2028
2240
  Map<String, Object> retError = new HashMap<>();
2029
- retError.put("message", "Rate limit exceeded");
2030
- retError.put("error", "rate_limit_exceeded");
2241
+ retError.put("message", rateLimit.message);
2242
+ retError.put("error", rateLimit.error);
2031
2243
  retError.put("kind", "failed");
2032
2244
  retError.put("statusCode", statusCode);
2033
2245
  callback.callback(retError);
@@ -2080,6 +2292,16 @@ public class CapgoUpdater {
2080
2292
  }
2081
2293
 
2082
2294
  public void getLatest(final String updateUrl, final String channel, final String appIdOverride, final Callback callback) {
2295
+ if (isRemoteBlocked()) {
2296
+ final RemoteBlockResult blocked = remoteBlockedClientError();
2297
+ logger.debug("Skipping getLatest due to remote block (" + blocked.error + ").");
2298
+ final Map<String, Object> retError = new HashMap<>();
2299
+ retError.put("message", blocked.message);
2300
+ retError.put("error", blocked.error);
2301
+ retError.put("kind", "failed");
2302
+ callback.callback(retError);
2303
+ return;
2304
+ }
2083
2305
  JSONObject json;
2084
2306
  try {
2085
2307
  json = this.createInfoObject(appIdOverride);
@@ -2149,12 +2371,12 @@ public class CapgoUpdater {
2149
2371
  return;
2150
2372
  }
2151
2373
 
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.");
2374
+ if (isRemoteBlocked()) {
2375
+ final RemoteBlockResult blocked = remoteBlockedClientError();
2376
+ logger.debug("Skipping setChannel due to remote block (" + blocked.error + ").");
2155
2377
  final Map<String, Object> retError = new HashMap<>();
2156
- retError.put("message", "Rate limit exceeded");
2157
- retError.put("error", "rate_limit_exceeded");
2378
+ retError.put("message", blocked.message);
2379
+ retError.put("error", blocked.error);
2158
2380
  callback.callback(retError);
2159
2381
  return;
2160
2382
  }
@@ -2208,12 +2430,12 @@ public class CapgoUpdater {
2208
2430
  }
2209
2431
 
2210
2432
  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.");
2433
+ if (isRemoteBlocked()) {
2434
+ final RemoteBlockResult blocked = remoteBlockedClientError();
2435
+ logger.debug("Skipping getChannel due to remote block (" + blocked.error + ").");
2214
2436
  final Map<String, Object> retError = new HashMap<>();
2215
- retError.put("message", "Rate limit exceeded");
2216
- retError.put("error", "rate_limit_exceeded");
2437
+ retError.put("message", blocked.message);
2438
+ retError.put("error", blocked.error);
2217
2439
  callback.callback(retError);
2218
2440
  return;
2219
2441
  }
@@ -2258,25 +2480,18 @@ public class CapgoUpdater {
2258
2480
  @Override
2259
2481
  public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
2260
2482
  try (ResponseBody responseBody = response.body()) {
2261
- // Check for 429 rate limit
2262
- if (checkAndHandleRateLimitResponse(response)) {
2483
+ final String responseData = responseBody != null ? responseBody.string() : "";
2484
+ final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
2485
+ if (rateLimit.blocked) {
2263
2486
  Map<String, Object> retError = new HashMap<>();
2264
- retError.put("message", "Rate limit exceeded");
2265
- retError.put("error", "rate_limit_exceeded");
2487
+ retError.put("message", rateLimit.message);
2488
+ retError.put("error", rateLimit.error);
2266
2489
  callback.callback(retError);
2267
2490
  return;
2268
2491
  }
2269
2492
 
2270
2493
  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()) {
2494
+ if (responseData.contains("channel_not_found") && !defaultChannel.isEmpty()) {
2280
2495
  Map<String, Object> ret = new HashMap<>();
2281
2496
  ret.put("channel", defaultChannel);
2282
2497
  ret.put("status", "default");
@@ -2294,14 +2509,13 @@ public class CapgoUpdater {
2294
2509
  return;
2295
2510
  }
2296
2511
 
2297
- if (responseBody == null) {
2512
+ if (responseData.isEmpty()) {
2298
2513
  Map<String, Object> retError = new HashMap<>();
2299
2514
  retError.put("message", "Empty response body");
2300
2515
  retError.put("error", "no_response_body");
2301
2516
  callback.callback(retError);
2302
2517
  return;
2303
2518
  }
2304
- String responseData = responseBody.string();
2305
2519
  JSONObject jsonResponse = new JSONObject(responseData);
2306
2520
 
2307
2521
  // Check for server-side errors first
@@ -2359,12 +2573,12 @@ public class CapgoUpdater {
2359
2573
  }
2360
2574
 
2361
2575
  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.");
2576
+ if (isRemoteBlocked()) {
2577
+ final RemoteBlockResult blocked = remoteBlockedClientError();
2578
+ logger.debug("Skipping listChannels due to remote block (" + blocked.error + ").");
2365
2579
  final Map<String, Object> retError = new HashMap<>();
2366
- retError.put("message", "Rate limit exceeded");
2367
- retError.put("error", "rate_limit_exceeded");
2580
+ retError.put("message", blocked.message);
2581
+ retError.put("error", blocked.error);
2368
2582
  callback.callback(retError);
2369
2583
  return;
2370
2584
  }
@@ -2423,11 +2637,12 @@ public class CapgoUpdater {
2423
2637
  @Override
2424
2638
  public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
2425
2639
  try (ResponseBody responseBody = response.body()) {
2426
- // Check for 429 rate limit
2427
- if (checkAndHandleRateLimitResponse(response)) {
2640
+ final String data = responseBody != null ? responseBody.string() : "";
2641
+ final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, data);
2642
+ if (rateLimit.blocked) {
2428
2643
  Map<String, Object> retError = new HashMap<>();
2429
- retError.put("message", "Rate limit exceeded");
2430
- retError.put("error", "rate_limit_exceeded");
2644
+ retError.put("message", rateLimit.message);
2645
+ retError.put("error", rateLimit.error);
2431
2646
  callback.callback(retError);
2432
2647
  return;
2433
2648
  }
@@ -2440,14 +2655,13 @@ public class CapgoUpdater {
2440
2655
  return;
2441
2656
  }
2442
2657
 
2443
- if (responseBody == null) {
2658
+ if (data.isEmpty()) {
2444
2659
  Map<String, Object> retError = new HashMap<>();
2445
2660
  retError.put("message", "Empty response body");
2446
2661
  retError.put("error", "no_response_body");
2447
2662
  callback.callback(retError);
2448
2663
  return;
2449
2664
  }
2450
- String data = responseBody.string();
2451
2665
 
2452
2666
  try {
2453
2667
  Map<String, Object> ret = parseListChannelsResponse(data);
@@ -2542,12 +2756,6 @@ public class CapgoUpdater {
2542
2756
  return;
2543
2757
  }
2544
2758
 
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
2759
  String statsUrl = this.statsUrl;
2552
2760
  if (statsUrl == null || statsUrl.isEmpty()) {
2553
2761
  return;
@@ -2569,7 +2777,10 @@ public class CapgoUpdater {
2569
2777
  return;
2570
2778
  }
2571
2779
 
2572
- statsQueue.add(new QueuedStatsEvent(json, onSent));
2780
+ // Same lock as the flush transaction, so an event added mid-flush is never dropped.
2781
+ synchronized (statsQueue) {
2782
+ statsQueue.add(new QueuedStatsEvent(json, onSent));
2783
+ }
2573
2784
  ensureStatsTimerStarted();
2574
2785
  }
2575
2786
 
@@ -2589,9 +2800,17 @@ public class CapgoUpdater {
2589
2800
  return;
2590
2801
  }
2591
2802
 
2803
+ // While Retry-After is active, keep stats queued and skip the network call.
2804
+ if (isRemoteBlocked()) {
2805
+ logger.debug("Deferring stats flush until Retry-After expires.");
2806
+ return;
2807
+ }
2808
+
2592
2809
  String statsUrl = this.statsUrl;
2593
2810
  if (statsUrl == null || statsUrl.isEmpty()) {
2594
- statsQueue.clear();
2811
+ synchronized (statsQueue) {
2812
+ statsQueue.clear();
2813
+ }
2595
2814
  return;
2596
2815
  }
2597
2816
 
@@ -2620,6 +2839,8 @@ public class CapgoUpdater {
2620
2839
  new okhttp3.Callback() {
2621
2840
  @Override
2622
2841
  public void onFailure(@NonNull Call call, @NonNull IOException e) {
2842
+ // Keep events for a later flush attempt.
2843
+ requeueStatsEvents(eventsToSend);
2623
2844
  logger.error("Failed to send stats batch");
2624
2845
  logger.debug("Error: " + e.getMessage());
2625
2846
  }
@@ -2627,8 +2848,9 @@ public class CapgoUpdater {
2627
2848
  @Override
2628
2849
  public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
2629
2850
  try (ResponseBody responseBody = response.body()) {
2630
- // Check for 429 rate limit
2631
- if (checkAndHandleRateLimitResponse(response)) {
2851
+ final String responseData = responseBody != null ? responseBody.string() : "";
2852
+ if (checkAndHandleRateLimitResponse(response, responseData).blocked) {
2853
+ requeueStatsEvents(eventsToSend);
2632
2854
  return;
2633
2855
  }
2634
2856
 
@@ -2636,8 +2858,13 @@ public class CapgoUpdater {
2636
2858
  logger.info("Stats batch sent successfully");
2637
2859
  logger.debug("Sent " + eventCount + " events");
2638
2860
  runStatsCallbacks(eventsToSend);
2639
- } else {
2861
+ } else if (isTransientStatsFailure(response.code())) {
2862
+ requeueStatsEvents(eventsToSend);
2640
2863
  logger.error("Error sending stats batch");
2864
+ logger.debug("Retrying later, response code: " + response.code());
2865
+ } else {
2866
+ // Permanent rejection: retrying would loop forever and block the queue.
2867
+ logger.error("Dropping stats batch after permanent error");
2641
2868
  logger.debug("Response code: " + response.code());
2642
2869
  }
2643
2870
  }
@@ -2646,6 +2873,23 @@ public class CapgoUpdater {
2646
2873
  );
2647
2874
  }
2648
2875
 
2876
+ /**
2877
+ * Only 429, request timeout and 5xx are worth retrying; other 4xx are permanent rejections.
2878
+ */
2879
+ private static boolean isTransientStatsFailure(final int statusCode) {
2880
+ return statusCode == 429 || statusCode == 408 || statusCode >= 500;
2881
+ }
2882
+
2883
+ private void requeueStatsEvents(final List<QueuedStatsEvent> events) {
2884
+ if (events == null || events.isEmpty()) {
2885
+ return;
2886
+ }
2887
+ synchronized (statsQueue) {
2888
+ statsQueue.addAll(0, events);
2889
+ }
2890
+ ensureStatsTimerStarted();
2891
+ }
2892
+
2649
2893
  private void runStatsCallbacks(final List<QueuedStatsEvent> sentEvents) {
2650
2894
  for (final QueuedStatsEvent sentEvent : sentEvents) {
2651
2895
  if (sentEvent.onSent == null) {
@@ -793,8 +793,20 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
793
793
 
794
794
  String latestUrl = getString(latestRes, "url");
795
795
 
796
- // Check if there's an actual update available
797
- if ("up_to_date".equals(latestKind) || latestUrl == null || latestUrl.isEmpty()) {
796
+ Object manifestObj = latestRes.get("manifest");
797
+ JSONArray manifestArray = null;
798
+ if (manifestObj instanceof JSONArray) {
799
+ manifestArray = (JSONArray) manifestObj;
800
+ } else if (manifestObj instanceof List) {
801
+ manifestArray = new JSONArray((List<?>) manifestObj);
802
+ }
803
+ final boolean hasManifest = manifestArray != null && manifestArray.length() > 0;
804
+
805
+ // Check if there's an actual update available. A manifest-only
806
+ // response legitimately has no URL (the files come from the
807
+ // manifest, not a zip), so only report "already on latest" when
808
+ // the URL is empty AND there is no manifest to download from.
809
+ if ("up_to_date".equals(latestKind) || ((latestUrl == null || latestUrl.isEmpty()) && !hasManifest)) {
798
810
  activity.runOnUiThread(() -> {
799
811
  progressDialog.dismiss();
800
812
  showSuccess("Channel set to " + channelName + ". Already on latest version.");
@@ -817,25 +829,18 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
817
829
 
818
830
  String sessionKey = getString(latestRes, "sessionKey");
819
831
  String checksum = getString(latestRes, "checksum");
820
- Object manifestObj = latestRes.get("manifest");
832
+
833
+ // A manifest-only response has no zip URL; downloadManifest
834
+ // tolerates the placeholder URL the plugin already uses.
835
+ final String downloadUrl =
836
+ latestUrl == null || latestUrl.isEmpty() ? "https://404.capgo.app/no.zip" : latestUrl;
821
837
 
822
838
  // Download the update
823
839
  try {
824
840
  BundleInfo bundle;
825
- if (manifestObj != null) {
826
- JSONArray manifestArray = null;
827
- if (manifestObj instanceof JSONArray) {
828
- manifestArray = (JSONArray) manifestObj;
829
- } else if (manifestObj instanceof List) {
830
- manifestArray = new JSONArray((List<?>) manifestObj);
831
- }
832
-
833
- if (manifestArray == null) {
834
- throw new IllegalArgumentException("Invalid manifest format");
835
- }
836
-
841
+ if (hasManifest) {
837
842
  bundle = updater.downloadManifest(
838
- latestUrl,
843
+ downloadUrl,
839
844
  versionForUi,
840
845
  sessionKey != null ? sessionKey : "",
841
846
  checksum != null ? checksum : "",
@@ -843,7 +848,7 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
843
848
  );
844
849
  } else {
845
850
  bundle = updater.download(
846
- latestUrl,
851
+ downloadUrl,
847
852
  versionForUi,
848
853
  sessionKey != null ? sessionKey : "",
849
854
  checksum != null ? checksum : ""
@@ -92,7 +92,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
92
92
  CAPPluginMethod(name: "completeFlexibleUpdate", returnType: CAPPluginReturnPromise)
93
93
  ]
94
94
  public var implementation = CapgoUpdater()
95
- private let pluginVersion: String = "8.51.3"
95
+ private let pluginVersion: String = "8.51.5"
96
96
  private let launchStartedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
97
97
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
98
98
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
@@ -2754,7 +2754,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2754
2754
 
2755
2755
  let alert = UIAlertController(
2756
2756
  title: "Preview started",
2757
- message: "Shake your device anytime to reload or leave the test app.",
2757
+ message: self.shakeMenuGesture == Self.shakeMenuGestureThreeFingerPinch ? "Three-finger pinch to open menu." : "Shake to open menu.",
2758
2758
  preferredStyle: .alert
2759
2759
  )
2760
2760
  alert.addAction(UIAlertAction(title: "Got it", style: .default))
@@ -49,12 +49,20 @@ import UIKit
49
49
  // Cached key ID calculated once from publicKey
50
50
  private var cachedKeyId: String?
51
51
 
52
- // Flag to track if we received a 429 response - stops requests until app restart
53
- private static var rateLimitExceeded = false
54
-
55
- // Flag to track if we've already sent the rate limit statistic - prevents infinite loop
52
+ // Temporary 429 block until this epoch ms (Retry-After / rateLimitResetAt). No sticky latch.
53
+ // Guarded by rateLimitStateLock so concurrent 429s cannot shorten the window or mix metadata.
54
+ private static let rateLimitStateLock = NSLock()
55
+ private static var rateLimitBlockedUntilMs: Double = 0
56
+ private static var rateLimitBlockedError: String = "too_many_requests"
57
+ private static var rateLimitBlockedMessage: String = "Too many requests"
58
+
59
+ // Flag to track if we've already sent the rate limit statistic - prevents infinite loop.
60
+ // Released again when the send fails, so a later 429 can retry it.
56
61
  private static var rateLimitStatisticSent = false
57
62
 
63
+ // Upper bound for a client-side 429 block, so a bogus Retry-After cannot block the app for days.
64
+ private static let maxRateLimitWindowMs: Double = 24 * 60 * 60 * 1000
65
+
58
66
  // Stats batching - queue events and send max once per second
59
67
  private var statsQueue: [QueuedStatsEvent] = []
60
68
  private let statsQueueLock = NSLock()
@@ -421,26 +429,149 @@ import UIKit
421
429
  }
422
430
  }
423
431
 
432
+ private struct RemoteBlockResult {
433
+ let blocked: Bool
434
+ let error: String
435
+ let message: String
436
+ }
437
+
424
438
  /**
425
- * Check if a 429 (Too Many Requests) response was received and set the flag
439
+ * Handle HTTP 429 responses by honouring Retry-After / rateLimitResetAt.
440
+ * All 429s use the same temporary client block — no sticky latch until restart.
426
441
  */
427
- private func checkAndHandleRateLimitResponse(statusCode: Int?) -> Bool {
428
- if statusCode == 429 {
429
- // Send a statistic about the rate limit BEFORE setting the flag
430
- // Only send once to prevent infinite loop if the stat request itself gets rate limited
431
- if !previewSession && !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
432
- CapgoUpdater.rateLimitStatisticSent = true
433
-
434
- // Dispatch to background queue to avoid blocking the main thread
435
- DispatchQueue.global(qos: .utility).async {
436
- self.sendRateLimitStatistic()
437
- }
442
+ private func checkAndHandleRateLimitResponse(
443
+ statusCode: Int?,
444
+ data: Data? = nil,
445
+ response: HTTPURLResponse? = nil
446
+ ) -> RemoteBlockResult {
447
+ guard statusCode == 429 else {
448
+ return RemoteBlockResult(blocked: false, error: "", message: "")
449
+ }
450
+
451
+ let parsed = parseRemoteError(from: data)
452
+ let errorCode = parsed.error.isEmpty ? "too_many_requests" : parsed.error
453
+ let message = parsed.message.isEmpty ? "Too many requests" : parsed.message
454
+
455
+ let retryUntilMs = resolveRateLimitBlockedUntilMs(data: data, response: response)
456
+ CapgoUpdater.recordRateLimitBlock(untilMs: retryUntilMs, error: errorCode, message: message)
457
+
458
+ // Claim last, and only when there is somewhere to send it, so a 429 burst with no
459
+ // stats URL does not claim and release the latch once per response.
460
+ if errorCode == "too_many_requests" && !previewSession && !statsUrl.isEmpty && CapgoUpdater.claimRateLimitStatistic() {
461
+ DispatchQueue.global(qos: .utility).async {
462
+ self.sendRateLimitStatistic()
438
463
  }
439
- CapgoUpdater.rateLimitExceeded = true
440
- logger.warn("Rate limit exceeded (429). Stopping all stats and channel requests until app restart.")
441
- return true
442
464
  }
443
- return false
465
+
466
+ let nowMs = Date().timeIntervalSince1970 * 1000
467
+ let retryAfter = CapgoUpdater.retryAfterSecondsForLog(untilMs: retryUntilMs, nowMs: nowMs)
468
+ logger.warn("Received 429 (\(errorCode)). Honouring Retry-After: \(retryAfter)s.")
469
+ return RemoteBlockResult(blocked: true, error: errorCode, message: message)
470
+ }
471
+
472
+ /// Stores the block deadline and its metadata together, keeping the longest deadline
473
+ /// so a concurrent 429 with a shorter window cannot cut the block short.
474
+ private static func recordRateLimitBlock(untilMs: Double, error: String, message: String) {
475
+ rateLimitStateLock.lock()
476
+ defer { rateLimitStateLock.unlock() }
477
+ if untilMs > rateLimitBlockedUntilMs {
478
+ rateLimitBlockedUntilMs = untilMs
479
+ rateLimitBlockedError = error
480
+ rateLimitBlockedMessage = message
481
+ } else if rateLimitBlockedUntilMs <= 0 {
482
+ rateLimitBlockedError = error
483
+ rateLimitBlockedMessage = message
484
+ }
485
+ }
486
+
487
+ /// Seconds left in the block, clamped and finite so the Int conversion can never trap.
488
+ private static func retryAfterSecondsForLog(untilMs: Double, nowMs: Double) -> Int {
489
+ let seconds = ((untilMs - nowMs) / 1000).rounded(.up)
490
+ guard seconds.isFinite, seconds > 0 else {
491
+ return 0
492
+ }
493
+ return Int(min(seconds, maxRateLimitWindowMs / 1000))
494
+ }
495
+
496
+ /// Returns true for the first 429 only, so the rate-limit statistic is sent once.
497
+ private static func claimRateLimitStatistic() -> Bool {
498
+ rateLimitStateLock.lock()
499
+ defer { rateLimitStateLock.unlock() }
500
+ if rateLimitStatisticSent {
501
+ return false
502
+ }
503
+ rateLimitStatisticSent = true
504
+ return true
505
+ }
506
+
507
+ /// Gives the claim back when the statistic never made it out, so a later 429 can retry it.
508
+ private static func releaseRateLimitStatisticClaim() {
509
+ rateLimitStateLock.lock()
510
+ defer { rateLimitStateLock.unlock() }
511
+ rateLimitStatisticSent = false
512
+ }
513
+
514
+ private func parseRemoteError(from data: Data?) -> (error: String, message: String) {
515
+ guard let data = data,
516
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
517
+ return ("", "")
518
+ }
519
+ let error = json["error"] as? String ?? ""
520
+ let message = json["message"] as? String ?? ""
521
+ return (error, message)
522
+ }
523
+
524
+ private func resolveRateLimitBlockedUntilMs(data: Data?, response: HTTPURLResponse?) -> Double {
525
+ let nowMs = Date().timeIntervalSince1970 * 1000
526
+ let candidate = rawRateLimitDeadlineMs(data: data, response: response, nowMs: nowMs)
527
+ // NaN and past deadlines mean "no client-side block"; anything further out is capped.
528
+ guard candidate > nowMs else {
529
+ return 0
530
+ }
531
+ return min(candidate, nowMs + CapgoUpdater.maxRateLimitWindowMs)
532
+ }
533
+
534
+ private func rawRateLimitDeadlineMs(data: Data?, response: HTTPURLResponse?, nowMs: Double) -> Double {
535
+ if let header = response?.value(forHTTPHeaderField: "Retry-After")?.trimmingCharacters(in: .whitespacesAndNewlines),
536
+ let seconds = Double(header), seconds >= 0 {
537
+ return nowMs + seconds * 1000
538
+ }
539
+
540
+ if let data = data,
541
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
542
+ let moreInfo = json["moreInfo"] as? [String: Any]
543
+ if let retryAfter = (moreInfo?["retryAfterSeconds"] as? NSNumber)?.doubleValue
544
+ ?? (json["retryAfterSeconds"] as? NSNumber)?.doubleValue,
545
+ retryAfter >= 0 {
546
+ return nowMs + retryAfter * 1000
547
+ }
548
+ if let resetAt = (moreInfo?["rateLimitResetAt"] as? NSNumber)?.doubleValue
549
+ ?? (json["rateLimitResetAt"] as? NSNumber)?.doubleValue {
550
+ return resetAt
551
+ }
552
+ }
553
+
554
+ // No retry hint — do not hold a client-side block; allow immediate retry to the worker
555
+ return 0
556
+ }
557
+
558
+ private func isRemoteBlocked() -> Bool {
559
+ CapgoUpdater.rateLimitStateLock.lock()
560
+ defer { CapgoUpdater.rateLimitStateLock.unlock() }
561
+ if CapgoUpdater.rateLimitBlockedUntilMs <= 0 {
562
+ return false
563
+ }
564
+ if Date().timeIntervalSince1970 * 1000 >= CapgoUpdater.rateLimitBlockedUntilMs {
565
+ CapgoUpdater.rateLimitBlockedUntilMs = 0
566
+ return false
567
+ }
568
+ return true
569
+ }
570
+
571
+ private func remoteBlockedClientError() -> (error: String, message: String) {
572
+ CapgoUpdater.rateLimitStateLock.lock()
573
+ defer { CapgoUpdater.rateLimitStateLock.unlock() }
574
+ return (CapgoUpdater.rateLimitBlockedError, CapgoUpdater.rateLimitBlockedMessage)
444
575
  }
445
576
 
446
577
  /**
@@ -450,6 +581,8 @@ import UIKit
450
581
  */
451
582
  private func sendRateLimitStatistic() {
452
583
  guard !statsUrl.isEmpty else {
584
+ // The URL was cleared after the claim was taken; nothing went out, so hand it back.
585
+ CapgoUpdater.releaseRateLimitStatisticClaim()
453
586
  return
454
587
  }
455
588
 
@@ -468,10 +601,16 @@ import UIKit
468
601
  encoding: JSONEncoding.default,
469
602
  requestModifier: { $0.timeoutInterval = self.timeout }
470
603
  ).responseData { response in
604
+ let statusCode = response.response?.statusCode
471
605
  switch response.result {
472
- case .success:
606
+ case .success where (200...299).contains(statusCode ?? 0):
473
607
  self.logger.info("Rate limit statistic sent")
608
+ case .success:
609
+ CapgoUpdater.releaseRateLimitStatisticClaim()
610
+ self.logger.error("Error sending rate limit statistic")
611
+ self.logger.debug("Response code: \(statusCode.map(String.init) ?? "nil")")
474
612
  case let .failure(error):
613
+ CapgoUpdater.releaseRateLimitStatisticClaim()
475
614
  self.logger.error("Error sending rate limit statistic")
476
615
  self.logger.debug("Error: \(error.localizedDescription)")
477
616
  }
@@ -825,6 +964,14 @@ import UIKit
825
964
 
826
965
  public func getLatest(url: URL, channel: String?, appIdOverride: String? = nil) -> AppVersion {
827
966
  let latest: AppVersion = AppVersion()
967
+ if isRemoteBlocked() {
968
+ let blocked = remoteBlockedClientError()
969
+ logger.debug("Skipping getLatest due to remote block (\(blocked.error)).")
970
+ latest.message = blocked.message
971
+ latest.error = blocked.error
972
+ latest.kind = "failed"
973
+ return latest
974
+ }
828
975
  func applyLatestResponse(_ value: AppVersionDec?) {
829
976
  if let url = value?.url {
830
977
  latest.url = url
@@ -905,9 +1052,14 @@ import UIKit
905
1052
  return latest
906
1053
  }
907
1054
 
908
- if self.checkAndHandleRateLimitResponse(statusCode: latest.statusCode) {
909
- latest.message = "Rate limit exceeded"
910
- latest.error = "rate_limit_exceeded"
1055
+ let rateLimit = self.checkAndHandleRateLimitResponse(
1056
+ statusCode: latest.statusCode,
1057
+ data: data,
1058
+ response: result.response
1059
+ )
1060
+ if rateLimit.blocked {
1061
+ latest.message = rateLimit.message
1062
+ latest.error = rateLimit.error
911
1063
  latest.kind = "failed"
912
1064
  return latest
913
1065
  }
@@ -2419,11 +2571,11 @@ import UIKit
2419
2571
  return setChannel
2420
2572
  }
2421
2573
 
2422
- // Check if rate limit was exceeded
2423
- if CapgoUpdater.rateLimitExceeded {
2424
- logger.debug("Skipping setChannel due to rate limit (429). Requests will resume after app restart.")
2425
- setChannel.message = "Rate limit exceeded"
2426
- setChannel.error = "rate_limit_exceeded"
2574
+ if isRemoteBlocked() {
2575
+ let blocked = remoteBlockedClientError()
2576
+ logger.debug("Skipping setChannel due to remote block (\(blocked.error)).")
2577
+ setChannel.message = blocked.message
2578
+ setChannel.error = blocked.error
2427
2579
  return setChannel
2428
2580
  }
2429
2581
 
@@ -2448,9 +2600,14 @@ import UIKit
2448
2600
 
2449
2601
  let result = performRequest(request, label: "setChannel")
2450
2602
 
2451
- if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
2452
- setChannel.message = "Rate limit exceeded"
2453
- setChannel.error = "rate_limit_exceeded"
2603
+ let rateLimit = self.checkAndHandleRateLimitResponse(
2604
+ statusCode: result.response?.statusCode,
2605
+ data: result.data,
2606
+ response: result.response
2607
+ )
2608
+ if rateLimit.blocked {
2609
+ setChannel.message = rateLimit.message
2610
+ setChannel.error = rateLimit.error
2454
2611
  return setChannel
2455
2612
  }
2456
2613
 
@@ -2509,10 +2666,11 @@ import UIKit
2509
2666
  func getChannel(defaultChannelKey: String? = nil) -> GetChannel {
2510
2667
  let getChannel: GetChannel = GetChannel()
2511
2668
  // Check if rate limit was exceeded
2512
- if CapgoUpdater.rateLimitExceeded {
2513
- logger.debug("Skipping getChannel due to rate limit (429). Requests will resume after app restart.")
2514
- getChannel.message = "Rate limit exceeded"
2515
- getChannel.error = "rate_limit_exceeded"
2669
+ if isRemoteBlocked() {
2670
+ let blocked = remoteBlockedClientError()
2671
+ logger.debug("Skipping getChannel due to remote block (\(blocked.error)).")
2672
+ getChannel.message = blocked.message
2673
+ getChannel.error = blocked.error
2516
2674
  return getChannel
2517
2675
  }
2518
2676
 
@@ -2536,9 +2694,14 @@ import UIKit
2536
2694
 
2537
2695
  let result = performRequest(request, label: "getChannel")
2538
2696
 
2539
- if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
2540
- getChannel.message = "Rate limit exceeded"
2541
- getChannel.error = "rate_limit_exceeded"
2697
+ let rateLimit = self.checkAndHandleRateLimitResponse(
2698
+ statusCode: result.response?.statusCode,
2699
+ data: result.data,
2700
+ response: result.response
2701
+ )
2702
+ if rateLimit.blocked {
2703
+ getChannel.message = rateLimit.message
2704
+ getChannel.error = rateLimit.error
2542
2705
  return getChannel
2543
2706
  }
2544
2707
 
@@ -2616,9 +2779,10 @@ import UIKit
2616
2779
  let listChannels: ListChannels = ListChannels()
2617
2780
 
2618
2781
  // Check if rate limit was exceeded
2619
- if CapgoUpdater.rateLimitExceeded {
2620
- logger.debug("Skipping listChannels due to rate limit (429). Requests will resume after app restart.")
2621
- listChannels.error = "rate_limit_exceeded"
2782
+ if isRemoteBlocked() {
2783
+ let blocked = remoteBlockedClientError()
2784
+ logger.debug("Skipping listChannels due to remote block (\(blocked.error)).")
2785
+ listChannels.error = blocked.error
2622
2786
  return listChannels
2623
2787
  }
2624
2788
 
@@ -2652,8 +2816,13 @@ import UIKit
2652
2816
 
2653
2817
  let result = performRequest(request, label: "listChannels")
2654
2818
 
2655
- if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
2656
- listChannels.error = "rate_limit_exceeded"
2819
+ let rateLimit = self.checkAndHandleRateLimitResponse(
2820
+ statusCode: result.response?.statusCode,
2821
+ data: result.data,
2822
+ response: result.response
2823
+ )
2824
+ if rateLimit.blocked {
2825
+ listChannels.error = rateLimit.error
2657
2826
  return listChannels
2658
2827
  }
2659
2828
 
@@ -2737,12 +2906,6 @@ import UIKit
2737
2906
  return
2738
2907
  }
2739
2908
 
2740
- // Check if rate limit was exceeded
2741
- if CapgoUpdater.rateLimitExceeded {
2742
- logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.")
2743
- return
2744
- }
2745
-
2746
2909
  guard !statsUrl.isEmpty else {
2747
2910
  return
2748
2911
  }
@@ -2795,6 +2958,12 @@ import UIKit
2795
2958
  }
2796
2959
 
2797
2960
  private func flushStatsQueue() {
2961
+ // While Retry-After is active, keep stats queued and skip the network call.
2962
+ if isRemoteBlocked() {
2963
+ logger.debug("Deferring stats flush until Retry-After expires.")
2964
+ return
2965
+ }
2966
+
2798
2967
  statsQueueLock.lock()
2799
2968
  guard !statsQueue.isEmpty else {
2800
2969
  statsQueueLock.unlock()
@@ -2805,7 +2974,6 @@ import UIKit
2805
2974
  statsQueueLock.unlock()
2806
2975
 
2807
2976
  let eventsToSend = queuedEvents.map(\.event)
2808
- let onSentCallbacks = queuedEvents.compactMap(\.onSent)
2809
2977
 
2810
2978
  operationQueue.maxConcurrentOperationCount = 1
2811
2979
 
@@ -2818,15 +2986,23 @@ import UIKit
2818
2986
  encoder: JSONParameterEncoder.default,
2819
2987
  requestModifier: { $0.timeoutInterval = self.timeout }
2820
2988
  ).responseData { response in
2821
- // Check for 429 rate limit
2822
- if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
2989
+ // Check for 429 rate limit — requeue so events are not lost.
2990
+ if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode, data: response.data, response: response.response).blocked {
2991
+ self.requeueStatsEvents(queuedEvents)
2823
2992
  semaphore.signal()
2824
2993
  return
2825
2994
  }
2826
2995
 
2827
2996
  if let statusCode = response.response?.statusCode, !(200...299).contains(statusCode) {
2828
- self.logger.error("Error sending stats batch")
2829
- self.logger.debug("Response code: \(statusCode)")
2997
+ if CapgoUpdater.isTransientStatsFailure(statusCode) {
2998
+ self.requeueStatsEvents(queuedEvents)
2999
+ self.logger.error("Error sending stats batch")
3000
+ self.logger.debug("Retrying later, response code: \(statusCode)")
3001
+ } else {
3002
+ // Permanent rejection: retrying would loop forever and block the queue.
3003
+ self.logger.error("Dropping stats batch after permanent error")
3004
+ self.logger.debug("Response code: \(statusCode)")
3005
+ }
2830
3006
  semaphore.signal()
2831
3007
  return
2832
3008
  }
@@ -2835,8 +3011,9 @@ import UIKit
2835
3011
  case .success:
2836
3012
  self.logger.info("Stats batch sent successfully")
2837
3013
  self.logger.debug("Sent \(eventsToSend.count) events")
2838
- onSentCallbacks.forEach { $0() }
3014
+ self.runStatsCallbacks(queuedEvents)
2839
3015
  case let .failure(error):
3016
+ self.requeueStatsEvents(queuedEvents)
2840
3017
  self.logger.error("Error sending stats batch")
2841
3018
  self.logger.debug("Response: \(response.value?.debugDescription ?? "nil"), Error: \(error.localizedDescription)")
2842
3019
  }
@@ -2847,6 +3024,25 @@ import UIKit
2847
3024
  operationQueue.addOperation(operation)
2848
3025
  }
2849
3026
 
3027
+ /// Only 429, request timeout and 5xx are worth retrying; other 4xx are permanent rejections.
3028
+ private static func isTransientStatsFailure(_ statusCode: Int) -> Bool {
3029
+ return statusCode == 429 || statusCode == 408 || statusCode >= 500
3030
+ }
3031
+
3032
+ private func runStatsCallbacks(_ sentEvents: [QueuedStatsEvent]) {
3033
+ for sentEvent in sentEvents {
3034
+ sentEvent.onSent?()
3035
+ }
3036
+ }
3037
+
3038
+ private func requeueStatsEvents(_ events: [QueuedStatsEvent]) {
3039
+ guard !events.isEmpty else { return }
3040
+ statsQueueLock.lock()
3041
+ statsQueue.insert(contentsOf: events, at: 0)
3042
+ statsQueueLock.unlock()
3043
+ ensureStatsTimerStarted()
3044
+ }
3045
+
2850
3046
  public func getBundleInfo(id: String?) -> BundleInfo {
2851
3047
  var trueId = BundleInfo.VERSION_UNKNOWN
2852
3048
  if id != nil {
@@ -726,8 +726,12 @@ extension UIWindow {
726
726
  return
727
727
  }
728
728
 
729
- // Check if there's an actual update available
730
- if latestKind == "up_to_date" || latest.url.isEmpty {
729
+ // Check if there's an actual update available. A manifest-only
730
+ // response legitimately has no URL (the files come from the
731
+ // manifest, not a zip), so only report "already on latest" when
732
+ // the URL is empty AND there is no manifest to download from.
733
+ let hasManifest = !(latest.manifest?.isEmpty ?? true)
734
+ if latestKind == "up_to_date" || (latest.url.isEmpty && !hasManifest) {
731
735
  DispatchQueue.main.async {
732
736
  progressAlert.dismiss(animated: true) {
733
737
  self.showSuccess(message: "Channel set to \(name). Already on latest version.", plugin: plugin)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "8.51.3",
3
+ "version": "8.51.5",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",