@capgo/capacitor-updater 5.50.1 → 5.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/CapgoCapacitorUpdater.podspec +1 -1
- package/Package.swift +3 -2
- package/README.md +53 -48
- package/android/build.gradle +1 -0
- 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 +476 -151
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1354 -412
- 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 +540 -224
- 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 +131 -145
- 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 +787 -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);
|
|
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;
|
|
1270
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 {
|
|
@@ -1322,10 +1770,12 @@ public class CapgoUpdater {
|
|
|
1322
1770
|
}
|
|
1323
1771
|
|
|
1324
1772
|
void restoreResetState(final ResetState state) {
|
|
1325
|
-
final String currentBundlePath =
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1773
|
+
final String currentBundlePath = state.currentBundlePath == null || state.currentBundlePath.trim().isEmpty()
|
|
1774
|
+
? "public"
|
|
1775
|
+
: state.currentBundlePath;
|
|
1776
|
+
final String fallbackBundleId = state.fallbackBundleId == null || state.fallbackBundleId.isEmpty()
|
|
1777
|
+
? BundleInfo.ID_BUILTIN
|
|
1778
|
+
: state.fallbackBundleId;
|
|
1329
1779
|
|
|
1330
1780
|
this.editor.putString(this.CAP_SERVER_PATH, currentBundlePath);
|
|
1331
1781
|
this.editor.putString(FALLBACK_VERSION, fallbackBundleId);
|
|
@@ -1476,21 +1926,54 @@ public class CapgoUpdater {
|
|
|
1476
1926
|
// Only attempt to delete when the fallback is a different bundle than the
|
|
1477
1927
|
// currently loaded one. Otherwise we spam logs with "Cannot delete <id>"
|
|
1478
1928
|
// because delete() protects the current bundle from removal.
|
|
1479
|
-
|
|
1480
|
-
|
|
1929
|
+
final String previousFallbackId = fallback.getId();
|
|
1930
|
+
final String previousFallbackVersion = fallback.getVersionName();
|
|
1931
|
+
final BundleInfo nextBundle = this.getNextBundle();
|
|
1932
|
+
final boolean previousIsNext =
|
|
1933
|
+
nextBundle != null &&
|
|
1934
|
+
previousFallbackId != null &&
|
|
1935
|
+
previousFallbackId.equals(nextBundle.getId()) &&
|
|
1936
|
+
!nextBundle.isDeleted() &&
|
|
1937
|
+
!nextBundle.isErrorStatus() &&
|
|
1938
|
+
!nextBundle.isDeleting();
|
|
1939
|
+
final boolean shouldDeletePrevious =
|
|
1940
|
+
Boolean.TRUE.equals(autoDeletePrevious) &&
|
|
1481
1941
|
!fallback.isBuiltin() &&
|
|
1482
|
-
|
|
1483
|
-
!
|
|
1484
|
-
!fallbackIsPreviewFallback
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
if (
|
|
1488
|
-
logger.
|
|
1489
|
-
|
|
1490
|
-
|
|
1942
|
+
previousFallbackId != null &&
|
|
1943
|
+
!previousFallbackId.equals(bundle.getId()) &&
|
|
1944
|
+
!fallbackIsPreviewFallback &&
|
|
1945
|
+
!previousIsNext;
|
|
1946
|
+
if (shouldDeletePrevious) {
|
|
1947
|
+
if (!this.saveBundleInfo(previousFallbackId, fallback.setStatus(BundleStatus.DELETING))) {
|
|
1948
|
+
logger.error("Failed to persist DELETING for previous bundle; queueing durable retry");
|
|
1949
|
+
logger.debug("Bundle ID: " + previousFallbackId);
|
|
1950
|
+
this.enqueuePendingDelete(previousFallbackId);
|
|
1491
1951
|
}
|
|
1492
1952
|
}
|
|
1953
|
+
boolean deletePreviousAsync = shouldDeletePrevious;
|
|
1493
1954
|
this.setFallbackBundle(bundle);
|
|
1955
|
+
if (deletePreviousAsync) {
|
|
1956
|
+
final String asyncPreviousFallbackId = previousFallbackId;
|
|
1957
|
+
final String asyncPreviousFallbackVersion = previousFallbackVersion;
|
|
1958
|
+
io.execute(() -> {
|
|
1959
|
+
if (this.activity != null) {
|
|
1960
|
+
if (!DownloadWorkerManager.cancelVersionDownloadAndAwait(this.activity, asyncPreviousFallbackVersion)) {
|
|
1961
|
+
logger.error("Failed to cancel previous version download before delete");
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
try {
|
|
1966
|
+
final Boolean res = this.delete(asyncPreviousFallbackId, true, false);
|
|
1967
|
+
if (Boolean.TRUE.equals(res)) {
|
|
1968
|
+
logger.info("Deleted previous bundle: " + asyncPreviousFallbackVersion);
|
|
1969
|
+
} else {
|
|
1970
|
+
logger.debug("Previous bundle delete incomplete, will retry: " + asyncPreviousFallbackId);
|
|
1971
|
+
}
|
|
1972
|
+
} catch (final IOException e) {
|
|
1973
|
+
logger.error("Failed to delete previous bundle: " + asyncPreviousFallbackId + " " + e.getMessage());
|
|
1974
|
+
}
|
|
1975
|
+
});
|
|
1976
|
+
}
|
|
1494
1977
|
}
|
|
1495
1978
|
|
|
1496
1979
|
public void setError(final BundleInfo bundle) {
|
|
@@ -1532,56 +2015,229 @@ public class CapgoUpdater {
|
|
|
1532
2015
|
return json;
|
|
1533
2016
|
}
|
|
1534
2017
|
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
}
|
|
1546
|
-
rateLimitExceeded = true;
|
|
1547
|
-
logger.warn("Rate limit exceeded (429). Stopping all stats and channel requests until app restart.");
|
|
1548
|
-
return true;
|
|
2018
|
+
private static final class RemoteBlockResult {
|
|
2019
|
+
|
|
2020
|
+
final boolean blocked;
|
|
2021
|
+
final String error;
|
|
2022
|
+
final String message;
|
|
2023
|
+
|
|
2024
|
+
RemoteBlockResult(final boolean blocked, final String error, final String message) {
|
|
2025
|
+
this.blocked = blocked;
|
|
2026
|
+
this.error = error;
|
|
2027
|
+
this.message = message;
|
|
1549
2028
|
}
|
|
1550
|
-
return false;
|
|
1551
2029
|
}
|
|
1552
2030
|
|
|
1553
2031
|
/**
|
|
1554
|
-
*
|
|
2032
|
+
* Handle HTTP 429 responses by honouring Retry-After / rateLimitResetAt.
|
|
2033
|
+
* All 429s use the same temporary client block — no sticky latch until restart.
|
|
1555
2034
|
*/
|
|
1556
|
-
private
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
2035
|
+
private RemoteBlockResult checkAndHandleRateLimitResponse(Response response, String responseData) {
|
|
2036
|
+
if (response == null || response.code() != 429) {
|
|
2037
|
+
return new RemoteBlockResult(false, "", "");
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
final String parsedError = parseRemoteError(responseData);
|
|
2041
|
+
final String parsedMessage = parseRemoteMessage(responseData);
|
|
2042
|
+
final String errorCode = parsedError.isEmpty() ? "too_many_requests" : parsedError;
|
|
2043
|
+
final String message = parsedMessage.isEmpty() ? "Too many requests" : parsedMessage;
|
|
2044
|
+
|
|
2045
|
+
final long retryUntilMs = resolveRateLimitBlockedUntilMs(response, responseData);
|
|
2046
|
+
synchronized (rateLimitStateLock) {
|
|
2047
|
+
if (retryUntilMs > rateLimitBlockedUntilMs) {
|
|
2048
|
+
rateLimitBlockedUntilMs = retryUntilMs;
|
|
2049
|
+
rateLimitBlockedError = errorCode;
|
|
2050
|
+
rateLimitBlockedMessage = message;
|
|
2051
|
+
} else if (rateLimitBlockedUntilMs <= 0L) {
|
|
2052
|
+
rateLimitBlockedError = errorCode;
|
|
2053
|
+
rateLimitBlockedMessage = message;
|
|
2054
|
+
}
|
|
1560
2055
|
}
|
|
1561
2056
|
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
json.put("action", "rate_limit_reached");
|
|
2057
|
+
// Claim last, and only when there is somewhere to send it, so a 429 burst with no
|
|
2058
|
+
// stats URL does not claim and release the latch once per response.
|
|
2059
|
+
if ("too_many_requests".equals(errorCode) && !this.previewSession && this.hasStatsUrl() && claimRateLimitStatistic()) {
|
|
2060
|
+
sendRateLimitStatistic();
|
|
2061
|
+
}
|
|
1568
2062
|
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
2063
|
+
final long nowMs = System.currentTimeMillis();
|
|
2064
|
+
final long retryAfter = Math.max(0L, (Math.max(retryUntilMs, nowMs) - nowMs + 999L) / 1000L);
|
|
2065
|
+
logger.warn("Received 429 (" + errorCode + "). Honouring Retry-After: " + retryAfter + "s.");
|
|
2066
|
+
return new RemoteBlockResult(true, errorCode, message);
|
|
2067
|
+
}
|
|
1573
2068
|
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
2069
|
+
private String parseRemoteError(final String responseData) {
|
|
2070
|
+
if (responseData == null || responseData.isEmpty()) {
|
|
2071
|
+
return "";
|
|
2072
|
+
}
|
|
2073
|
+
try {
|
|
2074
|
+
final JSONObject json = new JSONObject(responseData);
|
|
2075
|
+
return json.optString("error", "");
|
|
2076
|
+
} catch (JSONException ignored) {
|
|
2077
|
+
return "";
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
private String parseRemoteMessage(final String responseData) {
|
|
2082
|
+
if (responseData == null || responseData.isEmpty()) {
|
|
2083
|
+
return "";
|
|
2084
|
+
}
|
|
2085
|
+
try {
|
|
2086
|
+
final JSONObject json = new JSONObject(responseData);
|
|
2087
|
+
return json.optString("message", "");
|
|
2088
|
+
} catch (JSONException ignored) {
|
|
2089
|
+
return "";
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
private long resolveRateLimitBlockedUntilMs(final Response response, final String responseData) {
|
|
2094
|
+
final long nowMs = System.currentTimeMillis();
|
|
2095
|
+
final double candidate = rawRateLimitDeadlineMs(response, responseData, nowMs);
|
|
2096
|
+
// NaN and past deadlines mean "no client-side block"; anything further out is capped.
|
|
2097
|
+
if (!(candidate > nowMs)) {
|
|
2098
|
+
return 0L;
|
|
2099
|
+
}
|
|
2100
|
+
return (long) Math.min(candidate, (double) nowMs + MAX_RATE_LIMIT_WINDOW_MS);
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
private double rawRateLimitDeadlineMs(final Response response, final String responseData, final long nowMs) {
|
|
2104
|
+
final String header = response.header("Retry-After");
|
|
2105
|
+
if (header != null) {
|
|
2106
|
+
try {
|
|
2107
|
+
final double seconds = Double.parseDouble(header.trim());
|
|
2108
|
+
if (seconds >= 0) {
|
|
2109
|
+
return nowMs + seconds * 1000d;
|
|
2110
|
+
}
|
|
2111
|
+
} catch (NumberFormatException ignored) {
|
|
2112
|
+
// Fall through to body fields
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
if (responseData != null && !responseData.isEmpty()) {
|
|
2117
|
+
try {
|
|
2118
|
+
final JSONObject json = new JSONObject(responseData);
|
|
2119
|
+
final JSONObject moreInfo = json.optJSONObject("moreInfo");
|
|
2120
|
+
if (moreInfo != null && moreInfo.has("retryAfterSeconds")) {
|
|
2121
|
+
final double retryAfter = moreInfo.getDouble("retryAfterSeconds");
|
|
2122
|
+
if (retryAfter >= 0) {
|
|
2123
|
+
return nowMs + retryAfter * 1000d;
|
|
2124
|
+
}
|
|
2125
|
+
} else if (json.has("retryAfterSeconds")) {
|
|
2126
|
+
final double retryAfter = json.getDouble("retryAfterSeconds");
|
|
2127
|
+
if (retryAfter >= 0) {
|
|
2128
|
+
return nowMs + retryAfter * 1000d;
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
if (moreInfo != null && moreInfo.has("rateLimitResetAt")) {
|
|
2132
|
+
return moreInfo.getDouble("rateLimitResetAt");
|
|
2133
|
+
} else if (json.has("rateLimitResetAt")) {
|
|
2134
|
+
return json.getDouble("rateLimitResetAt");
|
|
1582
2135
|
}
|
|
2136
|
+
} catch (JSONException ignored) {
|
|
2137
|
+
// No retry hint
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// No retry hint — do not hold a client-side block; allow immediate retry to the worker
|
|
2142
|
+
return 0d;
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
private static boolean claimRateLimitStatistic() {
|
|
2146
|
+
synchronized (rateLimitStateLock) {
|
|
2147
|
+
if (rateLimitStatisticSent) {
|
|
2148
|
+
return false;
|
|
2149
|
+
}
|
|
2150
|
+
rateLimitStatisticSent = true;
|
|
2151
|
+
return true;
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
/**
|
|
2156
|
+
* Give the claim back when the statistic never made it out, so a later 429 can retry it.
|
|
2157
|
+
*/
|
|
2158
|
+
private static void releaseRateLimitStatisticClaim() {
|
|
2159
|
+
synchronized (rateLimitStateLock) {
|
|
2160
|
+
rateLimitStatisticSent = false;
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
private boolean hasStatsUrl() {
|
|
2165
|
+
final String url = this.statsUrl;
|
|
2166
|
+
return url != null && !url.isEmpty();
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
private boolean isRemoteBlocked() {
|
|
2170
|
+
synchronized (rateLimitStateLock) {
|
|
2171
|
+
if (rateLimitBlockedUntilMs <= 0L) {
|
|
2172
|
+
return false;
|
|
2173
|
+
}
|
|
2174
|
+
if (System.currentTimeMillis() >= rateLimitBlockedUntilMs) {
|
|
2175
|
+
rateLimitBlockedUntilMs = 0L;
|
|
2176
|
+
return false;
|
|
1583
2177
|
}
|
|
2178
|
+
return true;
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
private RemoteBlockResult remoteBlockedClientError() {
|
|
2183
|
+
synchronized (rateLimitStateLock) {
|
|
2184
|
+
return new RemoteBlockResult(true, rateLimitBlockedError, rateLimitBlockedMessage);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
/**
|
|
2189
|
+
* Send a statistic about rate limiting.
|
|
2190
|
+
* Dispatched through OkHttp so no caller thread waits on the request.
|
|
2191
|
+
*/
|
|
2192
|
+
private void sendRateLimitStatistic() {
|
|
2193
|
+
String statsUrl = this.statsUrl;
|
|
2194
|
+
if (statsUrl == null || statsUrl.isEmpty()) {
|
|
2195
|
+
// The URL was cleared after the claim was taken; nothing went out, so hand it back.
|
|
2196
|
+
releaseRateLimitStatisticClaim();
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
try {
|
|
2201
|
+
BundleInfo current = this.getCurrentBundle();
|
|
2202
|
+
JSONObject json = this.createInfoObject();
|
|
2203
|
+
json.put("version_name", current.getVersionName());
|
|
2204
|
+
json.put("old_version_name", "");
|
|
2205
|
+
json.put("action", "rate_limit_reached");
|
|
2206
|
+
|
|
2207
|
+
Request request = new Request.Builder()
|
|
2208
|
+
.url(statsUrl)
|
|
2209
|
+
.post(RequestBody.create(json.toString(), MediaType.get("application/json")))
|
|
2210
|
+
.build();
|
|
2211
|
+
|
|
2212
|
+
// User-Agent header is automatically added by DownloadService.sharedClient interceptor
|
|
2213
|
+
DownloadService.sharedClient
|
|
2214
|
+
.newCall(request)
|
|
2215
|
+
.enqueue(
|
|
2216
|
+
new okhttp3.Callback() {
|
|
2217
|
+
@Override
|
|
2218
|
+
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
|
2219
|
+
releaseRateLimitStatisticClaim();
|
|
2220
|
+
logger.error("Failed to send rate limit statistic");
|
|
2221
|
+
logger.debug("Error: " + e.getMessage());
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
@Override
|
|
2225
|
+
public void onResponse(@NonNull Call call, @NonNull Response response) {
|
|
2226
|
+
// The body is unused here; closing the Response closes it.
|
|
2227
|
+
try (response) {
|
|
2228
|
+
if (response.isSuccessful()) {
|
|
2229
|
+
logger.info("Rate limit statistic sent");
|
|
2230
|
+
} else {
|
|
2231
|
+
releaseRateLimitStatisticClaim();
|
|
2232
|
+
logger.error("Error sending rate limit statistic");
|
|
2233
|
+
logger.debug("Response code: " + response.code());
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
);
|
|
1584
2239
|
} catch (final Exception e) {
|
|
2240
|
+
releaseRateLimitStatisticClaim();
|
|
1585
2241
|
logger.error("Failed to send rate limit statistic");
|
|
1586
2242
|
logger.debug("Error: " + e.getMessage());
|
|
1587
2243
|
}
|
|
@@ -1593,105 +2249,130 @@ public class CapgoUpdater {
|
|
|
1593
2249
|
|
|
1594
2250
|
Request request = new Request.Builder().url(url).post(body).build();
|
|
1595
2251
|
|
|
1596
|
-
DownloadService.sharedClient
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
2252
|
+
DownloadService.sharedClient
|
|
2253
|
+
.newCall(request)
|
|
2254
|
+
.enqueue(
|
|
2255
|
+
new okhttp3.Callback() {
|
|
2256
|
+
@Override
|
|
2257
|
+
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
|
2258
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2259
|
+
retError.put("message", "Request failed: " + e.getMessage());
|
|
2260
|
+
retError.put("error", "network_error");
|
|
2261
|
+
retError.put("kind", "failed");
|
|
2262
|
+
callback.callback(retError);
|
|
2263
|
+
}
|
|
1606
2264
|
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
2265
|
+
@Override
|
|
2266
|
+
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
2267
|
+
try (ResponseBody responseBody = response.body()) {
|
|
2268
|
+
final int statusCode = response.code();
|
|
2269
|
+
final String responseData = responseBody != null ? responseBody.string() : "";
|
|
2270
|
+
JSONObject jsonResponse = null;
|
|
2271
|
+
if (!responseData.isEmpty()) {
|
|
2272
|
+
try {
|
|
2273
|
+
jsonResponse = new JSONObject(responseData);
|
|
2274
|
+
} catch (JSONException ignored) {
|
|
2275
|
+
// Non-JSON responses are handled as response or parse errors below.
|
|
2276
|
+
}
|
|
1618
2277
|
}
|
|
1619
|
-
}
|
|
1620
2278
|
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
2279
|
+
if (jsonResponse != null && (jsonResponse.has("error") || jsonResponse.has("kind"))) {
|
|
2280
|
+
if (statusCode == 429) {
|
|
2281
|
+
final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
|
|
2282
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2283
|
+
retError.put(
|
|
2284
|
+
"error",
|
|
2285
|
+
rateLimit.error.isEmpty() ? jsonResponse.optString("error", "too_many_requests") : rateLimit.error
|
|
2286
|
+
);
|
|
2287
|
+
retError.put(
|
|
2288
|
+
"message",
|
|
2289
|
+
rateLimit.message.isEmpty()
|
|
2290
|
+
? jsonResponse.optString("message", "Too many requests")
|
|
2291
|
+
: rateLimit.message
|
|
2292
|
+
);
|
|
2293
|
+
if (jsonResponse.has("kind") && !jsonResponse.isNull("kind")) {
|
|
2294
|
+
retError.put("kind", jsonResponse.getString("kind"));
|
|
2295
|
+
} else {
|
|
2296
|
+
retError.put("kind", "failed");
|
|
2297
|
+
}
|
|
2298
|
+
if (jsonResponse.has("version") && !jsonResponse.isNull("version")) {
|
|
2299
|
+
retError.put("version", jsonResponse.getString("version"));
|
|
2300
|
+
}
|
|
2301
|
+
retError.put("statusCode", statusCode);
|
|
2302
|
+
callback.callback(retError);
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2305
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2306
|
+
if (jsonResponse.has("error") && !jsonResponse.isNull("error")) {
|
|
2307
|
+
retError.put("error", jsonResponse.getString("error"));
|
|
2308
|
+
}
|
|
2309
|
+
if (jsonResponse.has("kind") && !jsonResponse.isNull("kind")) {
|
|
2310
|
+
retError.put("kind", jsonResponse.getString("kind"));
|
|
2311
|
+
}
|
|
2312
|
+
if (jsonResponse.has("message") && !jsonResponse.isNull("message")) {
|
|
2313
|
+
retError.put("message", jsonResponse.getString("message"));
|
|
2314
|
+
} else {
|
|
2315
|
+
retError.put("message", "server did not provide a message");
|
|
2316
|
+
}
|
|
2317
|
+
if (jsonResponse.has("version") && !jsonResponse.isNull("version")) {
|
|
2318
|
+
retError.put("version", jsonResponse.getString("version"));
|
|
2319
|
+
}
|
|
2320
|
+
retError.put("statusCode", statusCode);
|
|
2321
|
+
callback.callback(retError);
|
|
2322
|
+
return;
|
|
1628
2323
|
}
|
|
1629
|
-
|
|
1630
|
-
|
|
2324
|
+
|
|
2325
|
+
// Check for 429 rate limit without JSON body
|
|
2326
|
+
final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
|
|
2327
|
+
if (rateLimit.blocked) {
|
|
2328
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2329
|
+
retError.put("message", rateLimit.message);
|
|
2330
|
+
retError.put("error", rateLimit.error);
|
|
2331
|
+
retError.put("kind", "failed");
|
|
2332
|
+
retError.put("statusCode", statusCode);
|
|
2333
|
+
callback.callback(retError);
|
|
2334
|
+
return;
|
|
1631
2335
|
}
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
retError.put("message", "
|
|
2336
|
+
|
|
2337
|
+
if (!response.isSuccessful()) {
|
|
2338
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2339
|
+
retError.put("message", "Server error: " + response.code());
|
|
2340
|
+
retError.put("error", "response_error");
|
|
2341
|
+
retError.put("kind", "failed");
|
|
2342
|
+
retError.put("statusCode", statusCode);
|
|
2343
|
+
callback.callback(retError);
|
|
2344
|
+
return;
|
|
1636
2345
|
}
|
|
1637
|
-
|
|
1638
|
-
|
|
2346
|
+
|
|
2347
|
+
if (jsonResponse == null) {
|
|
2348
|
+
throw new JSONException("Response is not a JSON object");
|
|
1639
2349
|
}
|
|
1640
|
-
retError.put("statusCode", statusCode);
|
|
1641
|
-
callback.callback(retError);
|
|
1642
|
-
return;
|
|
1643
|
-
}
|
|
1644
2350
|
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
Map<String, Object> retError = new HashMap<>();
|
|
1648
|
-
retError.put("message", "Rate limit exceeded");
|
|
1649
|
-
retError.put("error", "rate_limit_exceeded");
|
|
1650
|
-
retError.put("kind", "failed");
|
|
1651
|
-
retError.put("statusCode", statusCode);
|
|
1652
|
-
callback.callback(retError);
|
|
1653
|
-
return;
|
|
1654
|
-
}
|
|
2351
|
+
Map<String, Object> ret = new HashMap<>();
|
|
2352
|
+
ret.put("statusCode", statusCode);
|
|
1655
2353
|
|
|
1656
|
-
|
|
2354
|
+
Iterator<String> keys = jsonResponse.keys();
|
|
2355
|
+
while (keys.hasNext()) {
|
|
2356
|
+
String key = keys.next();
|
|
2357
|
+
if (jsonResponse.has(key)) {
|
|
2358
|
+
if ("session_key".equals(key)) {
|
|
2359
|
+
ret.put("sessionKey", jsonResponse.get(key));
|
|
2360
|
+
} else {
|
|
2361
|
+
ret.put(key, jsonResponse.get(key));
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
callback.callback(ret);
|
|
2366
|
+
} catch (JSONException e) {
|
|
1657
2367
|
Map<String, Object> retError = new HashMap<>();
|
|
1658
|
-
retError.put("message", "
|
|
1659
|
-
retError.put("error", "
|
|
2368
|
+
retError.put("message", "JSON parse error: " + e.getMessage());
|
|
2369
|
+
retError.put("error", "parse_error");
|
|
1660
2370
|
retError.put("kind", "failed");
|
|
1661
|
-
retError.put("statusCode", statusCode);
|
|
1662
2371
|
callback.callback(retError);
|
|
1663
|
-
return;
|
|
1664
2372
|
}
|
|
1665
|
-
|
|
1666
|
-
if (jsonResponse == null) {
|
|
1667
|
-
throw new JSONException("Response is not a JSON object");
|
|
1668
|
-
}
|
|
1669
|
-
|
|
1670
|
-
Map<String, Object> ret = new HashMap<>();
|
|
1671
|
-
ret.put("statusCode", statusCode);
|
|
1672
|
-
|
|
1673
|
-
Iterator<String> keys = jsonResponse.keys();
|
|
1674
|
-
while (keys.hasNext()) {
|
|
1675
|
-
String key = keys.next();
|
|
1676
|
-
if (jsonResponse.has(key)) {
|
|
1677
|
-
if ("session_key".equals(key)) {
|
|
1678
|
-
ret.put("sessionKey", jsonResponse.get(key));
|
|
1679
|
-
} else {
|
|
1680
|
-
ret.put(key, jsonResponse.get(key));
|
|
1681
|
-
}
|
|
1682
|
-
}
|
|
1683
|
-
}
|
|
1684
|
-
callback.callback(ret);
|
|
1685
|
-
} catch (JSONException e) {
|
|
1686
|
-
Map<String, Object> retError = new HashMap<>();
|
|
1687
|
-
retError.put("message", "JSON parse error: " + e.getMessage());
|
|
1688
|
-
retError.put("error", "parse_error");
|
|
1689
|
-
retError.put("kind", "failed");
|
|
1690
|
-
callback.callback(retError);
|
|
1691
2373
|
}
|
|
1692
2374
|
}
|
|
1693
|
-
|
|
1694
|
-
);
|
|
2375
|
+
);
|
|
1695
2376
|
}
|
|
1696
2377
|
|
|
1697
2378
|
public void getLatest(final String updateUrl, final String channel, final Callback callback) {
|
|
@@ -1699,6 +2380,16 @@ public class CapgoUpdater {
|
|
|
1699
2380
|
}
|
|
1700
2381
|
|
|
1701
2382
|
public void getLatest(final String updateUrl, final String channel, final String appIdOverride, final Callback callback) {
|
|
2383
|
+
if (isRemoteBlocked()) {
|
|
2384
|
+
final RemoteBlockResult blocked = remoteBlockedClientError();
|
|
2385
|
+
logger.debug("Skipping getLatest due to remote block (" + blocked.error + ").");
|
|
2386
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
2387
|
+
retError.put("message", blocked.message);
|
|
2388
|
+
retError.put("error", blocked.error);
|
|
2389
|
+
retError.put("kind", "failed");
|
|
2390
|
+
callback.callback(retError);
|
|
2391
|
+
return;
|
|
2392
|
+
}
|
|
1702
2393
|
JSONObject json;
|
|
1703
2394
|
try {
|
|
1704
2395
|
json = this.createInfoObject(appIdOverride);
|
|
@@ -1768,12 +2459,12 @@ public class CapgoUpdater {
|
|
|
1768
2459
|
return;
|
|
1769
2460
|
}
|
|
1770
2461
|
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
logger.debug("Skipping setChannel due to
|
|
2462
|
+
if (isRemoteBlocked()) {
|
|
2463
|
+
final RemoteBlockResult blocked = remoteBlockedClientError();
|
|
2464
|
+
logger.debug("Skipping setChannel due to remote block (" + blocked.error + ").");
|
|
1774
2465
|
final Map<String, Object> retError = new HashMap<>();
|
|
1775
|
-
retError.put("message",
|
|
1776
|
-
retError.put("error",
|
|
2466
|
+
retError.put("message", blocked.message);
|
|
2467
|
+
retError.put("error", blocked.error);
|
|
1777
2468
|
callback.callback(retError);
|
|
1778
2469
|
return;
|
|
1779
2470
|
}
|
|
@@ -1813,7 +2504,6 @@ public class CapgoUpdater {
|
|
|
1813
2504
|
logger.info("Public channel requested, channel override removed");
|
|
1814
2505
|
callback.callback(res);
|
|
1815
2506
|
} else {
|
|
1816
|
-
// Success - persist defaultChannel
|
|
1817
2507
|
this.defaultChannel = channel;
|
|
1818
2508
|
editor.putString(defaultChannelKey, channel);
|
|
1819
2509
|
editor.apply();
|
|
@@ -1828,12 +2518,12 @@ public class CapgoUpdater {
|
|
|
1828
2518
|
}
|
|
1829
2519
|
|
|
1830
2520
|
public void getChannel(final Callback callback, final SharedPreferences.Editor editor, final String defaultChannelKey) {
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
logger.debug("Skipping getChannel due to
|
|
2521
|
+
if (isRemoteBlocked()) {
|
|
2522
|
+
final RemoteBlockResult blocked = remoteBlockedClientError();
|
|
2523
|
+
logger.debug("Skipping getChannel due to remote block (" + blocked.error + ").");
|
|
1834
2524
|
final Map<String, Object> retError = new HashMap<>();
|
|
1835
|
-
retError.put("message",
|
|
1836
|
-
retError.put("error",
|
|
2525
|
+
retError.put("message", blocked.message);
|
|
2526
|
+
retError.put("error", blocked.error);
|
|
1837
2527
|
callback.callback(retError);
|
|
1838
2528
|
return;
|
|
1839
2529
|
}
|
|
@@ -1865,99 +2555,93 @@ public class CapgoUpdater {
|
|
|
1865
2555
|
.put(RequestBody.create(json.toString(), MediaType.get("application/json")))
|
|
1866
2556
|
.build();
|
|
1867
2557
|
|
|
1868
|
-
DownloadService.sharedClient
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
2558
|
+
DownloadService.sharedClient
|
|
2559
|
+
.newCall(request)
|
|
2560
|
+
.enqueue(
|
|
2561
|
+
new okhttp3.Callback() {
|
|
2562
|
+
@Override
|
|
2563
|
+
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
|
2564
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2565
|
+
retError.put("message", "Request failed: " + e.getMessage());
|
|
2566
|
+
retError.put("error", "network_error");
|
|
2567
|
+
callback.callback(retError);
|
|
2568
|
+
}
|
|
1877
2569
|
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
2570
|
+
@Override
|
|
2571
|
+
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
2572
|
+
try (ResponseBody responseBody = response.body()) {
|
|
2573
|
+
final String responseData = responseBody != null ? responseBody.string() : "";
|
|
2574
|
+
final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
|
|
2575
|
+
if (rateLimit.blocked) {
|
|
2576
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2577
|
+
retError.put("message", rateLimit.message);
|
|
2578
|
+
retError.put("error", rateLimit.error);
|
|
2579
|
+
callback.callback(retError);
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
if (response.code() == 400) {
|
|
2584
|
+
if (responseData.contains("channel_not_found") && !defaultChannel.isEmpty()) {
|
|
2585
|
+
Map<String, Object> ret = new HashMap<>();
|
|
2586
|
+
ret.put("channel", defaultChannel);
|
|
2587
|
+
ret.put("status", "default");
|
|
2588
|
+
logger.info("Channel get to \"" + ret);
|
|
2589
|
+
callback.callback(ret);
|
|
2590
|
+
return;
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
1889
2593
|
|
|
1890
|
-
|
|
1891
|
-
|
|
2594
|
+
if (!response.isSuccessful()) {
|
|
2595
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2596
|
+
retError.put("message", "Server error: " + response.code());
|
|
2597
|
+
retError.put("error", "response_error");
|
|
2598
|
+
callback.callback(retError);
|
|
2599
|
+
return;
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
if (responseData.isEmpty()) {
|
|
1892
2603
|
Map<String, Object> retError = new HashMap<>();
|
|
1893
2604
|
retError.put("message", "Empty response body");
|
|
1894
2605
|
retError.put("error", "no_response_body");
|
|
1895
2606
|
callback.callback(retError);
|
|
1896
2607
|
return;
|
|
1897
2608
|
}
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
2609
|
+
JSONObject jsonResponse = new JSONObject(responseData);
|
|
2610
|
+
|
|
2611
|
+
// Check for server-side errors first
|
|
2612
|
+
if (jsonResponse.has("error")) {
|
|
2613
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2614
|
+
retError.put("error", jsonResponse.getString("error"));
|
|
2615
|
+
if (jsonResponse.has("message")) {
|
|
2616
|
+
retError.put("message", jsonResponse.getString("message"));
|
|
2617
|
+
} else {
|
|
2618
|
+
retError.put("message", "server did not provide a message");
|
|
2619
|
+
}
|
|
2620
|
+
callback.callback(retError);
|
|
1905
2621
|
return;
|
|
1906
2622
|
}
|
|
1907
|
-
}
|
|
1908
2623
|
|
|
1909
|
-
|
|
1910
|
-
Map<String, Object> retError = new HashMap<>();
|
|
1911
|
-
retError.put("message", "Server error: " + response.code());
|
|
1912
|
-
retError.put("error", "response_error");
|
|
1913
|
-
callback.callback(retError);
|
|
1914
|
-
return;
|
|
1915
|
-
}
|
|
1916
|
-
|
|
1917
|
-
if (responseBody == null) {
|
|
1918
|
-
Map<String, Object> retError = new HashMap<>();
|
|
1919
|
-
retError.put("message", "Empty response body");
|
|
1920
|
-
retError.put("error", "no_response_body");
|
|
1921
|
-
callback.callback(retError);
|
|
1922
|
-
return;
|
|
1923
|
-
}
|
|
1924
|
-
String responseData = responseBody.string();
|
|
1925
|
-
JSONObject jsonResponse = new JSONObject(responseData);
|
|
2624
|
+
Map<String, Object> ret = new HashMap<>();
|
|
1926
2625
|
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
} else {
|
|
1934
|
-
retError.put("message", "server did not provide a message");
|
|
2626
|
+
Iterator<String> keys = jsonResponse.keys();
|
|
2627
|
+
while (keys.hasNext()) {
|
|
2628
|
+
String key = keys.next();
|
|
2629
|
+
if (jsonResponse.has(key)) {
|
|
2630
|
+
ret.put(key, jsonResponse.get(key));
|
|
2631
|
+
}
|
|
1935
2632
|
}
|
|
2633
|
+
persistDefaultChannelFromResponse(ret.get("channel"), editor, defaultChannelKey);
|
|
2634
|
+
logger.info("Channel get to \"" + ret);
|
|
2635
|
+
callback.callback(ret);
|
|
2636
|
+
} catch (JSONException e) {
|
|
2637
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2638
|
+
retError.put("message", "JSON parse error: " + e.getMessage());
|
|
2639
|
+
retError.put("error", "parse_error");
|
|
1936
2640
|
callback.callback(retError);
|
|
1937
|
-
return;
|
|
1938
2641
|
}
|
|
1939
|
-
|
|
1940
|
-
Map<String, Object> ret = new HashMap<>();
|
|
1941
|
-
|
|
1942
|
-
Iterator<String> keys = jsonResponse.keys();
|
|
1943
|
-
while (keys.hasNext()) {
|
|
1944
|
-
String key = keys.next();
|
|
1945
|
-
if (jsonResponse.has(key)) {
|
|
1946
|
-
ret.put(key, jsonResponse.get(key));
|
|
1947
|
-
}
|
|
1948
|
-
}
|
|
1949
|
-
persistDefaultChannelFromResponse(ret.get("channel"), editor, defaultChannelKey);
|
|
1950
|
-
logger.info("Channel get to \"" + ret);
|
|
1951
|
-
callback.callback(ret);
|
|
1952
|
-
} catch (JSONException e) {
|
|
1953
|
-
Map<String, Object> retError = new HashMap<>();
|
|
1954
|
-
retError.put("message", "JSON parse error: " + e.getMessage());
|
|
1955
|
-
retError.put("error", "parse_error");
|
|
1956
|
-
callback.callback(retError);
|
|
1957
2642
|
}
|
|
1958
2643
|
}
|
|
1959
|
-
|
|
1960
|
-
);
|
|
2644
|
+
);
|
|
1961
2645
|
}
|
|
1962
2646
|
|
|
1963
2647
|
void persistDefaultChannelFromResponse(final Object channel, final SharedPreferences.Editor editor, final String defaultChannelKey) {
|
|
@@ -1979,12 +2663,12 @@ public class CapgoUpdater {
|
|
|
1979
2663
|
}
|
|
1980
2664
|
|
|
1981
2665
|
public void listChannels(final Callback callback) {
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
logger.debug("Skipping listChannels due to
|
|
2666
|
+
if (isRemoteBlocked()) {
|
|
2667
|
+
final RemoteBlockResult blocked = remoteBlockedClientError();
|
|
2668
|
+
logger.debug("Skipping listChannels due to remote block (" + blocked.error + ").");
|
|
1985
2669
|
final Map<String, Object> retError = new HashMap<>();
|
|
1986
|
-
retError.put("message",
|
|
1987
|
-
retError.put("error",
|
|
2670
|
+
retError.put("message", blocked.message);
|
|
2671
|
+
retError.put("error", blocked.error);
|
|
1988
2672
|
callback.callback(retError);
|
|
1989
2673
|
return;
|
|
1990
2674
|
}
|
|
@@ -2030,83 +2714,85 @@ public class CapgoUpdater {
|
|
|
2030
2714
|
|
|
2031
2715
|
Request request = new Request.Builder().url(urlBuilder.build()).get().build();
|
|
2032
2716
|
|
|
2033
|
-
DownloadService.sharedClient
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
2045
|
-
try (ResponseBody responseBody = response.body()) {
|
|
2046
|
-
// Check for 429 rate limit
|
|
2047
|
-
if (checkAndHandleRateLimitResponse(response)) {
|
|
2048
|
-
Map<String, Object> retError = new HashMap<>();
|
|
2049
|
-
retError.put("message", "Rate limit exceeded");
|
|
2050
|
-
retError.put("error", "rate_limit_exceeded");
|
|
2051
|
-
callback.callback(retError);
|
|
2052
|
-
return;
|
|
2053
|
-
}
|
|
2717
|
+
DownloadService.sharedClient
|
|
2718
|
+
.newCall(request)
|
|
2719
|
+
.enqueue(
|
|
2720
|
+
new okhttp3.Callback() {
|
|
2721
|
+
@Override
|
|
2722
|
+
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
|
2723
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2724
|
+
retError.put("message", "Request failed: " + e.getMessage());
|
|
2725
|
+
retError.put("error", "network_error");
|
|
2726
|
+
callback.callback(retError);
|
|
2727
|
+
}
|
|
2054
2728
|
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2729
|
+
@Override
|
|
2730
|
+
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
2731
|
+
try (ResponseBody responseBody = response.body()) {
|
|
2732
|
+
final String data = responseBody != null ? responseBody.string() : "";
|
|
2733
|
+
final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, data);
|
|
2734
|
+
if (rateLimit.blocked) {
|
|
2735
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2736
|
+
retError.put("message", rateLimit.message);
|
|
2737
|
+
retError.put("error", rateLimit.error);
|
|
2738
|
+
callback.callback(retError);
|
|
2739
|
+
return;
|
|
2740
|
+
}
|
|
2062
2741
|
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
String data = responseBody.string();
|
|
2742
|
+
if (!response.isSuccessful()) {
|
|
2743
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2744
|
+
retError.put("message", "Server error: " + response.code());
|
|
2745
|
+
retError.put("error", "response_error");
|
|
2746
|
+
callback.callback(retError);
|
|
2747
|
+
return;
|
|
2748
|
+
}
|
|
2071
2749
|
|
|
2072
|
-
|
|
2073
|
-
|
|
2750
|
+
if (data.isEmpty()) {
|
|
2751
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2752
|
+
retError.put("message", "Empty response body");
|
|
2753
|
+
retError.put("error", "no_response_body");
|
|
2754
|
+
callback.callback(retError);
|
|
2755
|
+
return;
|
|
2756
|
+
}
|
|
2074
2757
|
|
|
2075
|
-
logger.info("Channels listed successfully");
|
|
2076
|
-
callback.callback(ret);
|
|
2077
|
-
} catch (JSONException arrayException) {
|
|
2078
|
-
// If not an array, try to parse as error object
|
|
2079
2758
|
try {
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2759
|
+
Map<String, Object> ret = parseListChannelsResponse(data);
|
|
2760
|
+
|
|
2761
|
+
logger.info("Channels listed successfully");
|
|
2762
|
+
callback.callback(ret);
|
|
2763
|
+
} catch (JSONException arrayException) {
|
|
2764
|
+
// If not an array, try to parse as error object
|
|
2765
|
+
try {
|
|
2766
|
+
JSONObject json = new JSONObject(data);
|
|
2767
|
+
if (json.has("error")) {
|
|
2768
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2769
|
+
retError.put("error", json.getString("error"));
|
|
2770
|
+
if (json.has("message")) {
|
|
2771
|
+
retError.put("message", json.getString("message"));
|
|
2772
|
+
} else {
|
|
2773
|
+
retError.put("message", "server did not provide a message");
|
|
2774
|
+
}
|
|
2775
|
+
callback.callback(retError);
|
|
2776
|
+
return;
|
|
2088
2777
|
}
|
|
2778
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2779
|
+
retError.put("message", "Unexpected channels response format");
|
|
2780
|
+
retError.put("error", "parse_error");
|
|
2089
2781
|
callback.callback(retError);
|
|
2090
2782
|
return;
|
|
2783
|
+
} catch (JSONException objException) {
|
|
2784
|
+
// If neither array nor object, throw parse error
|
|
2785
|
+
arrayException.addSuppressed(objException);
|
|
2786
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2787
|
+
retError.put("message", "JSON parse error: " + arrayException.getMessage());
|
|
2788
|
+
retError.put("error", "parse_error");
|
|
2789
|
+
callback.callback(retError);
|
|
2091
2790
|
}
|
|
2092
|
-
Map<String, Object> retError = new HashMap<>();
|
|
2093
|
-
retError.put("message", "Unexpected channels response format");
|
|
2094
|
-
retError.put("error", "parse_error");
|
|
2095
|
-
callback.callback(retError);
|
|
2096
|
-
return;
|
|
2097
|
-
} catch (JSONException objException) {
|
|
2098
|
-
// If neither array nor object, throw parse error
|
|
2099
|
-
arrayException.addSuppressed(objException);
|
|
2100
|
-
Map<String, Object> retError = new HashMap<>();
|
|
2101
|
-
retError.put("message", "JSON parse error: " + arrayException.getMessage());
|
|
2102
|
-
retError.put("error", "parse_error");
|
|
2103
|
-
callback.callback(retError);
|
|
2104
2791
|
}
|
|
2105
2792
|
}
|
|
2106
2793
|
}
|
|
2107
2794
|
}
|
|
2108
|
-
|
|
2109
|
-
);
|
|
2795
|
+
);
|
|
2110
2796
|
}
|
|
2111
2797
|
|
|
2112
2798
|
static Map<String, Object> parseListChannelsResponse(final String data) throws JSONException {
|
|
@@ -2155,6 +2841,10 @@ public class CapgoUpdater {
|
|
|
2155
2841
|
final Map<String, String> metadata,
|
|
2156
2842
|
final Runnable onSent
|
|
2157
2843
|
) {
|
|
2844
|
+
if (statsStopped.get()) {
|
|
2845
|
+
return;
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2158
2848
|
if (this.previewSession) {
|
|
2159
2849
|
if (logger != null) {
|
|
2160
2850
|
logger.debug("Skipping sendStats during preview session.");
|
|
@@ -2162,12 +2852,6 @@ public class CapgoUpdater {
|
|
|
2162
2852
|
return;
|
|
2163
2853
|
}
|
|
2164
2854
|
|
|
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
2855
|
String statsUrl = this.statsUrl;
|
|
2172
2856
|
if (statsUrl == null || statsUrl.isEmpty()) {
|
|
2173
2857
|
return;
|
|
@@ -2184,16 +2868,190 @@ public class CapgoUpdater {
|
|
|
2184
2868
|
json.put("metadata", new JSONObject(metadata));
|
|
2185
2869
|
}
|
|
2186
2870
|
} catch (JSONException e) {
|
|
2187
|
-
logger
|
|
2188
|
-
|
|
2871
|
+
if (logger != null) {
|
|
2872
|
+
logger.error("Error preparing stats");
|
|
2873
|
+
logger.debug("JSONException: " + e.getMessage());
|
|
2874
|
+
}
|
|
2189
2875
|
return;
|
|
2190
2876
|
}
|
|
2191
2877
|
|
|
2192
|
-
|
|
2878
|
+
synchronized (statsQueue) {
|
|
2879
|
+
if (statsStopped.get()) {
|
|
2880
|
+
return;
|
|
2881
|
+
}
|
|
2882
|
+
while (statsQueue.size() >= MAX_PENDING_STATS) {
|
|
2883
|
+
statsQueue.remove(0);
|
|
2884
|
+
}
|
|
2885
|
+
statsQueue.add(new QueuedStatsEvent(json, onSent));
|
|
2886
|
+
}
|
|
2193
2887
|
ensureStatsTimerStarted();
|
|
2194
2888
|
}
|
|
2195
2889
|
|
|
2890
|
+
public void restorePendingStats() {
|
|
2891
|
+
File file = pendingStatsFile();
|
|
2892
|
+
if (file == null) {
|
|
2893
|
+
return;
|
|
2894
|
+
}
|
|
2895
|
+
File backup = new File(file.getAbsolutePath() + ".bak");
|
|
2896
|
+
if (!file.exists() && backup.exists() && !backup.renameTo(file)) {
|
|
2897
|
+
if (logger != null) {
|
|
2898
|
+
logger.error("Failed to restore stats backup");
|
|
2899
|
+
}
|
|
2900
|
+
return;
|
|
2901
|
+
}
|
|
2902
|
+
if (!file.exists()) {
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2905
|
+
try {
|
|
2906
|
+
String raw = readFileUtf8(file);
|
|
2907
|
+
JSONArray arr = new JSONArray(raw);
|
|
2908
|
+
synchronized (statsQueue) {
|
|
2909
|
+
for (int i = 0; i < arr.length(); i++) {
|
|
2910
|
+
if (statsQueue.size() >= MAX_PENDING_STATS) {
|
|
2911
|
+
break;
|
|
2912
|
+
}
|
|
2913
|
+
statsQueue.add(new QueuedStatsEvent(arr.getJSONObject(i), null));
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
if (backup.exists() && !backup.delete()) {
|
|
2917
|
+
if (logger != null) {
|
|
2918
|
+
logger.error("Failed to delete stats backup");
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
if (!statsQueue.isEmpty()) {
|
|
2922
|
+
if (logger != null) {
|
|
2923
|
+
logger.info("Restored " + statsQueue.size() + " pending stats events");
|
|
2924
|
+
}
|
|
2925
|
+
ensureStatsTimerStarted();
|
|
2926
|
+
}
|
|
2927
|
+
} catch (Exception e) {
|
|
2928
|
+
if (logger != null) {
|
|
2929
|
+
logger.error("Failed to restore pending stats");
|
|
2930
|
+
logger.debug("Error: " + e.getMessage());
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
int pendingStatsCount() {
|
|
2936
|
+
return statsQueue.size();
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
public void persistPendingStats() {
|
|
2940
|
+
persistStatsQueue();
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
private File pendingStatsFile() {
|
|
2944
|
+
final File dir = this.noBackupDir != null ? this.noBackupDir : this.documentsDir;
|
|
2945
|
+
if (dir == null) {
|
|
2946
|
+
return null;
|
|
2947
|
+
}
|
|
2948
|
+
return new File(dir, PENDING_STATS_FILE);
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
private void persistStatsQueue() {
|
|
2952
|
+
persistStatsQueue(false);
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
private void persistStatsQueue(final boolean force) {
|
|
2956
|
+
File file = pendingStatsFile();
|
|
2957
|
+
if (file == null) {
|
|
2958
|
+
return;
|
|
2959
|
+
}
|
|
2960
|
+
synchronized (pendingStatsPersistLock) {
|
|
2961
|
+
if (statsStopped.get() && !force) {
|
|
2962
|
+
return;
|
|
2963
|
+
}
|
|
2964
|
+
JSONArray arr = new JSONArray();
|
|
2965
|
+
synchronized (statsQueue) {
|
|
2966
|
+
final List<QueuedStatsEvent> combined = new ArrayList<>(statsInFlight.size() + statsQueue.size());
|
|
2967
|
+
combined.addAll(statsInFlight);
|
|
2968
|
+
combined.addAll(statsQueue);
|
|
2969
|
+
final int start = Math.max(0, combined.size() - MAX_PENDING_STATS);
|
|
2970
|
+
for (int i = start; i < combined.size(); i++) {
|
|
2971
|
+
arr.put(combined.get(i).event);
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
try {
|
|
2975
|
+
if (arr.length() == 0) {
|
|
2976
|
+
writeFileAtomically(file, "[]".getBytes(StandardCharsets.UTF_8));
|
|
2977
|
+
File backup = new File(file.getAbsolutePath() + ".bak");
|
|
2978
|
+
if (backup.exists() && !backup.delete()) {
|
|
2979
|
+
if (logger != null) {
|
|
2980
|
+
logger.error("Failed to delete empty stats backup");
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
return;
|
|
2984
|
+
}
|
|
2985
|
+
writeFileAtomically(file, arr.toString().getBytes(StandardCharsets.UTF_8));
|
|
2986
|
+
} catch (Exception e) {
|
|
2987
|
+
if (logger != null) {
|
|
2988
|
+
logger.error("Failed to persist stats queue");
|
|
2989
|
+
logger.debug("Error: " + e.getMessage());
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
private static String readFileUtf8(final File file) throws IOException {
|
|
2996
|
+
final long length = file.length();
|
|
2997
|
+
final byte[] buf = new byte[length > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) length];
|
|
2998
|
+
try (FileInputStream in = new FileInputStream(file)) {
|
|
2999
|
+
int offset = 0;
|
|
3000
|
+
while (offset < buf.length) {
|
|
3001
|
+
final int read = in.read(buf, offset, buf.length - offset);
|
|
3002
|
+
if (read < 0) {
|
|
3003
|
+
break;
|
|
3004
|
+
}
|
|
3005
|
+
offset += read;
|
|
3006
|
+
}
|
|
3007
|
+
return new String(buf, 0, offset, StandardCharsets.UTF_8);
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
private void writeFileAtomically(final File file, final byte[] bytes) throws IOException {
|
|
3012
|
+
final File tmp = new File(file.getAbsolutePath() + ".tmp");
|
|
3013
|
+
File backup = null;
|
|
3014
|
+
try {
|
|
3015
|
+
try (FileOutputStream out = new FileOutputStream(tmp)) {
|
|
3016
|
+
out.write(bytes);
|
|
3017
|
+
out.flush();
|
|
3018
|
+
}
|
|
3019
|
+
if (tmp.renameTo(file)) {
|
|
3020
|
+
return;
|
|
3021
|
+
}
|
|
3022
|
+
if (file.exists()) {
|
|
3023
|
+
backup = new File(file.getAbsolutePath() + ".bak");
|
|
3024
|
+
if (backup.exists() && !backup.delete()) {
|
|
3025
|
+
throw new IOException("Failed to replace " + file.getAbsolutePath());
|
|
3026
|
+
}
|
|
3027
|
+
if (!file.renameTo(backup)) {
|
|
3028
|
+
throw new IOException("Failed to replace " + file.getAbsolutePath());
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
if (tmp.renameTo(file)) {
|
|
3032
|
+
if (backup != null && backup.exists() && !backup.delete()) {
|
|
3033
|
+
if (logger != null) {
|
|
3034
|
+
logger.error("Failed to delete stats backup");
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
backup = null;
|
|
3038
|
+
return;
|
|
3039
|
+
}
|
|
3040
|
+
throw new IOException("Failed to persist " + file.getAbsolutePath());
|
|
3041
|
+
} finally {
|
|
3042
|
+
if (backup != null && !file.exists()) {
|
|
3043
|
+
backup.renameTo(file);
|
|
3044
|
+
}
|
|
3045
|
+
if (tmp.exists() && !tmp.delete()) {
|
|
3046
|
+
tmp.deleteOnExit();
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
|
|
2196
3051
|
private synchronized void ensureStatsTimerStarted() {
|
|
3052
|
+
if (statsStopped.get()) {
|
|
3053
|
+
return;
|
|
3054
|
+
}
|
|
2197
3055
|
if (statsFlushTask == null || statsFlushTask.isCancelled() || statsFlushTask.isDone()) {
|
|
2198
3056
|
statsFlushTask = statsScheduler.scheduleAtFixedRate(
|
|
2199
3057
|
this::flushStatsQueue,
|
|
@@ -2205,25 +3063,45 @@ public class CapgoUpdater {
|
|
|
2205
3063
|
}
|
|
2206
3064
|
|
|
2207
3065
|
private void flushStatsQueue() {
|
|
3066
|
+
if (statsStopped.get()) {
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
2208
3069
|
if (statsQueue.isEmpty()) {
|
|
2209
3070
|
return;
|
|
2210
3071
|
}
|
|
2211
3072
|
|
|
3073
|
+
// While Retry-After is active, keep stats queued and skip the network call.
|
|
3074
|
+
if (isRemoteBlocked()) {
|
|
3075
|
+
logger.debug("Deferring stats flush until Retry-After expires.");
|
|
3076
|
+
return;
|
|
3077
|
+
}
|
|
3078
|
+
|
|
2212
3079
|
String statsUrl = this.statsUrl;
|
|
2213
3080
|
if (statsUrl == null || statsUrl.isEmpty()) {
|
|
2214
|
-
statsQueue
|
|
3081
|
+
synchronized (statsQueue) {
|
|
3082
|
+
statsQueue.clear();
|
|
3083
|
+
statsInFlight.clear();
|
|
3084
|
+
}
|
|
3085
|
+
persistStatsQueue();
|
|
3086
|
+
return;
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
if (!statsFlushInFlight.compareAndSet(false, true)) {
|
|
2215
3090
|
return;
|
|
2216
3091
|
}
|
|
2217
3092
|
|
|
2218
|
-
|
|
2219
|
-
List<QueuedStatsEvent> eventsToSend;
|
|
3093
|
+
final List<QueuedStatsEvent> eventsToSend;
|
|
2220
3094
|
synchronized (statsQueue) {
|
|
2221
3095
|
if (statsQueue.isEmpty()) {
|
|
3096
|
+
statsFlushInFlight.set(false);
|
|
2222
3097
|
return;
|
|
2223
3098
|
}
|
|
2224
3099
|
eventsToSend = new ArrayList<>(statsQueue);
|
|
2225
3100
|
statsQueue.clear();
|
|
3101
|
+
statsInFlight.clear();
|
|
3102
|
+
statsInFlight.addAll(eventsToSend);
|
|
2226
3103
|
}
|
|
3104
|
+
persistStatsQueue();
|
|
2227
3105
|
|
|
2228
3106
|
JSONArray jsonArray = new JSONArray();
|
|
2229
3107
|
for (QueuedStatsEvent queuedEvent : eventsToSend) {
|
|
@@ -2236,34 +3114,97 @@ public class CapgoUpdater {
|
|
|
2236
3114
|
.build();
|
|
2237
3115
|
|
|
2238
3116
|
final int eventCount = eventsToSend.size();
|
|
2239
|
-
DownloadService.sharedClient
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
@Override
|
|
2248
|
-
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
2249
|
-
try (ResponseBody responseBody = response.body()) {
|
|
2250
|
-
// Check for 429 rate limit
|
|
2251
|
-
if (checkAndHandleRateLimitResponse(response)) {
|
|
3117
|
+
DownloadService.sharedClient
|
|
3118
|
+
.newCall(request)
|
|
3119
|
+
.enqueue(
|
|
3120
|
+
new okhttp3.Callback() {
|
|
3121
|
+
@Override
|
|
3122
|
+
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
|
3123
|
+
if (abandonStoppedStatsFlush()) {
|
|
2252
3124
|
return;
|
|
2253
3125
|
}
|
|
3126
|
+
requeueStatsEvents(eventsToSend);
|
|
3127
|
+
if (logger != null) {
|
|
3128
|
+
logger.error("Failed to send stats batch");
|
|
3129
|
+
logger.debug("Error: " + e.getMessage());
|
|
3130
|
+
}
|
|
3131
|
+
statsFlushInFlight.set(false);
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
@Override
|
|
3135
|
+
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
3136
|
+
try (ResponseBody responseBody = response.body()) {
|
|
3137
|
+
if (abandonStoppedStatsFlush()) {
|
|
3138
|
+
return;
|
|
3139
|
+
}
|
|
3140
|
+
final String responseData = responseBody != null ? responseBody.string() : "";
|
|
3141
|
+
if (checkAndHandleRateLimitResponse(response, responseData).blocked) {
|
|
3142
|
+
requeueStatsEvents(eventsToSend);
|
|
3143
|
+
return;
|
|
3144
|
+
}
|
|
2254
3145
|
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
3146
|
+
if (response.isSuccessful()) {
|
|
3147
|
+
synchronized (statsQueue) {
|
|
3148
|
+
statsInFlight.clear();
|
|
3149
|
+
}
|
|
3150
|
+
persistStatsQueue();
|
|
3151
|
+
if (logger != null) {
|
|
3152
|
+
logger.info("Stats batch sent successfully");
|
|
3153
|
+
logger.debug("Sent " + eventCount + " events");
|
|
3154
|
+
}
|
|
3155
|
+
runStatsCallbacks(eventsToSend);
|
|
3156
|
+
} else if (isTransientStatsFailure(response.code())) {
|
|
3157
|
+
requeueStatsEvents(eventsToSend);
|
|
3158
|
+
if (logger != null) {
|
|
3159
|
+
logger.error("Error sending stats batch");
|
|
3160
|
+
logger.debug("Retrying later, response code: " + response.code());
|
|
3161
|
+
}
|
|
3162
|
+
} else {
|
|
3163
|
+
synchronized (statsQueue) {
|
|
3164
|
+
statsInFlight.clear();
|
|
3165
|
+
}
|
|
3166
|
+
persistStatsQueue();
|
|
3167
|
+
if (logger != null) {
|
|
3168
|
+
logger.error("Dropping stats batch after permanent error");
|
|
3169
|
+
logger.debug("Response code: " + response.code());
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
} finally {
|
|
3173
|
+
statsFlushInFlight.set(false);
|
|
2262
3174
|
}
|
|
2263
3175
|
}
|
|
2264
3176
|
}
|
|
3177
|
+
);
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
private boolean abandonStoppedStatsFlush() {
|
|
3181
|
+
if (!statsStopped.get()) {
|
|
3182
|
+
return false;
|
|
3183
|
+
}
|
|
3184
|
+
statsFlushInFlight.set(false);
|
|
3185
|
+
return true;
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
/**
|
|
3189
|
+
* Only 429, request timeout and 5xx are worth retrying; other 4xx are permanent rejections.
|
|
3190
|
+
*/
|
|
3191
|
+
private static boolean isTransientStatsFailure(final int statusCode) {
|
|
3192
|
+
return statusCode == 429 || statusCode == 408 || statusCode >= 500;
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
private void requeueStatsEvents(final List<QueuedStatsEvent> events) {
|
|
3196
|
+
if (statsStopped.get() || events == null || events.isEmpty()) {
|
|
3197
|
+
return;
|
|
3198
|
+
}
|
|
3199
|
+
synchronized (statsQueue) {
|
|
3200
|
+
statsInFlight.clear();
|
|
3201
|
+
statsQueue.addAll(0, events);
|
|
3202
|
+
while (statsQueue.size() > MAX_PENDING_STATS) {
|
|
3203
|
+
statsQueue.remove(0);
|
|
2265
3204
|
}
|
|
2266
|
-
|
|
3205
|
+
}
|
|
3206
|
+
persistStatsQueue();
|
|
3207
|
+
ensureStatsTimerStarted();
|
|
2267
3208
|
}
|
|
2268
3209
|
|
|
2269
3210
|
private void runStatsCallbacks(final List<QueuedStatsEvent> sentEvents) {
|
|
@@ -2333,10 +3274,10 @@ public class CapgoUpdater {
|
|
|
2333
3274
|
this.saveBundleInfo(id, null);
|
|
2334
3275
|
}
|
|
2335
3276
|
|
|
2336
|
-
public
|
|
3277
|
+
public boolean saveBundleInfo(final String id, final BundleInfo info) {
|
|
2337
3278
|
if (id == null || (info != null && (info.isBuiltin() || info.isUnknown()))) {
|
|
2338
3279
|
logger.debug("Not saving info for bundle: [" + id + "] " + info);
|
|
2339
|
-
return;
|
|
3280
|
+
return false;
|
|
2340
3281
|
}
|
|
2341
3282
|
|
|
2342
3283
|
if (info == null) {
|
|
@@ -2348,7 +3289,7 @@ public class CapgoUpdater {
|
|
|
2348
3289
|
logger.debug("Storing info for bundle [" + id + "] " + update.getClass().getName() + " -> " + jsonString);
|
|
2349
3290
|
this.editor.putString(id + INFO_SUFFIX, jsonString);
|
|
2350
3291
|
}
|
|
2351
|
-
this.editor.commit();
|
|
3292
|
+
return this.editor.commit();
|
|
2352
3293
|
}
|
|
2353
3294
|
|
|
2354
3295
|
private void setBundleStatus(final String id, final BundleStatus status) {
|
|
@@ -2453,14 +3394,15 @@ public class CapgoUpdater {
|
|
|
2453
3394
|
* Should be called when the plugin is destroyed to prevent resource leaks.
|
|
2454
3395
|
*/
|
|
2455
3396
|
public void shutdown() {
|
|
3397
|
+
statsStopped.set(true);
|
|
2456
3398
|
// Cancel the scheduled task
|
|
2457
3399
|
if (statsFlushTask != null) {
|
|
2458
3400
|
statsFlushTask.cancel(false);
|
|
2459
3401
|
statsFlushTask = null;
|
|
2460
3402
|
}
|
|
2461
3403
|
|
|
2462
|
-
//
|
|
2463
|
-
|
|
3404
|
+
// Write once, then ignore later callbacks so they cannot delete a newer instance's file.
|
|
3405
|
+
persistStatsQueue(true);
|
|
2464
3406
|
|
|
2465
3407
|
// Shutdown the scheduler
|
|
2466
3408
|
statsScheduler.shutdown();
|