@capgo/capacitor-updater 8.41.4 → 8.41.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/build.gradle +2 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/AppLifecycleObserver.java +88 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +71 -15
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +17 -4
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +2 -2
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +3 -3
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +13 -5
- package/package.json +1 -1
package/android/build.gradle
CHANGED
|
@@ -50,7 +50,9 @@ repositories {
|
|
|
50
50
|
|
|
51
51
|
dependencies {
|
|
52
52
|
def work_version = "2.10.5"
|
|
53
|
+
def lifecycle_version = "2.8.7"
|
|
53
54
|
implementation "androidx.work:work-runtime:$work_version"
|
|
55
|
+
implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"
|
|
54
56
|
implementation "com.google.android.gms:play-services-tasks:18.4.0"
|
|
55
57
|
implementation "com.google.guava:guava:33.5.0-android"
|
|
56
58
|
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
|
@@ -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;
|
|
@@ -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 = "8.41.
|
|
87
|
+
private final String pluginVersion = "8.41.6";
|
|
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;
|
|
@@ -431,6 +434,31 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
431
434
|
this.delayUpdateUtils.checkCancelDelay(DelayUpdateUtils.CancelDelaySource.KILLED);
|
|
432
435
|
|
|
433
436
|
this.checkForUpdateAfterDelay();
|
|
437
|
+
|
|
438
|
+
// On Android 14+ (API 34+), topActivity in RecentTaskInfo returns null due to
|
|
439
|
+
// security restrictions (StrandHogg task hijacking mitigations). Use ProcessLifecycleOwner
|
|
440
|
+
// for reliable app-level foreground/background detection on these versions.
|
|
441
|
+
// On older versions, we use the traditional activity lifecycle callbacks in handleOnStart/handleOnStop.
|
|
442
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
443
|
+
this.appLifecycleObserver = new AppLifecycleObserver(
|
|
444
|
+
new AppLifecycleObserver.AppLifecycleListener() {
|
|
445
|
+
@Override
|
|
446
|
+
public void onAppMovedToForeground() {
|
|
447
|
+
CapacitorUpdaterPlugin.this.appMovedToForeground();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
@Override
|
|
451
|
+
public void onAppMovedToBackground() {
|
|
452
|
+
CapacitorUpdaterPlugin.this.appMovedToBackground();
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
logger
|
|
456
|
+
);
|
|
457
|
+
this.appLifecycleObserver.register();
|
|
458
|
+
logger.info("Using ProcessLifecycleOwner for foreground/background detection (Android 14+)");
|
|
459
|
+
} else {
|
|
460
|
+
logger.info("Using activity lifecycle callbacks for foreground/background detection (Android <14)");
|
|
461
|
+
}
|
|
434
462
|
}
|
|
435
463
|
|
|
436
464
|
private void semaphoreWait(Number waitTime) {
|
|
@@ -2129,16 +2157,22 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
2129
2157
|
}
|
|
2130
2158
|
}
|
|
2131
2159
|
|
|
2160
|
+
/**
|
|
2161
|
+
* Check if the current activity is the main activity.
|
|
2162
|
+
* Used for activity-based foreground/background detection on Android < 14.
|
|
2163
|
+
* On Android 14+, topActivity returns null due to security restrictions, so we use
|
|
2164
|
+
* ProcessLifecycleOwner instead.
|
|
2165
|
+
*/
|
|
2132
2166
|
private boolean isMainActivity() {
|
|
2133
2167
|
try {
|
|
2134
2168
|
Context mContext = this.getContext();
|
|
2135
|
-
ActivityManager activityManager = (ActivityManager) mContext.getSystemService(Context.ACTIVITY_SERVICE);
|
|
2136
|
-
List<ActivityManager.AppTask> runningTasks = activityManager.getAppTasks();
|
|
2169
|
+
android.app.ActivityManager activityManager = (android.app.ActivityManager) mContext.getSystemService(Context.ACTIVITY_SERVICE);
|
|
2170
|
+
java.util.List<android.app.ActivityManager.AppTask> runningTasks = activityManager.getAppTasks();
|
|
2137
2171
|
if (runningTasks.isEmpty()) {
|
|
2138
2172
|
return false;
|
|
2139
2173
|
}
|
|
2140
|
-
ActivityManager.RecentTaskInfo runningTask = runningTasks.get(0).getTaskInfo();
|
|
2141
|
-
String className = Objects.requireNonNull(runningTask.baseIntent.getComponent()).getClassName();
|
|
2174
|
+
android.app.ActivityManager.RecentTaskInfo runningTask = runningTasks.get(0).getTaskInfo();
|
|
2175
|
+
String className = java.util.Objects.requireNonNull(runningTask.baseIntent.getComponent()).getClassName();
|
|
2142
2176
|
if (runningTask.topActivity == null) {
|
|
2143
2177
|
return false;
|
|
2144
2178
|
}
|
|
@@ -2152,12 +2186,17 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
2152
2186
|
@Override
|
|
2153
2187
|
public void handleOnStart() {
|
|
2154
2188
|
try {
|
|
2155
|
-
if (isPreviousMainActivity) {
|
|
2156
|
-
logger.info("handleOnStart: appMovedToForeground");
|
|
2157
|
-
this.appMovedToForeground();
|
|
2158
|
-
}
|
|
2159
2189
|
logger.info("handleOnStart: onActivityStarted " + getActivity().getClass().getName());
|
|
2160
|
-
|
|
2190
|
+
|
|
2191
|
+
// On Android < 14, use activity lifecycle for foreground detection
|
|
2192
|
+
// On Android 14+, ProcessLifecycleOwner handles this via AppLifecycleObserver
|
|
2193
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
2194
|
+
if (isPreviousMainActivity) {
|
|
2195
|
+
logger.info("handleOnStart: appMovedToForeground (Android <14 path)");
|
|
2196
|
+
this.appMovedToForeground();
|
|
2197
|
+
}
|
|
2198
|
+
isPreviousMainActivity = true;
|
|
2199
|
+
}
|
|
2161
2200
|
|
|
2162
2201
|
// Initialize shake menu if enabled and activity is BridgeActivity
|
|
2163
2202
|
if (shakeMenuEnabled && getActivity() instanceof com.getcapacitor.BridgeActivity && shakeMenu == null) {
|
|
@@ -2176,10 +2215,16 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
2176
2215
|
@Override
|
|
2177
2216
|
public void handleOnStop() {
|
|
2178
2217
|
try {
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2218
|
+
logger.info("handleOnStop: onActivityStopped");
|
|
2219
|
+
|
|
2220
|
+
// On Android < 14, use activity lifecycle for background detection
|
|
2221
|
+
// On Android 14+, ProcessLifecycleOwner handles this via AppLifecycleObserver
|
|
2222
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
2223
|
+
isPreviousMainActivity = isMainActivity();
|
|
2224
|
+
if (isPreviousMainActivity) {
|
|
2225
|
+
logger.info("handleOnStop: appMovedToBackground (Android <14 path)");
|
|
2226
|
+
this.appMovedToBackground();
|
|
2227
|
+
}
|
|
2183
2228
|
}
|
|
2184
2229
|
} catch (Exception e) {
|
|
2185
2230
|
logger.error("Failed to run handleOnStop: " + e.getMessage());
|
|
@@ -2604,6 +2649,17 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
2604
2649
|
logger.error("Failed to clean up shake menu: " + e.getMessage());
|
|
2605
2650
|
}
|
|
2606
2651
|
}
|
|
2652
|
+
|
|
2653
|
+
// Clean up app lifecycle observer
|
|
2654
|
+
if (appLifecycleObserver != null) {
|
|
2655
|
+
try {
|
|
2656
|
+
appLifecycleObserver.unregister();
|
|
2657
|
+
appLifecycleObserver = null;
|
|
2658
|
+
logger.info("AppLifecycleObserver cleaned up");
|
|
2659
|
+
} catch (Exception e) {
|
|
2660
|
+
logger.error("Failed to clean up AppLifecycleObserver: " + e.getMessage());
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2607
2663
|
} catch (Exception e) {
|
|
2608
2664
|
logger.error("Failed to run handleOnDestroy: " + e.getMessage());
|
|
2609
2665
|
}
|
|
@@ -153,12 +153,25 @@ public class CapgoUpdater {
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
public void setPublicKey(String publicKey) {
|
|
156
|
-
|
|
157
|
-
if (
|
|
158
|
-
this.
|
|
159
|
-
} else {
|
|
156
|
+
// Empty string means no encryption - proceed normally
|
|
157
|
+
if (publicKey == null || publicKey.isEmpty()) {
|
|
158
|
+
this.publicKey = "";
|
|
160
159
|
this.cachedKeyId = "";
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Non-empty: must be a valid RSA key or crash
|
|
164
|
+
try {
|
|
165
|
+
CryptoCipher.stringToPublicKey(publicKey);
|
|
166
|
+
} catch (Exception e) {
|
|
167
|
+
throw new RuntimeException(
|
|
168
|
+
"Invalid public key in capacitor.config.json: failed to parse RSA key. Remove the key or provide a valid PEM-formatted RSA public key.",
|
|
169
|
+
e
|
|
170
|
+
);
|
|
161
171
|
}
|
|
172
|
+
|
|
173
|
+
this.publicKey = publicKey;
|
|
174
|
+
this.cachedKeyId = CryptoCipher.calcKeyId(publicKey);
|
|
162
175
|
}
|
|
163
176
|
|
|
164
177
|
public String getKeyId() {
|
|
@@ -291,7 +291,7 @@ public class DownloadService extends Worker {
|
|
|
291
291
|
String fileHash = entry.getString("file_hash");
|
|
292
292
|
String downloadUrl = entry.getString("download_url");
|
|
293
293
|
|
|
294
|
-
if (!publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
|
|
294
|
+
if (publicKey != null && !publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
|
|
295
295
|
try {
|
|
296
296
|
fileHash = CryptoCipher.decryptChecksum(fileHash, publicKey);
|
|
297
297
|
} catch (Exception e) {
|
|
@@ -633,7 +633,7 @@ public class DownloadService extends Worker {
|
|
|
633
633
|
// Use OkIO for atomic write
|
|
634
634
|
writeFileAtomic(compressedFile, responseBody.byteStream(), null);
|
|
635
635
|
|
|
636
|
-
if (!publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
|
|
636
|
+
if (publicKey != null && !publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
|
|
637
637
|
logger.debug("Decrypting file " + targetFile.getName());
|
|
638
638
|
CryptoCipher.decryptFile(compressedFile, publicKey, sessionKey);
|
|
639
639
|
}
|
|
@@ -60,7 +60,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
60
60
|
CAPPluginMethod(name: "completeFlexibleUpdate", returnType: CAPPluginReturnPromise)
|
|
61
61
|
]
|
|
62
62
|
public var implementation = CapgoUpdater()
|
|
63
|
-
private let pluginVersion: String = "8.41.
|
|
63
|
+
private let pluginVersion: String = "8.41.6"
|
|
64
64
|
static let updateUrlDefault = "https://plugin.capgo.app/updates"
|
|
65
65
|
static let statsUrlDefault = "https://plugin.capgo.app/stats"
|
|
66
66
|
static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
|
|
@@ -387,13 +387,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
387
387
|
// The initial report: https://discord.com/channels/912707985829163099/1456985639345061969
|
|
388
388
|
// The bug happens in a very specific scenario. Here is the reproduction steps, followed by the lackof busniess impact
|
|
389
389
|
// Reproduction steps:
|
|
390
|
-
// 1. Install iOS app via app store. Version: 10.13.0. Version v10 of the app uses Capacitor 6 (6.3.13) - a version where the key was still "LatestVersionNative"
|
|
390
|
+
// 1. Install iOS app via app store. Version: 10.13.0. Version v10 of the app uses Capacitor 6 (6.3.13) - a version where the key was still "LatestVersionNative"
|
|
391
391
|
// 2. The plugin writes "10.13.0" to the key "LatestVersionNative"
|
|
392
392
|
// 3. Update the app to version 10.17.0 via Capgo.
|
|
393
393
|
// 4. Update the app via testflight to version 11.0.0. This version uses Capacitor 8 (8.41.3) - a version where the key was changed to "LatestNativeBuildVersion"
|
|
394
394
|
// 5. During the initial load of then new native version, the plugin will read "LatestNativeBuildVersion", not find it, read "LatestVersionNative", find it and revert to builtin version sucessfully.
|
|
395
395
|
// 6. The plugin writes "11.0.0" to the key "LatestNativeBuildVersion"
|
|
396
|
-
// 7. The app is now in a state where it is using the builtin version, but the key "LatestNativeBuildVersion" is still set to "11.0.0" and "LatestVersionNative" is still set to "10.13.0".
|
|
396
|
+
// 7. The app is now in a state where it is using the builtin version, but the key "LatestNativeBuildVersion" is still set to "11.0.0" and "LatestVersionNative" is still set to "10.13.0".
|
|
397
397
|
// 8. The user downgrades using app store back to version 10.13.0.
|
|
398
398
|
// 9. The old plugin reads "LatestVersionNative", finds "10.13.0," so it doesn't revert to builtin version. // <--- THIS IS THE FIRST PART OF THE BUG
|
|
399
399
|
// 10. "LatestVersionNative" is written to "10.13.0" but "LatestNativeBuildVersion" is not touched, and stays at "11.0.0"
|
|
@@ -80,12 +80,20 @@ import UIKit
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
public func setPublicKey(_ publicKey: String) {
|
|
83
|
-
|
|
84
|
-
if
|
|
85
|
-
self.
|
|
86
|
-
} else {
|
|
83
|
+
// Empty string means no encryption - proceed normally
|
|
84
|
+
if publicKey.isEmpty {
|
|
85
|
+
self.publicKey = ""
|
|
87
86
|
self.cachedKeyId = nil
|
|
87
|
+
return
|
|
88
88
|
}
|
|
89
|
+
|
|
90
|
+
// Non-empty: must be a valid RSA key or crash
|
|
91
|
+
guard RSAPublicKey.load(rsaPublicKey: publicKey) != nil else {
|
|
92
|
+
fatalError("Invalid public key in capacitor.config.json: failed to parse RSA key. Remove the key or provide a valid PEM-formatted RSA public key.")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
self.publicKey = publicKey
|
|
96
|
+
self.cachedKeyId = CryptoCipher.calcKeyId(publicKey: publicKey)
|
|
89
97
|
}
|
|
90
98
|
|
|
91
99
|
public func getKeyId() -> String? {
|
|
@@ -1306,7 +1314,7 @@ import UIKit
|
|
|
1306
1314
|
let fileName = url.lastPathComponent
|
|
1307
1315
|
// Only cleanup package_*.tmp and update_*.dat files
|
|
1308
1316
|
let isDownloadTemp = (fileName.hasPrefix("package_") && fileName.hasSuffix(".tmp")) ||
|
|
1309
|
-
|
|
1317
|
+
(fileName.hasPrefix("update_") && fileName.hasSuffix(".dat"))
|
|
1310
1318
|
if !isDownloadTemp {
|
|
1311
1319
|
continue
|
|
1312
1320
|
}
|