@capgo/capacitor-updater 7.23.3 → 7.24.0
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/README.md +117 -94
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +16 -18
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +149 -3
- package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +221 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +1 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +1 -1
- package/dist/docs.json +1 -1
- package/dist/esm/definitions.d.ts +10 -1
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +14 -21
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +140 -4
- package/ios/Sources/CapacitorUpdaterPlugin/DeviceIdHelper.swift +120 -0
- package/package.json +3 -4
|
@@ -71,7 +71,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
71
71
|
private static final String[] BREAKING_EVENT_NAMES = { "breakingAvailable", "majorAvailable" };
|
|
72
72
|
private static final String LAST_FAILED_BUNDLE_PREF_KEY = "CapacitorUpdater.lastFailedBundle";
|
|
73
73
|
|
|
74
|
-
private final String
|
|
74
|
+
private final String pluginVersion = "7.24.0";
|
|
75
75
|
private static final String DELAY_CONDITION_PREFERENCES = "";
|
|
76
76
|
|
|
77
77
|
private SharedPreferences.Editor editor;
|
|
@@ -81,7 +81,6 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
81
81
|
private Boolean persistModifyUrl = false;
|
|
82
82
|
|
|
83
83
|
private Integer appReadyTimeout = 10000;
|
|
84
|
-
private Integer counterActivityCreate = 0;
|
|
85
84
|
private Integer periodCheckDelay = 0;
|
|
86
85
|
private Boolean autoDeleteFailed = true;
|
|
87
86
|
private Boolean autoDeletePrevious = true;
|
|
@@ -190,7 +189,6 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
190
189
|
@Override
|
|
191
190
|
public void load() {
|
|
192
191
|
super.load();
|
|
193
|
-
this.counterActivityCreate++;
|
|
194
192
|
this.prefs = this.getContext().getSharedPreferences(WebView.WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE);
|
|
195
193
|
this.editor = this.prefs.edit();
|
|
196
194
|
|
|
@@ -221,7 +219,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
221
219
|
this.implementation.activity = this.getActivity();
|
|
222
220
|
this.implementation.versionBuild = this.getConfig().getString("version", pInfo.versionName);
|
|
223
221
|
this.implementation.CAP_SERVER_PATH = WebView.CAP_SERVER_PATH;
|
|
224
|
-
this.implementation.
|
|
222
|
+
this.implementation.pluginVersion = this.pluginVersion;
|
|
225
223
|
this.implementation.versionCode = Integer.toString(pInfo.versionCode);
|
|
226
224
|
// Removed unused OkHttpClient creation - using shared client in DownloadService instead
|
|
227
225
|
// Handle directUpdate configuration - support string values and backward compatibility
|
|
@@ -310,12 +308,11 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
310
308
|
this.implementation.prefs = this.prefs;
|
|
311
309
|
this.implementation.editor = this.editor;
|
|
312
310
|
this.implementation.versionOs = Build.VERSION.RELEASE;
|
|
313
|
-
|
|
314
|
-
this.
|
|
315
|
-
this.editor.apply();
|
|
311
|
+
// Use DeviceIdHelper to get or create device ID that persists across reinstalls
|
|
312
|
+
this.implementation.deviceID = DeviceIdHelper.getOrCreateDeviceId(this.getContext(), this.prefs);
|
|
316
313
|
|
|
317
314
|
// Update User-Agent for shared OkHttpClient with OS version
|
|
318
|
-
DownloadService.updateUserAgent(this.implementation.appId, this.
|
|
315
|
+
DownloadService.updateUserAgent(this.implementation.appId, this.pluginVersion, this.implementation.versionOs);
|
|
319
316
|
|
|
320
317
|
if (Boolean.TRUE.equals(this.persistCustomId)) {
|
|
321
318
|
final String storedCustomId = this.prefs.getString(CUSTOM_ID_PREF_KEY, "");
|
|
@@ -358,6 +355,11 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
358
355
|
if (resetWhenUpdate) {
|
|
359
356
|
this.cleanupObsoleteVersions();
|
|
360
357
|
}
|
|
358
|
+
|
|
359
|
+
// Check for 'kill' delay condition on app launch
|
|
360
|
+
// This handles cases where the app was killed by the system (onDestroy is not reliable)
|
|
361
|
+
this.delayUpdateUtils.checkCancelDelay(DelayUpdateUtils.CancelDelaySource.KILLED);
|
|
362
|
+
|
|
361
363
|
this.checkForUpdateAfterDelay();
|
|
362
364
|
}
|
|
363
365
|
|
|
@@ -807,7 +809,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
807
809
|
public void getPluginVersion(final PluginCall call) {
|
|
808
810
|
try {
|
|
809
811
|
final JSObject ret = new JSObject();
|
|
810
|
-
ret.put("version", this.
|
|
812
|
+
ret.put("version", this.pluginVersion);
|
|
811
813
|
call.resolve(ret);
|
|
812
814
|
} catch (final Exception e) {
|
|
813
815
|
logger.error("Could not get plugin version " + e.getMessage());
|
|
@@ -1911,6 +1913,11 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
1911
1913
|
) {
|
|
1912
1914
|
this.backgroundDownloadTask = this.backgroundDownload();
|
|
1913
1915
|
} else {
|
|
1916
|
+
final CapConfig config = CapConfig.loadDefault(this.getActivity());
|
|
1917
|
+
String serverUrl = config.getServerUrl();
|
|
1918
|
+
if (serverUrl != null && !serverUrl.isEmpty()) {
|
|
1919
|
+
CapacitorUpdaterPlugin.this.implementation.sendStats("blocked_by_server_url", current.getVersionName());
|
|
1920
|
+
}
|
|
1914
1921
|
logger.info("Auto update is disabled");
|
|
1915
1922
|
this.sendReadyToJs(current, "disabled");
|
|
1916
1923
|
}
|
|
@@ -1981,11 +1988,6 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
1981
1988
|
}
|
|
1982
1989
|
}
|
|
1983
1990
|
|
|
1984
|
-
private void appKilled() {
|
|
1985
|
-
logger.debug("onActivityDestroyed: all activity destroyed");
|
|
1986
|
-
this.delayUpdateUtils.checkCancelDelay(DelayUpdateUtils.CancelDelaySource.KILLED);
|
|
1987
|
-
}
|
|
1988
|
-
|
|
1989
1991
|
@Override
|
|
1990
1992
|
public void handleOnStart() {
|
|
1991
1993
|
try {
|
|
@@ -2049,10 +2051,6 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
2049
2051
|
try {
|
|
2050
2052
|
logger.info("onActivityDestroyed " + getActivity().getClass().getName());
|
|
2051
2053
|
this.implementation.activity = getActivity();
|
|
2052
|
-
counterActivityCreate--;
|
|
2053
|
-
if (counterActivityCreate == 0) {
|
|
2054
|
-
this.appKilled();
|
|
2055
|
-
}
|
|
2056
2054
|
|
|
2057
2055
|
// Clean up shake menu
|
|
2058
2056
|
if (shakeMenu != null) {
|
|
@@ -66,7 +66,7 @@ public class CapgoUpdater {
|
|
|
66
66
|
public File documentsDir;
|
|
67
67
|
public Boolean directUpdate = false;
|
|
68
68
|
public Activity activity;
|
|
69
|
-
public String
|
|
69
|
+
public String pluginVersion = "";
|
|
70
70
|
public String versionBuild = "";
|
|
71
71
|
public String versionCode = "";
|
|
72
72
|
public String versionOs = "";
|
|
@@ -81,6 +81,12 @@ public class CapgoUpdater {
|
|
|
81
81
|
public String deviceID = "";
|
|
82
82
|
public int timeout = 20000;
|
|
83
83
|
|
|
84
|
+
// Flag to track if we received a 429 response - stops requests until app restart
|
|
85
|
+
private static volatile boolean rateLimitExceeded = false;
|
|
86
|
+
|
|
87
|
+
// Flag to track if we've already sent the rate limit statistic - prevents infinite loop
|
|
88
|
+
private static volatile boolean rateLimitStatisticSent = false;
|
|
89
|
+
|
|
84
90
|
private final Map<String, CompletableFuture<BundleInfo>> downloadFutures = new ConcurrentHashMap<>();
|
|
85
91
|
private final ExecutorService io = Executors.newSingleThreadExecutor();
|
|
86
92
|
|
|
@@ -347,7 +353,7 @@ public class CapgoUpdater {
|
|
|
347
353
|
manifest != null,
|
|
348
354
|
this.isEmulator(),
|
|
349
355
|
this.appId,
|
|
350
|
-
this.
|
|
356
|
+
this.pluginVersion
|
|
351
357
|
);
|
|
352
358
|
|
|
353
359
|
if (manifest != null) {
|
|
@@ -769,13 +775,66 @@ public class CapgoUpdater {
|
|
|
769
775
|
json.put("version_code", this.versionCode);
|
|
770
776
|
json.put("version_os", this.versionOs);
|
|
771
777
|
json.put("version_name", this.getCurrentBundle().getVersionName());
|
|
772
|
-
json.put("plugin_version", this.
|
|
778
|
+
json.put("plugin_version", this.pluginVersion);
|
|
773
779
|
json.put("is_emulator", this.isEmulator());
|
|
774
780
|
json.put("is_prod", this.isProd());
|
|
775
781
|
json.put("defaultChannel", this.defaultChannel);
|
|
776
782
|
return json;
|
|
777
783
|
}
|
|
778
784
|
|
|
785
|
+
/**
|
|
786
|
+
* Check if a 429 (Too Many Requests) response was received and set the flag
|
|
787
|
+
*/
|
|
788
|
+
private boolean checkAndHandleRateLimitResponse(Response response) {
|
|
789
|
+
if (response.code() == 429) {
|
|
790
|
+
// Send a statistic about the rate limit BEFORE setting the flag
|
|
791
|
+
// Only send once to prevent infinite loop if the stat request itself gets rate limited
|
|
792
|
+
if (!rateLimitExceeded && !rateLimitStatisticSent) {
|
|
793
|
+
rateLimitStatisticSent = true;
|
|
794
|
+
sendRateLimitStatistic();
|
|
795
|
+
}
|
|
796
|
+
rateLimitExceeded = true;
|
|
797
|
+
logger.warn("Rate limit exceeded (429). Stopping all stats and channel requests until app restart.");
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
return false;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Send a synchronous statistic about rate limiting
|
|
805
|
+
*/
|
|
806
|
+
private void sendRateLimitStatistic() {
|
|
807
|
+
String statsUrl = this.statsUrl;
|
|
808
|
+
if (statsUrl == null || statsUrl.isEmpty()) {
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
try {
|
|
813
|
+
BundleInfo current = this.getCurrentBundle();
|
|
814
|
+
JSONObject json = this.createInfoObject();
|
|
815
|
+
json.put("version_name", current.getVersionName());
|
|
816
|
+
json.put("old_version_name", "");
|
|
817
|
+
json.put("action", "rate_limit_reached");
|
|
818
|
+
|
|
819
|
+
Request request = new Request.Builder()
|
|
820
|
+
.url(statsUrl)
|
|
821
|
+
.post(RequestBody.create(json.toString(), MediaType.get("application/json")))
|
|
822
|
+
.build();
|
|
823
|
+
|
|
824
|
+
// Send synchronously to ensure it goes out before the flag is set
|
|
825
|
+
// User-Agent header is automatically added by DownloadService.sharedClient interceptor
|
|
826
|
+
try (Response response = DownloadService.sharedClient.newCall(request).execute()) {
|
|
827
|
+
if (response.isSuccessful()) {
|
|
828
|
+
logger.info("Rate limit statistic sent");
|
|
829
|
+
} else {
|
|
830
|
+
logger.error("Error sending rate limit statistic: " + response.code());
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
} catch (final Exception e) {
|
|
834
|
+
logger.error("Failed to send rate limit statistic: " + e.getMessage());
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
779
838
|
private void makeJsonRequest(String url, JSONObject jsonBody, Callback callback) {
|
|
780
839
|
MediaType JSON = MediaType.get("application/json; charset=utf-8");
|
|
781
840
|
RequestBody body = RequestBody.create(jsonBody.toString(), JSON);
|
|
@@ -797,6 +856,15 @@ public class CapgoUpdater {
|
|
|
797
856
|
@Override
|
|
798
857
|
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
799
858
|
try (ResponseBody responseBody = response.body()) {
|
|
859
|
+
// Check for 429 rate limit
|
|
860
|
+
if (checkAndHandleRateLimitResponse(response)) {
|
|
861
|
+
Map<String, Object> retError = new HashMap<>();
|
|
862
|
+
retError.put("message", "Rate limit exceeded");
|
|
863
|
+
retError.put("error", "rate_limit_exceeded");
|
|
864
|
+
callback.callback(retError);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
|
|
800
868
|
if (!response.isSuccessful()) {
|
|
801
869
|
Map<String, Object> retError = new HashMap<>();
|
|
802
870
|
retError.put("message", "Server error: " + response.code());
|
|
@@ -865,6 +933,16 @@ public class CapgoUpdater {
|
|
|
865
933
|
}
|
|
866
934
|
|
|
867
935
|
public void unsetChannel(final Callback callback) {
|
|
936
|
+
// Check if rate limit was exceeded
|
|
937
|
+
if (rateLimitExceeded) {
|
|
938
|
+
logger.debug("Skipping unsetChannel due to rate limit (429). Requests will resume after app restart.");
|
|
939
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
940
|
+
retError.put("message", "Rate limit exceeded");
|
|
941
|
+
retError.put("error", "rate_limit_exceeded");
|
|
942
|
+
callback.callback(retError);
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
|
|
868
946
|
String channelUrl = this.channelUrl;
|
|
869
947
|
if (channelUrl == null || channelUrl.isEmpty()) {
|
|
870
948
|
logger.error("Channel URL is not set");
|
|
@@ -906,6 +984,15 @@ public class CapgoUpdater {
|
|
|
906
984
|
@Override
|
|
907
985
|
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
908
986
|
try (ResponseBody responseBody = response.body()) {
|
|
987
|
+
// Check for 429 rate limit
|
|
988
|
+
if (checkAndHandleRateLimitResponse(response)) {
|
|
989
|
+
Map<String, Object> retError = new HashMap<>();
|
|
990
|
+
retError.put("message", "Rate limit exceeded");
|
|
991
|
+
retError.put("error", "rate_limit_exceeded");
|
|
992
|
+
callback.callback(retError);
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
|
|
909
996
|
if (!response.isSuccessful()) {
|
|
910
997
|
Map<String, Object> retError = new HashMap<>();
|
|
911
998
|
retError.put("message", "Server error: " + response.code());
|
|
@@ -950,6 +1037,16 @@ public class CapgoUpdater {
|
|
|
950
1037
|
}
|
|
951
1038
|
|
|
952
1039
|
public void setChannel(final String channel, final Callback callback) {
|
|
1040
|
+
// Check if rate limit was exceeded
|
|
1041
|
+
if (rateLimitExceeded) {
|
|
1042
|
+
logger.debug("Skipping setChannel due to rate limit (429). Requests will resume after app restart.");
|
|
1043
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
1044
|
+
retError.put("message", "Rate limit exceeded");
|
|
1045
|
+
retError.put("error", "rate_limit_exceeded");
|
|
1046
|
+
callback.callback(retError);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
953
1050
|
String channelUrl = this.channelUrl;
|
|
954
1051
|
if (channelUrl == null || channelUrl.isEmpty()) {
|
|
955
1052
|
logger.error("Channel URL is not set");
|
|
@@ -976,6 +1073,16 @@ public class CapgoUpdater {
|
|
|
976
1073
|
}
|
|
977
1074
|
|
|
978
1075
|
public void getChannel(final Callback callback) {
|
|
1076
|
+
// Check if rate limit was exceeded
|
|
1077
|
+
if (rateLimitExceeded) {
|
|
1078
|
+
logger.debug("Skipping getChannel due to rate limit (429). Requests will resume after app restart.");
|
|
1079
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
1080
|
+
retError.put("message", "Rate limit exceeded");
|
|
1081
|
+
retError.put("error", "rate_limit_exceeded");
|
|
1082
|
+
callback.callback(retError);
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
979
1086
|
String channelUrl = this.channelUrl;
|
|
980
1087
|
if (channelUrl == null || channelUrl.isEmpty()) {
|
|
981
1088
|
logger.error("Channel URL is not set");
|
|
@@ -1017,6 +1124,15 @@ public class CapgoUpdater {
|
|
|
1017
1124
|
@Override
|
|
1018
1125
|
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
1019
1126
|
try (ResponseBody responseBody = response.body()) {
|
|
1127
|
+
// Check for 429 rate limit
|
|
1128
|
+
if (checkAndHandleRateLimitResponse(response)) {
|
|
1129
|
+
Map<String, Object> retError = new HashMap<>();
|
|
1130
|
+
retError.put("message", "Rate limit exceeded");
|
|
1131
|
+
retError.put("error", "rate_limit_exceeded");
|
|
1132
|
+
callback.callback(retError);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1020
1136
|
if (response.code() == 400) {
|
|
1021
1137
|
assert responseBody != null;
|
|
1022
1138
|
String data = responseBody.string();
|
|
@@ -1074,6 +1190,16 @@ public class CapgoUpdater {
|
|
|
1074
1190
|
}
|
|
1075
1191
|
|
|
1076
1192
|
public void listChannels(final Callback callback) {
|
|
1193
|
+
// Check if rate limit was exceeded
|
|
1194
|
+
if (rateLimitExceeded) {
|
|
1195
|
+
logger.debug("Skipping listChannels due to rate limit (429). Requests will resume after app restart.");
|
|
1196
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
1197
|
+
retError.put("message", "Rate limit exceeded");
|
|
1198
|
+
retError.put("error", "rate_limit_exceeded");
|
|
1199
|
+
callback.callback(retError);
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1077
1203
|
String channelUrl = this.channelUrl;
|
|
1078
1204
|
if (channelUrl == null || channelUrl.isEmpty()) {
|
|
1079
1205
|
logger.error("Channel URL is not set");
|
|
@@ -1114,6 +1240,15 @@ public class CapgoUpdater {
|
|
|
1114
1240
|
@Override
|
|
1115
1241
|
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
1116
1242
|
try (ResponseBody responseBody = response.body()) {
|
|
1243
|
+
// Check for 429 rate limit
|
|
1244
|
+
if (checkAndHandleRateLimitResponse(response)) {
|
|
1245
|
+
Map<String, Object> retError = new HashMap<>();
|
|
1246
|
+
retError.put("message", "Rate limit exceeded");
|
|
1247
|
+
retError.put("error", "rate_limit_exceeded");
|
|
1248
|
+
callback.callback(retError);
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1117
1252
|
if (!response.isSuccessful()) {
|
|
1118
1253
|
Map<String, Object> retError = new HashMap<>();
|
|
1119
1254
|
retError.put("message", "Server error: " + response.code());
|
|
@@ -1185,6 +1320,12 @@ public class CapgoUpdater {
|
|
|
1185
1320
|
}
|
|
1186
1321
|
|
|
1187
1322
|
public void sendStats(final String action, final String versionName, final String oldVersionName) {
|
|
1323
|
+
// Check if rate limit was exceeded
|
|
1324
|
+
if (rateLimitExceeded) {
|
|
1325
|
+
logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.");
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1188
1329
|
String statsUrl = this.statsUrl;
|
|
1189
1330
|
if (statsUrl == null || statsUrl.isEmpty()) {
|
|
1190
1331
|
return;
|
|
@@ -1217,6 +1358,11 @@ public class CapgoUpdater {
|
|
|
1217
1358
|
@Override
|
|
1218
1359
|
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
1219
1360
|
try (ResponseBody responseBody = response.body()) {
|
|
1361
|
+
// Check for 429 rate limit
|
|
1362
|
+
if (checkAndHandleRateLimitResponse(response)) {
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1220
1366
|
if (response.isSuccessful()) {
|
|
1221
1367
|
logger.info("Stats send for \"" + action + "\", version " + versionName);
|
|
1222
1368
|
} else {
|
|
@@ -0,0 +1,221 @@
|
|
|
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 android.content.Context;
|
|
10
|
+
import android.content.SharedPreferences;
|
|
11
|
+
import android.os.Build;
|
|
12
|
+
import android.security.keystore.KeyGenParameterSpec;
|
|
13
|
+
import android.security.keystore.KeyProperties;
|
|
14
|
+
import java.io.IOException;
|
|
15
|
+
import java.nio.charset.StandardCharsets;
|
|
16
|
+
import java.security.KeyStore;
|
|
17
|
+
import java.security.KeyStoreException;
|
|
18
|
+
import java.security.NoSuchAlgorithmException;
|
|
19
|
+
import java.security.NoSuchProviderException;
|
|
20
|
+
import java.security.UnrecoverableEntryException;
|
|
21
|
+
import java.security.cert.CertificateException;
|
|
22
|
+
import java.util.UUID;
|
|
23
|
+
import javax.crypto.Cipher;
|
|
24
|
+
import javax.crypto.KeyGenerator;
|
|
25
|
+
import javax.crypto.SecretKey;
|
|
26
|
+
import javax.crypto.spec.GCMParameterSpec;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Helper class to manage device ID persistence across app installations.
|
|
30
|
+
* Uses Android Keystore to persist the device ID across reinstalls.
|
|
31
|
+
*
|
|
32
|
+
* The device ID is a random UUID stored in the Android Keystore, which persists
|
|
33
|
+
* even after app uninstall/reinstall on Android 6.0+ (API 23+).
|
|
34
|
+
*/
|
|
35
|
+
public class DeviceIdHelper {
|
|
36
|
+
|
|
37
|
+
private static final String KEYSTORE_ALIAS = "capgo_device_id_key";
|
|
38
|
+
private static final String ANDROID_KEYSTORE = "AndroidKeyStore";
|
|
39
|
+
private static final String LEGACY_PREFS_KEY = "appUUID";
|
|
40
|
+
private static final String DEVICE_ID_PREFS = "capgo_device_id";
|
|
41
|
+
private static final String DEVICE_ID_KEY = "deviceId";
|
|
42
|
+
private static final String IV_KEY = "iv";
|
|
43
|
+
private static final int GCM_TAG_LENGTH = 128;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Gets or creates a device ID that persists across reinstalls.
|
|
47
|
+
*
|
|
48
|
+
* This method:
|
|
49
|
+
* 1. First checks for an existing ID in Keystore-encrypted storage (persists across reinstalls)
|
|
50
|
+
* 2. Falls back to legacy SharedPreferences (for migration)
|
|
51
|
+
* 3. Generates a new UUID if neither exists
|
|
52
|
+
* 4. Stores the ID in Keystore-encrypted storage for future use
|
|
53
|
+
*
|
|
54
|
+
* @param context Application context
|
|
55
|
+
* @param legacyPrefs Legacy SharedPreferences (for migration)
|
|
56
|
+
* @return Device ID as a lowercase UUID string
|
|
57
|
+
*/
|
|
58
|
+
public static String getOrCreateDeviceId(Context context, SharedPreferences legacyPrefs) {
|
|
59
|
+
// API 23+ required for Android Keystore
|
|
60
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
|
61
|
+
return getFallbackDeviceId(legacyPrefs);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
// Try to get device ID from Keystore storage
|
|
66
|
+
String deviceId = getDeviceIdFromKeystore(context);
|
|
67
|
+
|
|
68
|
+
if (deviceId != null && !deviceId.isEmpty()) {
|
|
69
|
+
return deviceId.toLowerCase();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Migration: Check legacy SharedPreferences for existing device ID
|
|
73
|
+
deviceId = legacyPrefs.getString(LEGACY_PREFS_KEY, null);
|
|
74
|
+
|
|
75
|
+
if (deviceId == null || deviceId.isEmpty()) {
|
|
76
|
+
// Generate new device ID if none exists
|
|
77
|
+
deviceId = UUID.randomUUID().toString();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Ensure lowercase for consistency
|
|
81
|
+
deviceId = deviceId.toLowerCase();
|
|
82
|
+
|
|
83
|
+
// Save to Keystore storage
|
|
84
|
+
saveDeviceIdToKeystore(context, deviceId);
|
|
85
|
+
|
|
86
|
+
return deviceId;
|
|
87
|
+
} catch (Exception e) {
|
|
88
|
+
// Fallback to legacy method if Keystore fails
|
|
89
|
+
return getFallbackDeviceId(legacyPrefs);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Retrieves the device ID from Keystore-encrypted storage.
|
|
95
|
+
*
|
|
96
|
+
* @param context Application context
|
|
97
|
+
* @return Device ID string or null if not found
|
|
98
|
+
*/
|
|
99
|
+
private static String getDeviceIdFromKeystore(Context context) throws Exception {
|
|
100
|
+
SharedPreferences prefs = context.getSharedPreferences(DEVICE_ID_PREFS, Context.MODE_PRIVATE);
|
|
101
|
+
String encryptedDeviceId = prefs.getString(DEVICE_ID_KEY, null);
|
|
102
|
+
String ivString = prefs.getString(IV_KEY, null);
|
|
103
|
+
|
|
104
|
+
if (encryptedDeviceId == null || ivString == null) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Get the encryption key from Keystore
|
|
109
|
+
SecretKey key = getOrCreateKey();
|
|
110
|
+
if (key == null) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Decrypt the device ID
|
|
115
|
+
byte[] encryptedBytes = android.util.Base64.decode(encryptedDeviceId, android.util.Base64.DEFAULT);
|
|
116
|
+
byte[] iv = android.util.Base64.decode(ivString, android.util.Base64.DEFAULT);
|
|
117
|
+
|
|
118
|
+
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
|
119
|
+
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
|
120
|
+
cipher.init(Cipher.DECRYPT_MODE, key, spec);
|
|
121
|
+
|
|
122
|
+
byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
|
|
123
|
+
return new String(decryptedBytes, StandardCharsets.UTF_8);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Saves the device ID to Keystore-encrypted storage.
|
|
128
|
+
*
|
|
129
|
+
* The device ID is encrypted using AES/GCM with a key stored in Android Keystore.
|
|
130
|
+
* The Keystore key persists across reinstalls on Android 6.0+ (API 23+).
|
|
131
|
+
*
|
|
132
|
+
* @param context Application context
|
|
133
|
+
* @param deviceId The device ID to save
|
|
134
|
+
*/
|
|
135
|
+
private static void saveDeviceIdToKeystore(Context context, String deviceId) throws Exception {
|
|
136
|
+
// Get or create encryption key in Keystore
|
|
137
|
+
SecretKey key = getOrCreateKey();
|
|
138
|
+
if (key == null) {
|
|
139
|
+
throw new Exception("Failed to get encryption key");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Encrypt the device ID
|
|
143
|
+
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
|
144
|
+
cipher.init(Cipher.ENCRYPT_MODE, key);
|
|
145
|
+
|
|
146
|
+
byte[] iv = cipher.getIV();
|
|
147
|
+
byte[] encryptedBytes = cipher.doFinal(deviceId.getBytes(StandardCharsets.UTF_8));
|
|
148
|
+
|
|
149
|
+
// Store encrypted device ID and IV in SharedPreferences
|
|
150
|
+
SharedPreferences prefs = context.getSharedPreferences(DEVICE_ID_PREFS, Context.MODE_PRIVATE);
|
|
151
|
+
prefs
|
|
152
|
+
.edit()
|
|
153
|
+
.putString(DEVICE_ID_KEY, android.util.Base64.encodeToString(encryptedBytes, android.util.Base64.DEFAULT))
|
|
154
|
+
.putString(IV_KEY, android.util.Base64.encodeToString(iv, android.util.Base64.DEFAULT))
|
|
155
|
+
.apply();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Gets or creates the encryption key in Android Keystore.
|
|
160
|
+
*
|
|
161
|
+
* The key is configured to persist across reinstalls and not require user authentication.
|
|
162
|
+
*
|
|
163
|
+
* @return SecretKey from Keystore or null if failed
|
|
164
|
+
*/
|
|
165
|
+
private static SecretKey getOrCreateKey() {
|
|
166
|
+
try {
|
|
167
|
+
KeyStore keyStore = KeyStore.getInstance(ANDROID_KEYSTORE);
|
|
168
|
+
keyStore.load(null);
|
|
169
|
+
|
|
170
|
+
// Check if key already exists
|
|
171
|
+
if (keyStore.containsAlias(KEYSTORE_ALIAS)) {
|
|
172
|
+
KeyStore.SecretKeyEntry entry = (KeyStore.SecretKeyEntry) keyStore.getEntry(KEYSTORE_ALIAS, null);
|
|
173
|
+
return entry.getSecretKey();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Create new key
|
|
177
|
+
KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE);
|
|
178
|
+
|
|
179
|
+
KeyGenParameterSpec keySpec = new KeyGenParameterSpec.Builder(
|
|
180
|
+
KEYSTORE_ALIAS,
|
|
181
|
+
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT
|
|
182
|
+
)
|
|
183
|
+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
184
|
+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
185
|
+
.setKeySize(256)
|
|
186
|
+
.setRandomizedEncryptionRequired(true)
|
|
187
|
+
.build();
|
|
188
|
+
|
|
189
|
+
keyGenerator.init(keySpec);
|
|
190
|
+
return keyGenerator.generateKey();
|
|
191
|
+
} catch (
|
|
192
|
+
KeyStoreException
|
|
193
|
+
| CertificateException
|
|
194
|
+
| NoSuchAlgorithmException
|
|
195
|
+
| IOException
|
|
196
|
+
| NoSuchProviderException
|
|
197
|
+
| UnrecoverableEntryException e
|
|
198
|
+
) {
|
|
199
|
+
return null;
|
|
200
|
+
} catch (Exception e) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Fallback method using legacy SharedPreferences if Keystore fails or API < 23.
|
|
207
|
+
*
|
|
208
|
+
* @param legacyPrefs Legacy SharedPreferences
|
|
209
|
+
* @return Device ID string
|
|
210
|
+
*/
|
|
211
|
+
private static String getFallbackDeviceId(SharedPreferences legacyPrefs) {
|
|
212
|
+
String deviceId = legacyPrefs.getString(LEGACY_PREFS_KEY, null);
|
|
213
|
+
|
|
214
|
+
if (deviceId == null || deviceId.isEmpty()) {
|
|
215
|
+
deviceId = UUID.randomUUID().toString();
|
|
216
|
+
legacyPrefs.edit().putString(LEGACY_PREFS_KEY, deviceId).apply();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return deviceId.toLowerCase();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -63,7 +63,7 @@ public class DownloadService extends Worker {
|
|
|
63
63
|
public static final String PUBLIC_KEY = "publickey";
|
|
64
64
|
public static final String IS_MANIFEST = "is_manifest";
|
|
65
65
|
public static final String APP_ID = "app_id";
|
|
66
|
-
public static final String
|
|
66
|
+
public static final String pluginVersion = "plugin_version";
|
|
67
67
|
private static final String UPDATE_FILE = "update.dat";
|
|
68
68
|
|
|
69
69
|
// Shared OkHttpClient to prevent resource leaks
|
|
@@ -80,7 +80,7 @@ public class DownloadWorkerManager {
|
|
|
80
80
|
.putBoolean(DownloadService.IS_MANIFEST, isManifest)
|
|
81
81
|
.putString(DownloadService.PUBLIC_KEY, publicKey)
|
|
82
82
|
.putString(DownloadService.APP_ID, appId)
|
|
83
|
-
.putString(DownloadService.
|
|
83
|
+
.putString(DownloadService.pluginVersion, pluginVersion)
|
|
84
84
|
.build();
|
|
85
85
|
|
|
86
86
|
// Create network constraints - be more lenient on emulators
|
package/dist/docs.json
CHANGED
|
@@ -679,7 +679,7 @@
|
|
|
679
679
|
"text": "{Error}"
|
|
680
680
|
}
|
|
681
681
|
],
|
|
682
|
-
"docs": "Get unique ID used to identify device (sent to auto update server)
|
|
682
|
+
"docs": "Get unique ID used to identify device (sent to auto update server).\n\nThis ID is privacy-friendly and follows Apple and Google best practices:\n- Generated as a UUID and stored securely\n- Android: Uses EncryptedSharedPreferences with Auto Backup (persists across reinstalls)\n- iOS: Uses Keychain with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly (persists across reinstalls)\n- Data stays on device (not synced to cloud on iOS)\n- Can be cleared by user via system settings (Android) or keychain access (iOS)\n\nThe device ID now persists between app reinstalls to maintain consistent device identity.",
|
|
683
683
|
"complexTypes": [
|
|
684
684
|
"DeviceId"
|
|
685
685
|
],
|
|
@@ -516,7 +516,16 @@ export interface CapacitorUpdaterPlugin {
|
|
|
516
516
|
*/
|
|
517
517
|
getBuiltinVersion(): Promise<BuiltinVersion>;
|
|
518
518
|
/**
|
|
519
|
-
* Get unique ID used to identify device (sent to auto update server)
|
|
519
|
+
* Get unique ID used to identify device (sent to auto update server).
|
|
520
|
+
*
|
|
521
|
+
* This ID is privacy-friendly and follows Apple and Google best practices:
|
|
522
|
+
* - Generated as a UUID and stored securely
|
|
523
|
+
* - Android: Uses EncryptedSharedPreferences with Auto Backup (persists across reinstalls)
|
|
524
|
+
* - iOS: Uses Keychain with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly (persists across reinstalls)
|
|
525
|
+
* - Data stays on device (not synced to cloud on iOS)
|
|
526
|
+
* - Can be cleared by user via system settings (Android) or keychain access (iOS)
|
|
527
|
+
*
|
|
528
|
+
* The device ID now persists between app reinstalls to maintain consistent device identity.
|
|
520
529
|
*
|
|
521
530
|
* @returns {Promise<DeviceId>} A Promise with id for this device
|
|
522
531
|
* @throws {Error}
|