@capgo/capacitor-updater 8.51.5 → 8.51.6

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.
@@ -6,10 +6,13 @@
6
6
 
7
7
  package ee.forgr.capacitor_updater;
8
8
 
9
+ import android.content.Context;
9
10
  import androidx.annotation.NonNull;
10
11
  import androidx.lifecycle.DefaultLifecycleObserver;
11
12
  import androidx.lifecycle.LifecycleOwner;
13
+ import androidx.lifecycle.ProcessLifecycleInitializer;
12
14
  import androidx.lifecycle.ProcessLifecycleOwner;
15
+ import androidx.startup.AppInitializer;
13
16
 
14
17
  /**
15
18
  * Observes app-level lifecycle events using ProcessLifecycleOwner.
@@ -41,16 +44,40 @@ public class AppLifecycleObserver implements DefaultLifecycleObserver {
41
44
  this.logger = logger;
42
45
  }
43
46
 
44
- public void register() {
47
+ public boolean isRegistered() {
48
+ return isRegistered;
49
+ }
50
+
51
+ public void register(Context context) {
45
52
  if (isRegistered) {
46
53
  return;
47
54
  }
55
+ if (context == null) {
56
+ logger.error("Cannot register AppLifecycleObserver without context");
57
+ return;
58
+ }
59
+ // get()/addObserver() succeed even when ProcessLifecycleOwner.init() never ran.
60
+ // Initialize first; if that fails, leave isRegistered false so activity fallback runs.
61
+ try {
62
+ AppInitializer.getInstance(context.getApplicationContext()).initializeComponent(ProcessLifecycleInitializer.class);
63
+ } catch (Exception e) {
64
+ logger.error("Failed to initialize ProcessLifecycleOwner: " + e.getMessage());
65
+ return;
66
+ }
67
+ if (!addObserver()) {
68
+ logger.error("Failed to register AppLifecycleObserver with ProcessLifecycleOwner");
69
+ }
70
+ }
71
+
72
+ private boolean addObserver() {
48
73
  try {
49
74
  ProcessLifecycleOwner.get().getLifecycle().addObserver(this);
50
75
  isRegistered = true;
51
76
  logger.info("AppLifecycleObserver registered with ProcessLifecycleOwner");
77
+ return true;
52
78
  } catch (Exception e) {
53
- logger.error("Failed to register AppLifecycleObserver: " + e.getMessage());
79
+ logger.error("Failed to add AppLifecycleObserver: " + e.getMessage());
80
+ return false;
54
81
  }
55
82
  }
56
83
 
@@ -146,7 +146,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
146
146
  static final int APPLICATION_EXIT_REASON_USER_REQUESTED = 10;
147
147
  static final int APPLICATION_EXIT_REASON_DEPENDENCY_DIED = 12;
148
148
 
149
- private final String pluginVersion = "8.51.5";
149
+ private final String pluginVersion = "8.51.6";
150
150
  private static final String DELAY_CONDITION_PREFERENCES = "";
151
151
 
152
152
  private SharedPreferences.Editor editor;
@@ -282,6 +282,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
282
282
  // App lifecycle observer using ProcessLifecycleOwner for reliable foreground/background detection
283
283
  private AppLifecycleObserver appLifecycleObserver;
284
284
 
285
+ private boolean isProcessLifecycleObserverActive() {
286
+ return this.appLifecycleObserver != null && this.appLifecycleObserver.isRegistered();
287
+ }
288
+
285
289
  // Play Store In-App Updates
286
290
  private AppUpdateManager appUpdateManager;
287
291
  private AppUpdateInfo cachedAppUpdateInfo;
@@ -833,11 +837,13 @@ public class CapacitorUpdaterPlugin extends Plugin {
833
837
  this.periodCheckDelay = normalizedPeriodCheckDelayMs(this.getConfig().getInt("periodCheckDelay", 0));
834
838
 
835
839
  this.implementation.documentsDir = this.getContext().getFilesDir();
840
+ this.implementation.noBackupDir = this.getContext().getNoBackupFilesDir();
836
841
  this.implementation.prefs = this.prefs;
837
842
  this.implementation.editor = this.editor;
838
843
  this.implementation.versionOs = Build.VERSION.RELEASE;
839
844
  // Use DeviceIdHelper to get or create device ID that persists across reinstalls
840
845
  this.implementation.deviceID = DeviceIdHelper.getOrCreateDeviceId(this.getContext(), this.prefs);
846
+ this.implementation.restorePendingStats();
841
847
 
842
848
  // Update User-Agent for shared OkHttpClient with OS version
843
849
  DownloadService.updateUserAgent(this.implementation.appId, this.pluginVersion, this.implementation.versionOs);
@@ -945,8 +951,12 @@ public class CapacitorUpdaterPlugin extends Plugin {
945
951
  },
946
952
  logger
947
953
  );
948
- this.appLifecycleObserver.register();
949
- logger.info("Using ProcessLifecycleOwner for foreground/background detection (Android 14+)");
954
+ this.appLifecycleObserver.register(this.getContext());
955
+ if (!this.appLifecycleObserver.isRegistered()) {
956
+ logger.warn("ProcessLifecycleOwner unavailable; using activity lifecycle callbacks");
957
+ } else {
958
+ logger.info("Using ProcessLifecycleOwner for foreground/background detection (Android 14+)");
959
+ }
950
960
  } else {
951
961
  logger.info("Using activity lifecycle callbacks for foreground/background detection (Android <14)");
952
962
  }
@@ -5250,6 +5260,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
5250
5260
 
5251
5261
  // Do other background work after splashscreen is shown
5252
5262
  CapacitorUpdaterPlugin.this.implementation.sendStats("app_moved_to_background", current.getVersionName());
5263
+ CapacitorUpdaterPlugin.this.implementation.persistPendingStats();
5253
5264
  logger.info("Checking for pending update");
5254
5265
 
5255
5266
  try {
@@ -5325,9 +5336,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
5325
5336
 
5326
5337
  // On Android < 14, use activity lifecycle for foreground detection
5327
5338
  // On Android 14+, ProcessLifecycleOwner handles this via AppLifecycleObserver
5328
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
5329
- if (isPreviousMainActivity) {
5330
- logger.info("handleOnStart: appMovedToForeground (Android <14 path)");
5339
+ if (!this.isProcessLifecycleObserverActive()) {
5340
+ if (isPreviousMainActivity || Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
5341
+ logger.info("handleOnStart: appMovedToForeground");
5331
5342
  this.appMovedToForeground();
5332
5343
  }
5333
5344
  isPreviousMainActivity = true;
@@ -5346,11 +5357,16 @@ public class CapacitorUpdaterPlugin extends Plugin {
5346
5357
 
5347
5358
  // On Android < 14, use activity lifecycle for background detection
5348
5359
  // On Android 14+, ProcessLifecycleOwner handles this via AppLifecycleObserver
5349
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
5350
- isPreviousMainActivity = isMainActivity();
5351
- if (isPreviousMainActivity) {
5352
- logger.info("handleOnStop: appMovedToBackground (Android <14 path)");
5360
+ if (!this.isProcessLifecycleObserverActive()) {
5361
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
5362
+ logger.info("handleOnStop: appMovedToBackground");
5353
5363
  this.appMovedToBackground();
5364
+ } else {
5365
+ isPreviousMainActivity = isMainActivity();
5366
+ if (isPreviousMainActivity) {
5367
+ logger.info("handleOnStop: appMovedToBackground (Android <14 path)");
5368
+ this.appMovedToBackground();
5369
+ }
5354
5370
  }
5355
5371
  }
5356
5372
  } catch (Exception e) {
@@ -54,6 +54,7 @@ import java.util.concurrent.Executors;
54
54
  import java.util.concurrent.ScheduledExecutorService;
55
55
  import java.util.concurrent.ScheduledFuture;
56
56
  import java.util.concurrent.TimeUnit;
57
+ import java.util.concurrent.atomic.AtomicBoolean;
57
58
  import java.util.zip.ZipEntry;
58
59
  import java.util.zip.ZipInputStream;
59
60
  import okhttp3.*;
@@ -91,6 +92,7 @@ public class CapgoUpdater {
91
92
  public SharedPreferences prefs;
92
93
 
93
94
  public File documentsDir;
95
+ public File noBackupDir;
94
96
  public Boolean directUpdate = false;
95
97
  public Activity activity;
96
98
  public String pluginVersion = "";
@@ -128,9 +130,15 @@ public class CapgoUpdater {
128
130
 
129
131
  // Stats batching - queue events and send max once per second
130
132
  private final List<QueuedStatsEvent> statsQueue = new CopyOnWriteArrayList<>();
133
+ private final List<QueuedStatsEvent> statsInFlight = new ArrayList<>();
134
+ private final Object pendingStatsPersistLock = new Object();
131
135
  private final ScheduledExecutorService statsScheduler = Executors.newSingleThreadScheduledExecutor();
132
136
  private ScheduledFuture<?> statsFlushTask = null;
137
+ private final AtomicBoolean statsFlushInFlight = new AtomicBoolean(false);
138
+ private final AtomicBoolean statsStopped = new AtomicBoolean(false);
133
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;
134
142
 
135
143
  private static final class QueuedStatsEvent {
136
144
 
@@ -2749,6 +2757,10 @@ public class CapgoUpdater {
2749
2757
  final Map<String, String> metadata,
2750
2758
  final Runnable onSent
2751
2759
  ) {
2760
+ if (statsStopped.get()) {
2761
+ return;
2762
+ }
2763
+
2752
2764
  if (this.previewSession) {
2753
2765
  if (logger != null) {
2754
2766
  logger.debug("Skipping sendStats during preview session.");
@@ -2772,19 +2784,150 @@ public class CapgoUpdater {
2772
2784
  json.put("metadata", new JSONObject(metadata));
2773
2785
  }
2774
2786
  } catch (JSONException e) {
2775
- logger.error("Error preparing stats");
2776
- logger.debug("JSONException: " + e.getMessage());
2787
+ if (logger != null) {
2788
+ logger.error("Error preparing stats");
2789
+ logger.debug("JSONException: " + e.getMessage());
2790
+ }
2777
2791
  return;
2778
2792
  }
2779
2793
 
2780
- // Same lock as the flush transaction, so an event added mid-flush is never dropped.
2781
2794
  synchronized (statsQueue) {
2795
+ if (statsStopped.get()) {
2796
+ return;
2797
+ }
2798
+ while (statsQueue.size() >= MAX_PENDING_STATS) {
2799
+ statsQueue.remove(0);
2800
+ }
2782
2801
  statsQueue.add(new QueuedStatsEvent(json, onSent));
2783
2802
  }
2784
2803
  ensureStatsTimerStarted();
2785
2804
  }
2786
2805
 
2806
+ public void restorePendingStats() {
2807
+ File file = pendingStatsFile();
2808
+ if (file == null || !file.exists()) {
2809
+ return;
2810
+ }
2811
+ try {
2812
+ String raw = readFileUtf8(file);
2813
+ JSONArray arr = new JSONArray(raw);
2814
+ synchronized (statsQueue) {
2815
+ for (int i = 0; i < arr.length(); i++) {
2816
+ if (statsQueue.size() >= MAX_PENDING_STATS) {
2817
+ break;
2818
+ }
2819
+ statsQueue.add(new QueuedStatsEvent(arr.getJSONObject(i), null));
2820
+ }
2821
+ }
2822
+ if (!statsQueue.isEmpty()) {
2823
+ if (logger != null) {
2824
+ logger.info("Restored " + statsQueue.size() + " pending stats events");
2825
+ }
2826
+ ensureStatsTimerStarted();
2827
+ }
2828
+ } catch (Exception e) {
2829
+ if (logger != null) {
2830
+ logger.error("Failed to restore pending stats");
2831
+ logger.debug("Error: " + e.getMessage());
2832
+ }
2833
+ }
2834
+ }
2835
+
2836
+ int pendingStatsCount() {
2837
+ return statsQueue.size();
2838
+ }
2839
+
2840
+ public void persistPendingStats() {
2841
+ persistStatsQueue();
2842
+ }
2843
+
2844
+ private File pendingStatsFile() {
2845
+ final File dir = this.noBackupDir != null ? this.noBackupDir : this.documentsDir;
2846
+ if (dir == null) {
2847
+ return null;
2848
+ }
2849
+ return new File(dir, PENDING_STATS_FILE);
2850
+ }
2851
+
2852
+ private void persistStatsQueue() {
2853
+ persistStatsQueue(false);
2854
+ }
2855
+
2856
+ private void persistStatsQueue(final boolean force) {
2857
+ File file = pendingStatsFile();
2858
+ if (file == null) {
2859
+ return;
2860
+ }
2861
+ synchronized (pendingStatsPersistLock) {
2862
+ if (statsStopped.get() && !force) {
2863
+ return;
2864
+ }
2865
+ JSONArray arr = new JSONArray();
2866
+ synchronized (statsQueue) {
2867
+ final List<QueuedStatsEvent> combined = new ArrayList<>(statsInFlight.size() + statsQueue.size());
2868
+ combined.addAll(statsInFlight);
2869
+ combined.addAll(statsQueue);
2870
+ final int start = Math.max(0, combined.size() - MAX_PENDING_STATS);
2871
+ for (int i = start; i < combined.size(); i++) {
2872
+ arr.put(combined.get(i).event);
2873
+ }
2874
+ }
2875
+ try {
2876
+ if (arr.length() == 0) {
2877
+ if (file.exists() && !file.delete()) {
2878
+ if (logger != null) {
2879
+ logger.error("Failed to delete empty stats queue file");
2880
+ }
2881
+ }
2882
+ return;
2883
+ }
2884
+ writeFileAtomically(file, arr.toString().getBytes(StandardCharsets.UTF_8));
2885
+ } catch (Exception e) {
2886
+ if (logger != null) {
2887
+ logger.error("Failed to persist stats queue");
2888
+ logger.debug("Error: " + e.getMessage());
2889
+ }
2890
+ }
2891
+ }
2892
+ }
2893
+
2894
+ private static String readFileUtf8(final File file) throws IOException {
2895
+ final long length = file.length();
2896
+ final byte[] buf = new byte[length > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) length];
2897
+ try (FileInputStream in = new FileInputStream(file)) {
2898
+ int offset = 0;
2899
+ while (offset < buf.length) {
2900
+ final int read = in.read(buf, offset, buf.length - offset);
2901
+ if (read < 0) {
2902
+ break;
2903
+ }
2904
+ offset += read;
2905
+ }
2906
+ return new String(buf, 0, offset, StandardCharsets.UTF_8);
2907
+ }
2908
+ }
2909
+
2910
+ private static void writeFileAtomically(final File file, final byte[] bytes) throws IOException {
2911
+ final File tmp = new File(file.getAbsolutePath() + ".tmp");
2912
+ try (FileOutputStream out = new FileOutputStream(tmp)) {
2913
+ out.write(bytes);
2914
+ out.flush();
2915
+ }
2916
+ if (tmp.renameTo(file)) {
2917
+ return;
2918
+ }
2919
+ if (file.exists() && !file.delete()) {
2920
+ throw new IOException("Failed to replace " + file.getAbsolutePath());
2921
+ }
2922
+ if (!tmp.renameTo(file)) {
2923
+ throw new IOException("Failed to persist " + file.getAbsolutePath());
2924
+ }
2925
+ }
2926
+
2787
2927
  private synchronized void ensureStatsTimerStarted() {
2928
+ if (statsStopped.get()) {
2929
+ return;
2930
+ }
2788
2931
  if (statsFlushTask == null || statsFlushTask.isCancelled() || statsFlushTask.isDone()) {
2789
2932
  statsFlushTask = statsScheduler.scheduleAtFixedRate(
2790
2933
  this::flushStatsQueue,
@@ -2796,6 +2939,9 @@ public class CapgoUpdater {
2796
2939
  }
2797
2940
 
2798
2941
  private void flushStatsQueue() {
2942
+ if (statsStopped.get()) {
2943
+ return;
2944
+ }
2799
2945
  if (statsQueue.isEmpty()) {
2800
2946
  return;
2801
2947
  }
@@ -2810,19 +2956,28 @@ public class CapgoUpdater {
2810
2956
  if (statsUrl == null || statsUrl.isEmpty()) {
2811
2957
  synchronized (statsQueue) {
2812
2958
  statsQueue.clear();
2959
+ statsInFlight.clear();
2813
2960
  }
2961
+ persistStatsQueue();
2962
+ return;
2963
+ }
2964
+
2965
+ if (!statsFlushInFlight.compareAndSet(false, true)) {
2814
2966
  return;
2815
2967
  }
2816
2968
 
2817
- // Copy and clear the queue atomically using synchronized block
2818
- List<QueuedStatsEvent> eventsToSend;
2969
+ final List<QueuedStatsEvent> eventsToSend;
2819
2970
  synchronized (statsQueue) {
2820
2971
  if (statsQueue.isEmpty()) {
2972
+ statsFlushInFlight.set(false);
2821
2973
  return;
2822
2974
  }
2823
2975
  eventsToSend = new ArrayList<>(statsQueue);
2824
2976
  statsQueue.clear();
2977
+ statsInFlight.clear();
2978
+ statsInFlight.addAll(eventsToSend);
2825
2979
  }
2980
+ persistStatsQueue();
2826
2981
 
2827
2982
  JSONArray jsonArray = new JSONArray();
2828
2983
  for (QueuedStatsEvent queuedEvent : eventsToSend) {
@@ -2839,15 +2994,23 @@ public class CapgoUpdater {
2839
2994
  new okhttp3.Callback() {
2840
2995
  @Override
2841
2996
  public void onFailure(@NonNull Call call, @NonNull IOException e) {
2842
- // Keep events for a later flush attempt.
2997
+ if (abandonStoppedStatsFlush()) {
2998
+ return;
2999
+ }
2843
3000
  requeueStatsEvents(eventsToSend);
2844
- logger.error("Failed to send stats batch");
2845
- logger.debug("Error: " + e.getMessage());
3001
+ if (logger != null) {
3002
+ logger.error("Failed to send stats batch");
3003
+ logger.debug("Error: " + e.getMessage());
3004
+ }
3005
+ statsFlushInFlight.set(false);
2846
3006
  }
2847
3007
 
2848
3008
  @Override
2849
3009
  public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
2850
3010
  try (ResponseBody responseBody = response.body()) {
3011
+ if (abandonStoppedStatsFlush()) {
3012
+ return;
3013
+ }
2851
3014
  final String responseData = responseBody != null ? responseBody.string() : "";
2852
3015
  if (checkAndHandleRateLimitResponse(response, responseData).blocked) {
2853
3016
  requeueStatsEvents(eventsToSend);
@@ -2855,24 +3018,47 @@ public class CapgoUpdater {
2855
3018
  }
2856
3019
 
2857
3020
  if (response.isSuccessful()) {
2858
- logger.info("Stats batch sent successfully");
2859
- logger.debug("Sent " + eventCount + " events");
3021
+ synchronized (statsQueue) {
3022
+ statsInFlight.clear();
3023
+ }
3024
+ persistStatsQueue();
3025
+ if (logger != null) {
3026
+ logger.info("Stats batch sent successfully");
3027
+ logger.debug("Sent " + eventCount + " events");
3028
+ }
2860
3029
  runStatsCallbacks(eventsToSend);
2861
3030
  } else if (isTransientStatsFailure(response.code())) {
2862
3031
  requeueStatsEvents(eventsToSend);
2863
- logger.error("Error sending stats batch");
2864
- logger.debug("Retrying later, response code: " + response.code());
3032
+ if (logger != null) {
3033
+ logger.error("Error sending stats batch");
3034
+ logger.debug("Retrying later, response code: " + response.code());
3035
+ }
2865
3036
  } else {
2866
- // Permanent rejection: retrying would loop forever and block the queue.
2867
- logger.error("Dropping stats batch after permanent error");
2868
- logger.debug("Response code: " + response.code());
3037
+ synchronized (statsQueue) {
3038
+ statsInFlight.clear();
3039
+ }
3040
+ persistStatsQueue();
3041
+ if (logger != null) {
3042
+ logger.error("Dropping stats batch after permanent error");
3043
+ logger.debug("Response code: " + response.code());
3044
+ }
2869
3045
  }
3046
+ } finally {
3047
+ statsFlushInFlight.set(false);
2870
3048
  }
2871
3049
  }
2872
3050
  }
2873
3051
  );
2874
3052
  }
2875
3053
 
3054
+ private boolean abandonStoppedStatsFlush() {
3055
+ if (!statsStopped.get()) {
3056
+ return false;
3057
+ }
3058
+ statsFlushInFlight.set(false);
3059
+ return true;
3060
+ }
3061
+
2876
3062
  /**
2877
3063
  * Only 429, request timeout and 5xx are worth retrying; other 4xx are permanent rejections.
2878
3064
  */
@@ -2881,12 +3067,17 @@ public class CapgoUpdater {
2881
3067
  }
2882
3068
 
2883
3069
  private void requeueStatsEvents(final List<QueuedStatsEvent> events) {
2884
- if (events == null || events.isEmpty()) {
3070
+ if (statsStopped.get() || events == null || events.isEmpty()) {
2885
3071
  return;
2886
3072
  }
2887
3073
  synchronized (statsQueue) {
3074
+ statsInFlight.clear();
2888
3075
  statsQueue.addAll(0, events);
3076
+ while (statsQueue.size() > MAX_PENDING_STATS) {
3077
+ statsQueue.remove(0);
3078
+ }
2889
3079
  }
3080
+ persistStatsQueue();
2890
3081
  ensureStatsTimerStarted();
2891
3082
  }
2892
3083
 
@@ -3077,14 +3268,15 @@ public class CapgoUpdater {
3077
3268
  * Should be called when the plugin is destroyed to prevent resource leaks.
3078
3269
  */
3079
3270
  public void shutdown() {
3271
+ statsStopped.set(true);
3080
3272
  // Cancel the scheduled task
3081
3273
  if (statsFlushTask != null) {
3082
3274
  statsFlushTask.cancel(false);
3083
3275
  statsFlushTask = null;
3084
3276
  }
3085
3277
 
3086
- // Flush any remaining stats before shutdown
3087
- flushStatsQueue();
3278
+ // Write once, then ignore later callbacks so they cannot delete a newer instance's file.
3279
+ persistStatsQueue(true);
3088
3280
 
3089
3281
  // Shutdown the scheduler
3090
3282
  statsScheduler.shutdown();
@@ -92,7 +92,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
92
92
  CAPPluginMethod(name: "completeFlexibleUpdate", returnType: CAPPluginReturnPromise)
93
93
  ]
94
94
  public var implementation = CapgoUpdater()
95
- private let pluginVersion: String = "8.51.5"
95
+
96
+ deinit {
97
+ implementation.shutdown()
98
+ }
99
+ private let pluginVersion: String = "8.51.6"
96
100
  private let launchStartedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
97
101
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
98
102
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
@@ -353,6 +357,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
353
357
  logger.info("Loaded persisted channelUrl")
354
358
  }
355
359
  }
360
+ implementation.restorePendingStats()
356
361
 
357
362
  let nativeBuildVersionChanged = self.hasNativeBuildVersionChanged()
358
363
  let defaultChannelPersistenceDisabled = !persistDefaultChannelOnReinstall
@@ -4616,6 +4621,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4616
4621
 
4617
4622
  let current: BundleInfo = self.implementation.getCurrentBundle()
4618
4623
  self.implementation.sendStats(action: "app_moved_to_background", versionName: current.getVersionName())
4624
+ self.implementation.persistPendingStats()
4619
4625
  logger.info("Check for pending update")
4620
4626
 
4621
4627
  // Show splashscreen only if autoSplashscreen is enabled AND autoUpdate is enabled AND directUpdate would be used
@@ -65,9 +65,14 @@ import UIKit
65
65
 
66
66
  // Stats batching - queue events and send max once per second
67
67
  private var statsQueue: [QueuedStatsEvent] = []
68
+ private var statsInFlight: [QueuedStatsEvent] = []
68
69
  private let statsQueueLock = NSLock()
70
+ private let statsPersistLock = NSLock()
69
71
  private var statsFlushTimer: Timer?
72
+ private var statsStopped = false
70
73
  private static let statsFlushInterval: TimeInterval = 1.0
74
+ private static let maxPendingStats = 200
75
+ private let pendingStatsFileName = "capgo_pending_stats.json"
71
76
 
72
77
  private struct QueuedStatsEvent {
73
78
  let event: StatsEvent
@@ -331,12 +336,16 @@ import UIKit
331
336
  }
332
337
 
333
338
  deinit {
334
- // Invalidate the stats timer to prevent memory leaks
339
+ shutdown()
340
+ }
341
+
342
+ public func shutdown() {
343
+ statsPersistLock.lock()
344
+ statsStopped = true
345
+ statsPersistLock.unlock()
335
346
  statsFlushTimer?.invalidate()
336
347
  statsFlushTimer = nil
337
-
338
- // Flush any remaining stats before deallocation
339
- flushStatsQueue()
348
+ persistStatsQueue(force: true)
340
349
  }
341
350
 
342
351
  private func calcTotalPercent(percent: Int, min: Int, max: Int) -> Int {
@@ -2901,6 +2910,10 @@ import UIKit
2901
2910
  metadata: [String: String]?,
2902
2911
  onSent: (() -> Void)?
2903
2912
  ) {
2913
+ if statsStopped {
2914
+ return
2915
+ }
2916
+
2904
2917
  if previewSession {
2905
2918
  logger.debug("Skipping sendStats during preview session.")
2906
2919
  return
@@ -2936,15 +2949,90 @@ import UIKit
2936
2949
  )
2937
2950
 
2938
2951
  statsQueueLock.lock()
2952
+ if statsStopped {
2953
+ statsQueueLock.unlock()
2954
+ return
2955
+ }
2956
+ if statsQueue.count >= CapgoUpdater.maxPendingStats {
2957
+ statsQueue.removeFirst(statsQueue.count - CapgoUpdater.maxPendingStats + 1)
2958
+ }
2939
2959
  statsQueue.append(QueuedStatsEvent(event: event, onSent: onSent))
2940
2960
  statsQueueLock.unlock()
2941
2961
 
2942
2962
  ensureStatsTimerStarted()
2943
2963
  }
2944
2964
 
2965
+ func restorePendingStats() {
2966
+ let fileURL = pendingStatsFileURL()
2967
+ guard FileManager.default.fileExists(atPath: fileURL.path),
2968
+ let data = try? Data(contentsOf: fileURL),
2969
+ let events = try? JSONDecoder().decode([StatsEvent].self, from: data) else {
2970
+ return
2971
+ }
2972
+
2973
+ statsQueueLock.lock()
2974
+ for event in events {
2975
+ if statsQueue.count >= CapgoUpdater.maxPendingStats {
2976
+ break
2977
+ }
2978
+ statsQueue.append(QueuedStatsEvent(event: event, onSent: nil))
2979
+ }
2980
+ let restoredCount = statsQueue.count
2981
+ statsQueueLock.unlock()
2982
+
2983
+ if restoredCount > 0 {
2984
+ logger.info("Restored \(restoredCount) pending stats events")
2985
+ ensureStatsTimerStarted()
2986
+ }
2987
+ }
2988
+
2989
+ func persistPendingStats() {
2990
+ persistStatsQueue()
2991
+ }
2992
+
2993
+ private func pendingStatsFileURL() -> URL {
2994
+ libraryDir.appendingPathComponent(pendingStatsFileName)
2995
+ }
2996
+
2997
+ private func persistStatsQueue(force: Bool = false) {
2998
+ statsPersistLock.lock()
2999
+ defer { statsPersistLock.unlock() }
3000
+ if statsStopped && !force {
3001
+ return
3002
+ }
3003
+
3004
+ statsQueueLock.lock()
3005
+ var events = statsInFlight.map(\.event) + statsQueue.map(\.event)
3006
+ statsQueueLock.unlock()
3007
+ if events.count > CapgoUpdater.maxPendingStats {
3008
+ events = Array(events.suffix(CapgoUpdater.maxPendingStats))
3009
+ }
3010
+
3011
+ let fileURL = pendingStatsFileURL()
3012
+ if events.isEmpty {
3013
+ try? FileManager.default.removeItem(at: fileURL)
3014
+ return
3015
+ }
3016
+
3017
+ do {
3018
+ let data = try JSONEncoder().encode(events)
3019
+ try data.write(to: fileURL, options: .atomic)
3020
+ var resourceURL = fileURL
3021
+ var values = URLResourceValues()
3022
+ values.isExcludedFromBackup = true
3023
+ try resourceURL.setResourceValues(values)
3024
+ } catch {
3025
+ logger.error("Failed to persist stats queue")
3026
+ logger.debug("Error: \(error.localizedDescription)")
3027
+ }
3028
+ }
3029
+
2945
3030
  private func ensureStatsTimerStarted() {
3031
+ if statsStopped {
3032
+ return
3033
+ }
2946
3034
  DispatchQueue.main.async { [weak self] in
2947
- guard let self = self else { return }
3035
+ guard let self = self, !self.statsStopped else { return }
2948
3036
  if self.statsFlushTimer == nil || !self.statsFlushTimer!.isValid {
2949
3037
  // Use closure-based timer to avoid strong reference cycle
2950
3038
  self.statsFlushTimer = Timer.scheduledTimer(
@@ -2958,6 +3046,9 @@ import UIKit
2958
3046
  }
2959
3047
 
2960
3048
  private func flushStatsQueue() {
3049
+ if statsStopped {
3050
+ return
3051
+ }
2961
3052
  // While Retry-After is active, keep stats queued and skip the network call.
2962
3053
  if isRemoteBlocked() {
2963
3054
  logger.debug("Deferring stats flush until Retry-After expires.")
@@ -2965,13 +3056,15 @@ import UIKit
2965
3056
  }
2966
3057
 
2967
3058
  statsQueueLock.lock()
2968
- guard !statsQueue.isEmpty else {
3059
+ guard statsInFlight.isEmpty, !statsQueue.isEmpty else {
2969
3060
  statsQueueLock.unlock()
2970
3061
  return
2971
3062
  }
2972
3063
  let queuedEvents = statsQueue
2973
3064
  statsQueue.removeAll()
3065
+ statsInFlight = queuedEvents
2974
3066
  statsQueueLock.unlock()
3067
+ persistStatsQueue()
2975
3068
 
2976
3069
  let eventsToSend = queuedEvents.map(\.event)
2977
3070
 
@@ -2986,7 +3079,10 @@ import UIKit
2986
3079
  encoder: JSONParameterEncoder.default,
2987
3080
  requestModifier: { $0.timeoutInterval = self.timeout }
2988
3081
  ).responseData { response in
2989
- // Check for 429 rate limit — requeue so events are not lost.
3082
+ if self.abandonStoppedStatsFlush() {
3083
+ semaphore.signal()
3084
+ return
3085
+ }
2990
3086
  if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode, data: response.data, response: response.response).blocked {
2991
3087
  self.requeueStatsEvents(queuedEvents)
2992
3088
  semaphore.signal()
@@ -2999,7 +3095,7 @@ import UIKit
2999
3095
  self.logger.error("Error sending stats batch")
3000
3096
  self.logger.debug("Retrying later, response code: \(statusCode)")
3001
3097
  } else {
3002
- // Permanent rejection: retrying would loop forever and block the queue.
3098
+ self.clearStatsInFlight()
3003
3099
  self.logger.error("Dropping stats batch after permanent error")
3004
3100
  self.logger.debug("Response code: \(statusCode)")
3005
3101
  }
@@ -3009,6 +3105,7 @@ import UIKit
3009
3105
 
3010
3106
  switch response.result {
3011
3107
  case .success:
3108
+ self.clearStatsInFlight()
3012
3109
  self.logger.info("Stats batch sent successfully")
3013
3110
  self.logger.debug("Sent \(eventsToSend.count) events")
3014
3111
  self.runStatsCallbacks(queuedEvents)
@@ -3020,10 +3117,17 @@ import UIKit
3020
3117
  semaphore.signal()
3021
3118
  }
3022
3119
  semaphore.wait()
3120
+ if !self.statsStopped {
3121
+ self.persistStatsQueue()
3122
+ }
3023
3123
  }
3024
3124
  operationQueue.addOperation(operation)
3025
3125
  }
3026
3126
 
3127
+ private func abandonStoppedStatsFlush() -> Bool {
3128
+ statsStopped
3129
+ }
3130
+
3027
3131
  /// Only 429, request timeout and 5xx are worth retrying; other 4xx are permanent rejections.
3028
3132
  private static func isTransientStatsFailure(_ statusCode: Int) -> Bool {
3029
3133
  return statusCode == 429 || statusCode == 408 || statusCode >= 500
@@ -3036,13 +3140,24 @@ import UIKit
3036
3140
  }
3037
3141
 
3038
3142
  private func requeueStatsEvents(_ events: [QueuedStatsEvent]) {
3039
- guard !events.isEmpty else { return }
3143
+ guard !statsStopped, !events.isEmpty else { return }
3040
3144
  statsQueueLock.lock()
3145
+ statsInFlight.removeAll()
3041
3146
  statsQueue.insert(contentsOf: events, at: 0)
3147
+ if statsQueue.count > CapgoUpdater.maxPendingStats {
3148
+ statsQueue.removeFirst(statsQueue.count - CapgoUpdater.maxPendingStats)
3149
+ }
3042
3150
  statsQueueLock.unlock()
3151
+ persistStatsQueue()
3043
3152
  ensureStatsTimerStarted()
3044
3153
  }
3045
3154
 
3155
+ private func clearStatsInFlight() {
3156
+ statsQueueLock.lock()
3157
+ statsInFlight.removeAll()
3158
+ statsQueueLock.unlock()
3159
+ }
3160
+
3046
3161
  public func getBundleInfo(id: String?) -> BundleInfo {
3047
3162
  var trueId = BundleInfo.VERSION_UNKNOWN
3048
3163
  if id != nil {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "8.51.5",
3
+ "version": "8.51.6",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",