@capgo/capacitor-updater 6.41.1 → 6.42.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Package.swift +2 -2
- package/README.md +66 -39
- package/android/build.gradle +5 -3
- package/android/src/main/java/ee/forgr/capacitor_updater/AppLifecycleObserver.java +88 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +82 -16
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +188 -11
- package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +13 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +32 -10
- package/android/src/main/java/ee/forgr/capacitor_updater/Logger.java +36 -14
- package/dist/docs.json +20 -4
- package/dist/esm/definitions.d.ts +19 -4
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +42 -2
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +168 -31
- package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +9 -1
- package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +23 -0
- package/package.json +1 -1
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
package ee.forgr.capacitor_updater;
|
|
8
8
|
|
|
9
9
|
import android.app.Activity;
|
|
10
|
-
import android.app.ActivityManager;
|
|
11
10
|
import android.content.Context;
|
|
12
11
|
import android.content.Intent;
|
|
13
12
|
import android.content.SharedPreferences;
|
|
@@ -71,7 +70,7 @@ import org.json.JSONObject;
|
|
|
71
70
|
@CapacitorPlugin(name = "CapacitorUpdater")
|
|
72
71
|
public class CapacitorUpdaterPlugin extends Plugin {
|
|
73
72
|
|
|
74
|
-
private
|
|
73
|
+
private Logger logger;
|
|
75
74
|
|
|
76
75
|
private static final String updateUrlDefault = "https://plugin.capgo.app/updates";
|
|
77
76
|
private static final String statsUrlDefault = "https://plugin.capgo.app/stats";
|
|
@@ -85,7 +84,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
85
84
|
private static final String[] BREAKING_EVENT_NAMES = { "breakingAvailable", "majorAvailable" };
|
|
86
85
|
private static final String LAST_FAILED_BUNDLE_PREF_KEY = "CapacitorUpdater.lastFailedBundle";
|
|
87
86
|
|
|
88
|
-
private final String pluginVersion = "6.
|
|
87
|
+
private final String pluginVersion = "6.42.9";
|
|
89
88
|
private static final String DELAY_CONDITION_PREFERENCES = "";
|
|
90
89
|
|
|
91
90
|
private SharedPreferences.Editor editor;
|
|
@@ -116,6 +115,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
116
115
|
private Boolean allowManualBundleError = false;
|
|
117
116
|
private Boolean allowSetDefaultChannel = true;
|
|
118
117
|
|
|
118
|
+
// Used for activity-based foreground/background detection on Android < 14
|
|
119
119
|
private Boolean isPreviousMainActivity = true;
|
|
120
120
|
|
|
121
121
|
private volatile Thread backgroundDownloadTask;
|
|
@@ -138,6 +138,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
138
138
|
private FrameLayout splashscreenLoaderOverlay;
|
|
139
139
|
private Runnable splashscreenTimeoutRunnable;
|
|
140
140
|
|
|
141
|
+
// App lifecycle observer using ProcessLifecycleOwner for reliable foreground/background detection
|
|
142
|
+
private AppLifecycleObserver appLifecycleObserver;
|
|
143
|
+
|
|
141
144
|
// Play Store In-App Updates
|
|
142
145
|
private AppUpdateManager appUpdateManager;
|
|
143
146
|
private AppUpdateInfo cachedAppUpdateInfo;
|
|
@@ -216,6 +219,13 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
216
219
|
@Override
|
|
217
220
|
public void load() {
|
|
218
221
|
super.load();
|
|
222
|
+
|
|
223
|
+
// Initialize logger with osLogging config
|
|
224
|
+
// Default to true for both platforms to enable system logging by default
|
|
225
|
+
boolean osLogging = this.getConfig().getBoolean("osLogging", true);
|
|
226
|
+
Logger.Options loggerOptions = new Logger.Options(osLogging);
|
|
227
|
+
this.logger = new Logger("CapgoUpdater", loggerOptions);
|
|
228
|
+
|
|
219
229
|
this.prefs = this.getContext().getSharedPreferences(WebView.WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE);
|
|
220
230
|
this.editor = this.prefs.edit();
|
|
221
231
|
|
|
@@ -431,6 +441,31 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
431
441
|
this.delayUpdateUtils.checkCancelDelay(DelayUpdateUtils.CancelDelaySource.KILLED);
|
|
432
442
|
|
|
433
443
|
this.checkForUpdateAfterDelay();
|
|
444
|
+
|
|
445
|
+
// On Android 14+ (API 34+), topActivity in RecentTaskInfo returns null due to
|
|
446
|
+
// security restrictions (StrandHogg task hijacking mitigations). Use ProcessLifecycleOwner
|
|
447
|
+
// for reliable app-level foreground/background detection on these versions.
|
|
448
|
+
// On older versions, we use the traditional activity lifecycle callbacks in handleOnStart/handleOnStop.
|
|
449
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
450
|
+
this.appLifecycleObserver = new AppLifecycleObserver(
|
|
451
|
+
new AppLifecycleObserver.AppLifecycleListener() {
|
|
452
|
+
@Override
|
|
453
|
+
public void onAppMovedToForeground() {
|
|
454
|
+
CapacitorUpdaterPlugin.this.appMovedToForeground();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
@Override
|
|
458
|
+
public void onAppMovedToBackground() {
|
|
459
|
+
CapacitorUpdaterPlugin.this.appMovedToBackground();
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
logger
|
|
463
|
+
);
|
|
464
|
+
this.appLifecycleObserver.register();
|
|
465
|
+
logger.info("Using ProcessLifecycleOwner for foreground/background detection (Android 14+)");
|
|
466
|
+
} else {
|
|
467
|
+
logger.info("Using activity lifecycle callbacks for foreground/background detection (Android <14)");
|
|
468
|
+
}
|
|
434
469
|
}
|
|
435
470
|
|
|
436
471
|
private void semaphoreWait(Number waitTime) {
|
|
@@ -2081,6 +2116,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
2081
2116
|
}
|
|
2082
2117
|
|
|
2083
2118
|
public void appMovedToBackground() {
|
|
2119
|
+
// Reset timeout flag at start of each background cycle
|
|
2120
|
+
this.autoSplashscreenTimedOut = false;
|
|
2121
|
+
|
|
2084
2122
|
final BundleInfo current = CapacitorUpdaterPlugin.this.implementation.getCurrentBundle();
|
|
2085
2123
|
|
|
2086
2124
|
// Show splashscreen FIRST, before any other background work to ensure launcher shows it
|
|
@@ -2129,16 +2167,22 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
2129
2167
|
}
|
|
2130
2168
|
}
|
|
2131
2169
|
|
|
2170
|
+
/**
|
|
2171
|
+
* Check if the current activity is the main activity.
|
|
2172
|
+
* Used for activity-based foreground/background detection on Android < 14.
|
|
2173
|
+
* On Android 14+, topActivity returns null due to security restrictions, so we use
|
|
2174
|
+
* ProcessLifecycleOwner instead.
|
|
2175
|
+
*/
|
|
2132
2176
|
private boolean isMainActivity() {
|
|
2133
2177
|
try {
|
|
2134
2178
|
Context mContext = this.getContext();
|
|
2135
|
-
ActivityManager activityManager = (ActivityManager) mContext.getSystemService(Context.ACTIVITY_SERVICE);
|
|
2136
|
-
List<ActivityManager.AppTask> runningTasks = activityManager.getAppTasks();
|
|
2179
|
+
android.app.ActivityManager activityManager = (android.app.ActivityManager) mContext.getSystemService(Context.ACTIVITY_SERVICE);
|
|
2180
|
+
java.util.List<android.app.ActivityManager.AppTask> runningTasks = activityManager.getAppTasks();
|
|
2137
2181
|
if (runningTasks.isEmpty()) {
|
|
2138
2182
|
return false;
|
|
2139
2183
|
}
|
|
2140
|
-
ActivityManager.RecentTaskInfo runningTask = runningTasks.get(0).getTaskInfo();
|
|
2141
|
-
String className = Objects.requireNonNull(runningTask.baseIntent.getComponent()).getClassName();
|
|
2184
|
+
android.app.ActivityManager.RecentTaskInfo runningTask = runningTasks.get(0).getTaskInfo();
|
|
2185
|
+
String className = java.util.Objects.requireNonNull(runningTask.baseIntent.getComponent()).getClassName();
|
|
2142
2186
|
if (runningTask.topActivity == null) {
|
|
2143
2187
|
return false;
|
|
2144
2188
|
}
|
|
@@ -2152,12 +2196,17 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
2152
2196
|
@Override
|
|
2153
2197
|
public void handleOnStart() {
|
|
2154
2198
|
try {
|
|
2155
|
-
if (isPreviousMainActivity) {
|
|
2156
|
-
logger.info("handleOnStart: appMovedToForeground");
|
|
2157
|
-
this.appMovedToForeground();
|
|
2158
|
-
}
|
|
2159
2199
|
logger.info("handleOnStart: onActivityStarted " + getActivity().getClass().getName());
|
|
2160
|
-
|
|
2200
|
+
|
|
2201
|
+
// On Android < 14, use activity lifecycle for foreground detection
|
|
2202
|
+
// On Android 14+, ProcessLifecycleOwner handles this via AppLifecycleObserver
|
|
2203
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
2204
|
+
if (isPreviousMainActivity) {
|
|
2205
|
+
logger.info("handleOnStart: appMovedToForeground (Android <14 path)");
|
|
2206
|
+
this.appMovedToForeground();
|
|
2207
|
+
}
|
|
2208
|
+
isPreviousMainActivity = true;
|
|
2209
|
+
}
|
|
2161
2210
|
|
|
2162
2211
|
// Initialize shake menu if enabled and activity is BridgeActivity
|
|
2163
2212
|
if (shakeMenuEnabled && getActivity() instanceof com.getcapacitor.BridgeActivity && shakeMenu == null) {
|
|
@@ -2176,10 +2225,16 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
2176
2225
|
@Override
|
|
2177
2226
|
public void handleOnStop() {
|
|
2178
2227
|
try {
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2228
|
+
logger.info("handleOnStop: onActivityStopped");
|
|
2229
|
+
|
|
2230
|
+
// On Android < 14, use activity lifecycle for background detection
|
|
2231
|
+
// On Android 14+, ProcessLifecycleOwner handles this via AppLifecycleObserver
|
|
2232
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
2233
|
+
isPreviousMainActivity = isMainActivity();
|
|
2234
|
+
if (isPreviousMainActivity) {
|
|
2235
|
+
logger.info("handleOnStop: appMovedToBackground (Android <14 path)");
|
|
2236
|
+
this.appMovedToBackground();
|
|
2237
|
+
}
|
|
2183
2238
|
}
|
|
2184
2239
|
} catch (Exception e) {
|
|
2185
2240
|
logger.error("Failed to run handleOnStop: " + e.getMessage());
|
|
@@ -2604,6 +2659,17 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
2604
2659
|
logger.error("Failed to clean up shake menu: " + e.getMessage());
|
|
2605
2660
|
}
|
|
2606
2661
|
}
|
|
2662
|
+
|
|
2663
|
+
// Clean up app lifecycle observer
|
|
2664
|
+
if (appLifecycleObserver != null) {
|
|
2665
|
+
try {
|
|
2666
|
+
appLifecycleObserver.unregister();
|
|
2667
|
+
appLifecycleObserver = null;
|
|
2668
|
+
logger.info("AppLifecycleObserver cleaned up");
|
|
2669
|
+
} catch (Exception e) {
|
|
2670
|
+
logger.error("Failed to clean up AppLifecycleObserver: " + e.getMessage());
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2607
2673
|
} catch (Exception e) {
|
|
2608
2674
|
logger.error("Failed to run handleOnDestroy: " + e.getMessage());
|
|
2609
2675
|
}
|
|
@@ -35,8 +35,11 @@ import java.util.Objects;
|
|
|
35
35
|
import java.util.Set;
|
|
36
36
|
import java.util.concurrent.CompletableFuture;
|
|
37
37
|
import java.util.concurrent.ConcurrentHashMap;
|
|
38
|
+
import java.util.concurrent.CopyOnWriteArrayList;
|
|
38
39
|
import java.util.concurrent.ExecutorService;
|
|
39
40
|
import java.util.concurrent.Executors;
|
|
41
|
+
import java.util.concurrent.ScheduledExecutorService;
|
|
42
|
+
import java.util.concurrent.ScheduledFuture;
|
|
40
43
|
import java.util.concurrent.TimeUnit;
|
|
41
44
|
import java.util.zip.ZipEntry;
|
|
42
45
|
import java.util.zip.ZipInputStream;
|
|
@@ -93,6 +96,12 @@ public class CapgoUpdater {
|
|
|
93
96
|
// Flag to track if we've already sent the rate limit statistic - prevents infinite loop
|
|
94
97
|
private static volatile boolean rateLimitStatisticSent = false;
|
|
95
98
|
|
|
99
|
+
// Stats batching - queue events and send max once per second
|
|
100
|
+
private final List<JSONObject> statsQueue = new CopyOnWriteArrayList<>();
|
|
101
|
+
private final ScheduledExecutorService statsScheduler = Executors.newSingleThreadScheduledExecutor();
|
|
102
|
+
private ScheduledFuture<?> statsFlushTask = null;
|
|
103
|
+
private static final long STATS_FLUSH_INTERVAL_MS = 1000;
|
|
104
|
+
|
|
96
105
|
private final Map<String, CompletableFuture<BundleInfo>> downloadFutures = new ConcurrentHashMap<>();
|
|
97
106
|
private final ExecutorService io = Executors.newSingleThreadExecutor();
|
|
98
107
|
|
|
@@ -155,12 +164,25 @@ public class CapgoUpdater {
|
|
|
155
164
|
}
|
|
156
165
|
|
|
157
166
|
public void setPublicKey(String publicKey) {
|
|
158
|
-
|
|
159
|
-
if (
|
|
160
|
-
this.
|
|
161
|
-
} else {
|
|
167
|
+
// Empty string means no encryption - proceed normally
|
|
168
|
+
if (publicKey == null || publicKey.isEmpty()) {
|
|
169
|
+
this.publicKey = "";
|
|
162
170
|
this.cachedKeyId = "";
|
|
171
|
+
return;
|
|
163
172
|
}
|
|
173
|
+
|
|
174
|
+
// Non-empty: must be a valid RSA key or crash
|
|
175
|
+
try {
|
|
176
|
+
CryptoCipher.stringToPublicKey(publicKey);
|
|
177
|
+
} catch (Exception e) {
|
|
178
|
+
throw new RuntimeException(
|
|
179
|
+
"Invalid public key in capacitor.config.json: failed to parse RSA key. Remove the key or provide a valid PEM-formatted RSA public key.",
|
|
180
|
+
e
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
this.publicKey = publicKey;
|
|
185
|
+
this.cachedKeyId = CryptoCipher.calcKeyId(publicKey);
|
|
164
186
|
}
|
|
165
187
|
|
|
166
188
|
public String getKeyId() {
|
|
@@ -241,13 +263,90 @@ public class CapgoUpdater {
|
|
|
241
263
|
}
|
|
242
264
|
if (entries.length == 1 && !"index.html".equals(entries[0])) {
|
|
243
265
|
final File child = new File(sourceFile, entries[0]);
|
|
244
|
-
child.renameTo(destinationFile)
|
|
266
|
+
if (!child.renameTo(destinationFile)) {
|
|
267
|
+
throw new IOException("Failed to move bundle contents: " + child.getPath() + " -> " + destinationFile.getPath());
|
|
268
|
+
}
|
|
245
269
|
} else {
|
|
246
|
-
sourceFile.renameTo(destinationFile)
|
|
270
|
+
if (!sourceFile.renameTo(destinationFile)) {
|
|
271
|
+
throw new IOException("Failed to move bundle contents: " + sourceFile.getPath() + " -> " + destinationFile.getPath());
|
|
272
|
+
}
|
|
247
273
|
}
|
|
248
274
|
sourceFile.delete();
|
|
249
275
|
}
|
|
250
276
|
|
|
277
|
+
private void cacheBundleFilesAsync(final String id) {
|
|
278
|
+
io.execute(() -> cacheBundleFiles(id));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private void cacheBundleFiles(final String id) {
|
|
282
|
+
if (this.activity == null) {
|
|
283
|
+
logger.debug("Skip delta cache population: activity is null");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
final File bundleDir = this.getBundleDirectory(id);
|
|
288
|
+
if (!bundleDir.exists()) {
|
|
289
|
+
logger.debug("Skip delta cache population: bundle dir missing");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
final File cacheDir = new File(this.activity.getCacheDir(), "capgo_downloads");
|
|
294
|
+
if (cacheDir.exists() && !cacheDir.isDirectory()) {
|
|
295
|
+
logger.debug("Skip delta cache population: cache dir is not a directory");
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (!cacheDir.exists() && !cacheDir.mkdirs()) {
|
|
299
|
+
logger.debug("Skip delta cache population: failed to create cache dir");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
final List<File> files = new ArrayList<>();
|
|
304
|
+
collectFiles(bundleDir, files);
|
|
305
|
+
for (File file : files) {
|
|
306
|
+
final String checksum = CryptoCipher.calcChecksum(file);
|
|
307
|
+
if (checksum.isEmpty()) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
final String cacheName = checksum + "_" + file.getName();
|
|
311
|
+
final File cacheFile = new File(cacheDir, cacheName);
|
|
312
|
+
if (cacheFile.exists()) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
copyFile(file, cacheFile);
|
|
317
|
+
} catch (IOException e) {
|
|
318
|
+
logger.debug("Delta cache copy failed: " + file.getPath());
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private void collectFiles(final File dir, final List<File> files) {
|
|
324
|
+
final File[] entries = dir.listFiles();
|
|
325
|
+
if (entries == null) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
for (File entry : entries) {
|
|
329
|
+
if (!this.filter.accept(dir, entry.getName())) {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (entry.isDirectory()) {
|
|
333
|
+
collectFiles(entry, files);
|
|
334
|
+
} else if (entry.isFile()) {
|
|
335
|
+
files.add(entry);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private void copyFile(final File source, final File dest) throws IOException {
|
|
341
|
+
try (final FileInputStream input = new FileInputStream(source); final FileOutputStream output = new FileOutputStream(dest)) {
|
|
342
|
+
final byte[] buffer = new byte[1024 * 1024];
|
|
343
|
+
int length;
|
|
344
|
+
while ((length = input.read(buffer)) != -1) {
|
|
345
|
+
output.write(buffer, 0, length);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
251
350
|
private void observeWorkProgress(Context context, String id) {
|
|
252
351
|
if (!(context instanceof LifecycleOwner)) {
|
|
253
352
|
logger.error("Context is not a LifecycleOwner, cannot observe work progress");
|
|
@@ -465,6 +564,7 @@ public class CapgoUpdater {
|
|
|
465
564
|
this.notifyDownload(id, 91);
|
|
466
565
|
final String idName = bundleDirectory + "/" + id;
|
|
467
566
|
this.flattenAssets(extractedDir, idName);
|
|
567
|
+
this.cacheBundleFilesAsync(id);
|
|
468
568
|
} else {
|
|
469
569
|
this.notifyDownload(id, 91);
|
|
470
570
|
final String idName = bundleDirectory + "/" + id;
|
|
@@ -1148,6 +1248,13 @@ public class CapgoUpdater {
|
|
|
1148
1248
|
makeJsonRequest(channelUrl, json, (res) -> {
|
|
1149
1249
|
if (res.containsKey("error")) {
|
|
1150
1250
|
callback.callback(res);
|
|
1251
|
+
} else if (Boolean.TRUE.equals(res.get("unset"))) {
|
|
1252
|
+
// Server requested to unset channel (public channel was requested)
|
|
1253
|
+
// Clear persisted defaultChannel and revert to config value
|
|
1254
|
+
editor.remove(defaultChannelKey);
|
|
1255
|
+
editor.apply();
|
|
1256
|
+
logger.info("Public channel requested, channel override removed");
|
|
1257
|
+
callback.callback(res);
|
|
1151
1258
|
} else {
|
|
1152
1259
|
// Success - persist defaultChannel
|
|
1153
1260
|
this.defaultChannel = channel;
|
|
@@ -1442,30 +1549,74 @@ public class CapgoUpdater {
|
|
|
1442
1549
|
if (statsUrl == null || statsUrl.isEmpty()) {
|
|
1443
1550
|
return;
|
|
1444
1551
|
}
|
|
1552
|
+
|
|
1445
1553
|
JSONObject json;
|
|
1446
1554
|
try {
|
|
1447
1555
|
json = this.createInfoObject();
|
|
1448
1556
|
json.put("version_name", versionName);
|
|
1449
1557
|
json.put("old_version_name", oldVersionName);
|
|
1450
1558
|
json.put("action", action);
|
|
1559
|
+
json.put("timestamp", System.currentTimeMillis());
|
|
1451
1560
|
} catch (JSONException e) {
|
|
1452
1561
|
logger.error("Error preparing stats");
|
|
1453
1562
|
logger.debug("JSONException: " + e.getMessage());
|
|
1454
1563
|
return;
|
|
1455
1564
|
}
|
|
1456
1565
|
|
|
1566
|
+
statsQueue.add(json);
|
|
1567
|
+
ensureStatsTimerStarted();
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
private synchronized void ensureStatsTimerStarted() {
|
|
1571
|
+
if (statsFlushTask == null || statsFlushTask.isCancelled() || statsFlushTask.isDone()) {
|
|
1572
|
+
statsFlushTask = statsScheduler.scheduleAtFixedRate(
|
|
1573
|
+
this::flushStatsQueue,
|
|
1574
|
+
STATS_FLUSH_INTERVAL_MS,
|
|
1575
|
+
STATS_FLUSH_INTERVAL_MS,
|
|
1576
|
+
TimeUnit.MILLISECONDS
|
|
1577
|
+
);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
private void flushStatsQueue() {
|
|
1582
|
+
if (statsQueue.isEmpty()) {
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
String statsUrl = this.statsUrl;
|
|
1587
|
+
if (statsUrl == null || statsUrl.isEmpty()) {
|
|
1588
|
+
statsQueue.clear();
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// Copy and clear the queue atomically using synchronized block
|
|
1593
|
+
List<JSONObject> eventsToSend;
|
|
1594
|
+
synchronized (statsQueue) {
|
|
1595
|
+
if (statsQueue.isEmpty()) {
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
eventsToSend = new ArrayList<>(statsQueue);
|
|
1599
|
+
statsQueue.clear();
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
JSONArray jsonArray = new JSONArray();
|
|
1603
|
+
for (JSONObject event : eventsToSend) {
|
|
1604
|
+
jsonArray.put(event);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1457
1607
|
Request request = new Request.Builder()
|
|
1458
1608
|
.url(statsUrl)
|
|
1459
|
-
.post(RequestBody.create(
|
|
1609
|
+
.post(RequestBody.create(jsonArray.toString(), MediaType.get("application/json")))
|
|
1460
1610
|
.build();
|
|
1461
1611
|
|
|
1612
|
+
final int eventCount = eventsToSend.size();
|
|
1462
1613
|
DownloadService.sharedClient
|
|
1463
1614
|
.newCall(request)
|
|
1464
1615
|
.enqueue(
|
|
1465
1616
|
new okhttp3.Callback() {
|
|
1466
1617
|
@Override
|
|
1467
1618
|
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
|
1468
|
-
logger.error("Failed to send stats");
|
|
1619
|
+
logger.error("Failed to send stats batch");
|
|
1469
1620
|
logger.debug("Error: " + e.getMessage());
|
|
1470
1621
|
}
|
|
1471
1622
|
|
|
@@ -1478,10 +1629,10 @@ public class CapgoUpdater {
|
|
|
1478
1629
|
}
|
|
1479
1630
|
|
|
1480
1631
|
if (response.isSuccessful()) {
|
|
1481
|
-
logger.info("Stats sent successfully");
|
|
1482
|
-
logger.debug("
|
|
1632
|
+
logger.info("Stats batch sent successfully");
|
|
1633
|
+
logger.debug("Sent " + eventCount + " events");
|
|
1483
1634
|
} else {
|
|
1484
|
-
logger.error("Error sending stats");
|
|
1635
|
+
logger.error("Error sending stats batch");
|
|
1485
1636
|
logger.debug("Response code: " + response.code());
|
|
1486
1637
|
}
|
|
1487
1638
|
}
|
|
@@ -1615,4 +1766,30 @@ public class CapgoUpdater {
|
|
|
1615
1766
|
this.editor.commit();
|
|
1616
1767
|
return true;
|
|
1617
1768
|
}
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* Shuts down the stats scheduler and flushes any pending stats.
|
|
1772
|
+
* Should be called when the plugin is destroyed to prevent resource leaks.
|
|
1773
|
+
*/
|
|
1774
|
+
public void shutdown() {
|
|
1775
|
+
// Cancel the scheduled task
|
|
1776
|
+
if (statsFlushTask != null) {
|
|
1777
|
+
statsFlushTask.cancel(false);
|
|
1778
|
+
statsFlushTask = null;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
// Flush any remaining stats before shutdown
|
|
1782
|
+
flushStatsQueue();
|
|
1783
|
+
|
|
1784
|
+
// Shutdown the scheduler
|
|
1785
|
+
statsScheduler.shutdown();
|
|
1786
|
+
try {
|
|
1787
|
+
if (!statsScheduler.awaitTermination(2, TimeUnit.SECONDS)) {
|
|
1788
|
+
statsScheduler.shutdownNow();
|
|
1789
|
+
}
|
|
1790
|
+
} catch (InterruptedException e) {
|
|
1791
|
+
statsScheduler.shutdownNow();
|
|
1792
|
+
Thread.currentThread().interrupt();
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1618
1795
|
}
|
|
@@ -210,7 +210,7 @@ public class CryptoCipher {
|
|
|
210
210
|
detectedFormat = "base64";
|
|
211
211
|
}
|
|
212
212
|
logger.debug(
|
|
213
|
-
"Received
|
|
213
|
+
"Received checksum format: " +
|
|
214
214
|
detectedFormat +
|
|
215
215
|
" (length: " +
|
|
216
216
|
checksum.length() +
|
|
@@ -218,6 +218,18 @@ public class CryptoCipher {
|
|
|
218
218
|
checksumBytes.length +
|
|
219
219
|
" bytes)"
|
|
220
220
|
);
|
|
221
|
+
|
|
222
|
+
// RSA-2048 encrypted data must be exactly 256 bytes
|
|
223
|
+
// If the checksum is not 256 bytes, the bundle was not encrypted properly
|
|
224
|
+
if (checksumBytes.length != 256) {
|
|
225
|
+
logger.error(
|
|
226
|
+
"Checksum is not RSA encrypted (size: " +
|
|
227
|
+
checksumBytes.length +
|
|
228
|
+
" bytes, expected 256 for RSA-2048). Bundle must be uploaded with encryption when public key is configured."
|
|
229
|
+
);
|
|
230
|
+
throw new IOException("Bundle checksum is not encrypted. Upload bundle with --key flag when encryption is configured.");
|
|
231
|
+
}
|
|
232
|
+
|
|
221
233
|
PublicKey pKey = CryptoCipher.stringToPublicKey(publicKey);
|
|
222
234
|
byte[] decryptedChecksum = CryptoCipher.decryptRSA(checksumBytes, pKey);
|
|
223
235
|
// Return as hex string to match calcChecksum output format
|
|
@@ -288,10 +288,16 @@ public class DownloadService extends Worker {
|
|
|
288
288
|
for (int i = 0; i < totalFiles; i++) {
|
|
289
289
|
JSONObject entry = manifest.getJSONObject(i);
|
|
290
290
|
String fileName = entry.getString("file_name");
|
|
291
|
-
String fileHash = entry.
|
|
291
|
+
String fileHash = entry.optString("file_hash", "");
|
|
292
292
|
String downloadUrl = entry.getString("download_url");
|
|
293
293
|
|
|
294
|
-
if (
|
|
294
|
+
if (fileHash.isEmpty()) {
|
|
295
|
+
logger.error("Missing file_hash for manifest entry: " + fileName);
|
|
296
|
+
hasError.set(true);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (publicKey != null && !publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
|
|
295
301
|
try {
|
|
296
302
|
fileHash = CryptoCipher.decryptChecksum(fileHash, publicKey);
|
|
297
303
|
} catch (Exception e) {
|
|
@@ -308,7 +314,9 @@ public class DownloadService extends Worker {
|
|
|
308
314
|
String targetFileName = isBrotli ? fileName.substring(0, fileName.length() - 3) : fileName;
|
|
309
315
|
|
|
310
316
|
File targetFile = new File(destFolder, targetFileName);
|
|
311
|
-
|
|
317
|
+
String cacheBaseName = new File(isBrotli ? targetFileName : fileName).getName();
|
|
318
|
+
File cacheFile = new File(cacheFolder, finalFileHash + "_" + cacheBaseName);
|
|
319
|
+
final File legacyCacheFile = isBrotli ? new File(cacheFolder, finalFileHash + "_" + new File(fileName).getName()) : null;
|
|
312
320
|
File builtinFile = new File(builtinFolder, fileName);
|
|
313
321
|
|
|
314
322
|
// Ensure parent directories of the target file exist
|
|
@@ -324,7 +332,10 @@ public class DownloadService extends Worker {
|
|
|
324
332
|
if (builtinFile.exists() && verifyChecksum(builtinFile, finalFileHash)) {
|
|
325
333
|
copyFile(builtinFile, targetFile);
|
|
326
334
|
logger.debug("using builtin file " + fileName);
|
|
327
|
-
} else if (
|
|
335
|
+
} else if (
|
|
336
|
+
tryCopyFromCache(cacheFile, targetFile, finalFileHash) ||
|
|
337
|
+
(legacyCacheFile != null && tryCopyFromCache(legacyCacheFile, targetFile, finalFileHash))
|
|
338
|
+
) {
|
|
328
339
|
logger.debug("already cached " + fileName);
|
|
329
340
|
} else {
|
|
330
341
|
downloadAndVerify(downloadUrl, targetFile, cacheFile, finalFileHash, sessionKey, publicKey, finalIsBrotli);
|
|
@@ -614,8 +625,12 @@ public class DownloadService extends Worker {
|
|
|
614
625
|
// targetFile is already the final destination without .br extension
|
|
615
626
|
File finalTargetFile = targetFile;
|
|
616
627
|
|
|
617
|
-
// Create a temporary file for the compressed data
|
|
618
|
-
|
|
628
|
+
// Create a temporary file for the compressed data with a unique name to avoid race conditions
|
|
629
|
+
// between threads processing files with the same basename in different directories
|
|
630
|
+
File compressedFile = new File(
|
|
631
|
+
getApplicationContext().getCacheDir(),
|
|
632
|
+
"temp_" + java.util.UUID.randomUUID().toString() + "_" + targetFile.getName() + ".tmp"
|
|
633
|
+
);
|
|
619
634
|
|
|
620
635
|
try {
|
|
621
636
|
try (Response response = sharedClient.newCall(request).execute()) {
|
|
@@ -633,7 +648,7 @@ public class DownloadService extends Worker {
|
|
|
633
648
|
// Use OkIO for atomic write
|
|
634
649
|
writeFileAtomic(compressedFile, responseBody.byteStream(), null);
|
|
635
650
|
|
|
636
|
-
if (!publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
|
|
651
|
+
if (publicKey != null && !publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
|
|
637
652
|
logger.debug("Decrypting file " + targetFile.getName());
|
|
638
653
|
CryptoCipher.decryptFile(compressedFile, publicKey, sessionKey);
|
|
639
654
|
}
|
|
@@ -643,7 +658,14 @@ public class DownloadService extends Worker {
|
|
|
643
658
|
// Use new decompression method with atomic write
|
|
644
659
|
try (FileInputStream fis = new FileInputStream(compressedFile)) {
|
|
645
660
|
byte[] compressedData = new byte[(int) compressedFile.length()];
|
|
646
|
-
|
|
661
|
+
int offset = 0;
|
|
662
|
+
int bytesRead;
|
|
663
|
+
while (
|
|
664
|
+
offset < compressedData.length &&
|
|
665
|
+
(bytesRead = fis.read(compressedData, offset, compressedData.length - offset)) != -1
|
|
666
|
+
) {
|
|
667
|
+
offset += bytesRead;
|
|
668
|
+
}
|
|
647
669
|
byte[] decompressedData;
|
|
648
670
|
try {
|
|
649
671
|
decompressedData = decompressBrotli(compressedData, targetFile.getName());
|
|
@@ -674,7 +696,7 @@ public class DownloadService extends Worker {
|
|
|
674
696
|
CryptoCipher.logChecksumInfo("Expected checksum", expectedHash);
|
|
675
697
|
|
|
676
698
|
// Verify checksum
|
|
677
|
-
if (calculatedHash.
|
|
699
|
+
if (calculatedHash.equalsIgnoreCase(expectedHash)) {
|
|
678
700
|
// Only cache if checksum is correct - use atomic copy
|
|
679
701
|
try (FileInputStream fis = new FileInputStream(finalTargetFile)) {
|
|
680
702
|
writeFileAtomic(cacheFile, fis, expectedHash);
|
|
@@ -707,7 +729,7 @@ public class DownloadService extends Worker {
|
|
|
707
729
|
private boolean verifyChecksum(File file, String expectedHash) {
|
|
708
730
|
try {
|
|
709
731
|
String actualHash = calculateFileHash(file);
|
|
710
|
-
return actualHash.
|
|
732
|
+
return actualHash.equalsIgnoreCase(expectedHash);
|
|
711
733
|
} catch (Exception e) {
|
|
712
734
|
e.printStackTrace();
|
|
713
735
|
return false;
|