@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.
- package/android/src/main/java/ee/forgr/capacitor_updater/AppLifecycleObserver.java +29 -2
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +26 -10
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +210 -18
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +7 -1
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +124 -9
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
5329
|
-
if (isPreviousMainActivity) {
|
|
5330
|
-
logger.info("handleOnStart: appMovedToForeground
|
|
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 (
|
|
5350
|
-
|
|
5351
|
-
|
|
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
|
|
2776
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2997
|
+
if (abandonStoppedStatsFlush()) {
|
|
2998
|
+
return;
|
|
2999
|
+
}
|
|
2843
3000
|
requeueStatsEvents(eventsToSend);
|
|
2844
|
-
logger
|
|
2845
|
-
|
|
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
|
-
|
|
2859
|
-
|
|
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
|
|
2864
|
-
|
|
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
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
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
|
-
//
|
|
3087
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|