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