@capgo/cordova-updater 8.1.1
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/LICENSE +373 -0
- package/README.md +82 -0
- package/dist/esm/definitions.d.ts +2684 -0
- package/dist/esm/definitions.js +109 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/exec.d.ts +4 -0
- package/dist/esm/exec.js +25 -0
- package/dist/esm/exec.js.map +1 -0
- package/dist/esm/history.d.ts +1 -0
- package/dist/esm/history.js +283 -0
- package/dist/esm/history.js.map +1 -0
- package/dist/esm/index.d.ts +57 -0
- package/dist/esm/index.js +68 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/plugin.d.ts +57 -0
- package/dist/esm/plugin.js +15 -0
- package/dist/esm/plugin.js.map +1 -0
- package/dist/plugin.cjs.js +217 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +222 -0
- package/dist/plugin.js.map +1 -0
- package/package.json +96 -0
- package/plugin.xml +207 -0
- package/src/android/CordovaUpdaterPlugin.java +5662 -0
- package/src/android/UpdaterPathHandler.java +99 -0
- package/src/android/app/capgo/cordova/updater/AndroidAppExitReporter.java +92 -0
- package/src/android/app/capgo/cordova/updater/AppLifecycleObserver.java +115 -0
- package/src/android/app/capgo/cordova/updater/BundleInfo.java +246 -0
- package/src/android/app/capgo/cordova/updater/BundleStatus.java +45 -0
- package/src/android/app/capgo/cordova/updater/Callback.java +13 -0
- package/src/android/app/capgo/cordova/updater/CapgoUpdater.java +3404 -0
- package/src/android/app/capgo/cordova/updater/CordovaUpdaterConfig.java +56 -0
- package/src/android/app/capgo/cordova/updater/CryptoCipher.java +462 -0
- package/src/android/app/capgo/cordova/updater/DataManager.java +44 -0
- package/src/android/app/capgo/cordova/updater/DelayCondition.java +56 -0
- package/src/android/app/capgo/cordova/updater/DelayUntilNext.java +14 -0
- package/src/android/app/capgo/cordova/updater/DelayUpdateUtils.java +307 -0
- package/src/android/app/capgo/cordova/updater/DeviceIdHelper.java +230 -0
- package/src/android/app/capgo/cordova/updater/DownloadService.java +1219 -0
- package/src/android/app/capgo/cordova/updater/DownloadWorkerManager.java +260 -0
- package/src/android/app/capgo/cordova/updater/InternalUtils.java +54 -0
- package/src/android/app/capgo/cordova/updater/Logger.java +345 -0
- package/src/android/app/capgo/cordova/updater/ShakeDetector.java +72 -0
- package/src/android/app/capgo/cordova/updater/ShakeMenu.java +39 -0
- package/src/android/app/capgo/cordova/updater/ThreeFingerPinchDetector.java +323 -0
- package/src/android/app/capgo/cordova/updater/compat/JSArray.java +9 -0
- package/src/android/app/capgo/cordova/updater/compat/JSObject.java +83 -0
- package/src/android/app/capgo/cordova/updater/compat/PluginCall.java +165 -0
- package/src/android/capgo-cordova-updater.gradle +19 -0
- package/src/android/capgo-updater.xml +3 -0
- package/src/android/test/app/capgo/cordova/updater/CapacitorUpdaterUnitTest.java.skip +3039 -0
- package/src/android/test/app/capgo/cordova/updater/DelayUpdateUtilsTest.java +177 -0
- package/src/android/test/app/capgo/cordova/updater/NativeContractTest.java +105 -0
- package/src/ios/AES.swift +216 -0
- package/src/ios/AppHealthTracker.swift +82 -0
- package/src/ios/BundleInfo.swift +185 -0
- package/src/ios/BundleStatus.swift +127 -0
- package/src/ios/CapgoRawRsa.swift +36 -0
- package/src/ios/CapgoUpdater.swift +3544 -0
- package/src/ios/CordovaCompat.swift +11 -0
- package/src/ios/CordovaPluginCall.swift +190 -0
- package/src/ios/CordovaUpdaterCommands.swift +270 -0
- package/src/ios/CordovaUpdaterPlugin.swift +4706 -0
- package/src/ios/CryptoCipher.swift +326 -0
- package/src/ios/DelayCondition.swift +74 -0
- package/src/ios/DelayUntilNext.swift +30 -0
- package/src/ios/DelayUpdateUtils.swift +257 -0
- package/src/ios/DeviceIdHelper.swift +120 -0
- package/src/ios/Info.plist +28 -0
- package/src/ios/InternalUtils.swift +471 -0
- package/src/ios/Logger.swift +283 -0
- package/src/ios/RSA.swift +96 -0
- package/src/ios/ShakeMenu.swift +52 -0
- package/src/ios/UpdaterSchemeHandler.swift +81 -0
- package/src/ios/UserDefaultsExtension.swift +46 -0
- package/src/ios/WebViewStatsReporter.swift +303 -0
|
@@ -0,0 +1,3404 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
package app.capgo.cordova.updater;
|
|
8
|
+
|
|
9
|
+
import android.app.Activity;
|
|
10
|
+
import android.content.Context;
|
|
11
|
+
import android.content.SharedPreferences;
|
|
12
|
+
import android.content.pm.PackageManager;
|
|
13
|
+
import android.os.Build;
|
|
14
|
+
import androidx.annotation.NonNull;
|
|
15
|
+
import androidx.lifecycle.LifecycleOwner;
|
|
16
|
+
import androidx.work.Constraints;
|
|
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;
|
|
24
|
+
import androidx.work.WorkInfo;
|
|
25
|
+
import androidx.work.WorkManager;
|
|
26
|
+
import com.google.common.util.concurrent.Futures;
|
|
27
|
+
import com.google.common.util.concurrent.ListenableFuture;
|
|
28
|
+
import java.io.BufferedInputStream;
|
|
29
|
+
import java.io.BufferedReader;
|
|
30
|
+
import java.io.File;
|
|
31
|
+
import java.io.FileInputStream;
|
|
32
|
+
import java.io.FileNotFoundException;
|
|
33
|
+
import java.io.FileOutputStream;
|
|
34
|
+
import java.io.FilenameFilter;
|
|
35
|
+
import java.io.IOException;
|
|
36
|
+
import java.io.InputStreamReader;
|
|
37
|
+
import java.nio.charset.StandardCharsets;
|
|
38
|
+
import java.security.SecureRandom;
|
|
39
|
+
import java.util.ArrayList;
|
|
40
|
+
import java.util.Date;
|
|
41
|
+
import java.util.HashMap;
|
|
42
|
+
import java.util.HashSet;
|
|
43
|
+
import java.util.Iterator;
|
|
44
|
+
import java.util.LinkedHashSet;
|
|
45
|
+
import java.util.List;
|
|
46
|
+
import java.util.Map;
|
|
47
|
+
import java.util.Objects;
|
|
48
|
+
import java.util.Set;
|
|
49
|
+
import java.util.concurrent.CompletableFuture;
|
|
50
|
+
import java.util.concurrent.ConcurrentHashMap;
|
|
51
|
+
import java.util.concurrent.CopyOnWriteArrayList;
|
|
52
|
+
import java.util.concurrent.ExecutorService;
|
|
53
|
+
import java.util.concurrent.Executors;
|
|
54
|
+
import java.util.concurrent.ScheduledExecutorService;
|
|
55
|
+
import java.util.concurrent.ScheduledFuture;
|
|
56
|
+
import java.util.concurrent.TimeUnit;
|
|
57
|
+
import java.util.concurrent.atomic.AtomicBoolean;
|
|
58
|
+
import java.util.zip.ZipEntry;
|
|
59
|
+
import java.util.zip.ZipInputStream;
|
|
60
|
+
import okhttp3.*;
|
|
61
|
+
import okhttp3.HttpUrl;
|
|
62
|
+
import org.json.JSONArray;
|
|
63
|
+
import org.json.JSONException;
|
|
64
|
+
import org.json.JSONObject;
|
|
65
|
+
|
|
66
|
+
public class CapgoUpdater {
|
|
67
|
+
|
|
68
|
+
private final Logger logger;
|
|
69
|
+
|
|
70
|
+
private static final String AB = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
71
|
+
private static final SecureRandom rnd = new SecureRandom();
|
|
72
|
+
|
|
73
|
+
private static final String INFO_SUFFIX = "_info";
|
|
74
|
+
|
|
75
|
+
private static final String FALLBACK_VERSION = "pastVersion";
|
|
76
|
+
private static final String NEXT_VERSION = "nextVersion";
|
|
77
|
+
private static final String PREVIEW_FALLBACK_VERSION = "previewFallbackVersion";
|
|
78
|
+
private static final String PENDING_DELETE_IDS = "pendingDeleteIds";
|
|
79
|
+
private static final String bundleDirectory = "versions";
|
|
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();
|
|
83
|
+
private static final String CAPACITOR_CONFIG_ASSET = "capacitor.config.json";
|
|
84
|
+
private static final String BACKGROUND_RUNNER_CONFIG_KEY = "BackgroundRunner";
|
|
85
|
+
private static final String BACKGROUND_RUNNER_WORKER_CLASS = "io.ionic.backgroundrunner.plugin.RunnerWorker";
|
|
86
|
+
|
|
87
|
+
public static final String TAG = "Capacitor-updater";
|
|
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;
|
|
92
|
+
public SharedPreferences prefs;
|
|
93
|
+
|
|
94
|
+
public File documentsDir;
|
|
95
|
+
public File noBackupDir;
|
|
96
|
+
public Boolean directUpdate = false;
|
|
97
|
+
public Activity activity;
|
|
98
|
+
public String pluginVersion = "";
|
|
99
|
+
public String versionBuild = "";
|
|
100
|
+
public String versionCode = "";
|
|
101
|
+
public String versionOs = "";
|
|
102
|
+
public String CAP_SERVER_PATH = "";
|
|
103
|
+
|
|
104
|
+
public String customId = "";
|
|
105
|
+
public String statsUrl = "";
|
|
106
|
+
public String channelUrl = "";
|
|
107
|
+
public String defaultChannel = "";
|
|
108
|
+
public String appId = "";
|
|
109
|
+
public volatile boolean previewSession = false;
|
|
110
|
+
public String publicKey = "";
|
|
111
|
+
public String deviceID = "";
|
|
112
|
+
public int timeout = 20000;
|
|
113
|
+
|
|
114
|
+
// Cached key ID calculated once from publicKey
|
|
115
|
+
private String cachedKeyId = "";
|
|
116
|
+
|
|
117
|
+
// Temporary 429 block until this epoch ms (Retry-After / rateLimitResetAt). No sticky latch.
|
|
118
|
+
// Guarded by rateLimitStateLock so concurrent 429s cannot shorten the window or mix metadata.
|
|
119
|
+
private static final Object rateLimitStateLock = new Object();
|
|
120
|
+
private static long rateLimitBlockedUntilMs = 0L;
|
|
121
|
+
private static String rateLimitBlockedError = "too_many_requests";
|
|
122
|
+
private static String rateLimitBlockedMessage = "Too many requests";
|
|
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;
|
|
127
|
+
|
|
128
|
+
// Upper bound for a client-side 429 block, so a bogus Retry-After cannot block the app for days.
|
|
129
|
+
private static final long MAX_RATE_LIMIT_WINDOW_MS = 24 * 60 * 60 * 1000L;
|
|
130
|
+
|
|
131
|
+
// Stats batching - queue events and send max once per second
|
|
132
|
+
private final List<QueuedStatsEvent> statsQueue = new CopyOnWriteArrayList<>();
|
|
133
|
+
private final List<QueuedStatsEvent> statsInFlight = new ArrayList<>();
|
|
134
|
+
private final Object pendingStatsPersistLock = new Object();
|
|
135
|
+
private final ScheduledExecutorService statsScheduler = Executors.newSingleThreadScheduledExecutor();
|
|
136
|
+
private ScheduledFuture<?> statsFlushTask = null;
|
|
137
|
+
private final AtomicBoolean statsFlushInFlight = new AtomicBoolean(false);
|
|
138
|
+
private final AtomicBoolean statsStopped = new AtomicBoolean(false);
|
|
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;
|
|
142
|
+
|
|
143
|
+
private static final class QueuedStatsEvent {
|
|
144
|
+
|
|
145
|
+
private final JSONObject event;
|
|
146
|
+
private final Runnable onSent;
|
|
147
|
+
|
|
148
|
+
private QueuedStatsEvent(final JSONObject event, final Runnable onSent) {
|
|
149
|
+
this.event = event;
|
|
150
|
+
this.onSent = onSent;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private final Map<String, CompletableFuture<BundleInfo>> downloadFutures = new ConcurrentHashMap<>();
|
|
155
|
+
private final ExecutorService io = Executors.newSingleThreadExecutor();
|
|
156
|
+
|
|
157
|
+
public CapgoUpdater(Logger logger) {
|
|
158
|
+
this.logger = logger;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private final FilenameFilter filter = (f, name) -> {
|
|
162
|
+
// ignore directories generated by mac os x
|
|
163
|
+
return !name.startsWith("__MACOSX") && !name.startsWith(".") && !name.startsWith(".DS_Store");
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
private boolean isProd() {
|
|
167
|
+
try {
|
|
168
|
+
if (activity == null) {
|
|
169
|
+
return true; // Default to production if no activity context
|
|
170
|
+
}
|
|
171
|
+
return (activity.getApplicationInfo().flags & android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) == 0;
|
|
172
|
+
} catch (Exception e) {
|
|
173
|
+
return true; // Default to production if we can't determine
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
static String installSourceForInstallerPackage(final String installerPackageName) {
|
|
178
|
+
if (installerPackageName == null || installerPackageName.trim().isEmpty()) {
|
|
179
|
+
return "";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
switch (installerPackageName) {
|
|
183
|
+
case "com.android.vending":
|
|
184
|
+
// Android exposes the Google Play installer package, but not whether the app came from production, alpha, beta, or internal testing.
|
|
185
|
+
return "google_play";
|
|
186
|
+
case "com.amazon.venezia":
|
|
187
|
+
return "amazon_appstore";
|
|
188
|
+
case "com.sec.android.app.samsungapps":
|
|
189
|
+
return "samsung_galaxy_store";
|
|
190
|
+
case "com.huawei.appmarket":
|
|
191
|
+
return "huawei_appgallery";
|
|
192
|
+
default:
|
|
193
|
+
return "";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
@SuppressWarnings("deprecation")
|
|
198
|
+
private String getInstallSource() {
|
|
199
|
+
if (activity == null) {
|
|
200
|
+
return "";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
PackageManager packageManager = activity.getPackageManager();
|
|
205
|
+
String packageName = activity.getPackageName();
|
|
206
|
+
String installerPackageName;
|
|
207
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
208
|
+
android.content.pm.InstallSourceInfo installSourceInfo = packageManager.getInstallSourceInfo(packageName);
|
|
209
|
+
installerPackageName = installSourceInfo.getInstallingPackageName();
|
|
210
|
+
if (installerPackageName == null || installerPackageName.trim().isEmpty()) {
|
|
211
|
+
installerPackageName = installSourceInfo.getInitiatingPackageName();
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
installerPackageName = packageManager.getInstallerPackageName(packageName);
|
|
215
|
+
}
|
|
216
|
+
return installSourceForInstallerPackage(installerPackageName);
|
|
217
|
+
} catch (Exception e) {
|
|
218
|
+
return "";
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private boolean isEmulator() {
|
|
223
|
+
final String brand = String.valueOf(Build.BRAND);
|
|
224
|
+
final String device = String.valueOf(Build.DEVICE);
|
|
225
|
+
final String fingerprint = String.valueOf(Build.FINGERPRINT);
|
|
226
|
+
final String hardware = String.valueOf(Build.HARDWARE);
|
|
227
|
+
final String model = String.valueOf(Build.MODEL);
|
|
228
|
+
final String manufacturer = String.valueOf(Build.MANUFACTURER);
|
|
229
|
+
final String product = String.valueOf(Build.PRODUCT);
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
(brand.startsWith("generic") && device.startsWith("generic")) ||
|
|
233
|
+
fingerprint.startsWith("generic") ||
|
|
234
|
+
fingerprint.startsWith("unknown") ||
|
|
235
|
+
hardware.contains("goldfish") ||
|
|
236
|
+
hardware.contains("ranchu") ||
|
|
237
|
+
model.contains("google_sdk") ||
|
|
238
|
+
model.contains("Emulator") ||
|
|
239
|
+
model.contains("Android SDK built for x86") ||
|
|
240
|
+
manufacturer.contains("Genymotion") ||
|
|
241
|
+
product.contains("sdk_google") ||
|
|
242
|
+
product.contains("google_sdk") ||
|
|
243
|
+
product.contains("sdk") ||
|
|
244
|
+
product.contains("sdk_x86") ||
|
|
245
|
+
product.contains("sdk_gphone64_arm64") ||
|
|
246
|
+
product.contains("vbox86p") ||
|
|
247
|
+
product.contains("emulator") ||
|
|
248
|
+
product.contains("simulator")
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private int calcTotalPercent(final int percent, final int min, final int max) {
|
|
253
|
+
return (percent * (max - min)) / 100 + min;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
void notifyDownload(final String id, final int percent) {}
|
|
257
|
+
|
|
258
|
+
void directUpdateFinish(final BundleInfo latest) {}
|
|
259
|
+
|
|
260
|
+
void notifyListeners(final String id, final Map<String, Object> res) {}
|
|
261
|
+
|
|
262
|
+
public String randomString() {
|
|
263
|
+
final StringBuilder sb = new StringBuilder(10);
|
|
264
|
+
for (int i = 0; i < 10; i++) sb.append(AB.charAt(rnd.nextInt(AB.length())));
|
|
265
|
+
return sb.toString();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
public void setPublicKey(String publicKey) {
|
|
269
|
+
// Empty string means no encryption - proceed normally
|
|
270
|
+
if (publicKey == null || publicKey.isEmpty()) {
|
|
271
|
+
this.publicKey = "";
|
|
272
|
+
this.cachedKeyId = "";
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Non-empty: must be a valid RSA key or crash
|
|
277
|
+
try {
|
|
278
|
+
CryptoCipher.stringToPublicKey(publicKey);
|
|
279
|
+
} catch (Exception e) {
|
|
280
|
+
throw new RuntimeException(
|
|
281
|
+
"Invalid public key in capacitor.config.json: failed to parse RSA key. Remove the key or provide a valid PEM-formatted RSA public key.",
|
|
282
|
+
e
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
this.publicKey = publicKey;
|
|
287
|
+
this.cachedKeyId = CryptoCipher.calcKeyId(publicKey);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
static File resolvePathInsideDirectory(final File baseDirectory, final String relativePath) throws IOException {
|
|
291
|
+
if (relativePath == null || relativePath.isEmpty()) {
|
|
292
|
+
throw new IOException("Invalid empty path");
|
|
293
|
+
}
|
|
294
|
+
if (relativePath.contains("\\") || relativePath.indexOf('\0') >= 0) {
|
|
295
|
+
throw new IOException("Invalid path separator");
|
|
296
|
+
}
|
|
297
|
+
if (new File(relativePath).isAbsolute()) {
|
|
298
|
+
throw new IOException("Absolute paths are not allowed");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
final File canonicalBase = baseDirectory.getCanonicalFile();
|
|
302
|
+
final File canonicalTarget = new File(canonicalBase, relativePath).getCanonicalFile();
|
|
303
|
+
final String basePath = canonicalBase.getPath();
|
|
304
|
+
final String targetPath = canonicalTarget.getPath();
|
|
305
|
+
final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
|
|
306
|
+
|
|
307
|
+
if (!targetPath.equals(basePath) && !targetPath.startsWith(normalizedBasePath)) {
|
|
308
|
+
throw new IOException("Path escapes base directory: " + relativePath);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return canonicalTarget;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
public String getKeyId() {
|
|
315
|
+
return this.cachedKeyId;
|
|
316
|
+
}
|
|
317
|
+
|
|
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 {
|
|
323
|
+
final File targetDirectory = new File(this.documentsDir, dest);
|
|
324
|
+
try (
|
|
325
|
+
final BufferedInputStream bis = new BufferedInputStream(new FileInputStream(zipFile));
|
|
326
|
+
final ZipInputStream zis = new ZipInputStream(bis)
|
|
327
|
+
) {
|
|
328
|
+
int count;
|
|
329
|
+
final byte[] buffer = new byte[bufferSize];
|
|
330
|
+
final long lengthTotal = zipFile.length();
|
|
331
|
+
long lengthRead = bufferSize;
|
|
332
|
+
int percent = 0;
|
|
333
|
+
this.notifyDownload(id, 75);
|
|
334
|
+
|
|
335
|
+
ZipEntry entry;
|
|
336
|
+
while ((entry = zis.getNextEntry()) != null) {
|
|
337
|
+
final File file;
|
|
338
|
+
try {
|
|
339
|
+
file = resolvePathInsideDirectory(targetDirectory, entry.getName());
|
|
340
|
+
} catch (IOException e) {
|
|
341
|
+
if (entry.getName().contains("\\")) {
|
|
342
|
+
logger.error("Unzip failed: Windows path not supported");
|
|
343
|
+
logger.debug("Invalid path: " + entry.getName());
|
|
344
|
+
this.sendStats("windows_path_fail");
|
|
345
|
+
} else {
|
|
346
|
+
this.sendStats("canonical_path_fail");
|
|
347
|
+
}
|
|
348
|
+
throw e;
|
|
349
|
+
}
|
|
350
|
+
final File dir = entry.isDirectory() ? file : file.getParentFile();
|
|
351
|
+
|
|
352
|
+
assert dir != null;
|
|
353
|
+
if (!dir.isDirectory() && !dir.mkdirs()) {
|
|
354
|
+
this.sendStats("directory_path_fail");
|
|
355
|
+
throw new FileNotFoundException("Failed to ensure directory: " + dir.getAbsolutePath());
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (entry.isDirectory()) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
try (final FileOutputStream outputStream = new FileOutputStream(file)) {
|
|
363
|
+
while ((count = zis.read(buffer)) != -1) outputStream.write(buffer, 0, count);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
final int newPercent = (int) ((lengthRead / (float) lengthTotal) * 100);
|
|
367
|
+
if (lengthTotal > 1 && newPercent != percent) {
|
|
368
|
+
percent = newPercent;
|
|
369
|
+
this.notifyDownload(id, this.calcTotalPercent(percent, 75, 90));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
lengthRead += entry.getCompressedSize();
|
|
373
|
+
}
|
|
374
|
+
return targetDirectory;
|
|
375
|
+
} catch (IOException e) {
|
|
376
|
+
this.sendStats("unzip_fail");
|
|
377
|
+
throw new IOException("Failed to unzip: " + zipFile.getPath());
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private void flattenAssets(final File sourceFile, final String dest) throws IOException {
|
|
382
|
+
if (!sourceFile.exists()) {
|
|
383
|
+
throw new FileNotFoundException("Source file not found: " + sourceFile.getPath());
|
|
384
|
+
}
|
|
385
|
+
final File destinationFile = new File(this.documentsDir, dest);
|
|
386
|
+
Objects.requireNonNull(destinationFile.getParentFile()).mkdirs();
|
|
387
|
+
final String[] entries = sourceFile.list(this.filter);
|
|
388
|
+
if (entries == null || entries.length == 0) {
|
|
389
|
+
throw new IOException("Source file was not a directory or was empty: " + sourceFile.getPath());
|
|
390
|
+
}
|
|
391
|
+
if (entries.length == 1 && !"index.html".equals(entries[0])) {
|
|
392
|
+
final File child = new File(sourceFile, entries[0]);
|
|
393
|
+
if (!child.renameTo(destinationFile)) {
|
|
394
|
+
throw new IOException("Failed to move bundle contents: " + child.getPath() + " -> " + destinationFile.getPath());
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
if (!sourceFile.renameTo(destinationFile)) {
|
|
398
|
+
throw new IOException("Failed to move bundle contents: " + sourceFile.getPath() + " -> " + destinationFile.getPath());
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
sourceFile.delete();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private void cacheBundleFilesAsync(final String id) {
|
|
405
|
+
io.execute(() -> cacheBundleFiles(id));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
void cacheBundleFiles(final String id) {
|
|
409
|
+
if (this.activity == null) {
|
|
410
|
+
logger.debug("Skip delta cache population: activity is null");
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
final File bundleDir = this.getBundleDirectory(id);
|
|
415
|
+
if (!bundleDir.exists()) {
|
|
416
|
+
logger.debug("Skip delta cache population: bundle dir missing");
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
final File cacheDir = new File(this.activity.getCacheDir(), "capgo_downloads");
|
|
421
|
+
if (cacheDir.exists() && !cacheDir.isDirectory()) {
|
|
422
|
+
logger.debug("Skip delta cache population: cache dir is not a directory");
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (!cacheDir.exists() && !cacheDir.mkdirs()) {
|
|
426
|
+
logger.debug("Skip delta cache population: failed to create cache dir");
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
final File builtinFolder = new File(this.activity.getFilesDir(), "public");
|
|
431
|
+
|
|
432
|
+
final List<File> files = new ArrayList<>();
|
|
433
|
+
collectFiles(bundleDir, files);
|
|
434
|
+
final int bundlePrefixLength = bundleDir.getAbsolutePath().length() + 1;
|
|
435
|
+
for (File file : files) {
|
|
436
|
+
final String checksum = CryptoCipher.calcChecksum(file);
|
|
437
|
+
if (checksum.isEmpty()) {
|
|
438
|
+
continue;
|
|
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
|
+
|
|
449
|
+
final String cacheName = checksum + "_" + file.getName();
|
|
450
|
+
final File cacheFile = new File(cacheDir, cacheName);
|
|
451
|
+
if (cacheFile.exists()) {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
copyFileAtomically(file, cacheFile);
|
|
456
|
+
} catch (IOException e) {
|
|
457
|
+
logger.debug("Delta cache copy failed: " + file.getPath());
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private void collectFiles(final File dir, final List<File> files) {
|
|
463
|
+
final File[] entries = dir.listFiles();
|
|
464
|
+
if (entries == null) {
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
for (File entry : entries) {
|
|
468
|
+
if (!this.filter.accept(dir, entry.getName())) {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (entry.isDirectory()) {
|
|
472
|
+
collectFiles(entry, files);
|
|
473
|
+
} else if (entry.isFile()) {
|
|
474
|
+
files.add(entry);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private void copyFile(final File source, final File dest) throws IOException {
|
|
480
|
+
try (final FileInputStream input = new FileInputStream(source); final FileOutputStream output = new FileOutputStream(dest)) {
|
|
481
|
+
final byte[] buffer = new byte[CryptoCipher.copyBufferBytes()];
|
|
482
|
+
int length;
|
|
483
|
+
while ((length = input.read(buffer)) != -1) {
|
|
484
|
+
output.write(buffer, 0, length);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private boolean verifyChecksum(final File file, final String expectedHash) {
|
|
490
|
+
if (expectedHash == null || expectedHash.isEmpty() || file == null || !file.exists()) {
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
final String actualHash = CryptoCipher.calcChecksum(file);
|
|
494
|
+
return expectedHash.equalsIgnoreCase(actualHash);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private String resolveManifestFileHash(final JSONObject entry, final String sessionKey) {
|
|
498
|
+
String fileHash = entry.optString("file_hash", "");
|
|
499
|
+
if (fileHash.isEmpty()) {
|
|
500
|
+
return "";
|
|
501
|
+
}
|
|
502
|
+
if (this.publicKey != null && !this.publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
|
|
503
|
+
try {
|
|
504
|
+
fileHash = CryptoCipher.decryptChecksum(fileHash, this.publicKey);
|
|
505
|
+
} catch (Exception e) {
|
|
506
|
+
logger.error("Checksum decryption failed while checking missing manifest files");
|
|
507
|
+
logger.debug("File: " + entry.optString("file_name", "unknown") + ", Error: " + e.getMessage());
|
|
508
|
+
return "";
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return fileHash;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private boolean isManifestEntryAvailableLocally(final JSONObject entry, final String sessionKey) {
|
|
515
|
+
final String fileName = entry.optString("file_name", "");
|
|
516
|
+
final String fileHash = resolveManifestFileHash(entry, sessionKey);
|
|
517
|
+
if (fileName.isEmpty() || fileHash.isEmpty() || this.activity == null) {
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (DownloadService.builtinAssetMatches(this.activity.getAssets(), fileName, fileHash)) {
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
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
|
+
|
|
534
|
+
final boolean isBrotli = fileName.endsWith(".br");
|
|
535
|
+
final String fileNameWithoutPath = new File(fileName).getName();
|
|
536
|
+
final String cacheBaseName = isBrotli ? fileNameWithoutPath.substring(0, fileNameWithoutPath.length() - 3) : fileNameWithoutPath;
|
|
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
|
+
}
|
|
546
|
+
|
|
547
|
+
if (isBrotli) {
|
|
548
|
+
final File legacyCacheFile = new File(cacheFolder, fileHash + "_" + fileNameWithoutPath);
|
|
549
|
+
return isReusableCacheFile(legacyCacheFile, fileHash);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
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
|
+
|
|
589
|
+
public JSONArray getMissingBundleFiles(final JSONArray manifest, final String sessionKey) throws JSONException {
|
|
590
|
+
final JSONArray missing = new JSONArray();
|
|
591
|
+
for (int i = 0; i < manifest.length(); i++) {
|
|
592
|
+
final JSONObject entry = manifest.getJSONObject(i);
|
|
593
|
+
if (!isManifestEntryAvailableLocally(entry, sessionKey)) {
|
|
594
|
+
missing.put(entry);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return missing;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
public JSONObject missingBundleFilesResult(final JSONArray manifest, final String sessionKey) throws JSONException {
|
|
601
|
+
final JSONArray missing = getMissingBundleFiles(manifest, sessionKey);
|
|
602
|
+
final JSONObject ret = new JSONObject();
|
|
603
|
+
ret.put("missing", missing);
|
|
604
|
+
ret.put("total", manifest.length());
|
|
605
|
+
ret.put("missingCount", missing.length());
|
|
606
|
+
ret.put("reusableCount", manifest.length() - missing.length());
|
|
607
|
+
return ret;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private String manifestSizeUrl(final String updateUrl) {
|
|
611
|
+
HttpUrl parsed = HttpUrl.parse(updateUrl);
|
|
612
|
+
if (parsed == null) {
|
|
613
|
+
return updateUrl;
|
|
614
|
+
}
|
|
615
|
+
return parsed.newBuilder().addPathSegment("manifest_size").query(null).build().toString();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private JSONObject unavailableBundleSizeResult(final JSONArray manifest, final String error) throws JSONException {
|
|
619
|
+
final JSONObject ret = new JSONObject();
|
|
620
|
+
final JSONArray files = new JSONArray();
|
|
621
|
+
for (int i = 0; i < manifest.length(); i++) {
|
|
622
|
+
final JSONObject entry = new JSONObject(manifest.getJSONObject(i).toString());
|
|
623
|
+
entry.put("error", error);
|
|
624
|
+
files.put(entry);
|
|
625
|
+
}
|
|
626
|
+
ret.put("totalSize", 0);
|
|
627
|
+
ret.put("knownFiles", 0);
|
|
628
|
+
ret.put("unknownFiles", manifest.length());
|
|
629
|
+
ret.put("files", files);
|
|
630
|
+
return ret;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
public JSONObject getBundleDownloadSize(final String updateUrl, final String version, final JSONArray manifest) throws JSONException {
|
|
634
|
+
if (manifest.length() == 0) {
|
|
635
|
+
final JSONObject ret = new JSONObject();
|
|
636
|
+
ret.put("totalSize", 0);
|
|
637
|
+
ret.put("knownFiles", 0);
|
|
638
|
+
ret.put("unknownFiles", 0);
|
|
639
|
+
ret.put("files", new JSONArray());
|
|
640
|
+
return ret;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
final JSONObject json = this.createInfoObject();
|
|
644
|
+
json.put("version", version != null ? version : "");
|
|
645
|
+
json.put("manifest", manifest);
|
|
646
|
+
|
|
647
|
+
Request request = new Request.Builder()
|
|
648
|
+
.url(manifestSizeUrl(updateUrl))
|
|
649
|
+
.post(RequestBody.create(json.toString(), MediaType.get("application/json; charset=utf-8")))
|
|
650
|
+
.build();
|
|
651
|
+
|
|
652
|
+
try (Response response = DownloadService.sharedClient.newCall(request).execute()) {
|
|
653
|
+
final ResponseBody responseBody = response.body();
|
|
654
|
+
final String responseData = responseBody != null ? responseBody.string() : "";
|
|
655
|
+
if (!response.isSuccessful() || responseData.isEmpty()) {
|
|
656
|
+
return unavailableBundleSizeResult(manifest, "response_error");
|
|
657
|
+
}
|
|
658
|
+
return new JSONObject(responseData);
|
|
659
|
+
} catch (IOException e) {
|
|
660
|
+
logger.error("Error getting bundle download size");
|
|
661
|
+
logger.debug("Error: " + e.getMessage());
|
|
662
|
+
return unavailableBundleSizeResult(manifest, "response_error");
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private void observeWorkProgress(Context context, String id, boolean setNext) {
|
|
667
|
+
if (!(context instanceof LifecycleOwner)) {
|
|
668
|
+
logger.error("Context is not a LifecycleOwner, cannot observe work progress");
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
activity.runOnUiThread(() -> {
|
|
673
|
+
WorkManager.getInstance(context)
|
|
674
|
+
.getWorkInfosByTagLiveData(id)
|
|
675
|
+
.observe((LifecycleOwner) context, (workInfos) -> {
|
|
676
|
+
if (workInfos == null || workInfos.isEmpty()) return;
|
|
677
|
+
|
|
678
|
+
WorkInfo workInfo = workInfos.get(0);
|
|
679
|
+
Data progress = workInfo.getProgress();
|
|
680
|
+
|
|
681
|
+
switch (workInfo.getState()) {
|
|
682
|
+
case RUNNING:
|
|
683
|
+
int percent = progress.getInt(DownloadService.PERCENT, 0);
|
|
684
|
+
notifyDownload(id, percent);
|
|
685
|
+
break;
|
|
686
|
+
case SUCCEEDED:
|
|
687
|
+
logger.info("Download succeeded: " + workInfo.getState());
|
|
688
|
+
Data outputData = workInfo.getOutputData();
|
|
689
|
+
String dest = outputData.getString(DownloadService.FILEDEST);
|
|
690
|
+
String version = outputData.getString(DownloadService.VERSION);
|
|
691
|
+
String sessionKey = outputData.getString(DownloadService.SESSIONKEY);
|
|
692
|
+
String checksum = outputData.getString(DownloadService.CHECKSUM);
|
|
693
|
+
boolean isManifest = outputData.getBoolean(DownloadService.IS_MANIFEST, false);
|
|
694
|
+
|
|
695
|
+
io.execute(() -> {
|
|
696
|
+
boolean success = finishDownload(id, dest, version, sessionKey, checksum, setNext, isManifest);
|
|
697
|
+
BundleInfo resultBundle;
|
|
698
|
+
if (!success) {
|
|
699
|
+
logger.error("Finish download failed");
|
|
700
|
+
logger.debug("Version: " + version);
|
|
701
|
+
resultBundle = new BundleInfo(
|
|
702
|
+
id,
|
|
703
|
+
version,
|
|
704
|
+
BundleStatus.ERROR,
|
|
705
|
+
new Date(System.currentTimeMillis()),
|
|
706
|
+
""
|
|
707
|
+
);
|
|
708
|
+
saveBundleInfo(id, resultBundle);
|
|
709
|
+
// Cleanup download tracking
|
|
710
|
+
DownloadWorkerManager.cancelBundleDownload(activity, id, version);
|
|
711
|
+
Map<String, Object> ret = new HashMap<>();
|
|
712
|
+
ret.put("version", version);
|
|
713
|
+
ret.put("error", "finish_download_fail");
|
|
714
|
+
sendStats("finish_download_fail", version);
|
|
715
|
+
notifyListeners("downloadFailed", ret);
|
|
716
|
+
} else {
|
|
717
|
+
// Successful download - cleanup tracking
|
|
718
|
+
DownloadWorkerManager.cancelBundleDownload(activity, id, version);
|
|
719
|
+
resultBundle = getBundleInfo(id);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Complete the future if it exists
|
|
723
|
+
CompletableFuture<BundleInfo> future = downloadFutures.remove(id);
|
|
724
|
+
if (future != null) {
|
|
725
|
+
future.complete(resultBundle);
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
break;
|
|
729
|
+
case FAILED:
|
|
730
|
+
Data failedData = workInfo.getOutputData();
|
|
731
|
+
String error = failedData.getString(DownloadService.ERROR);
|
|
732
|
+
logger.error("Download failed");
|
|
733
|
+
logger.debug("Error: " + error + ", State: " + workInfo.getState());
|
|
734
|
+
String failedVersion = failedData.getString(DownloadService.VERSION);
|
|
735
|
+
|
|
736
|
+
io.execute(() -> {
|
|
737
|
+
BundleInfo failedBundle = new BundleInfo(
|
|
738
|
+
id,
|
|
739
|
+
failedVersion,
|
|
740
|
+
BundleStatus.ERROR,
|
|
741
|
+
new Date(System.currentTimeMillis()),
|
|
742
|
+
""
|
|
743
|
+
);
|
|
744
|
+
saveBundleInfo(id, failedBundle);
|
|
745
|
+
// Cleanup download tracking for failed downloads
|
|
746
|
+
DownloadWorkerManager.cancelBundleDownload(activity, id, failedVersion);
|
|
747
|
+
Map<String, Object> ret = new HashMap<>();
|
|
748
|
+
ret.put("version", failedVersion);
|
|
749
|
+
if ("low_mem_fail".equals(error)) {
|
|
750
|
+
sendStats("low_mem_fail", failedVersion);
|
|
751
|
+
}
|
|
752
|
+
if ("insufficient_disk_space".equals(error)) {
|
|
753
|
+
sendStats("insufficient_disk_space", failedVersion);
|
|
754
|
+
}
|
|
755
|
+
ret.put("error", error != null ? error : "download_fail");
|
|
756
|
+
sendStats("download_fail", failedVersion);
|
|
757
|
+
notifyListeners("downloadFailed", ret);
|
|
758
|
+
|
|
759
|
+
// Complete the future with error status
|
|
760
|
+
CompletableFuture<BundleInfo> failedFuture = downloadFutures.remove(id);
|
|
761
|
+
if (failedFuture != null) {
|
|
762
|
+
failedFuture.complete(failedBundle);
|
|
763
|
+
}
|
|
764
|
+
});
|
|
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;
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
private void download(
|
|
779
|
+
final String id,
|
|
780
|
+
final String url,
|
|
781
|
+
final String dest,
|
|
782
|
+
final String version,
|
|
783
|
+
final String sessionKey,
|
|
784
|
+
final String checksum,
|
|
785
|
+
final JSONArray manifest,
|
|
786
|
+
final boolean setNext
|
|
787
|
+
) {
|
|
788
|
+
if (this.activity == null) {
|
|
789
|
+
logger.error("Activity is null, cannot observe work progress");
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
observeWorkProgress(this.activity, id, setNext);
|
|
793
|
+
|
|
794
|
+
if (manifest != null) {
|
|
795
|
+
DataManager.getInstance().setManifest(id, manifest);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
DownloadWorkerManager.enqueueDownload(
|
|
799
|
+
this.activity,
|
|
800
|
+
url,
|
|
801
|
+
id,
|
|
802
|
+
this.documentsDir.getAbsolutePath(),
|
|
803
|
+
dest,
|
|
804
|
+
version,
|
|
805
|
+
sessionKey,
|
|
806
|
+
checksum,
|
|
807
|
+
this.publicKey,
|
|
808
|
+
manifest != null,
|
|
809
|
+
this.isEmulator(),
|
|
810
|
+
this.appId,
|
|
811
|
+
this.pluginVersion,
|
|
812
|
+
this.isProd(),
|
|
813
|
+
this.getInstallSource(),
|
|
814
|
+
this.statsUrl,
|
|
815
|
+
this.deviceID,
|
|
816
|
+
this.versionBuild,
|
|
817
|
+
this.versionCode,
|
|
818
|
+
this.versionOs,
|
|
819
|
+
this.customId,
|
|
820
|
+
this.defaultChannel
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
public Boolean finishDownload(
|
|
825
|
+
String id,
|
|
826
|
+
String dest,
|
|
827
|
+
String version,
|
|
828
|
+
String sessionKey,
|
|
829
|
+
String checksumRes,
|
|
830
|
+
Boolean setNext,
|
|
831
|
+
Boolean isManifest
|
|
832
|
+
) {
|
|
833
|
+
File downloaded = null;
|
|
834
|
+
File extractedDir = null;
|
|
835
|
+
String checksum = "";
|
|
836
|
+
|
|
837
|
+
try {
|
|
838
|
+
this.notifyDownload(id, 71);
|
|
839
|
+
downloaded = new File(this.documentsDir, dest);
|
|
840
|
+
|
|
841
|
+
if (!isManifest) {
|
|
842
|
+
String checksumDecrypted = Objects.requireNonNullElse(checksumRes, "");
|
|
843
|
+
|
|
844
|
+
// If public key is present but no checksum provided, refuse installation
|
|
845
|
+
if (!this.publicKey.isEmpty() && checksumDecrypted.isEmpty()) {
|
|
846
|
+
logger.error("Public key present but no checksum provided");
|
|
847
|
+
this.sendStats("checksum_required");
|
|
848
|
+
throw new IOException("Checksum required when public key is present: " + id);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (!sessionKey.isEmpty()) {
|
|
852
|
+
CryptoCipher.decryptFile(downloaded, publicKey, sessionKey);
|
|
853
|
+
checksumDecrypted = CryptoCipher.decryptChecksum(checksumRes, publicKey);
|
|
854
|
+
checksum = CryptoCipher.calcChecksum(downloaded);
|
|
855
|
+
} else {
|
|
856
|
+
checksum = CryptoCipher.calcChecksum(downloaded);
|
|
857
|
+
}
|
|
858
|
+
CryptoCipher.logChecksumInfo("Calculated checksum", checksum);
|
|
859
|
+
CryptoCipher.logChecksumInfo("Expected checksum", checksumDecrypted);
|
|
860
|
+
if ((!checksumDecrypted.isEmpty() || !this.publicKey.isEmpty()) && !checksumDecrypted.equals(checksum)) {
|
|
861
|
+
logger.error("Checksum mismatch");
|
|
862
|
+
logger.debug("Expected: " + checksumDecrypted + ", Got: " + checksum);
|
|
863
|
+
this.sendStats("checksum_fail");
|
|
864
|
+
throw new IOException("Checksum failed: " + id);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
// Remove the decryption for manifest downloads
|
|
868
|
+
} catch (Exception e) {
|
|
869
|
+
if (!isManifest) {
|
|
870
|
+
safeDelete(downloaded);
|
|
871
|
+
}
|
|
872
|
+
final Boolean res = this.delete(id);
|
|
873
|
+
if (!res) {
|
|
874
|
+
logger.info("Failed to cleanup after error");
|
|
875
|
+
logger.debug("Version: " + version);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
final Map<String, Object> ret = new HashMap<>();
|
|
879
|
+
ret.put("version", version);
|
|
880
|
+
|
|
881
|
+
CapgoUpdater.this.notifyListeners("downloadFailed", ret);
|
|
882
|
+
CapgoUpdater.this.sendStats("download_fail");
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
try {
|
|
887
|
+
if (!isManifest) {
|
|
888
|
+
extractedDir = this.unzip(id, downloaded, TEMP_UNZIP_PREFIX + this.randomString());
|
|
889
|
+
this.notifyDownload(id, 91);
|
|
890
|
+
final String idName = bundleDirectory + "/" + id;
|
|
891
|
+
this.flattenAssets(extractedDir, idName);
|
|
892
|
+
this.cacheBundleFilesAsync(id);
|
|
893
|
+
} else {
|
|
894
|
+
this.notifyDownload(id, 91);
|
|
895
|
+
final String idName = bundleDirectory + "/" + id;
|
|
896
|
+
this.flattenAssets(downloaded, idName);
|
|
897
|
+
downloaded.delete();
|
|
898
|
+
}
|
|
899
|
+
// Remove old bundle info and set new one
|
|
900
|
+
this.saveBundleInfo(id, null);
|
|
901
|
+
BundleInfo next = new BundleInfo(id, version, BundleStatus.PENDING, new Date(System.currentTimeMillis()), checksum);
|
|
902
|
+
this.saveBundleInfo(id, next);
|
|
903
|
+
this.notifyDownload(id, 100);
|
|
904
|
+
|
|
905
|
+
final Map<String, Object> ret = new HashMap<>();
|
|
906
|
+
ret.put("bundle", InternalUtils.mapToJSONObject(next.toJSONMap()));
|
|
907
|
+
logger.info("updateAvailable: " + ret);
|
|
908
|
+
CapgoUpdater.this.notifyListeners("updateAvailable", ret);
|
|
909
|
+
logger.info("setNext: " + setNext);
|
|
910
|
+
if (setNext) {
|
|
911
|
+
if (this.previewSession) {
|
|
912
|
+
logger.info("Preview session is active, skipping automatic install of downloaded bundle");
|
|
913
|
+
this.directUpdate = false;
|
|
914
|
+
} else if (this.directUpdate) {
|
|
915
|
+
logger.info("directUpdate: " + this.directUpdate);
|
|
916
|
+
CapgoUpdater.this.directUpdateFinish(next);
|
|
917
|
+
this.directUpdate = false;
|
|
918
|
+
} else {
|
|
919
|
+
logger.info("directUpdate: " + this.directUpdate);
|
|
920
|
+
this.setNextBundle(next.getId());
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
} catch (IOException e) {
|
|
924
|
+
if (!isManifest) {
|
|
925
|
+
safeDelete(extractedDir);
|
|
926
|
+
safeDelete(downloaded);
|
|
927
|
+
}
|
|
928
|
+
e.printStackTrace();
|
|
929
|
+
final Map<String, Object> ret = new HashMap<>();
|
|
930
|
+
ret.put("version", version);
|
|
931
|
+
CapgoUpdater.this.notifyListeners("downloadFailed", ret);
|
|
932
|
+
CapgoUpdater.this.sendStats("download_fail");
|
|
933
|
+
return false;
|
|
934
|
+
}
|
|
935
|
+
if (!isManifest) {
|
|
936
|
+
safeDelete(downloaded);
|
|
937
|
+
}
|
|
938
|
+
return true;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
private void deleteDirectory(final File file) throws IOException {
|
|
942
|
+
deleteDirectory(file, null);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
private void deleteDirectory(final File file, final Thread threadToCheck) throws IOException {
|
|
946
|
+
// Check if thread was interrupted (cancelled)
|
|
947
|
+
if (threadToCheck != null && threadToCheck.isInterrupted()) {
|
|
948
|
+
throw new IOException("Operation cancelled");
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (file.isDirectory()) {
|
|
952
|
+
final File[] entries = file.listFiles();
|
|
953
|
+
if (entries != null) {
|
|
954
|
+
for (final File entry : entries) {
|
|
955
|
+
this.deleteDirectory(entry, threadToCheck);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
if (!file.delete()) {
|
|
960
|
+
throw new IOException("Failed to delete: " + file);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
public void cleanupDeltaCache() {
|
|
965
|
+
cleanupDeltaCache(null);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
public void cleanupDeltaCache(final Thread threadToCheck) {
|
|
969
|
+
if (this.activity == null) {
|
|
970
|
+
logger.warn("Activity is null, skipping delta cache cleanup");
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
final File cacheFolder = new File(this.activity.getCacheDir(), "capgo_downloads");
|
|
974
|
+
if (!cacheFolder.exists()) {
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
try {
|
|
978
|
+
this.deleteDirectory(cacheFolder, threadToCheck);
|
|
979
|
+
logger.info("Cleaned up delta cache folder");
|
|
980
|
+
} catch (IOException e) {
|
|
981
|
+
logger.error("Failed to cleanup delta cache");
|
|
982
|
+
logger.debug("Error: " + e.getMessage());
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
public void cleanupDownloadDirectories(final Set<String> allowedIds) {
|
|
987
|
+
cleanupDownloadDirectories(allowedIds, null);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
public void cleanupDownloadDirectories(final Set<String> allowedIds, final Thread threadToCheck) {
|
|
991
|
+
if (this.documentsDir == null) {
|
|
992
|
+
logger.warn("Documents directory is null, skipping download cleanup");
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
final File bundleRoot = new File(this.documentsDir, bundleDirectory);
|
|
997
|
+
if (!bundleRoot.exists() || !bundleRoot.isDirectory()) {
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
final File[] entries = bundleRoot.listFiles();
|
|
1002
|
+
if (entries != null) {
|
|
1003
|
+
for (final File entry : entries) {
|
|
1004
|
+
// Check if thread was interrupted (cancelled)
|
|
1005
|
+
if (threadToCheck != null && threadToCheck.isInterrupted()) {
|
|
1006
|
+
logger.warn("cleanupDownloadDirectories was cancelled");
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if (!entry.isDirectory()) {
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
final String id = entry.getName();
|
|
1015
|
+
|
|
1016
|
+
if (allowedIds != null && allowedIds.contains(id)) {
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
try {
|
|
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
|
+
}
|
|
1027
|
+
this.removeBundleInfo(id);
|
|
1028
|
+
logger.info("Deleted orphan bundle directory");
|
|
1029
|
+
logger.debug("Bundle ID: " + id);
|
|
1030
|
+
} catch (IOException e) {
|
|
1031
|
+
logger.error("Failed to delete orphan bundle directory");
|
|
1032
|
+
logger.debug("Bundle ID: " + id + ", Error: " + e.getMessage());
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
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
|
+
|
|
1075
|
+
public void cleanupOrphanedTempFolders(final Thread threadToCheck) {
|
|
1076
|
+
if (this.documentsDir == null) {
|
|
1077
|
+
logger.warn("Documents directory is null, skipping temp folder cleanup");
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
final File[] entries = this.documentsDir.listFiles();
|
|
1082
|
+
if (entries == null) {
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
for (final File entry : entries) {
|
|
1087
|
+
// Check if thread was interrupted (cancelled)
|
|
1088
|
+
if (threadToCheck != null && threadToCheck.isInterrupted()) {
|
|
1089
|
+
logger.warn("cleanupOrphanedTempFolders was cancelled");
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (!entry.isDirectory()) {
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
final String folderName = entry.getName();
|
|
1098
|
+
|
|
1099
|
+
// Only delete folders with the temp unzip prefix
|
|
1100
|
+
if (!folderName.startsWith(TEMP_UNZIP_PREFIX)) {
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
try {
|
|
1105
|
+
this.deleteDirectory(entry, threadToCheck);
|
|
1106
|
+
logger.info("Deleted orphaned temp unzip folder");
|
|
1107
|
+
logger.debug("Folder: " + folderName);
|
|
1108
|
+
} catch (IOException e) {
|
|
1109
|
+
logger.error("Failed to delete orphaned temp folder");
|
|
1110
|
+
logger.debug("Folder: " + folderName + ", Error: " + e.getMessage());
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
private void safeDelete(final File target) {
|
|
1116
|
+
if (target == null || !target.exists()) {
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
try {
|
|
1120
|
+
if (target.isDirectory()) {
|
|
1121
|
+
this.deleteDirectory(target);
|
|
1122
|
+
} else if (!target.delete()) {
|
|
1123
|
+
logger.warn("Failed to delete file: " + target.getAbsolutePath());
|
|
1124
|
+
}
|
|
1125
|
+
} catch (IOException cleanupError) {
|
|
1126
|
+
logger.warn("Cleanup failed for " + target.getAbsolutePath() + ": " + cleanupError.getMessage());
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
private void setCurrentBundle(final File bundle) {
|
|
1131
|
+
this.resetBackgroundRunnerWorkForBundleSwitch(bundle);
|
|
1132
|
+
this.editor.putString(this.CAP_SERVER_PATH, bundle.getPath());
|
|
1133
|
+
logger.info("Current bundle set to: " + bundle);
|
|
1134
|
+
this.editor.commit();
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
static boolean shouldResetForForeignBundle(final String bundlePath, final boolean isBuiltin, final boolean hasStoredBundleInfo) {
|
|
1138
|
+
return bundlePath != null && !bundlePath.trim().isEmpty() && !isBuiltin && !hasStoredBundleInfo;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
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) {
|
|
1168
|
+
if (configJson == null || configJson.trim().isEmpty()) {
|
|
1169
|
+
return null;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
try {
|
|
1173
|
+
final JSONObject config = new JSONObject(configJson);
|
|
1174
|
+
final JSONObject plugins = config.optJSONObject("plugins");
|
|
1175
|
+
if (plugins == null) {
|
|
1176
|
+
return null;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
final JSONObject backgroundRunner = plugins.optJSONObject(BACKGROUND_RUNNER_CONFIG_KEY);
|
|
1180
|
+
if (backgroundRunner == null) {
|
|
1181
|
+
return null;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
final String label = backgroundRunner.optString("label", "").trim();
|
|
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
|
+
);
|
|
1199
|
+
} catch (JSONException ignored) {
|
|
1200
|
+
return null;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
static String getBackgroundRunnerLabelFromConfig(final String configJson) {
|
|
1205
|
+
final BackgroundRunnerWorkConfig config = getBackgroundRunnerWorkConfigFromConfig(configJson);
|
|
1206
|
+
return config == null ? null : config.label;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
private String readAssetAsString(final String assetPath) throws IOException {
|
|
1210
|
+
final StringBuilder buffer = new StringBuilder();
|
|
1211
|
+
try (
|
|
1212
|
+
final BufferedReader reader = new BufferedReader(
|
|
1213
|
+
new InputStreamReader(this.activity.getAssets().open(assetPath), StandardCharsets.UTF_8)
|
|
1214
|
+
)
|
|
1215
|
+
) {
|
|
1216
|
+
String line;
|
|
1217
|
+
while ((line = reader.readLine()) != null) {
|
|
1218
|
+
buffer.append(line).append('\n');
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
return buffer.toString();
|
|
1222
|
+
}
|
|
1223
|
+
|
|
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) {
|
|
1276
|
+
if (this.activity == null) {
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
final BackgroundRunnerWorkConfig config;
|
|
1281
|
+
try {
|
|
1282
|
+
config = getBackgroundRunnerWorkConfigFromConfig(this.readAssetAsString(CAPACITOR_CONFIG_ASSET));
|
|
1283
|
+
} catch (IOException ignored) {
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
if (config == null) {
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
try {
|
|
1292
|
+
final WorkManager workManager = WorkManager.getInstance(this.activity.getApplicationContext());
|
|
1293
|
+
workManager.cancelUniqueWork(config.label);
|
|
1294
|
+
workManager.cancelAllWorkByTag(config.label);
|
|
1295
|
+
logger.info("Cancelled Background Runner work before bundle switch.");
|
|
1296
|
+
logger.debug("Background Runner label: " + config.label);
|
|
1297
|
+
} catch (Exception e) {
|
|
1298
|
+
logger.warn("Failed to cancel Background Runner work before bundle switch.");
|
|
1299
|
+
logger.debug("Background Runner cancellation error: " + e.getMessage());
|
|
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
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
private boolean hasStoredBundleInfo(final String id) {
|
|
1352
|
+
return (
|
|
1353
|
+
id != null &&
|
|
1354
|
+
!id.isEmpty() &&
|
|
1355
|
+
!BundleInfo.ID_BUILTIN.equals(id) &&
|
|
1356
|
+
!BundleInfo.VERSION_UNKNOWN.equals(id) &&
|
|
1357
|
+
this.prefs.contains(id + INFO_SUFFIX)
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
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
|
+
|
|
1382
|
+
public void downloadBackground(
|
|
1383
|
+
final String url,
|
|
1384
|
+
final String version,
|
|
1385
|
+
final String sessionKey,
|
|
1386
|
+
final String checksum,
|
|
1387
|
+
final JSONArray manifest
|
|
1388
|
+
) {
|
|
1389
|
+
downloadBackground(url, version, sessionKey, checksum, manifest, true);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
public void downloadBackground(
|
|
1393
|
+
final String url,
|
|
1394
|
+
final String version,
|
|
1395
|
+
final String sessionKey,
|
|
1396
|
+
final String checksum,
|
|
1397
|
+
final JSONArray manifest,
|
|
1398
|
+
final boolean setNext
|
|
1399
|
+
) {
|
|
1400
|
+
if (!this.runDownloadGateQuiet()) {
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
final String id = this.randomString();
|
|
1404
|
+
|
|
1405
|
+
// Check if version is already downloading, but allow retry if previous download failed
|
|
1406
|
+
if (this.activity != null && DownloadWorkerManager.isVersionDownloading(this.activity, version)) {
|
|
1407
|
+
// Check if there's an existing bundle with error status that we can retry
|
|
1408
|
+
BundleInfo existingBundle = this.getBundleInfoByName(version);
|
|
1409
|
+
if (existingBundle != null && existingBundle.isErrorStatus()) {
|
|
1410
|
+
// Cancel the failed download and allow retry
|
|
1411
|
+
if (!DownloadWorkerManager.cancelVersionDownloadAndAwait(this.activity, version)) {
|
|
1412
|
+
logger.error("Failed to cancel previous download before retry");
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
logger.info("Retrying failed download for version: " + version);
|
|
1416
|
+
} else {
|
|
1417
|
+
logger.info("Version already downloading: " + version);
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
saveBundleInfo(id, new BundleInfo(id, version, BundleStatus.DOWNLOADING, new Date(System.currentTimeMillis()), ""));
|
|
1423
|
+
this.notifyDownload(id, 0);
|
|
1424
|
+
this.notifyDownload(id, 5);
|
|
1425
|
+
|
|
1426
|
+
this.download(id, url, this.randomString(), version, sessionKey, checksum, manifest, setNext);
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
public BundleInfo download(final String url, final String version, final String sessionKey, final String checksum) throws IOException {
|
|
1430
|
+
this.runDownloadGate();
|
|
1431
|
+
// Check for existing bundle with same version and clean up if in error state
|
|
1432
|
+
BundleInfo existingBundle = this.getBundleInfoByName(version);
|
|
1433
|
+
if (existingBundle != null && (existingBundle.isErrorStatus() || existingBundle.isDeleted() || existingBundle.isDeleting())) {
|
|
1434
|
+
logger.info("Found existing failed bundle for version " + version + ", deleting before retry");
|
|
1435
|
+
if (!Boolean.TRUE.equals(this.delete(existingBundle.getId(), true))) {
|
|
1436
|
+
throw new IOException("Failed to delete existing bundle before retry");
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
final String id = this.randomString();
|
|
1441
|
+
saveBundleInfo(id, new BundleInfo(id, version, BundleStatus.DOWNLOADING, new Date(System.currentTimeMillis()), ""));
|
|
1442
|
+
this.notifyDownload(id, 0);
|
|
1443
|
+
this.notifyDownload(id, 5);
|
|
1444
|
+
final String dest = this.randomString();
|
|
1445
|
+
|
|
1446
|
+
// Create a CompletableFuture to track download completion
|
|
1447
|
+
CompletableFuture<BundleInfo> downloadFuture = new CompletableFuture<>();
|
|
1448
|
+
downloadFutures.put(id, downloadFuture);
|
|
1449
|
+
|
|
1450
|
+
// Start the download
|
|
1451
|
+
this.download(id, url, dest, version, sessionKey, checksum, null, false);
|
|
1452
|
+
|
|
1453
|
+
// Wait for completion without timeout
|
|
1454
|
+
try {
|
|
1455
|
+
BundleInfo result = downloadFuture.get();
|
|
1456
|
+
if (result.isErrorStatus()) {
|
|
1457
|
+
throw new IOException("Download failed with status: " + result.getStatus());
|
|
1458
|
+
}
|
|
1459
|
+
return result;
|
|
1460
|
+
} catch (Exception e) {
|
|
1461
|
+
// Clean up on failure
|
|
1462
|
+
downloadFutures.remove(id);
|
|
1463
|
+
logger.error("Error waiting for download");
|
|
1464
|
+
logger.debug("Error: " + e.getMessage());
|
|
1465
|
+
BundleInfo errorBundle = new BundleInfo(id, version, BundleStatus.ERROR, new Date(System.currentTimeMillis()), "");
|
|
1466
|
+
saveBundleInfo(id, errorBundle);
|
|
1467
|
+
if (e instanceof IOException) {
|
|
1468
|
+
throw (IOException) e;
|
|
1469
|
+
}
|
|
1470
|
+
throw new IOException("Error waiting for download: " + e.getMessage(), e);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
public BundleInfo downloadManifest(
|
|
1475
|
+
final String url,
|
|
1476
|
+
final String version,
|
|
1477
|
+
final String sessionKey,
|
|
1478
|
+
final String checksum,
|
|
1479
|
+
final JSONArray manifest
|
|
1480
|
+
) throws IOException {
|
|
1481
|
+
this.runDownloadGate();
|
|
1482
|
+
if (manifest == null) {
|
|
1483
|
+
return download(url, version, sessionKey, checksum);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// Check for existing bundle with same version and clean up if in error state
|
|
1487
|
+
BundleInfo existingBundle = this.getBundleInfoByName(version);
|
|
1488
|
+
if (existingBundle != null && (existingBundle.isErrorStatus() || existingBundle.isDeleted() || existingBundle.isDeleting())) {
|
|
1489
|
+
logger.info("Found existing failed bundle for version " + version + ", deleting before retry");
|
|
1490
|
+
if (!Boolean.TRUE.equals(this.delete(existingBundle.getId(), true))) {
|
|
1491
|
+
throw new IOException("Failed to delete existing bundle before retry");
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
final String id = this.randomString();
|
|
1496
|
+
saveBundleInfo(id, new BundleInfo(id, version, BundleStatus.DOWNLOADING, new Date(System.currentTimeMillis()), ""));
|
|
1497
|
+
this.notifyDownload(id, 0);
|
|
1498
|
+
this.notifyDownload(id, 5);
|
|
1499
|
+
final String dest = this.randomString();
|
|
1500
|
+
|
|
1501
|
+
// Create a CompletableFuture to track download completion
|
|
1502
|
+
CompletableFuture<BundleInfo> downloadFuture = new CompletableFuture<>();
|
|
1503
|
+
downloadFutures.put(id, downloadFuture);
|
|
1504
|
+
|
|
1505
|
+
// Start the download
|
|
1506
|
+
this.download(id, url, dest, version, sessionKey, checksum, manifest, false);
|
|
1507
|
+
|
|
1508
|
+
// Wait for completion without timeout
|
|
1509
|
+
try {
|
|
1510
|
+
BundleInfo result = downloadFuture.get();
|
|
1511
|
+
if (result.isErrorStatus()) {
|
|
1512
|
+
throw new IOException("Download failed with status: " + result.getStatus());
|
|
1513
|
+
}
|
|
1514
|
+
return result;
|
|
1515
|
+
} catch (Exception e) {
|
|
1516
|
+
// Clean up on failure
|
|
1517
|
+
downloadFutures.remove(id);
|
|
1518
|
+
logger.error("Error waiting for download");
|
|
1519
|
+
logger.debug("Error: " + e.getMessage());
|
|
1520
|
+
BundleInfo errorBundle = new BundleInfo(id, version, BundleStatus.ERROR, new Date(System.currentTimeMillis()), "");
|
|
1521
|
+
saveBundleInfo(id, errorBundle);
|
|
1522
|
+
if (e instanceof IOException) {
|
|
1523
|
+
throw (IOException) e;
|
|
1524
|
+
}
|
|
1525
|
+
throw new IOException("Error waiting for download: " + e.getMessage(), e);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
public List<BundleInfo> list(boolean rawList) {
|
|
1530
|
+
if (!rawList) {
|
|
1531
|
+
final List<BundleInfo> res = new ArrayList<>();
|
|
1532
|
+
final File destHot = new File(this.documentsDir, bundleDirectory);
|
|
1533
|
+
logger.debug("list File : " + destHot.getPath());
|
|
1534
|
+
if (destHot.exists()) {
|
|
1535
|
+
for (final File i : Objects.requireNonNull(destHot.listFiles())) {
|
|
1536
|
+
final String id = i.getName();
|
|
1537
|
+
res.add(this.getBundleInfo(id));
|
|
1538
|
+
}
|
|
1539
|
+
} else {
|
|
1540
|
+
logger.info("No versions available to list" + destHot);
|
|
1541
|
+
}
|
|
1542
|
+
return res;
|
|
1543
|
+
} else {
|
|
1544
|
+
final List<BundleInfo> res = new ArrayList<>();
|
|
1545
|
+
for (String value : this.prefs.getAll().keySet()) {
|
|
1546
|
+
if (!value.matches("^[0-9A-Za-z]{10}_info$")) {
|
|
1547
|
+
continue;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
res.add(this.getBundleInfo(value.split("_")[0]));
|
|
1551
|
+
}
|
|
1552
|
+
return res;
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
public Boolean delete(final String id, final Boolean removeInfo) throws IOException {
|
|
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));
|
|
1633
|
+
} else {
|
|
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;
|
|
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);
|
|
1645
|
+
return true;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
public Boolean delete(final String id) {
|
|
1650
|
+
try {
|
|
1651
|
+
return this.delete(id, true);
|
|
1652
|
+
} catch (IOException e) {
|
|
1653
|
+
e.printStackTrace();
|
|
1654
|
+
logger.info("Failed to delete bundle (" + id + ")" + "\nError:\n" + e.toString());
|
|
1655
|
+
return false;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
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
|
+
|
|
1735
|
+
private File getBundleDirectory(final String id) {
|
|
1736
|
+
return new File(this.documentsDir, bundleDirectory + "/" + id);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
private boolean bundleExists(final String id) {
|
|
1740
|
+
final File bundle = this.getBundleDirectory(id);
|
|
1741
|
+
final BundleInfo bundleInfo = this.getBundleInfo(id);
|
|
1742
|
+
return (
|
|
1743
|
+
bundle.isDirectory() &&
|
|
1744
|
+
bundle.exists() &&
|
|
1745
|
+
new File(bundle.getPath(), "/index.html").exists() &&
|
|
1746
|
+
!bundleInfo.isDeleted() &&
|
|
1747
|
+
!bundleInfo.isDeleting()
|
|
1748
|
+
);
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
static final class ResetState {
|
|
1752
|
+
|
|
1753
|
+
final String currentBundlePath;
|
|
1754
|
+
final String fallbackBundleId;
|
|
1755
|
+
final String nextBundleId;
|
|
1756
|
+
|
|
1757
|
+
ResetState(final String currentBundlePath, final String fallbackBundleId, final String nextBundleId) {
|
|
1758
|
+
this.currentBundlePath = currentBundlePath;
|
|
1759
|
+
this.fallbackBundleId = fallbackBundleId;
|
|
1760
|
+
this.nextBundleId = nextBundleId;
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
ResetState captureResetState() {
|
|
1765
|
+
return new ResetState(
|
|
1766
|
+
this.getCurrentBundlePath(),
|
|
1767
|
+
this.prefs.getString(FALLBACK_VERSION, BundleInfo.ID_BUILTIN),
|
|
1768
|
+
this.prefs.getString(NEXT_VERSION, null)
|
|
1769
|
+
);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
void restoreResetState(final ResetState state) {
|
|
1773
|
+
final String currentBundlePath =
|
|
1774
|
+
state.currentBundlePath == null || state.currentBundlePath.trim().isEmpty() ? "public" : state.currentBundlePath;
|
|
1775
|
+
final String fallbackBundleId =
|
|
1776
|
+
state.fallbackBundleId == null || state.fallbackBundleId.isEmpty() ? BundleInfo.ID_BUILTIN : state.fallbackBundleId;
|
|
1777
|
+
|
|
1778
|
+
this.editor.putString(this.CAP_SERVER_PATH, currentBundlePath);
|
|
1779
|
+
this.editor.putString(FALLBACK_VERSION, fallbackBundleId);
|
|
1780
|
+
if (state.nextBundleId == null || state.nextBundleId.isEmpty()) {
|
|
1781
|
+
this.editor.remove(NEXT_VERSION);
|
|
1782
|
+
} else {
|
|
1783
|
+
this.editor.putString(NEXT_VERSION, state.nextBundleId);
|
|
1784
|
+
}
|
|
1785
|
+
this.editor.commit();
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
void prepareResetStateForTransition() {
|
|
1789
|
+
this.setCurrentBundle(new File("public"));
|
|
1790
|
+
this.setFallbackBundle(null);
|
|
1791
|
+
this.setNextBundle(null);
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
void finalizeResetTransition(final String previousBundleName, final boolean internal) {
|
|
1795
|
+
if (this.activity != null) {
|
|
1796
|
+
DownloadWorkerManager.cancelAllDownloads(this.activity);
|
|
1797
|
+
}
|
|
1798
|
+
if (!internal) {
|
|
1799
|
+
this.sendStats("reset", this.getCurrentBundle().getVersionName(), previousBundleName);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
boolean canSet(final BundleInfo bundle) {
|
|
1804
|
+
return bundle != null && (bundle.isBuiltin() || this.bundleExists(bundle.getId()));
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
public Boolean set(final BundleInfo bundle) {
|
|
1808
|
+
return this.set(bundle.getId());
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
public Boolean set(final String id) {
|
|
1812
|
+
final BundleInfo newBundle = this.getBundleInfo(id);
|
|
1813
|
+
if (newBundle.isBuiltin()) {
|
|
1814
|
+
this.reset();
|
|
1815
|
+
return true;
|
|
1816
|
+
}
|
|
1817
|
+
final File bundle = this.getBundleDirectory(id);
|
|
1818
|
+
logger.info("Setting next active bundle: " + id);
|
|
1819
|
+
if (this.bundleExists(id)) {
|
|
1820
|
+
var currentBundleName = this.getCurrentBundle().getVersionName();
|
|
1821
|
+
this.setCurrentBundle(bundle);
|
|
1822
|
+
this.setBundleStatus(id, BundleStatus.PENDING);
|
|
1823
|
+
this.sendStats("set", newBundle.getVersionName(), currentBundleName);
|
|
1824
|
+
return true;
|
|
1825
|
+
}
|
|
1826
|
+
this.setBundleStatus(id, BundleStatus.ERROR);
|
|
1827
|
+
this.sendStats("set_fail", newBundle.getVersionName());
|
|
1828
|
+
return false;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
boolean stagePendingReload(final BundleInfo bundle) {
|
|
1832
|
+
if (bundle == null || bundle.isBuiltin() || !this.bundleExists(bundle.getId())) {
|
|
1833
|
+
return false;
|
|
1834
|
+
}
|
|
1835
|
+
this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
|
|
1836
|
+
return true;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
boolean stagePreviewFallbackReload(final BundleInfo bundle) {
|
|
1840
|
+
if (bundle == null || bundle.isErrorStatus()) {
|
|
1841
|
+
return false;
|
|
1842
|
+
}
|
|
1843
|
+
if (bundle.isBuiltin()) {
|
|
1844
|
+
this.setCurrentBundle(new File("public"));
|
|
1845
|
+
return true;
|
|
1846
|
+
}
|
|
1847
|
+
if (!this.bundleExists(bundle.getId())) {
|
|
1848
|
+
return false;
|
|
1849
|
+
}
|
|
1850
|
+
this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
|
|
1851
|
+
return true;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
void finalizePendingReload(final BundleInfo bundle, final String previousBundleName) {
|
|
1855
|
+
if (bundle == null || bundle.isBuiltin()) {
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
this.sendStats("set", bundle.getVersionName(), previousBundleName);
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
@Deprecated
|
|
1862
|
+
public void autoReset() {
|
|
1863
|
+
this.autoReset(this.versionCode == null ? "" : this.versionCode);
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
public void autoReset(final String currentNativeBuildVersion) {
|
|
1867
|
+
this.autoReset(currentNativeBuildVersion, true);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
public void autoReset(final String currentNativeBuildVersion, final boolean resetWhenNativeVersionChanged) {
|
|
1871
|
+
final BundleInfo currentBundle = this.getCurrentBundle();
|
|
1872
|
+
if (!currentBundle.isBuiltin() && !this.bundleExists(currentBundle.getId())) {
|
|
1873
|
+
logger.info("Folder at bundle path does not exist. Triggering reset.");
|
|
1874
|
+
this.reset();
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
String bundlePath = this.prefs.getString(this.CAP_SERVER_PATH, null);
|
|
1878
|
+
if (shouldResetForForeignBundle(bundlePath, currentBundle.isBuiltin(), this.hasStoredBundleInfo(currentBundle.getId()))) {
|
|
1879
|
+
logger.info("Current bundle id is not one of the bundle ids stored by this plugin. Triggering reset.");
|
|
1880
|
+
this.reset();
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
final String previousNativeBuildVersion = this.getStoredNativeBuildVersion();
|
|
1884
|
+
if (
|
|
1885
|
+
resetWhenNativeVersionChanged &&
|
|
1886
|
+
!previousNativeBuildVersion.isEmpty() &&
|
|
1887
|
+
currentNativeBuildVersion != null &&
|
|
1888
|
+
!currentNativeBuildVersion.isEmpty() &&
|
|
1889
|
+
!Objects.equals(previousNativeBuildVersion, currentNativeBuildVersion)
|
|
1890
|
+
) {
|
|
1891
|
+
logger.info(
|
|
1892
|
+
"Stored native build version " +
|
|
1893
|
+
previousNativeBuildVersion +
|
|
1894
|
+
" does not match current native build version " +
|
|
1895
|
+
currentNativeBuildVersion +
|
|
1896
|
+
". Triggering reset."
|
|
1897
|
+
);
|
|
1898
|
+
this.reset();
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
private String getStoredNativeBuildVersion() {
|
|
1903
|
+
if (this.prefs == null) {
|
|
1904
|
+
return "";
|
|
1905
|
+
}
|
|
1906
|
+
String previousNativeBuildVersion = this.prefs.getString("LatestNativeBuildVersion", "");
|
|
1907
|
+
if (previousNativeBuildVersion == null || previousNativeBuildVersion.isEmpty()) {
|
|
1908
|
+
previousNativeBuildVersion = this.prefs.getString("LatestVersionNative", "");
|
|
1909
|
+
}
|
|
1910
|
+
return previousNativeBuildVersion == null ? "" : previousNativeBuildVersion;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
public void reset() {
|
|
1914
|
+
this.reset(false);
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
public void setSuccess(final BundleInfo bundle, Boolean autoDeletePrevious) {
|
|
1918
|
+
this.setBundleStatus(bundle.getId(), BundleStatus.SUCCESS);
|
|
1919
|
+
final BundleInfo fallback = this.getFallbackBundle();
|
|
1920
|
+
final BundleInfo previewFallback = this.getPreviewFallbackBundle();
|
|
1921
|
+
final boolean fallbackIsPreviewFallback = previewFallback != null && previewFallback.getId().equals(fallback.getId());
|
|
1922
|
+
logger.debug("Fallback bundle is: " + fallback);
|
|
1923
|
+
logger.info("Version successfully loaded: " + bundle.getVersionName());
|
|
1924
|
+
// Only attempt to delete when the fallback is a different bundle than the
|
|
1925
|
+
// currently loaded one. Otherwise we spam logs with "Cannot delete <id>"
|
|
1926
|
+
// because delete() protects the current bundle from removal.
|
|
1927
|
+
final String previousFallbackId = fallback.getId();
|
|
1928
|
+
final String previousFallbackVersion = fallback.getVersionName();
|
|
1929
|
+
final BundleInfo nextBundle = this.getNextBundle();
|
|
1930
|
+
final boolean previousIsNext =
|
|
1931
|
+
nextBundle != null &&
|
|
1932
|
+
previousFallbackId != null &&
|
|
1933
|
+
previousFallbackId.equals(nextBundle.getId()) &&
|
|
1934
|
+
!nextBundle.isDeleted() &&
|
|
1935
|
+
!nextBundle.isErrorStatus() &&
|
|
1936
|
+
!nextBundle.isDeleting();
|
|
1937
|
+
final boolean shouldDeletePrevious =
|
|
1938
|
+
Boolean.TRUE.equals(autoDeletePrevious) &&
|
|
1939
|
+
!fallback.isBuiltin() &&
|
|
1940
|
+
previousFallbackId != null &&
|
|
1941
|
+
!previousFallbackId.equals(bundle.getId()) &&
|
|
1942
|
+
!fallbackIsPreviewFallback &&
|
|
1943
|
+
!previousIsNext;
|
|
1944
|
+
if (shouldDeletePrevious) {
|
|
1945
|
+
if (!this.saveBundleInfo(previousFallbackId, fallback.setStatus(BundleStatus.DELETING))) {
|
|
1946
|
+
logger.error("Failed to persist DELETING for previous bundle; queueing durable retry");
|
|
1947
|
+
logger.debug("Bundle ID: " + previousFallbackId);
|
|
1948
|
+
this.enqueuePendingDelete(previousFallbackId);
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
boolean deletePreviousAsync = shouldDeletePrevious;
|
|
1952
|
+
this.setFallbackBundle(bundle);
|
|
1953
|
+
if (deletePreviousAsync) {
|
|
1954
|
+
final String asyncPreviousFallbackId = previousFallbackId;
|
|
1955
|
+
final String asyncPreviousFallbackVersion = previousFallbackVersion;
|
|
1956
|
+
io.execute(() -> {
|
|
1957
|
+
if (this.activity != null) {
|
|
1958
|
+
if (!DownloadWorkerManager.cancelVersionDownloadAndAwait(this.activity, asyncPreviousFallbackVersion)) {
|
|
1959
|
+
logger.error("Failed to cancel previous version download before delete");
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
try {
|
|
1964
|
+
final Boolean res = this.delete(asyncPreviousFallbackId, true, false);
|
|
1965
|
+
if (Boolean.TRUE.equals(res)) {
|
|
1966
|
+
logger.info("Deleted previous bundle: " + asyncPreviousFallbackVersion);
|
|
1967
|
+
} else {
|
|
1968
|
+
logger.debug("Previous bundle delete incomplete, will retry: " + asyncPreviousFallbackId);
|
|
1969
|
+
}
|
|
1970
|
+
} catch (final IOException e) {
|
|
1971
|
+
logger.error("Failed to delete previous bundle: " + asyncPreviousFallbackId + " " + e.getMessage());
|
|
1972
|
+
}
|
|
1973
|
+
});
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
public void setError(final BundleInfo bundle) {
|
|
1978
|
+
this.setBundleStatus(bundle.getId(), BundleStatus.ERROR);
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
public void reset(final boolean internal) {
|
|
1982
|
+
logger.debug("reset: " + internal);
|
|
1983
|
+
final String currentBundleName = this.getCurrentBundle().getVersionName();
|
|
1984
|
+
this.prepareResetStateForTransition();
|
|
1985
|
+
this.finalizeResetTransition(currentBundleName, internal);
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
private JSONObject createInfoObject() throws JSONException {
|
|
1989
|
+
return this.createInfoObject(null);
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
private JSONObject createInfoObject(final String appIdOverride) throws JSONException {
|
|
1993
|
+
JSONObject json = new JSONObject();
|
|
1994
|
+
json.put("platform", "android");
|
|
1995
|
+
json.put("device_id", this.deviceID);
|
|
1996
|
+
json.put("app_id", appIdOverride == null || appIdOverride.trim().isEmpty() ? this.appId : appIdOverride);
|
|
1997
|
+
json.put("custom_id", this.customId);
|
|
1998
|
+
json.put("version_build", this.versionBuild);
|
|
1999
|
+
json.put("version_code", this.versionCode);
|
|
2000
|
+
json.put("version_os", this.versionOs);
|
|
2001
|
+
json.put("version_name", this.getCurrentBundle().getVersionName());
|
|
2002
|
+
json.put("plugin_version", this.pluginVersion);
|
|
2003
|
+
json.put("is_emulator", this.isEmulator());
|
|
2004
|
+
json.put("is_prod", this.isProd());
|
|
2005
|
+
json.put("install_source", this.getInstallSource());
|
|
2006
|
+
json.put("defaultChannel", this.defaultChannel);
|
|
2007
|
+
|
|
2008
|
+
// Add encryption key ID if encryption is enabled (use cached value)
|
|
2009
|
+
if (!this.cachedKeyId.isEmpty()) {
|
|
2010
|
+
json.put("key_id", this.cachedKeyId);
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
return json;
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
private static final class RemoteBlockResult {
|
|
2017
|
+
|
|
2018
|
+
final boolean blocked;
|
|
2019
|
+
final String error;
|
|
2020
|
+
final String message;
|
|
2021
|
+
|
|
2022
|
+
RemoteBlockResult(final boolean blocked, final String error, final String message) {
|
|
2023
|
+
this.blocked = blocked;
|
|
2024
|
+
this.error = error;
|
|
2025
|
+
this.message = message;
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
/**
|
|
2030
|
+
* Handle HTTP 429 responses by honouring Retry-After / rateLimitResetAt.
|
|
2031
|
+
* All 429s use the same temporary client block — no sticky latch until restart.
|
|
2032
|
+
*/
|
|
2033
|
+
private RemoteBlockResult checkAndHandleRateLimitResponse(Response response, String responseData) {
|
|
2034
|
+
if (response == null || response.code() != 429) {
|
|
2035
|
+
return new RemoteBlockResult(false, "", "");
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
final String parsedError = parseRemoteError(responseData);
|
|
2039
|
+
final String parsedMessage = parseRemoteMessage(responseData);
|
|
2040
|
+
final String errorCode = parsedError.isEmpty() ? "too_many_requests" : parsedError;
|
|
2041
|
+
final String message = parsedMessage.isEmpty() ? "Too many requests" : parsedMessage;
|
|
2042
|
+
|
|
2043
|
+
final long retryUntilMs = resolveRateLimitBlockedUntilMs(response, responseData);
|
|
2044
|
+
synchronized (rateLimitStateLock) {
|
|
2045
|
+
if (retryUntilMs > rateLimitBlockedUntilMs) {
|
|
2046
|
+
rateLimitBlockedUntilMs = retryUntilMs;
|
|
2047
|
+
rateLimitBlockedError = errorCode;
|
|
2048
|
+
rateLimitBlockedMessage = message;
|
|
2049
|
+
} else if (rateLimitBlockedUntilMs <= 0L) {
|
|
2050
|
+
rateLimitBlockedError = errorCode;
|
|
2051
|
+
rateLimitBlockedMessage = message;
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
// Claim last, and only when there is somewhere to send it, so a 429 burst with no
|
|
2056
|
+
// stats URL does not claim and release the latch once per response.
|
|
2057
|
+
if ("too_many_requests".equals(errorCode) && !this.previewSession && this.hasStatsUrl() && claimRateLimitStatistic()) {
|
|
2058
|
+
sendRateLimitStatistic();
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
final long nowMs = System.currentTimeMillis();
|
|
2062
|
+
final long retryAfter = Math.max(0L, (Math.max(retryUntilMs, nowMs) - nowMs + 999L) / 1000L);
|
|
2063
|
+
logger.warn("Received 429 (" + errorCode + "). Honouring Retry-After: " + retryAfter + "s.");
|
|
2064
|
+
return new RemoteBlockResult(true, errorCode, message);
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
private String parseRemoteError(final String responseData) {
|
|
2068
|
+
if (responseData == null || responseData.isEmpty()) {
|
|
2069
|
+
return "";
|
|
2070
|
+
}
|
|
2071
|
+
try {
|
|
2072
|
+
final JSONObject json = new JSONObject(responseData);
|
|
2073
|
+
return json.optString("error", "");
|
|
2074
|
+
} catch (JSONException ignored) {
|
|
2075
|
+
return "";
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
private String parseRemoteMessage(final String responseData) {
|
|
2080
|
+
if (responseData == null || responseData.isEmpty()) {
|
|
2081
|
+
return "";
|
|
2082
|
+
}
|
|
2083
|
+
try {
|
|
2084
|
+
final JSONObject json = new JSONObject(responseData);
|
|
2085
|
+
return json.optString("message", "");
|
|
2086
|
+
} catch (JSONException ignored) {
|
|
2087
|
+
return "";
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
private long resolveRateLimitBlockedUntilMs(final Response response, final String responseData) {
|
|
2092
|
+
final long nowMs = System.currentTimeMillis();
|
|
2093
|
+
final double candidate = rawRateLimitDeadlineMs(response, responseData, nowMs);
|
|
2094
|
+
// NaN and past deadlines mean "no client-side block"; anything further out is capped.
|
|
2095
|
+
if (!(candidate > nowMs)) {
|
|
2096
|
+
return 0L;
|
|
2097
|
+
}
|
|
2098
|
+
return (long) Math.min(candidate, (double) nowMs + MAX_RATE_LIMIT_WINDOW_MS);
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
private double rawRateLimitDeadlineMs(final Response response, final String responseData, final long nowMs) {
|
|
2102
|
+
final String header = response.header("Retry-After");
|
|
2103
|
+
if (header != null) {
|
|
2104
|
+
try {
|
|
2105
|
+
final double seconds = Double.parseDouble(header.trim());
|
|
2106
|
+
if (seconds >= 0) {
|
|
2107
|
+
return nowMs + seconds * 1000d;
|
|
2108
|
+
}
|
|
2109
|
+
} catch (NumberFormatException ignored) {
|
|
2110
|
+
// Fall through to body fields
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
if (responseData != null && !responseData.isEmpty()) {
|
|
2115
|
+
try {
|
|
2116
|
+
final JSONObject json = new JSONObject(responseData);
|
|
2117
|
+
final JSONObject moreInfo = json.optJSONObject("moreInfo");
|
|
2118
|
+
if (moreInfo != null && moreInfo.has("retryAfterSeconds")) {
|
|
2119
|
+
final double retryAfter = moreInfo.getDouble("retryAfterSeconds");
|
|
2120
|
+
if (retryAfter >= 0) {
|
|
2121
|
+
return nowMs + retryAfter * 1000d;
|
|
2122
|
+
}
|
|
2123
|
+
} else if (json.has("retryAfterSeconds")) {
|
|
2124
|
+
final double retryAfter = json.getDouble("retryAfterSeconds");
|
|
2125
|
+
if (retryAfter >= 0) {
|
|
2126
|
+
return nowMs + retryAfter * 1000d;
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
if (moreInfo != null && moreInfo.has("rateLimitResetAt")) {
|
|
2130
|
+
return moreInfo.getDouble("rateLimitResetAt");
|
|
2131
|
+
} else if (json.has("rateLimitResetAt")) {
|
|
2132
|
+
return json.getDouble("rateLimitResetAt");
|
|
2133
|
+
}
|
|
2134
|
+
} catch (JSONException ignored) {
|
|
2135
|
+
// No retry hint
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
// No retry hint — do not hold a client-side block; allow immediate retry to the worker
|
|
2140
|
+
return 0d;
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
private static boolean claimRateLimitStatistic() {
|
|
2144
|
+
synchronized (rateLimitStateLock) {
|
|
2145
|
+
if (rateLimitStatisticSent) {
|
|
2146
|
+
return false;
|
|
2147
|
+
}
|
|
2148
|
+
rateLimitStatisticSent = true;
|
|
2149
|
+
return true;
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
/**
|
|
2154
|
+
* Give the claim back when the statistic never made it out, so a later 429 can retry it.
|
|
2155
|
+
*/
|
|
2156
|
+
private static void releaseRateLimitStatisticClaim() {
|
|
2157
|
+
synchronized (rateLimitStateLock) {
|
|
2158
|
+
rateLimitStatisticSent = false;
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
private boolean hasStatsUrl() {
|
|
2163
|
+
final String url = this.statsUrl;
|
|
2164
|
+
return url != null && !url.isEmpty();
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
private boolean isRemoteBlocked() {
|
|
2168
|
+
synchronized (rateLimitStateLock) {
|
|
2169
|
+
if (rateLimitBlockedUntilMs <= 0L) {
|
|
2170
|
+
return false;
|
|
2171
|
+
}
|
|
2172
|
+
if (System.currentTimeMillis() >= rateLimitBlockedUntilMs) {
|
|
2173
|
+
rateLimitBlockedUntilMs = 0L;
|
|
2174
|
+
return false;
|
|
2175
|
+
}
|
|
2176
|
+
return true;
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
private RemoteBlockResult remoteBlockedClientError() {
|
|
2181
|
+
synchronized (rateLimitStateLock) {
|
|
2182
|
+
return new RemoteBlockResult(true, rateLimitBlockedError, rateLimitBlockedMessage);
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
/**
|
|
2187
|
+
* Send a statistic about rate limiting.
|
|
2188
|
+
* Dispatched through OkHttp so no caller thread waits on the request.
|
|
2189
|
+
*/
|
|
2190
|
+
private void sendRateLimitStatistic() {
|
|
2191
|
+
String statsUrl = this.statsUrl;
|
|
2192
|
+
if (statsUrl == null || statsUrl.isEmpty()) {
|
|
2193
|
+
// The URL was cleared after the claim was taken; nothing went out, so hand it back.
|
|
2194
|
+
releaseRateLimitStatisticClaim();
|
|
2195
|
+
return;
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
try {
|
|
2199
|
+
BundleInfo current = this.getCurrentBundle();
|
|
2200
|
+
JSONObject json = this.createInfoObject();
|
|
2201
|
+
json.put("version_name", current.getVersionName());
|
|
2202
|
+
json.put("old_version_name", "");
|
|
2203
|
+
json.put("action", "rate_limit_reached");
|
|
2204
|
+
|
|
2205
|
+
Request request = new Request.Builder()
|
|
2206
|
+
.url(statsUrl)
|
|
2207
|
+
.post(RequestBody.create(json.toString(), MediaType.get("application/json")))
|
|
2208
|
+
.build();
|
|
2209
|
+
|
|
2210
|
+
// User-Agent header is automatically added by DownloadService.sharedClient interceptor
|
|
2211
|
+
DownloadService.sharedClient.newCall(request).enqueue(
|
|
2212
|
+
new okhttp3.Callback() {
|
|
2213
|
+
@Override
|
|
2214
|
+
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
|
2215
|
+
releaseRateLimitStatisticClaim();
|
|
2216
|
+
logger.error("Failed to send rate limit statistic");
|
|
2217
|
+
logger.debug("Error: " + e.getMessage());
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
@Override
|
|
2221
|
+
public void onResponse(@NonNull Call call, @NonNull Response response) {
|
|
2222
|
+
// The body is unused here; closing the Response closes it.
|
|
2223
|
+
try (response) {
|
|
2224
|
+
if (response.isSuccessful()) {
|
|
2225
|
+
logger.info("Rate limit statistic sent");
|
|
2226
|
+
} else {
|
|
2227
|
+
releaseRateLimitStatisticClaim();
|
|
2228
|
+
logger.error("Error sending rate limit statistic");
|
|
2229
|
+
logger.debug("Response code: " + response.code());
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
);
|
|
2235
|
+
} catch (final Exception e) {
|
|
2236
|
+
releaseRateLimitStatisticClaim();
|
|
2237
|
+
logger.error("Failed to send rate limit statistic");
|
|
2238
|
+
logger.debug("Error: " + e.getMessage());
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
private void makeJsonRequest(String url, JSONObject jsonBody, Callback callback) {
|
|
2243
|
+
MediaType JSON = MediaType.get("application/json; charset=utf-8");
|
|
2244
|
+
RequestBody body = RequestBody.create(jsonBody.toString(), JSON);
|
|
2245
|
+
|
|
2246
|
+
Request request = new Request.Builder().url(url).post(body).build();
|
|
2247
|
+
|
|
2248
|
+
DownloadService.sharedClient.newCall(request).enqueue(
|
|
2249
|
+
new okhttp3.Callback() {
|
|
2250
|
+
@Override
|
|
2251
|
+
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
|
2252
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2253
|
+
retError.put("message", "Request failed: " + e.getMessage());
|
|
2254
|
+
retError.put("error", "network_error");
|
|
2255
|
+
retError.put("kind", "failed");
|
|
2256
|
+
callback.callback(retError);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
@Override
|
|
2260
|
+
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
2261
|
+
try (ResponseBody responseBody = response.body()) {
|
|
2262
|
+
final int statusCode = response.code();
|
|
2263
|
+
final String responseData = responseBody != null ? responseBody.string() : "";
|
|
2264
|
+
JSONObject jsonResponse = null;
|
|
2265
|
+
if (!responseData.isEmpty()) {
|
|
2266
|
+
try {
|
|
2267
|
+
jsonResponse = new JSONObject(responseData);
|
|
2268
|
+
} catch (JSONException ignored) {
|
|
2269
|
+
// Non-JSON responses are handled as response or parse errors below.
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
if (jsonResponse != null && (jsonResponse.has("error") || jsonResponse.has("kind"))) {
|
|
2274
|
+
if (statusCode == 429) {
|
|
2275
|
+
final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
|
|
2276
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2277
|
+
retError.put(
|
|
2278
|
+
"error",
|
|
2279
|
+
rateLimit.error.isEmpty() ? jsonResponse.optString("error", "too_many_requests") : rateLimit.error
|
|
2280
|
+
);
|
|
2281
|
+
retError.put(
|
|
2282
|
+
"message",
|
|
2283
|
+
rateLimit.message.isEmpty() ? jsonResponse.optString("message", "Too many requests") : rateLimit.message
|
|
2284
|
+
);
|
|
2285
|
+
if (jsonResponse.has("kind") && !jsonResponse.isNull("kind")) {
|
|
2286
|
+
retError.put("kind", jsonResponse.getString("kind"));
|
|
2287
|
+
} else {
|
|
2288
|
+
retError.put("kind", "failed");
|
|
2289
|
+
}
|
|
2290
|
+
if (jsonResponse.has("version") && !jsonResponse.isNull("version")) {
|
|
2291
|
+
retError.put("version", jsonResponse.getString("version"));
|
|
2292
|
+
}
|
|
2293
|
+
retError.put("statusCode", statusCode);
|
|
2294
|
+
callback.callback(retError);
|
|
2295
|
+
return;
|
|
2296
|
+
}
|
|
2297
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2298
|
+
if (jsonResponse.has("error") && !jsonResponse.isNull("error")) {
|
|
2299
|
+
retError.put("error", jsonResponse.getString("error"));
|
|
2300
|
+
}
|
|
2301
|
+
if (jsonResponse.has("kind") && !jsonResponse.isNull("kind")) {
|
|
2302
|
+
retError.put("kind", jsonResponse.getString("kind"));
|
|
2303
|
+
}
|
|
2304
|
+
if (jsonResponse.has("message") && !jsonResponse.isNull("message")) {
|
|
2305
|
+
retError.put("message", jsonResponse.getString("message"));
|
|
2306
|
+
} else {
|
|
2307
|
+
retError.put("message", "server did not provide a message");
|
|
2308
|
+
}
|
|
2309
|
+
if (jsonResponse.has("version") && !jsonResponse.isNull("version")) {
|
|
2310
|
+
retError.put("version", jsonResponse.getString("version"));
|
|
2311
|
+
}
|
|
2312
|
+
retError.put("statusCode", statusCode);
|
|
2313
|
+
callback.callback(retError);
|
|
2314
|
+
return;
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
// Check for 429 rate limit without JSON body
|
|
2318
|
+
final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
|
|
2319
|
+
if (rateLimit.blocked) {
|
|
2320
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2321
|
+
retError.put("message", rateLimit.message);
|
|
2322
|
+
retError.put("error", rateLimit.error);
|
|
2323
|
+
retError.put("kind", "failed");
|
|
2324
|
+
retError.put("statusCode", statusCode);
|
|
2325
|
+
callback.callback(retError);
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
if (!response.isSuccessful()) {
|
|
2330
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2331
|
+
retError.put("message", "Server error: " + response.code());
|
|
2332
|
+
retError.put("error", "response_error");
|
|
2333
|
+
retError.put("kind", "failed");
|
|
2334
|
+
retError.put("statusCode", statusCode);
|
|
2335
|
+
callback.callback(retError);
|
|
2336
|
+
return;
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
if (jsonResponse == null) {
|
|
2340
|
+
throw new JSONException("Response is not a JSON object");
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
Map<String, Object> ret = new HashMap<>();
|
|
2344
|
+
ret.put("statusCode", statusCode);
|
|
2345
|
+
|
|
2346
|
+
Iterator<String> keys = jsonResponse.keys();
|
|
2347
|
+
while (keys.hasNext()) {
|
|
2348
|
+
String key = keys.next();
|
|
2349
|
+
if (jsonResponse.has(key)) {
|
|
2350
|
+
if ("session_key".equals(key)) {
|
|
2351
|
+
ret.put("sessionKey", jsonResponse.get(key));
|
|
2352
|
+
} else {
|
|
2353
|
+
ret.put(key, jsonResponse.get(key));
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
callback.callback(ret);
|
|
2358
|
+
} catch (JSONException e) {
|
|
2359
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2360
|
+
retError.put("message", "JSON parse error: " + e.getMessage());
|
|
2361
|
+
retError.put("error", "parse_error");
|
|
2362
|
+
retError.put("kind", "failed");
|
|
2363
|
+
callback.callback(retError);
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
);
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
public void getLatest(final String updateUrl, final String channel, final Callback callback) {
|
|
2371
|
+
this.getLatest(updateUrl, channel, null, callback);
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
public void getLatest(final String updateUrl, final String channel, final String appIdOverride, final Callback callback) {
|
|
2375
|
+
if (isRemoteBlocked()) {
|
|
2376
|
+
final RemoteBlockResult blocked = remoteBlockedClientError();
|
|
2377
|
+
logger.debug("Skipping getLatest due to remote block (" + blocked.error + ").");
|
|
2378
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
2379
|
+
retError.put("message", blocked.message);
|
|
2380
|
+
retError.put("error", blocked.error);
|
|
2381
|
+
retError.put("kind", "failed");
|
|
2382
|
+
callback.callback(retError);
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
JSONObject json;
|
|
2386
|
+
try {
|
|
2387
|
+
json = this.createInfoObject(appIdOverride);
|
|
2388
|
+
if (channel != null && json != null) {
|
|
2389
|
+
json.put("defaultChannel", channel);
|
|
2390
|
+
}
|
|
2391
|
+
} catch (JSONException e) {
|
|
2392
|
+
logger.error("Error getting latest version");
|
|
2393
|
+
logger.debug("JSONException: " + e.getMessage());
|
|
2394
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
2395
|
+
retError.put("message", "Cannot get info: " + e);
|
|
2396
|
+
retError.put("error", "json_error");
|
|
2397
|
+
callback.callback(retError);
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
if (logger != null) {
|
|
2402
|
+
logger.info("Auto-update parameters: " + json);
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
makeJsonRequest(updateUrl, json, callback);
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
public void unsetChannel(
|
|
2409
|
+
final SharedPreferences.Editor editor,
|
|
2410
|
+
final String defaultChannelKey,
|
|
2411
|
+
final String configDefaultChannel,
|
|
2412
|
+
final Callback callback
|
|
2413
|
+
) {
|
|
2414
|
+
// Clear persisted defaultChannel and revert to config value
|
|
2415
|
+
editor.remove(defaultChannelKey);
|
|
2416
|
+
editor.apply();
|
|
2417
|
+
this.defaultChannel = configDefaultChannel;
|
|
2418
|
+
logger.info("Persisted defaultChannel cleared, reverted to config value: " + configDefaultChannel);
|
|
2419
|
+
|
|
2420
|
+
Map<String, Object> ret = new HashMap<>();
|
|
2421
|
+
ret.put("status", "ok");
|
|
2422
|
+
ret.put("message", "Channel override removed");
|
|
2423
|
+
callback.callback(ret);
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
public void setChannel(
|
|
2427
|
+
final String channel,
|
|
2428
|
+
final SharedPreferences.Editor editor,
|
|
2429
|
+
final String defaultChannelKey,
|
|
2430
|
+
final boolean allowSetDefaultChannel,
|
|
2431
|
+
final Callback callback
|
|
2432
|
+
) {
|
|
2433
|
+
this.setChannel(channel, editor, defaultChannelKey, allowSetDefaultChannel, "", callback);
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
public void setChannel(
|
|
2437
|
+
final String channel,
|
|
2438
|
+
final SharedPreferences.Editor editor,
|
|
2439
|
+
final String defaultChannelKey,
|
|
2440
|
+
final boolean allowSetDefaultChannel,
|
|
2441
|
+
final String configDefaultChannel,
|
|
2442
|
+
final Callback callback
|
|
2443
|
+
) {
|
|
2444
|
+
// Check if setting defaultChannel is allowed
|
|
2445
|
+
if (!allowSetDefaultChannel) {
|
|
2446
|
+
logger.error("setChannel is disabled by allowSetDefaultChannel config");
|
|
2447
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
2448
|
+
retError.put("message", "setChannel is disabled by configuration");
|
|
2449
|
+
retError.put("error", "disabled_by_config");
|
|
2450
|
+
callback.callback(retError);
|
|
2451
|
+
return;
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
if (isRemoteBlocked()) {
|
|
2455
|
+
final RemoteBlockResult blocked = remoteBlockedClientError();
|
|
2456
|
+
logger.debug("Skipping setChannel due to remote block (" + blocked.error + ").");
|
|
2457
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
2458
|
+
retError.put("message", blocked.message);
|
|
2459
|
+
retError.put("error", blocked.error);
|
|
2460
|
+
callback.callback(retError);
|
|
2461
|
+
return;
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
String channelUrl = this.channelUrl;
|
|
2465
|
+
if (channelUrl == null || channelUrl.isEmpty()) {
|
|
2466
|
+
logger.error("Channel URL is not set");
|
|
2467
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
2468
|
+
retError.put("message", "channelUrl missing");
|
|
2469
|
+
retError.put("error", "missing_config");
|
|
2470
|
+
callback.callback(retError);
|
|
2471
|
+
return;
|
|
2472
|
+
}
|
|
2473
|
+
JSONObject json;
|
|
2474
|
+
try {
|
|
2475
|
+
json = this.createInfoObject();
|
|
2476
|
+
json.put("channel", channel);
|
|
2477
|
+
} catch (JSONException e) {
|
|
2478
|
+
logger.error("Error setting channel");
|
|
2479
|
+
logger.debug("JSONException: " + e.getMessage());
|
|
2480
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
2481
|
+
retError.put("message", "Cannot get info: " + e);
|
|
2482
|
+
retError.put("error", "json_error");
|
|
2483
|
+
callback.callback(retError);
|
|
2484
|
+
return;
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
makeJsonRequest(channelUrl, json, (res) -> {
|
|
2488
|
+
if (res.containsKey("error")) {
|
|
2489
|
+
callback.callback(res);
|
|
2490
|
+
} else if (Boolean.TRUE.equals(res.get("unset"))) {
|
|
2491
|
+
// Server requested to unset channel (public channel was requested)
|
|
2492
|
+
// Clear persisted defaultChannel and revert to config value
|
|
2493
|
+
editor.remove(defaultChannelKey);
|
|
2494
|
+
editor.apply();
|
|
2495
|
+
this.defaultChannel = configDefaultChannel;
|
|
2496
|
+
logger.info("Public channel requested, channel override removed");
|
|
2497
|
+
callback.callback(res);
|
|
2498
|
+
} else {
|
|
2499
|
+
this.defaultChannel = channel;
|
|
2500
|
+
editor.putString(defaultChannelKey, channel);
|
|
2501
|
+
editor.apply();
|
|
2502
|
+
logger.info("defaultChannel persisted locally: " + channel);
|
|
2503
|
+
callback.callback(res);
|
|
2504
|
+
}
|
|
2505
|
+
});
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
public void getChannel(final Callback callback) {
|
|
2509
|
+
this.getChannel(callback, null, null);
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
public void getChannel(final Callback callback, final SharedPreferences.Editor editor, final String defaultChannelKey) {
|
|
2513
|
+
if (isRemoteBlocked()) {
|
|
2514
|
+
final RemoteBlockResult blocked = remoteBlockedClientError();
|
|
2515
|
+
logger.debug("Skipping getChannel due to remote block (" + blocked.error + ").");
|
|
2516
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
2517
|
+
retError.put("message", blocked.message);
|
|
2518
|
+
retError.put("error", blocked.error);
|
|
2519
|
+
callback.callback(retError);
|
|
2520
|
+
return;
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
String channelUrl = this.channelUrl;
|
|
2524
|
+
if (channelUrl == null || channelUrl.isEmpty()) {
|
|
2525
|
+
logger.error("Channel URL is not set");
|
|
2526
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
2527
|
+
retError.put("message", "Channel URL is not set");
|
|
2528
|
+
retError.put("error", "missing_config");
|
|
2529
|
+
callback.callback(retError);
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
JSONObject json;
|
|
2533
|
+
try {
|
|
2534
|
+
json = this.createInfoObject();
|
|
2535
|
+
} catch (JSONException e) {
|
|
2536
|
+
logger.error("Error getting channel");
|
|
2537
|
+
logger.debug("JSONException: " + e.getMessage());
|
|
2538
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
2539
|
+
retError.put("message", "Cannot get info: " + e);
|
|
2540
|
+
retError.put("error", "json_error");
|
|
2541
|
+
callback.callback(retError);
|
|
2542
|
+
return;
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
Request request = new Request.Builder()
|
|
2546
|
+
.url(channelUrl)
|
|
2547
|
+
.put(RequestBody.create(json.toString(), MediaType.get("application/json")))
|
|
2548
|
+
.build();
|
|
2549
|
+
|
|
2550
|
+
DownloadService.sharedClient.newCall(request).enqueue(
|
|
2551
|
+
new okhttp3.Callback() {
|
|
2552
|
+
@Override
|
|
2553
|
+
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
|
2554
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2555
|
+
retError.put("message", "Request failed: " + e.getMessage());
|
|
2556
|
+
retError.put("error", "network_error");
|
|
2557
|
+
callback.callback(retError);
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
@Override
|
|
2561
|
+
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
2562
|
+
try (ResponseBody responseBody = response.body()) {
|
|
2563
|
+
final String responseData = responseBody != null ? responseBody.string() : "";
|
|
2564
|
+
final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
|
|
2565
|
+
if (rateLimit.blocked) {
|
|
2566
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2567
|
+
retError.put("message", rateLimit.message);
|
|
2568
|
+
retError.put("error", rateLimit.error);
|
|
2569
|
+
callback.callback(retError);
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
if (response.code() == 400) {
|
|
2574
|
+
if (responseData.contains("channel_not_found") && !defaultChannel.isEmpty()) {
|
|
2575
|
+
Map<String, Object> ret = new HashMap<>();
|
|
2576
|
+
ret.put("channel", defaultChannel);
|
|
2577
|
+
ret.put("status", "default");
|
|
2578
|
+
logger.info("Channel get to \"" + ret);
|
|
2579
|
+
callback.callback(ret);
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
if (!response.isSuccessful()) {
|
|
2585
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2586
|
+
retError.put("message", "Server error: " + response.code());
|
|
2587
|
+
retError.put("error", "response_error");
|
|
2588
|
+
callback.callback(retError);
|
|
2589
|
+
return;
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
if (responseData.isEmpty()) {
|
|
2593
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2594
|
+
retError.put("message", "Empty response body");
|
|
2595
|
+
retError.put("error", "no_response_body");
|
|
2596
|
+
callback.callback(retError);
|
|
2597
|
+
return;
|
|
2598
|
+
}
|
|
2599
|
+
JSONObject jsonResponse = new JSONObject(responseData);
|
|
2600
|
+
|
|
2601
|
+
// Check for server-side errors first
|
|
2602
|
+
if (jsonResponse.has("error")) {
|
|
2603
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2604
|
+
retError.put("error", jsonResponse.getString("error"));
|
|
2605
|
+
if (jsonResponse.has("message")) {
|
|
2606
|
+
retError.put("message", jsonResponse.getString("message"));
|
|
2607
|
+
} else {
|
|
2608
|
+
retError.put("message", "server did not provide a message");
|
|
2609
|
+
}
|
|
2610
|
+
callback.callback(retError);
|
|
2611
|
+
return;
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
Map<String, Object> ret = new HashMap<>();
|
|
2615
|
+
|
|
2616
|
+
Iterator<String> keys = jsonResponse.keys();
|
|
2617
|
+
while (keys.hasNext()) {
|
|
2618
|
+
String key = keys.next();
|
|
2619
|
+
if (jsonResponse.has(key)) {
|
|
2620
|
+
ret.put(key, jsonResponse.get(key));
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
persistDefaultChannelFromResponse(ret.get("channel"), editor, defaultChannelKey);
|
|
2624
|
+
logger.info("Channel get to \"" + ret);
|
|
2625
|
+
callback.callback(ret);
|
|
2626
|
+
} catch (JSONException e) {
|
|
2627
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2628
|
+
retError.put("message", "JSON parse error: " + e.getMessage());
|
|
2629
|
+
retError.put("error", "parse_error");
|
|
2630
|
+
callback.callback(retError);
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
);
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
void persistDefaultChannelFromResponse(final Object channel, final SharedPreferences.Editor editor, final String defaultChannelKey) {
|
|
2638
|
+
if (!(channel instanceof String)) {
|
|
2639
|
+
return;
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
final String channelName = ((String) channel).trim();
|
|
2643
|
+
if (channelName.isEmpty() || BundleInfo.ID_BUILTIN.equals(channelName)) {
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
this.defaultChannel = channelName;
|
|
2648
|
+
if (editor != null && defaultChannelKey != null && !defaultChannelKey.isEmpty()) {
|
|
2649
|
+
editor.putString(defaultChannelKey, channelName);
|
|
2650
|
+
editor.apply();
|
|
2651
|
+
}
|
|
2652
|
+
logger.info("defaultChannel synchronized from getChannel(): " + channelName);
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
public void listChannels(final Callback callback) {
|
|
2656
|
+
if (isRemoteBlocked()) {
|
|
2657
|
+
final RemoteBlockResult blocked = remoteBlockedClientError();
|
|
2658
|
+
logger.debug("Skipping listChannels due to remote block (" + blocked.error + ").");
|
|
2659
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
2660
|
+
retError.put("message", blocked.message);
|
|
2661
|
+
retError.put("error", blocked.error);
|
|
2662
|
+
callback.callback(retError);
|
|
2663
|
+
return;
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
String channelUrl = this.channelUrl;
|
|
2667
|
+
if (channelUrl == null || channelUrl.isEmpty()) {
|
|
2668
|
+
logger.error("Channel URL is not set");
|
|
2669
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
2670
|
+
retError.put("message", "Channel URL is not set");
|
|
2671
|
+
retError.put("error", "missing_config");
|
|
2672
|
+
callback.callback(retError);
|
|
2673
|
+
return;
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
JSONObject json;
|
|
2677
|
+
try {
|
|
2678
|
+
json = this.createInfoObject();
|
|
2679
|
+
} catch (JSONException e) {
|
|
2680
|
+
logger.error("Error creating info object");
|
|
2681
|
+
logger.debug("JSONException: " + e.getMessage());
|
|
2682
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
2683
|
+
retError.put("message", "Cannot get info: " + e);
|
|
2684
|
+
retError.put("error", "json_error");
|
|
2685
|
+
callback.callback(retError);
|
|
2686
|
+
return;
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
// Build URL with query parameters from JSON
|
|
2690
|
+
HttpUrl.Builder urlBuilder = HttpUrl.parse(channelUrl).newBuilder();
|
|
2691
|
+
try {
|
|
2692
|
+
Iterator<String> keys = json.keys();
|
|
2693
|
+
while (keys.hasNext()) {
|
|
2694
|
+
String key = keys.next();
|
|
2695
|
+
Object value = json.get(key);
|
|
2696
|
+
if (value != null) {
|
|
2697
|
+
urlBuilder.addQueryParameter(key, value.toString());
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
} catch (JSONException e) {
|
|
2701
|
+
logger.error("Error adding query parameters");
|
|
2702
|
+
logger.debug("JSONException: " + e.getMessage());
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
Request request = new Request.Builder().url(urlBuilder.build()).get().build();
|
|
2706
|
+
|
|
2707
|
+
DownloadService.sharedClient.newCall(request).enqueue(
|
|
2708
|
+
new okhttp3.Callback() {
|
|
2709
|
+
@Override
|
|
2710
|
+
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
|
2711
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2712
|
+
retError.put("message", "Request failed: " + e.getMessage());
|
|
2713
|
+
retError.put("error", "network_error");
|
|
2714
|
+
callback.callback(retError);
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
@Override
|
|
2718
|
+
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
2719
|
+
try (ResponseBody responseBody = response.body()) {
|
|
2720
|
+
final String data = responseBody != null ? responseBody.string() : "";
|
|
2721
|
+
final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, data);
|
|
2722
|
+
if (rateLimit.blocked) {
|
|
2723
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2724
|
+
retError.put("message", rateLimit.message);
|
|
2725
|
+
retError.put("error", rateLimit.error);
|
|
2726
|
+
callback.callback(retError);
|
|
2727
|
+
return;
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
if (!response.isSuccessful()) {
|
|
2731
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2732
|
+
retError.put("message", "Server error: " + response.code());
|
|
2733
|
+
retError.put("error", "response_error");
|
|
2734
|
+
callback.callback(retError);
|
|
2735
|
+
return;
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
if (data.isEmpty()) {
|
|
2739
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2740
|
+
retError.put("message", "Empty response body");
|
|
2741
|
+
retError.put("error", "no_response_body");
|
|
2742
|
+
callback.callback(retError);
|
|
2743
|
+
return;
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
try {
|
|
2747
|
+
Map<String, Object> ret = parseListChannelsResponse(data);
|
|
2748
|
+
|
|
2749
|
+
logger.info("Channels listed successfully");
|
|
2750
|
+
callback.callback(ret);
|
|
2751
|
+
} catch (JSONException arrayException) {
|
|
2752
|
+
// If not an array, try to parse as error object
|
|
2753
|
+
try {
|
|
2754
|
+
JSONObject json = new JSONObject(data);
|
|
2755
|
+
if (json.has("error")) {
|
|
2756
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2757
|
+
retError.put("error", json.getString("error"));
|
|
2758
|
+
if (json.has("message")) {
|
|
2759
|
+
retError.put("message", json.getString("message"));
|
|
2760
|
+
} else {
|
|
2761
|
+
retError.put("message", "server did not provide a message");
|
|
2762
|
+
}
|
|
2763
|
+
callback.callback(retError);
|
|
2764
|
+
return;
|
|
2765
|
+
}
|
|
2766
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2767
|
+
retError.put("message", "Unexpected channels response format");
|
|
2768
|
+
retError.put("error", "parse_error");
|
|
2769
|
+
callback.callback(retError);
|
|
2770
|
+
return;
|
|
2771
|
+
} catch (JSONException objException) {
|
|
2772
|
+
// If neither array nor object, throw parse error
|
|
2773
|
+
arrayException.addSuppressed(objException);
|
|
2774
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2775
|
+
retError.put("message", "JSON parse error: " + arrayException.getMessage());
|
|
2776
|
+
retError.put("error", "parse_error");
|
|
2777
|
+
callback.callback(retError);
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
);
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
static Map<String, Object> parseListChannelsResponse(final String data) throws JSONException {
|
|
2787
|
+
JSONArray channelsJson = new JSONArray(data);
|
|
2788
|
+
List<Map<String, Object>> channelsList = new ArrayList<>();
|
|
2789
|
+
|
|
2790
|
+
for (int i = 0; i < channelsJson.length(); i++) {
|
|
2791
|
+
JSONObject channelJson = channelsJson.getJSONObject(i);
|
|
2792
|
+
Object channelId = channelJson.get("id");
|
|
2793
|
+
if (!(channelId instanceof Number)) {
|
|
2794
|
+
throw new JSONException("Channel id must be a number");
|
|
2795
|
+
}
|
|
2796
|
+
Map<String, Object> channel = new HashMap<>();
|
|
2797
|
+
channel.put("id", channelId);
|
|
2798
|
+
channel.put("name", channelJson.optString("name", ""));
|
|
2799
|
+
channel.put("public", channelJson.optBoolean("public", false));
|
|
2800
|
+
channel.put("allow_self_set", channelJson.optBoolean("allow_self_set", false));
|
|
2801
|
+
channelsList.add(channel);
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
Map<String, Object> ret = new HashMap<>();
|
|
2805
|
+
ret.put("channels", channelsList);
|
|
2806
|
+
return ret;
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
public void sendStats(final String action) {
|
|
2810
|
+
this.sendStats(action, this.getCurrentBundle().getVersionName());
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
public void sendStats(final String action, final String versionName) {
|
|
2814
|
+
this.sendStats(action, versionName, "");
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
public void sendStats(final String action, final String versionName, final String oldVersionName) {
|
|
2818
|
+
this.sendStats(action, versionName, oldVersionName, null);
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
public void sendStats(final String action, final String versionName, final String oldVersionName, final Map<String, String> metadata) {
|
|
2822
|
+
this.sendStats(action, versionName, oldVersionName, metadata, null);
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
public void sendStats(
|
|
2826
|
+
final String action,
|
|
2827
|
+
final String versionName,
|
|
2828
|
+
final String oldVersionName,
|
|
2829
|
+
final Map<String, String> metadata,
|
|
2830
|
+
final Runnable onSent
|
|
2831
|
+
) {
|
|
2832
|
+
if (statsStopped.get()) {
|
|
2833
|
+
return;
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
if (this.previewSession) {
|
|
2837
|
+
if (logger != null) {
|
|
2838
|
+
logger.debug("Skipping sendStats during preview session.");
|
|
2839
|
+
}
|
|
2840
|
+
return;
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
String statsUrl = this.statsUrl;
|
|
2844
|
+
if (statsUrl == null || statsUrl.isEmpty()) {
|
|
2845
|
+
return;
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
JSONObject json;
|
|
2849
|
+
try {
|
|
2850
|
+
json = this.createInfoObject();
|
|
2851
|
+
json.put("version_name", versionName);
|
|
2852
|
+
json.put("old_version_name", oldVersionName);
|
|
2853
|
+
json.put("action", action);
|
|
2854
|
+
json.put("timestamp", System.currentTimeMillis());
|
|
2855
|
+
if (metadata != null && !metadata.isEmpty()) {
|
|
2856
|
+
json.put("metadata", new JSONObject(metadata));
|
|
2857
|
+
}
|
|
2858
|
+
} catch (JSONException e) {
|
|
2859
|
+
if (logger != null) {
|
|
2860
|
+
logger.error("Error preparing stats");
|
|
2861
|
+
logger.debug("JSONException: " + e.getMessage());
|
|
2862
|
+
}
|
|
2863
|
+
return;
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
synchronized (statsQueue) {
|
|
2867
|
+
if (statsStopped.get()) {
|
|
2868
|
+
return;
|
|
2869
|
+
}
|
|
2870
|
+
while (statsQueue.size() >= MAX_PENDING_STATS) {
|
|
2871
|
+
statsQueue.remove(0);
|
|
2872
|
+
}
|
|
2873
|
+
statsQueue.add(new QueuedStatsEvent(json, onSent));
|
|
2874
|
+
}
|
|
2875
|
+
ensureStatsTimerStarted();
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
public void restorePendingStats() {
|
|
2879
|
+
File file = pendingStatsFile();
|
|
2880
|
+
if (file == null) {
|
|
2881
|
+
return;
|
|
2882
|
+
}
|
|
2883
|
+
File backup = new File(file.getAbsolutePath() + ".bak");
|
|
2884
|
+
if (!file.exists() && backup.exists() && !backup.renameTo(file)) {
|
|
2885
|
+
if (logger != null) {
|
|
2886
|
+
logger.error("Failed to restore stats backup");
|
|
2887
|
+
}
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
if (!file.exists()) {
|
|
2891
|
+
return;
|
|
2892
|
+
}
|
|
2893
|
+
try {
|
|
2894
|
+
String raw = readFileUtf8(file);
|
|
2895
|
+
JSONArray arr = new JSONArray(raw);
|
|
2896
|
+
synchronized (statsQueue) {
|
|
2897
|
+
for (int i = 0; i < arr.length(); i++) {
|
|
2898
|
+
if (statsQueue.size() >= MAX_PENDING_STATS) {
|
|
2899
|
+
break;
|
|
2900
|
+
}
|
|
2901
|
+
statsQueue.add(new QueuedStatsEvent(arr.getJSONObject(i), null));
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
if (backup.exists() && !backup.delete()) {
|
|
2905
|
+
if (logger != null) {
|
|
2906
|
+
logger.error("Failed to delete stats backup");
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
if (!statsQueue.isEmpty()) {
|
|
2910
|
+
if (logger != null) {
|
|
2911
|
+
logger.info("Restored " + statsQueue.size() + " pending stats events");
|
|
2912
|
+
}
|
|
2913
|
+
ensureStatsTimerStarted();
|
|
2914
|
+
}
|
|
2915
|
+
} catch (Exception e) {
|
|
2916
|
+
if (logger != null) {
|
|
2917
|
+
logger.error("Failed to restore pending stats");
|
|
2918
|
+
logger.debug("Error: " + e.getMessage());
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
int pendingStatsCount() {
|
|
2924
|
+
return statsQueue.size();
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
public void persistPendingStats() {
|
|
2928
|
+
persistStatsQueue();
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
private File pendingStatsFile() {
|
|
2932
|
+
final File dir = this.noBackupDir != null ? this.noBackupDir : this.documentsDir;
|
|
2933
|
+
if (dir == null) {
|
|
2934
|
+
return null;
|
|
2935
|
+
}
|
|
2936
|
+
return new File(dir, PENDING_STATS_FILE);
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
private void persistStatsQueue() {
|
|
2940
|
+
persistStatsQueue(false);
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
private void persistStatsQueue(final boolean force) {
|
|
2944
|
+
File file = pendingStatsFile();
|
|
2945
|
+
if (file == null) {
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
synchronized (pendingStatsPersistLock) {
|
|
2949
|
+
if (statsStopped.get() && !force) {
|
|
2950
|
+
return;
|
|
2951
|
+
}
|
|
2952
|
+
JSONArray arr = new JSONArray();
|
|
2953
|
+
synchronized (statsQueue) {
|
|
2954
|
+
final List<QueuedStatsEvent> combined = new ArrayList<>(statsInFlight.size() + statsQueue.size());
|
|
2955
|
+
combined.addAll(statsInFlight);
|
|
2956
|
+
combined.addAll(statsQueue);
|
|
2957
|
+
final int start = Math.max(0, combined.size() - MAX_PENDING_STATS);
|
|
2958
|
+
for (int i = start; i < combined.size(); i++) {
|
|
2959
|
+
arr.put(combined.get(i).event);
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
try {
|
|
2963
|
+
if (arr.length() == 0) {
|
|
2964
|
+
writeFileAtomically(file, "[]".getBytes(StandardCharsets.UTF_8));
|
|
2965
|
+
File backup = new File(file.getAbsolutePath() + ".bak");
|
|
2966
|
+
if (backup.exists() && !backup.delete()) {
|
|
2967
|
+
if (logger != null) {
|
|
2968
|
+
logger.error("Failed to delete empty stats backup");
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2973
|
+
writeFileAtomically(file, arr.toString().getBytes(StandardCharsets.UTF_8));
|
|
2974
|
+
} catch (Exception e) {
|
|
2975
|
+
if (logger != null) {
|
|
2976
|
+
logger.error("Failed to persist stats queue");
|
|
2977
|
+
logger.debug("Error: " + e.getMessage());
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
private static String readFileUtf8(final File file) throws IOException {
|
|
2984
|
+
final long length = file.length();
|
|
2985
|
+
final byte[] buf = new byte[length > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) length];
|
|
2986
|
+
try (FileInputStream in = new FileInputStream(file)) {
|
|
2987
|
+
int offset = 0;
|
|
2988
|
+
while (offset < buf.length) {
|
|
2989
|
+
final int read = in.read(buf, offset, buf.length - offset);
|
|
2990
|
+
if (read < 0) {
|
|
2991
|
+
break;
|
|
2992
|
+
}
|
|
2993
|
+
offset += read;
|
|
2994
|
+
}
|
|
2995
|
+
return new String(buf, 0, offset, StandardCharsets.UTF_8);
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
private void writeFileAtomically(final File file, final byte[] bytes) throws IOException {
|
|
3000
|
+
final File tmp = new File(file.getAbsolutePath() + ".tmp");
|
|
3001
|
+
File backup = null;
|
|
3002
|
+
try {
|
|
3003
|
+
try (FileOutputStream out = new FileOutputStream(tmp)) {
|
|
3004
|
+
out.write(bytes);
|
|
3005
|
+
out.flush();
|
|
3006
|
+
}
|
|
3007
|
+
if (tmp.renameTo(file)) {
|
|
3008
|
+
return;
|
|
3009
|
+
}
|
|
3010
|
+
if (file.exists()) {
|
|
3011
|
+
backup = new File(file.getAbsolutePath() + ".bak");
|
|
3012
|
+
if (backup.exists() && !backup.delete()) {
|
|
3013
|
+
throw new IOException("Failed to replace " + file.getAbsolutePath());
|
|
3014
|
+
}
|
|
3015
|
+
if (!file.renameTo(backup)) {
|
|
3016
|
+
throw new IOException("Failed to replace " + file.getAbsolutePath());
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
if (tmp.renameTo(file)) {
|
|
3020
|
+
if (backup != null && backup.exists() && !backup.delete()) {
|
|
3021
|
+
if (logger != null) {
|
|
3022
|
+
logger.error("Failed to delete stats backup");
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
backup = null;
|
|
3026
|
+
return;
|
|
3027
|
+
}
|
|
3028
|
+
throw new IOException("Failed to persist " + file.getAbsolutePath());
|
|
3029
|
+
} finally {
|
|
3030
|
+
if (backup != null && !file.exists()) {
|
|
3031
|
+
backup.renameTo(file);
|
|
3032
|
+
}
|
|
3033
|
+
if (tmp.exists() && !tmp.delete()) {
|
|
3034
|
+
tmp.deleteOnExit();
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
private synchronized void ensureStatsTimerStarted() {
|
|
3040
|
+
if (statsStopped.get()) {
|
|
3041
|
+
return;
|
|
3042
|
+
}
|
|
3043
|
+
if (statsFlushTask == null || statsFlushTask.isCancelled() || statsFlushTask.isDone()) {
|
|
3044
|
+
statsFlushTask = statsScheduler.scheduleAtFixedRate(
|
|
3045
|
+
this::flushStatsQueue,
|
|
3046
|
+
STATS_FLUSH_INTERVAL_MS,
|
|
3047
|
+
STATS_FLUSH_INTERVAL_MS,
|
|
3048
|
+
TimeUnit.MILLISECONDS
|
|
3049
|
+
);
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
private void flushStatsQueue() {
|
|
3054
|
+
if (statsStopped.get()) {
|
|
3055
|
+
return;
|
|
3056
|
+
}
|
|
3057
|
+
if (statsQueue.isEmpty()) {
|
|
3058
|
+
return;
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
// While Retry-After is active, keep stats queued and skip the network call.
|
|
3062
|
+
if (isRemoteBlocked()) {
|
|
3063
|
+
logger.debug("Deferring stats flush until Retry-After expires.");
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
String statsUrl = this.statsUrl;
|
|
3068
|
+
if (statsUrl == null || statsUrl.isEmpty()) {
|
|
3069
|
+
synchronized (statsQueue) {
|
|
3070
|
+
statsQueue.clear();
|
|
3071
|
+
statsInFlight.clear();
|
|
3072
|
+
}
|
|
3073
|
+
persistStatsQueue();
|
|
3074
|
+
return;
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
if (!statsFlushInFlight.compareAndSet(false, true)) {
|
|
3078
|
+
return;
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
final List<QueuedStatsEvent> eventsToSend;
|
|
3082
|
+
synchronized (statsQueue) {
|
|
3083
|
+
if (statsQueue.isEmpty()) {
|
|
3084
|
+
statsFlushInFlight.set(false);
|
|
3085
|
+
return;
|
|
3086
|
+
}
|
|
3087
|
+
eventsToSend = new ArrayList<>(statsQueue);
|
|
3088
|
+
statsQueue.clear();
|
|
3089
|
+
statsInFlight.clear();
|
|
3090
|
+
statsInFlight.addAll(eventsToSend);
|
|
3091
|
+
}
|
|
3092
|
+
persistStatsQueue();
|
|
3093
|
+
|
|
3094
|
+
JSONArray jsonArray = new JSONArray();
|
|
3095
|
+
for (QueuedStatsEvent queuedEvent : eventsToSend) {
|
|
3096
|
+
jsonArray.put(queuedEvent.event);
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
Request request = new Request.Builder()
|
|
3100
|
+
.url(statsUrl)
|
|
3101
|
+
.post(RequestBody.create(jsonArray.toString(), MediaType.get("application/json")))
|
|
3102
|
+
.build();
|
|
3103
|
+
|
|
3104
|
+
final int eventCount = eventsToSend.size();
|
|
3105
|
+
DownloadService.sharedClient.newCall(request).enqueue(
|
|
3106
|
+
new okhttp3.Callback() {
|
|
3107
|
+
@Override
|
|
3108
|
+
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
|
3109
|
+
if (abandonStoppedStatsFlush()) {
|
|
3110
|
+
return;
|
|
3111
|
+
}
|
|
3112
|
+
requeueStatsEvents(eventsToSend);
|
|
3113
|
+
if (logger != null) {
|
|
3114
|
+
logger.error("Failed to send stats batch");
|
|
3115
|
+
logger.debug("Error: " + e.getMessage());
|
|
3116
|
+
}
|
|
3117
|
+
statsFlushInFlight.set(false);
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
@Override
|
|
3121
|
+
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
3122
|
+
try (ResponseBody responseBody = response.body()) {
|
|
3123
|
+
if (abandonStoppedStatsFlush()) {
|
|
3124
|
+
return;
|
|
3125
|
+
}
|
|
3126
|
+
final String responseData = responseBody != null ? responseBody.string() : "";
|
|
3127
|
+
if (checkAndHandleRateLimitResponse(response, responseData).blocked) {
|
|
3128
|
+
requeueStatsEvents(eventsToSend);
|
|
3129
|
+
return;
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
if (response.isSuccessful()) {
|
|
3133
|
+
synchronized (statsQueue) {
|
|
3134
|
+
statsInFlight.clear();
|
|
3135
|
+
}
|
|
3136
|
+
persistStatsQueue();
|
|
3137
|
+
if (logger != null) {
|
|
3138
|
+
logger.info("Stats batch sent successfully");
|
|
3139
|
+
logger.debug("Sent " + eventCount + " events");
|
|
3140
|
+
}
|
|
3141
|
+
runStatsCallbacks(eventsToSend);
|
|
3142
|
+
} else if (isTransientStatsFailure(response.code())) {
|
|
3143
|
+
requeueStatsEvents(eventsToSend);
|
|
3144
|
+
if (logger != null) {
|
|
3145
|
+
logger.error("Error sending stats batch");
|
|
3146
|
+
logger.debug("Retrying later, response code: " + response.code());
|
|
3147
|
+
}
|
|
3148
|
+
} else {
|
|
3149
|
+
synchronized (statsQueue) {
|
|
3150
|
+
statsInFlight.clear();
|
|
3151
|
+
}
|
|
3152
|
+
persistStatsQueue();
|
|
3153
|
+
if (logger != null) {
|
|
3154
|
+
logger.error("Dropping stats batch after permanent error");
|
|
3155
|
+
logger.debug("Response code: " + response.code());
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
} finally {
|
|
3159
|
+
statsFlushInFlight.set(false);
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
);
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
private boolean abandonStoppedStatsFlush() {
|
|
3167
|
+
if (!statsStopped.get()) {
|
|
3168
|
+
return false;
|
|
3169
|
+
}
|
|
3170
|
+
statsFlushInFlight.set(false);
|
|
3171
|
+
return true;
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
/**
|
|
3175
|
+
* Only 429, request timeout and 5xx are worth retrying; other 4xx are permanent rejections.
|
|
3176
|
+
*/
|
|
3177
|
+
private static boolean isTransientStatsFailure(final int statusCode) {
|
|
3178
|
+
return statusCode == 429 || statusCode == 408 || statusCode >= 500;
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
private void requeueStatsEvents(final List<QueuedStatsEvent> events) {
|
|
3182
|
+
if (statsStopped.get() || events == null || events.isEmpty()) {
|
|
3183
|
+
return;
|
|
3184
|
+
}
|
|
3185
|
+
synchronized (statsQueue) {
|
|
3186
|
+
statsInFlight.clear();
|
|
3187
|
+
statsQueue.addAll(0, events);
|
|
3188
|
+
while (statsQueue.size() > MAX_PENDING_STATS) {
|
|
3189
|
+
statsQueue.remove(0);
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3192
|
+
persistStatsQueue();
|
|
3193
|
+
ensureStatsTimerStarted();
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
private void runStatsCallbacks(final List<QueuedStatsEvent> sentEvents) {
|
|
3197
|
+
for (final QueuedStatsEvent sentEvent : sentEvents) {
|
|
3198
|
+
if (sentEvent.onSent == null) {
|
|
3199
|
+
continue;
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
try {
|
|
3203
|
+
sentEvent.onSent.run();
|
|
3204
|
+
} catch (Exception e) {
|
|
3205
|
+
if (logger != null) {
|
|
3206
|
+
logger.error("Error running stats sent callback");
|
|
3207
|
+
logger.debug("Error: " + e.getMessage());
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
public BundleInfo getBundleInfo(final String id) {
|
|
3214
|
+
String trueId = BundleInfo.VERSION_UNKNOWN;
|
|
3215
|
+
if (id != null) {
|
|
3216
|
+
trueId = id;
|
|
3217
|
+
}
|
|
3218
|
+
BundleInfo result;
|
|
3219
|
+
if (BundleInfo.ID_BUILTIN.equals(trueId)) {
|
|
3220
|
+
result = new BundleInfo(
|
|
3221
|
+
trueId,
|
|
3222
|
+
this.versionBuild == null || this.versionBuild.isEmpty() ? null : this.versionBuild,
|
|
3223
|
+
BundleStatus.SUCCESS,
|
|
3224
|
+
"",
|
|
3225
|
+
""
|
|
3226
|
+
);
|
|
3227
|
+
} else if (BundleInfo.VERSION_UNKNOWN.equals(trueId)) {
|
|
3228
|
+
result = new BundleInfo(trueId, null, BundleStatus.ERROR, "", "");
|
|
3229
|
+
} else {
|
|
3230
|
+
try {
|
|
3231
|
+
String stored = this.prefs.getString(trueId + INFO_SUFFIX, "");
|
|
3232
|
+
if (stored.isEmpty()) {
|
|
3233
|
+
result = new BundleInfo(trueId, null, BundleStatus.PENDING, "", "");
|
|
3234
|
+
} else {
|
|
3235
|
+
result = BundleInfo.fromJSON(stored);
|
|
3236
|
+
}
|
|
3237
|
+
} catch (JSONException e) {
|
|
3238
|
+
logger.error("Failed to parse bundle info");
|
|
3239
|
+
logger.debug("Bundle ID: " + trueId + ", Error: " + e.getMessage());
|
|
3240
|
+
// Clear corrupted data
|
|
3241
|
+
this.editor.remove(trueId + INFO_SUFFIX);
|
|
3242
|
+
this.editor.commit();
|
|
3243
|
+
result = new BundleInfo(trueId, null, BundleStatus.ERROR, "", "");
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
3246
|
+
return result;
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
public BundleInfo getBundleInfoByName(final String versionName) {
|
|
3250
|
+
final List<BundleInfo> installed = this.list(false);
|
|
3251
|
+
for (final BundleInfo i : installed) {
|
|
3252
|
+
if (i.getVersionName().equals(versionName)) {
|
|
3253
|
+
return i;
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
return null;
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
private void removeBundleInfo(final String id) {
|
|
3260
|
+
this.saveBundleInfo(id, null);
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
public boolean saveBundleInfo(final String id, final BundleInfo info) {
|
|
3264
|
+
if (id == null || (info != null && (info.isBuiltin() || info.isUnknown()))) {
|
|
3265
|
+
logger.debug("Not saving info for bundle: [" + id + "] " + info);
|
|
3266
|
+
return false;
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
if (info == null) {
|
|
3270
|
+
logger.debug("Removing info for bundle [" + id + "]");
|
|
3271
|
+
this.editor.remove(id + INFO_SUFFIX);
|
|
3272
|
+
} else {
|
|
3273
|
+
final BundleInfo update = info.setId(id);
|
|
3274
|
+
String jsonString = update.toString();
|
|
3275
|
+
logger.debug("Storing info for bundle [" + id + "] " + update.getClass().getName() + " -> " + jsonString);
|
|
3276
|
+
this.editor.putString(id + INFO_SUFFIX, jsonString);
|
|
3277
|
+
}
|
|
3278
|
+
return this.editor.commit();
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
private void setBundleStatus(final String id, final BundleStatus status) {
|
|
3282
|
+
if (id != null && status != null) {
|
|
3283
|
+
BundleInfo info = this.getBundleInfo(id);
|
|
3284
|
+
logger.debug("Setting status for bundle [" + id + "] to " + status);
|
|
3285
|
+
this.saveBundleInfo(id, info.setStatus(status));
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
|
|
3289
|
+
private String getCurrentBundleId() {
|
|
3290
|
+
if (this.isUsingBuiltin()) {
|
|
3291
|
+
return BundleInfo.ID_BUILTIN;
|
|
3292
|
+
} else {
|
|
3293
|
+
final String path = this.getCurrentBundlePath();
|
|
3294
|
+
return path.substring(path.lastIndexOf('/') + 1);
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
|
|
3298
|
+
public BundleInfo getCurrentBundle() {
|
|
3299
|
+
return this.getBundleInfo(this.getCurrentBundleId());
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
public String getCurrentBundlePath() {
|
|
3303
|
+
String path = this.prefs.getString(this.CAP_SERVER_PATH, "public");
|
|
3304
|
+
if (path.trim().isEmpty()) {
|
|
3305
|
+
return "public";
|
|
3306
|
+
}
|
|
3307
|
+
return path;
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
public Boolean isUsingBuiltin() {
|
|
3311
|
+
return this.getCurrentBundlePath().equals("public");
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
public BundleInfo getFallbackBundle() {
|
|
3315
|
+
final String id = this.prefs.getString(FALLBACK_VERSION, BundleInfo.ID_BUILTIN);
|
|
3316
|
+
return this.getBundleInfo(id);
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
private void setFallbackBundle(final BundleInfo fallback) {
|
|
3320
|
+
this.editor.putString(FALLBACK_VERSION, fallback == null ? BundleInfo.ID_BUILTIN : fallback.getId());
|
|
3321
|
+
this.editor.commit();
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
public BundleInfo getNextBundle() {
|
|
3325
|
+
final String id = this.prefs.getString(NEXT_VERSION, null);
|
|
3326
|
+
if (id == null) return null;
|
|
3327
|
+
return this.getBundleInfo(id);
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3330
|
+
public BundleInfo getPreviewFallbackBundle() {
|
|
3331
|
+
final String id = this.prefs.getString(PREVIEW_FALLBACK_VERSION, null);
|
|
3332
|
+
if (id == null) return null;
|
|
3333
|
+
final BundleInfo bundle = this.getBundleInfo(id);
|
|
3334
|
+
if (bundle.isErrorStatus() || (!bundle.isBuiltin() && !this.bundleExists(id))) {
|
|
3335
|
+
this.setPreviewFallbackBundle(null);
|
|
3336
|
+
return null;
|
|
3337
|
+
}
|
|
3338
|
+
return bundle;
|
|
3339
|
+
}
|
|
3340
|
+
|
|
3341
|
+
public boolean setPreviewFallbackBundle(final String fallback) {
|
|
3342
|
+
if (fallback == null) {
|
|
3343
|
+
this.editor.remove(PREVIEW_FALLBACK_VERSION);
|
|
3344
|
+
} else {
|
|
3345
|
+
final BundleInfo newBundle = this.getBundleInfo(fallback);
|
|
3346
|
+
if (newBundle.isErrorStatus() || (!newBundle.isBuiltin() && !this.bundleExists(fallback))) {
|
|
3347
|
+
return false;
|
|
3348
|
+
}
|
|
3349
|
+
this.editor.putString(PREVIEW_FALLBACK_VERSION, fallback);
|
|
3350
|
+
}
|
|
3351
|
+
this.editor.commit();
|
|
3352
|
+
return true;
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
public boolean setNextBundle(final String next) {
|
|
3356
|
+
BundleInfo bundleToNotify = null;
|
|
3357
|
+
if (next == null) {
|
|
3358
|
+
this.editor.remove(NEXT_VERSION);
|
|
3359
|
+
} else {
|
|
3360
|
+
final BundleInfo newBundle = this.getBundleInfo(next);
|
|
3361
|
+
if (!newBundle.isBuiltin() && !this.bundleExists(next)) {
|
|
3362
|
+
return false;
|
|
3363
|
+
}
|
|
3364
|
+
this.editor.putString(NEXT_VERSION, next);
|
|
3365
|
+
this.setBundleStatus(next, BundleStatus.PENDING);
|
|
3366
|
+
bundleToNotify = newBundle;
|
|
3367
|
+
}
|
|
3368
|
+
this.editor.commit();
|
|
3369
|
+
if (bundleToNotify != null) {
|
|
3370
|
+
this.sendStats("set_next", bundleToNotify.getVersionName(), this.getCurrentBundle().getVersionName());
|
|
3371
|
+
final Map<String, Object> payload = new HashMap<>();
|
|
3372
|
+
payload.put("bundle", bundleToNotify.toJSONMap());
|
|
3373
|
+
this.notifyListeners("setNext", payload);
|
|
3374
|
+
}
|
|
3375
|
+
return true;
|
|
3376
|
+
}
|
|
3377
|
+
|
|
3378
|
+
/**
|
|
3379
|
+
* Shuts down the stats scheduler and flushes any pending stats.
|
|
3380
|
+
* Should be called when the plugin is destroyed to prevent resource leaks.
|
|
3381
|
+
*/
|
|
3382
|
+
public void shutdown() {
|
|
3383
|
+
statsStopped.set(true);
|
|
3384
|
+
// Cancel the scheduled task
|
|
3385
|
+
if (statsFlushTask != null) {
|
|
3386
|
+
statsFlushTask.cancel(false);
|
|
3387
|
+
statsFlushTask = null;
|
|
3388
|
+
}
|
|
3389
|
+
|
|
3390
|
+
// Write once, then ignore later callbacks so they cannot delete a newer instance's file.
|
|
3391
|
+
persistStatsQueue(true);
|
|
3392
|
+
|
|
3393
|
+
// Shutdown the scheduler
|
|
3394
|
+
statsScheduler.shutdown();
|
|
3395
|
+
try {
|
|
3396
|
+
if (!statsScheduler.awaitTermination(2, TimeUnit.SECONDS)) {
|
|
3397
|
+
statsScheduler.shutdownNow();
|
|
3398
|
+
}
|
|
3399
|
+
} catch (InterruptedException e) {
|
|
3400
|
+
statsScheduler.shutdownNow();
|
|
3401
|
+
Thread.currentThread().interrupt();
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
}
|