@capgo/capacitor-updater 5.41.1 → 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 +3 -3
- package/README.md +27 -0
- package/android/build.gradle +4 -2
- 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 +181 -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 +9 -4
- package/android/src/main/java/ee/forgr/capacitor_updater/Logger.java +36 -14
- package/dist/docs.json +16 -0
- package/dist/esm/definitions.d.ts +14 -0
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +42 -2
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +145 -20
- package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +9 -1
- package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +22 -0
- package/package.json +1 -1
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: "
|
|
14
|
-
.package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.
|
|
15
|
-
.package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.
|
|
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
|
},
|
package/android/build.gradle
CHANGED
|
@@ -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 "
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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;
|
|
@@ -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
|
-
|
|
157
|
-
if (
|
|
158
|
-
this.
|
|
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(
|
|
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("
|
|
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
|
}
|