@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.
- package/android/src/main/java/ee/forgr/capacitor_updater/AppLifecycleObserver.java +29 -2
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +26 -10
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +525 -89
- package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +22 -17
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +7 -1
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +372 -61
- package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +6 -2
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
2082
|
+
* Give the claim back when the statistic never made it out, so a later 429 can retry it.
|
|
1918
2083
|
*/
|
|
1919
|
-
private
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
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
|
-
|
|
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",
|
|
2030
|
-
retError.put("error",
|
|
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
|
-
|
|
2153
|
-
|
|
2154
|
-
logger.debug("Skipping setChannel due to
|
|
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",
|
|
2157
|
-
retError.put("error",
|
|
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
|
-
|
|
2212
|
-
|
|
2213
|
-
logger.debug("Skipping getChannel due to
|
|
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",
|
|
2216
|
-
retError.put("error",
|
|
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
|
-
|
|
2262
|
-
|
|
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",
|
|
2265
|
-
retError.put("error",
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
2363
|
-
|
|
2364
|
-
logger.debug("Skipping listChannels due to
|
|
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",
|
|
2367
|
-
retError.put("error",
|
|
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
|
-
|
|
2427
|
-
|
|
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",
|
|
2430
|
-
retError.put("error",
|
|
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 (
|
|
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
|
|
2568
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2624
|
-
|
|
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
|
-
|
|
2631
|
-
|
|
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
|
-
|
|
2637
|
-
|
|
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
|
-
|
|
2641
|
-
|
|
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
|
-
//
|
|
2843
|
-
|
|
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();
|