@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.
@@ -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 PLUGIN_VERSION = "7.23.3";
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.PLUGIN_VERSION = this.PLUGIN_VERSION;
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
- this.implementation.deviceID = this.prefs.getString("appUUID", UUID.randomUUID().toString()).toLowerCase();
314
- this.editor.putString("appUUID", this.implementation.deviceID);
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.PLUGIN_VERSION, this.implementation.versionOs);
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.PLUGIN_VERSION);
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 PLUGIN_VERSION = "";
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.PLUGIN_VERSION
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.PLUGIN_VERSION);
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 PLUGIN_VERSION = "plugin_version";
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.PLUGIN_VERSION, pluginVersion)
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), this ID is made following Apple and Google privacy best practices, and not persisted between installs",
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), this ID is made following Apple and Google privacy best practices, and not persisted between installs
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}