@capgo/capacitor-updater 5.40.5 → 5.42.3

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 CHANGED
@@ -10,9 +10,9 @@ let package = Package(
10
10
  targets: ["CapacitorUpdaterPlugin"])
11
11
  ],
12
12
  dependencies: [
13
- .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "7.0.0"),
14
- .package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.10.2")),
15
- .package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.0"),
13
+ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "5.0.0"),
14
+ .package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.11.0")),
15
+ .package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.20"),
16
16
  .package(url: "https://github.com/mrackwitz/Version.git", exact: "0.8.0"),
17
17
  .package(url: "https://github.com/attaswift/BigInt.git", from: "5.7.0")
18
18
  ],
package/README.md CHANGED
@@ -100,6 +100,8 @@ Starting from v8, the plugin uses [ZIPFoundation](https://github.com/weichsel/ZI
100
100
  | v3.\*.\* | v3.\*.\* | ⚠️ Deprecated |
101
101
  | > 7 | v4.\*.\* | ⚠️ Deprecated, our CI got crazy and bumped too much version |
102
102
 
103
+ > **Note:** Versions 5, 6, 7, and 8 all share the same features. The major version simply follows your Capacitor version. You can safely use any of these versions that matches your Capacitor installation.
104
+
103
105
  ### iOS
104
106
 
105
107
  #### Privacy manifest
@@ -140,6 +142,24 @@ npm install @capgo/capacitor-updater
140
142
  npx cap sync
141
143
  ```
142
144
 
145
+ ### Install a specific version
146
+
147
+ Use npm tags to install the version matching your Capacitor version:
148
+
149
+ ```bash
150
+ # For Capacitor 8 (latest)
151
+ npm install @capgo/capacitor-updater@latest
152
+
153
+ # For Capacitor 7
154
+ npm install @capgo/capacitor-updater@lts-v7
155
+
156
+ # For Capacitor 6
157
+ npm install @capgo/capacitor-updater@lts-v6
158
+
159
+ # For Capacitor 5
160
+ npm install @capgo/capacitor-updater@lts-v5
161
+ ```
162
+
143
163
  ## Auto-update setup
144
164
 
145
165
  Create your account in [capgo.app](https://capgo.app) and get your [API key](https://console.capgo.app/dashboard/apikeys)
@@ -252,6 +272,10 @@ Capacitor Updater works by unzipping a compiled app bundle to the native device
252
272
  - Do not password encrypt the bundle zip file, or it will fail to unpack.
253
273
  - Make sure the bundle does not contain any extra hidden files or folders, or it may fail to unpack.
254
274
 
275
+ ### Downgrading to a previous version of the updater plugin
276
+
277
+ Downgrading to a previous version of the updater plugin is not supported.
278
+
255
279
  ## Updater Plugin Config
256
280
 
257
281
  <docgen-config>
@@ -294,6 +318,7 @@ CapacitorUpdater can be configured with these options:
294
318
  | **`appId`** | <code>string</code> | Configure the app id for the app in the config. | <code>undefined</code> | 6.0.0 |
295
319
  | **`keepUrlPathAfterReload`** | <code>boolean</code> | Configure the plugin to keep the URL path after a reload. WARNING: When a reload is triggered, 'window.history' will be cleared. | <code>false</code> | 6.8.0 |
296
320
  | **`disableJSLogging`** | <code>boolean</code> | Disable the JavaScript logging of the plugin. if true, the plugin will not log to the JavaScript console. only the native log will be done | <code>false</code> | 7.3.0 |
321
+ | **`osLogging`** | <code>boolean</code> | Enable OS-level logging. When enabled, logs are written to the system log which can be inspected in production builds. - **iOS**: Uses os_log instead of Swift.print, logs accessible via Console.app or Instruments - **Android**: Logs to Logcat (android.util.Log) When set to false, system logging is disabled on both platforms (only JavaScript console logging will occur if enabled). This is useful for debugging production apps (App Store/TestFlight builds on iOS, or production APKs on Android). | <code>true</code> | 8.42.0 |
297
322
  | **`shakeMenu`** | <code>boolean</code> | Enable shake gesture to show update menu for debugging/testing purposes | <code>false</code> | 7.5.0 |
298
323
 
299
324
  ### Examples
@@ -337,6 +362,7 @@ In `capacitor.config.json`:
337
362
  "appId": undefined,
338
363
  "keepUrlPathAfterReload": undefined,
339
364
  "disableJSLogging": undefined,
365
+ "osLogging": undefined,
340
366
  "shakeMenu": undefined
341
367
  }
342
368
  }
@@ -386,6 +412,7 @@ const config: CapacitorConfig = {
386
412
  appId: undefined,
387
413
  keepUrlPathAfterReload: undefined,
388
414
  disableJSLogging: undefined,
415
+ osLogging: undefined,
389
416
  shakeMenu: undefined,
390
417
  },
391
418
  },
@@ -50,8 +50,10 @@ repositories {
50
50
 
51
51
  dependencies {
52
52
  def work_version = "2.10.5"
53
+ def lifecycle_version = "2.10.0"
53
54
  implementation "androidx.work:work-runtime:$work_version"
54
- implementation "com.google.android.gms:play-services-tasks:18.4.0"
55
+ implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"
56
+ implementation "com.google.android.gms:play-services-tasks:18.4.1"
55
57
  implementation "com.google.guava:guava:33.5.0-android"
56
58
  implementation fileTree(dir: 'libs', include: ['*.jar'])
57
59
  implementation project(':capacitor-android')
@@ -61,7 +63,7 @@ dependencies {
61
63
  implementation 'com.google.android.play:app-update:2.1.0'
62
64
  implementation 'com.google.android.play:app-update-ktx:2.1.0'
63
65
  testImplementation "junit:junit:$junitVersion"
64
- testImplementation 'org.mockito:mockito-core:5.20.0'
66
+ testImplementation 'org.mockito:mockito-core:5.21.0'
65
67
  testImplementation 'org.json:json:20250517'
66
68
  testImplementation 'org.robolectric:robolectric:4.13'
67
69
  androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
@@ -0,0 +1,88 @@
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 ee.forgr.capacitor_updater;
8
+
9
+ import androidx.annotation.NonNull;
10
+ import androidx.lifecycle.DefaultLifecycleObserver;
11
+ import androidx.lifecycle.LifecycleOwner;
12
+ import androidx.lifecycle.ProcessLifecycleOwner;
13
+
14
+ /**
15
+ * Observes app-level lifecycle events using ProcessLifecycleOwner.
16
+ * This provides reliable detection of when the entire app (not just an activity)
17
+ * moves to foreground or background.
18
+ *
19
+ * Unlike activity lifecycle callbacks, this won't trigger false positives when:
20
+ * - Opening a native camera view
21
+ * - Opening share sheets
22
+ * - Any other native activity overlays within the app
23
+ *
24
+ * The ON_STOP event is dispatched with a ~700ms delay after the last activity
25
+ * passes through onStop(), which helps avoid false triggers during configuration
26
+ * changes or quick activity transitions.
27
+ */
28
+ public class AppLifecycleObserver implements DefaultLifecycleObserver {
29
+
30
+ public interface AppLifecycleListener {
31
+ void onAppMovedToForeground();
32
+ void onAppMovedToBackground();
33
+ }
34
+
35
+ private final AppLifecycleListener listener;
36
+ private final Logger logger;
37
+ private boolean isRegistered = false;
38
+
39
+ public AppLifecycleObserver(AppLifecycleListener listener, Logger logger) {
40
+ this.listener = listener;
41
+ this.logger = logger;
42
+ }
43
+
44
+ public void register() {
45
+ if (isRegistered) {
46
+ return;
47
+ }
48
+ try {
49
+ ProcessLifecycleOwner.get().getLifecycle().addObserver(this);
50
+ isRegistered = true;
51
+ logger.info("AppLifecycleObserver registered with ProcessLifecycleOwner");
52
+ } catch (Exception e) {
53
+ logger.error("Failed to register AppLifecycleObserver: " + e.getMessage());
54
+ }
55
+ }
56
+
57
+ public void unregister() {
58
+ if (!isRegistered) {
59
+ return;
60
+ }
61
+ try {
62
+ ProcessLifecycleOwner.get().getLifecycle().removeObserver(this);
63
+ isRegistered = false;
64
+ logger.info("AppLifecycleObserver unregistered from ProcessLifecycleOwner");
65
+ } catch (Exception e) {
66
+ logger.error("Failed to unregister AppLifecycleObserver: " + e.getMessage());
67
+ }
68
+ }
69
+
70
+ @Override
71
+ public void onStart(@NonNull LifecycleOwner owner) {
72
+ // App moved to foreground (at least one activity is visible)
73
+ logger.info("ProcessLifecycleOwner: App moved to foreground");
74
+ if (listener != null) {
75
+ listener.onAppMovedToForeground();
76
+ }
77
+ }
78
+
79
+ @Override
80
+ public void onStop(@NonNull LifecycleOwner owner) {
81
+ // App moved to background (no activities are visible)
82
+ // Note: This is called with a ~700ms delay to avoid false triggers
83
+ logger.info("ProcessLifecycleOwner: App moved to background");
84
+ if (listener != null) {
85
+ listener.onAppMovedToBackground();
86
+ }
87
+ }
88
+ }
@@ -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 final Logger logger = new Logger("CapgoUpdater");
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 = "5.40.5";
87
+ private final String pluginVersion = "5.42.3";
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
- isPreviousMainActivity = true;
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
- isPreviousMainActivity = isMainActivity();
2180
- if (isPreviousMainActivity) {
2181
- logger.info("handleOnStop: appMovedToBackground");
2182
- this.appMovedToBackground();
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;
@@ -91,6 +94,12 @@ public class CapgoUpdater {
91
94
  // Flag to track if we've already sent the rate limit statistic - prevents infinite loop
92
95
  private static volatile boolean rateLimitStatisticSent = false;
93
96
 
97
+ // Stats batching - queue events and send max once per second
98
+ private final List<JSONObject> statsQueue = new CopyOnWriteArrayList<>();
99
+ private final ScheduledExecutorService statsScheduler = Executors.newSingleThreadScheduledExecutor();
100
+ private ScheduledFuture<?> statsFlushTask = null;
101
+ private static final long STATS_FLUSH_INTERVAL_MS = 1000;
102
+
94
103
  private final Map<String, CompletableFuture<BundleInfo>> downloadFutures = new ConcurrentHashMap<>();
95
104
  private final ExecutorService io = Executors.newSingleThreadExecutor();
96
105
 
@@ -153,12 +162,25 @@ public class CapgoUpdater {
153
162
  }
154
163
 
155
164
  public void setPublicKey(String publicKey) {
156
- this.publicKey = publicKey;
157
- if (!publicKey.isEmpty()) {
158
- this.cachedKeyId = CryptoCipher.calcKeyId(publicKey);
159
- } else {
165
+ // Empty string means no encryption - proceed normally
166
+ if (publicKey == null || publicKey.isEmpty()) {
167
+ this.publicKey = "";
160
168
  this.cachedKeyId = "";
169
+ return;
161
170
  }
171
+
172
+ // Non-empty: must be a valid RSA key or crash
173
+ try {
174
+ CryptoCipher.stringToPublicKey(publicKey);
175
+ } catch (Exception e) {
176
+ throw new RuntimeException(
177
+ "Invalid public key in capacitor.config.json: failed to parse RSA key. Remove the key or provide a valid PEM-formatted RSA public key.",
178
+ e
179
+ );
180
+ }
181
+
182
+ this.publicKey = publicKey;
183
+ this.cachedKeyId = CryptoCipher.calcKeyId(publicKey);
162
184
  }
163
185
 
164
186
  public String getKeyId() {
@@ -239,13 +261,90 @@ public class CapgoUpdater {
239
261
  }
240
262
  if (entries.length == 1 && !"index.html".equals(entries[0])) {
241
263
  final File child = new File(sourceFile, entries[0]);
242
- child.renameTo(destinationFile);
264
+ if (!child.renameTo(destinationFile)) {
265
+ throw new IOException("Failed to move bundle contents: " + child.getPath() + " -> " + destinationFile.getPath());
266
+ }
243
267
  } else {
244
- sourceFile.renameTo(destinationFile);
268
+ if (!sourceFile.renameTo(destinationFile)) {
269
+ throw new IOException("Failed to move bundle contents: " + sourceFile.getPath() + " -> " + destinationFile.getPath());
270
+ }
245
271
  }
246
272
  sourceFile.delete();
247
273
  }
248
274
 
275
+ private void cacheBundleFilesAsync(final String id) {
276
+ io.execute(() -> cacheBundleFiles(id));
277
+ }
278
+
279
+ private void cacheBundleFiles(final String id) {
280
+ if (this.activity == null) {
281
+ logger.debug("Skip delta cache population: activity is null");
282
+ return;
283
+ }
284
+
285
+ final File bundleDir = this.getBundleDirectory(id);
286
+ if (!bundleDir.exists()) {
287
+ logger.debug("Skip delta cache population: bundle dir missing");
288
+ return;
289
+ }
290
+
291
+ final File cacheDir = new File(this.activity.getCacheDir(), "capgo_downloads");
292
+ if (cacheDir.exists() && !cacheDir.isDirectory()) {
293
+ logger.debug("Skip delta cache population: cache dir is not a directory");
294
+ return;
295
+ }
296
+ if (!cacheDir.exists() && !cacheDir.mkdirs()) {
297
+ logger.debug("Skip delta cache population: failed to create cache dir");
298
+ return;
299
+ }
300
+
301
+ final List<File> files = new ArrayList<>();
302
+ collectFiles(bundleDir, files);
303
+ for (File file : files) {
304
+ final String checksum = CryptoCipher.calcChecksum(file);
305
+ if (checksum.isEmpty()) {
306
+ continue;
307
+ }
308
+ final String cacheName = checksum + "_" + file.getName();
309
+ final File cacheFile = new File(cacheDir, cacheName);
310
+ if (cacheFile.exists()) {
311
+ continue;
312
+ }
313
+ try {
314
+ copyFile(file, cacheFile);
315
+ } catch (IOException e) {
316
+ logger.debug("Delta cache copy failed: " + file.getPath());
317
+ }
318
+ }
319
+ }
320
+
321
+ private void collectFiles(final File dir, final List<File> files) {
322
+ final File[] entries = dir.listFiles();
323
+ if (entries == null) {
324
+ return;
325
+ }
326
+ for (File entry : entries) {
327
+ if (!this.filter.accept(dir, entry.getName())) {
328
+ continue;
329
+ }
330
+ if (entry.isDirectory()) {
331
+ collectFiles(entry, files);
332
+ } else if (entry.isFile()) {
333
+ files.add(entry);
334
+ }
335
+ }
336
+ }
337
+
338
+ private void copyFile(final File source, final File dest) throws IOException {
339
+ try (final FileInputStream input = new FileInputStream(source); final FileOutputStream output = new FileOutputStream(dest)) {
340
+ final byte[] buffer = new byte[1024 * 1024];
341
+ int length;
342
+ while ((length = input.read(buffer)) != -1) {
343
+ output.write(buffer, 0, length);
344
+ }
345
+ }
346
+ }
347
+
249
348
  private void observeWorkProgress(Context context, String id) {
250
349
  if (!(context instanceof LifecycleOwner)) {
251
350
  logger.error("Context is not a LifecycleOwner, cannot observe work progress");
@@ -460,6 +559,7 @@ public class CapgoUpdater {
460
559
  this.notifyDownload(id, 91);
461
560
  final String idName = bundleDirectory + "/" + id;
462
561
  this.flattenAssets(extractedDir, idName);
562
+ this.cacheBundleFilesAsync(id);
463
563
  } else {
464
564
  this.notifyDownload(id, 91);
465
565
  final String idName = bundleDirectory + "/" + id;
@@ -1437,30 +1537,74 @@ public class CapgoUpdater {
1437
1537
  if (statsUrl == null || statsUrl.isEmpty()) {
1438
1538
  return;
1439
1539
  }
1540
+
1440
1541
  JSONObject json;
1441
1542
  try {
1442
1543
  json = this.createInfoObject();
1443
1544
  json.put("version_name", versionName);
1444
1545
  json.put("old_version_name", oldVersionName);
1445
1546
  json.put("action", action);
1547
+ json.put("timestamp", System.currentTimeMillis());
1446
1548
  } catch (JSONException e) {
1447
1549
  logger.error("Error preparing stats");
1448
1550
  logger.debug("JSONException: " + e.getMessage());
1449
1551
  return;
1450
1552
  }
1451
1553
 
1554
+ statsQueue.add(json);
1555
+ ensureStatsTimerStarted();
1556
+ }
1557
+
1558
+ private synchronized void ensureStatsTimerStarted() {
1559
+ if (statsFlushTask == null || statsFlushTask.isCancelled() || statsFlushTask.isDone()) {
1560
+ statsFlushTask = statsScheduler.scheduleAtFixedRate(
1561
+ this::flushStatsQueue,
1562
+ STATS_FLUSH_INTERVAL_MS,
1563
+ STATS_FLUSH_INTERVAL_MS,
1564
+ TimeUnit.MILLISECONDS
1565
+ );
1566
+ }
1567
+ }
1568
+
1569
+ private void flushStatsQueue() {
1570
+ if (statsQueue.isEmpty()) {
1571
+ return;
1572
+ }
1573
+
1574
+ String statsUrl = this.statsUrl;
1575
+ if (statsUrl == null || statsUrl.isEmpty()) {
1576
+ statsQueue.clear();
1577
+ return;
1578
+ }
1579
+
1580
+ // Copy and clear the queue atomically using synchronized block
1581
+ List<JSONObject> eventsToSend;
1582
+ synchronized (statsQueue) {
1583
+ if (statsQueue.isEmpty()) {
1584
+ return;
1585
+ }
1586
+ eventsToSend = new ArrayList<>(statsQueue);
1587
+ statsQueue.clear();
1588
+ }
1589
+
1590
+ JSONArray jsonArray = new JSONArray();
1591
+ for (JSONObject event : eventsToSend) {
1592
+ jsonArray.put(event);
1593
+ }
1594
+
1452
1595
  Request request = new Request.Builder()
1453
1596
  .url(statsUrl)
1454
- .post(RequestBody.create(json.toString(), MediaType.get("application/json")))
1597
+ .post(RequestBody.create(jsonArray.toString(), MediaType.get("application/json")))
1455
1598
  .build();
1456
1599
 
1600
+ final int eventCount = eventsToSend.size();
1457
1601
  DownloadService.sharedClient
1458
1602
  .newCall(request)
1459
1603
  .enqueue(
1460
1604
  new okhttp3.Callback() {
1461
1605
  @Override
1462
1606
  public void onFailure(@NonNull Call call, @NonNull IOException e) {
1463
- logger.error("Failed to send stats");
1607
+ logger.error("Failed to send stats batch");
1464
1608
  logger.debug("Error: " + e.getMessage());
1465
1609
  }
1466
1610
 
@@ -1473,10 +1617,10 @@ public class CapgoUpdater {
1473
1617
  }
1474
1618
 
1475
1619
  if (response.isSuccessful()) {
1476
- logger.info("Stats sent successfully");
1477
- logger.debug("Action: " + action + ", Version: " + versionName);
1620
+ logger.info("Stats batch sent successfully");
1621
+ logger.debug("Sent " + eventCount + " events");
1478
1622
  } else {
1479
- logger.error("Error sending stats");
1623
+ logger.error("Error sending stats batch");
1480
1624
  logger.debug("Response code: " + response.code());
1481
1625
  }
1482
1626
  }
@@ -1610,4 +1754,30 @@ public class CapgoUpdater {
1610
1754
  this.editor.commit();
1611
1755
  return true;
1612
1756
  }
1757
+
1758
+ /**
1759
+ * Shuts down the stats scheduler and flushes any pending stats.
1760
+ * Should be called when the plugin is destroyed to prevent resource leaks.
1761
+ */
1762
+ public void shutdown() {
1763
+ // Cancel the scheduled task
1764
+ if (statsFlushTask != null) {
1765
+ statsFlushTask.cancel(false);
1766
+ statsFlushTask = null;
1767
+ }
1768
+
1769
+ // Flush any remaining stats before shutdown
1770
+ flushStatsQueue();
1771
+
1772
+ // Shutdown the scheduler
1773
+ statsScheduler.shutdown();
1774
+ try {
1775
+ if (!statsScheduler.awaitTermination(2, TimeUnit.SECONDS)) {
1776
+ statsScheduler.shutdownNow();
1777
+ }
1778
+ } catch (InterruptedException e) {
1779
+ statsScheduler.shutdownNow();
1780
+ Thread.currentThread().interrupt();
1781
+ }
1782
+ }
1613
1783
  }