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