@capgo/capacitor-updater 7.43.3 → 7.50.1

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.
Files changed (36) hide show
  1. package/Package.swift +5 -2
  2. package/README.md +643 -115
  3. package/android/build.gradle +3 -3
  4. package/android/src/main/java/ee/forgr/capacitor_updater/AndroidAppExitReporter.java +92 -0
  5. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +3121 -353
  6. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +924 -290
  7. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +49 -13
  8. package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +45 -30
  9. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +75 -32
  10. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +2 -0
  11. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +3 -3
  12. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +401 -27
  13. package/android/src/main/java/ee/forgr/capacitor_updater/ThreeFingerPinchDetector.java +323 -0
  14. package/dist/docs.json +1590 -196
  15. package/dist/esm/definitions.d.ts +755 -66
  16. package/dist/esm/definitions.js.map +1 -1
  17. package/dist/esm/web.d.ts +11 -2
  18. package/dist/esm/web.js +59 -5
  19. package/dist/esm/web.js.map +1 -1
  20. package/dist/plugin.cjs.js +59 -5
  21. package/dist/plugin.cjs.js.map +1 -1
  22. package/dist/plugin.js +59 -5
  23. package/dist/plugin.js.map +1 -1
  24. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +0 -1
  25. package/ios/Sources/CapacitorUpdaterPlugin/AppHealthTracker.swift +82 -0
  26. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +0 -16
  27. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +2 -2
  28. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +78 -2
  29. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +2732 -417
  30. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1191 -363
  31. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +0 -1
  32. package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +37 -16
  33. package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +80 -1
  34. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +438 -39
  35. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +276 -0
  36. package/package.json +24 -9
@@ -2,9 +2,12 @@ package ee.forgr.capacitor_updater;
2
2
 
3
3
  import android.content.SharedPreferences;
4
4
  import io.github.g00fy2.versioncompare.Version;
5
+ import java.text.ParsePosition;
5
6
  import java.text.SimpleDateFormat;
6
7
  import java.util.ArrayList;
7
8
  import java.util.Date;
9
+ import java.util.Locale;
10
+ import java.util.TimeZone;
8
11
  import org.json.JSONArray;
9
12
  import org.json.JSONException;
10
13
  import org.json.JSONObject;
@@ -98,25 +101,16 @@ public class DelayUpdateUtils {
98
101
  break;
99
102
  case DelayUntilNext.date:
100
103
  if (!"".equals(value)) {
101
- try {
102
- final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS");
103
- Date date = sdf.parse(value);
104
- assert date != null;
104
+ Date date = parseDateCondition(value);
105
+ if (date != null) {
105
106
  if (new Date().compareTo(date) > 0) {
106
107
  logger.info("Date delay (value: " + value + ") condition removed due to expired date at index " + index);
107
108
  } else {
108
109
  delayConditionListToKeep.add(condition);
109
110
  logger.info("Date delay (value: " + value + ") condition kept at index " + index);
110
111
  }
111
- } catch (final Exception e) {
112
- logger.error(
113
- "Date delay (value: " +
114
- value +
115
- ") condition removed due to parsing issue at index " +
116
- index +
117
- " " +
118
- e.getMessage()
119
- );
112
+ } else {
113
+ logger.error("Date delay (value: " + value + ") condition removed due to parsing issue at index " + index);
120
114
  }
121
115
  } else {
122
116
  logger.debug("Date delay (value: " + value + ") condition removed due to empty value at index " + index);
@@ -190,6 +184,48 @@ public class DelayUpdateUtils {
190
184
  return conditions;
191
185
  }
192
186
 
187
+ private Date parseDateCondition(String value) {
188
+ String[] patterns = {
189
+ "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
190
+ "yyyy-MM-dd'T'HH:mm:ssXXX",
191
+ "yyyy-MM-dd'T'HH:mm:ss.SSSXX",
192
+ "yyyy-MM-dd'T'HH:mm:ssXX",
193
+ "yyyy-MM-dd'T'HH:mm:ss.SSSX",
194
+ "yyyy-MM-dd'T'HH:mm:ssX",
195
+ "yyyy-MM-dd'T'HH:mm:ss.SSS",
196
+ "yyyy-MM-dd'T'HH:mm:ss"
197
+ };
198
+
199
+ for (String pattern : patterns) {
200
+ Date parsed = parseDateWithPattern(value, pattern);
201
+ if (parsed != null) {
202
+ return parsed;
203
+ }
204
+ }
205
+
206
+ return null;
207
+ }
208
+
209
+ private Date parseDateWithPattern(String value, String pattern) {
210
+ try {
211
+ SimpleDateFormat sdf = new SimpleDateFormat(pattern, Locale.US);
212
+ sdf.setLenient(false);
213
+
214
+ // If no timezone is provided, keep historical behavior and interpret as local time.
215
+ if (!pattern.contains("X")) {
216
+ sdf.setTimeZone(TimeZone.getDefault());
217
+ }
218
+
219
+ ParsePosition position = new ParsePosition(0);
220
+ Date parsed = sdf.parse(value, position);
221
+ if (parsed != null && position.getIndex() == value.length()) {
222
+ return parsed;
223
+ }
224
+ } catch (Exception ignored) {}
225
+
226
+ return null;
227
+ }
228
+
193
229
  private String convertDelayConditionsToJson(ArrayList<DelayCondition> conditions) {
194
230
  JSONArray array = new JSONArray();
195
231
  for (DelayCondition condition : conditions) {
@@ -26,10 +26,10 @@ import javax.crypto.spec.GCMParameterSpec;
26
26
 
27
27
  /**
28
28
  * Helper class to manage device ID persistence across app installations.
29
- * Uses Android Keystore to persist the device ID across reinstalls.
29
+ * Uses Android Keystore-backed storage and backup-restorable SharedPreferences.
30
30
  *
31
- * The device ID is a random UUID stored in the Android Keystore, which persists
32
- * even after app uninstall/reinstall on Android 6.0+ (API 23+).
31
+ * The device ID is a random UUID stored in encrypted preferences and mirrored
32
+ * to appUUID so Android backup/restore can keep it across reinstalls.
33
33
  */
34
34
  public class DeviceIdHelper {
35
35
 
@@ -45,10 +45,10 @@ public class DeviceIdHelper {
45
45
  * Gets or creates a device ID that persists across reinstalls.
46
46
  *
47
47
  * This method:
48
- * 1. First checks for an existing ID in Keystore-encrypted storage (persists across reinstalls)
49
- * 2. Falls back to legacy SharedPreferences (for migration)
48
+ * 1. First checks for an existing ID in Keystore-encrypted storage
49
+ * 2. Falls back to legacy SharedPreferences restored by Android backup
50
50
  * 3. Generates a new UUID if neither exists
51
- * 4. Stores the ID in Keystore-encrypted storage for future use
51
+ * 4. Stores the ID synchronously in both stores for future use
52
52
  *
53
53
  * @param context Application context
54
54
  * @param legacyPrefs Legacy SharedPreferences (for migration)
@@ -60,7 +60,9 @@ public class DeviceIdHelper {
60
60
  String deviceId = getDeviceIdFromKeystore(context);
61
61
 
62
62
  if (deviceId != null && !deviceId.isEmpty()) {
63
- return deviceId.toLowerCase();
63
+ deviceId = deviceId.toLowerCase();
64
+ saveLegacyDeviceId(legacyPrefs, deviceId);
65
+ return deviceId;
64
66
  }
65
67
 
66
68
  // Migration: Check legacy SharedPreferences for existing device ID
@@ -74,7 +76,8 @@ public class DeviceIdHelper {
74
76
  // Ensure lowercase for consistency
75
77
  deviceId = deviceId.toLowerCase();
76
78
 
77
- // Save to Keystore storage
79
+ // Save to backup-restorable preferences and Keystore storage
80
+ saveLegacyDeviceId(legacyPrefs, deviceId);
78
81
  saveDeviceIdToKeystore(context, deviceId);
79
82
 
80
83
  return deviceId;
@@ -90,31 +93,35 @@ public class DeviceIdHelper {
90
93
  * @param context Application context
91
94
  * @return Device ID string or null if not found
92
95
  */
93
- private static String getDeviceIdFromKeystore(Context context) throws Exception {
94
- SharedPreferences prefs = context.getSharedPreferences(DEVICE_ID_PREFS, Context.MODE_PRIVATE);
95
- String encryptedDeviceId = prefs.getString(DEVICE_ID_KEY, null);
96
- String ivString = prefs.getString(IV_KEY, null);
96
+ private static String getDeviceIdFromKeystore(Context context) {
97
+ try {
98
+ SharedPreferences prefs = context.getSharedPreferences(DEVICE_ID_PREFS, Context.MODE_PRIVATE);
99
+ String encryptedDeviceId = prefs.getString(DEVICE_ID_KEY, null);
100
+ String ivString = prefs.getString(IV_KEY, null);
97
101
 
98
- if (encryptedDeviceId == null || ivString == null) {
99
- return null;
100
- }
102
+ if (encryptedDeviceId == null || ivString == null) {
103
+ return null;
104
+ }
101
105
 
102
- // Get the encryption key from Keystore
103
- SecretKey key = getOrCreateKey();
104
- if (key == null) {
105
- return null;
106
- }
106
+ // Get the encryption key from Keystore
107
+ SecretKey key = getOrCreateKey();
108
+ if (key == null) {
109
+ return null;
110
+ }
107
111
 
108
- // Decrypt the device ID
109
- byte[] encryptedBytes = android.util.Base64.decode(encryptedDeviceId, android.util.Base64.DEFAULT);
110
- byte[] iv = android.util.Base64.decode(ivString, android.util.Base64.DEFAULT);
112
+ // Decrypt the device ID
113
+ byte[] encryptedBytes = android.util.Base64.decode(encryptedDeviceId, android.util.Base64.DEFAULT);
114
+ byte[] iv = android.util.Base64.decode(ivString, android.util.Base64.DEFAULT);
111
115
 
112
- Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
113
- GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
114
- cipher.init(Cipher.DECRYPT_MODE, key, spec);
116
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
117
+ GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
118
+ cipher.init(Cipher.DECRYPT_MODE, key, spec);
115
119
 
116
- byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
117
- return new String(decryptedBytes, StandardCharsets.UTF_8);
120
+ byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
121
+ return new String(decryptedBytes, StandardCharsets.UTF_8);
122
+ } catch (Exception e) {
123
+ return null;
124
+ }
118
125
  }
119
126
 
120
127
  /**
@@ -146,7 +153,7 @@ public class DeviceIdHelper {
146
153
  .edit()
147
154
  .putString(DEVICE_ID_KEY, android.util.Base64.encodeToString(encryptedBytes, android.util.Base64.DEFAULT))
148
155
  .putString(IV_KEY, android.util.Base64.encodeToString(iv, android.util.Base64.DEFAULT))
149
- .apply();
156
+ .commit();
150
157
  }
151
158
 
152
159
  /**
@@ -207,9 +214,17 @@ public class DeviceIdHelper {
207
214
 
208
215
  if (deviceId == null || deviceId.isEmpty()) {
209
216
  deviceId = UUID.randomUUID().toString();
210
- legacyPrefs.edit().putString(LEGACY_PREFS_KEY, deviceId).apply();
217
+ saveLegacyDeviceId(legacyPrefs, deviceId);
211
218
  }
212
219
 
213
220
  return deviceId.toLowerCase();
214
221
  }
222
+
223
+ private static void saveLegacyDeviceId(SharedPreferences legacyPrefs, String deviceId) {
224
+ if (legacyPrefs == null || deviceId == null || deviceId.isEmpty()) {
225
+ return;
226
+ }
227
+
228
+ legacyPrefs.edit().putString(LEGACY_PREFS_KEY, deviceId).commit();
229
+ }
215
230
  }
@@ -68,6 +68,7 @@ public class DownloadService extends Worker {
68
68
  public static final String IS_MANIFEST = "is_manifest";
69
69
  public static final String APP_ID = "app_id";
70
70
  public static final String pluginVersion = "plugin_version";
71
+ public static final String INSTALL_SOURCE = "install_source";
71
72
  public static final String STATS_URL = "stats_url";
72
73
  public static final String DEVICE_ID = "device_id";
73
74
  public static final String CUSTOM_ID = "custom_id";
@@ -91,27 +92,50 @@ public class DownloadService extends Worker {
91
92
  .protocols(Arrays.asList(Protocol.HTTP_2, Protocol.HTTP_1_1))
92
93
  .addInterceptor((chain) -> {
93
94
  Request originalRequest = chain.request();
94
- String userAgent =
95
- "CapacitorUpdater/" +
96
- (currentPluginVersion != null ? currentPluginVersion : "unknown") +
97
- " (" +
98
- (currentAppId != null ? currentAppId : "unknown") +
99
- ") android/" +
100
- (currentVersionOs != null ? currentVersionOs : "unknown");
95
+ String userAgent = buildUserAgent(currentAppId, currentPluginVersion, currentVersionOs);
101
96
  Request requestWithUserAgent = originalRequest.newBuilder().header("User-Agent", userAgent).build();
102
97
  return chain.proceed(requestWithUserAgent);
103
98
  })
104
99
  .build();
105
100
  }
106
101
 
102
+ static String buildUserAgent(String appId, String pluginVersion, String versionOs) {
103
+ return (
104
+ "CapacitorUpdater/" +
105
+ sanitizeUserAgentValue(pluginVersion) +
106
+ " (" +
107
+ sanitizeUserAgentValue(appId) +
108
+ ") android/" +
109
+ sanitizeUserAgentValue(versionOs)
110
+ );
111
+ }
112
+
113
+ private static String sanitizeUserAgentValue(String value) {
114
+ if (value == null || value.isEmpty()) {
115
+ return "unknown";
116
+ }
117
+
118
+ StringBuilder sanitized = new StringBuilder();
119
+ value.codePoints().forEach((cp) -> {
120
+ boolean isVisibleAscii = cp >= 0x20 && cp <= 0x7E;
121
+ boolean isIso88591 = cp >= 0xA0 && cp <= 0xFF;
122
+ if (isVisibleAscii || isIso88591) {
123
+ sanitized.appendCodePoint(cp);
124
+ }
125
+ });
126
+
127
+ String result = sanitized.toString().trim();
128
+ return result.isEmpty() ? "unknown" : result;
129
+ }
130
+
107
131
  // Method to update User-Agent values
108
132
  public static void updateUserAgent(String appId, String pluginVersion, String versionOs) {
109
- currentAppId = appId != null ? appId : "unknown";
110
- currentPluginVersion = pluginVersion != null ? pluginVersion : "unknown";
111
- currentVersionOs = versionOs != null ? versionOs : "unknown";
112
- logger.debug(
113
- "Updated User-Agent: CapacitorUpdater/" + currentPluginVersion + " (" + currentAppId + ") android/" + currentVersionOs
114
- );
133
+ currentAppId = sanitizeUserAgentValue(appId);
134
+ currentPluginVersion = sanitizeUserAgentValue(pluginVersion);
135
+ currentVersionOs = sanitizeUserAgentValue(versionOs);
136
+ if (logger != null) {
137
+ logger.debug("Updated User-Agent: " + buildUserAgent(currentAppId, currentPluginVersion, currentVersionOs));
138
+ }
115
139
  }
116
140
 
117
141
  public DownloadService(@NonNull Context context, @NonNull WorkerParameters params) {
@@ -143,6 +167,16 @@ public class DownloadService extends Worker {
143
167
  return Result.success(output);
144
168
  }
145
169
 
170
+ static File resolveManifestTargetFile(final File destFolder, final String fileName) throws IOException {
171
+ final boolean isBrotli = fileName.endsWith(".br");
172
+ final String targetFileName = isBrotli ? fileName.substring(0, fileName.length() - 3) : fileName;
173
+ return CapgoUpdater.resolvePathInsideDirectory(destFolder, targetFileName);
174
+ }
175
+
176
+ static File resolveManifestBuiltinFile(final File builtinFolder, final String fileName) throws IOException {
177
+ return CapgoUpdater.resolvePathInsideDirectory(builtinFolder, fileName);
178
+ }
179
+
146
180
  private String getInputString(String key, String fallback) {
147
181
  String value = getInputData().getString(key);
148
182
  return value != null ? value : fallback;
@@ -203,6 +237,7 @@ public class DownloadService extends Worker {
203
237
  json.put("platform", "android");
204
238
  json.put("app_id", getInputString(APP_ID, "unknown"));
205
239
  json.put("plugin_version", getInputString(pluginVersion, "unknown"));
240
+ json.put("install_source", getInputString(INSTALL_SOURCE, ""));
206
241
  json.put("version_name", version != null ? version : "");
207
242
  json.put("old_version_name", "");
208
243
  json.put("action", action);
@@ -220,27 +255,26 @@ public class DownloadService extends Worker {
220
255
  .post(RequestBody.create(json.toString(), MediaType.get("application/json")))
221
256
  .build();
222
257
 
223
- sharedClient
224
- .newCall(request)
225
- .enqueue(
226
- new Callback() {
227
- @Override
228
- public void onFailure(@NonNull Call call, @NonNull IOException e) {
229
- if (logger != null) {
230
- logger.error("Failed to send stats: " + e.getMessage());
231
- }
258
+ sharedClient.newCall(request).enqueue(
259
+ new Callback() {
260
+ @Override
261
+ public void onFailure(@NonNull Call call, @NonNull IOException e) {
262
+ if (logger != null) {
263
+ logger.error("Failed to send stats: " + e.getMessage());
232
264
  }
265
+ }
233
266
 
234
- @Override
235
- public void onResponse(@NonNull Call call, @NonNull Response response) {
236
- try (ResponseBody body = response.body()) {
237
- // nothing else to do, just closing body
238
- } catch (Exception ignored) {} finally {
239
- response.close();
240
- }
267
+ @Override
268
+ public void onResponse(@NonNull Call call, @NonNull Response response) {
269
+ try (ResponseBody body = response.body()) {
270
+ // nothing else to do, just closing body
271
+ } catch (Exception ignored) {
272
+ } finally {
273
+ response.close();
241
274
  }
242
275
  }
243
- );
276
+ }
277
+ );
244
278
  } catch (Exception e) {
245
279
  if (logger != null) {
246
280
  logger.error("sendStatsAsync error: " + e.getMessage());
@@ -313,11 +347,20 @@ public class DownloadService extends Worker {
313
347
  boolean isBrotli = fileName.endsWith(".br");
314
348
  String targetFileName = isBrotli ? fileName.substring(0, fileName.length() - 3) : fileName;
315
349
 
316
- File targetFile = new File(destFolder, targetFileName);
350
+ File targetFile;
351
+ File builtinFile;
352
+ try {
353
+ targetFile = resolveManifestTargetFile(destFolder, fileName);
354
+ builtinFile = resolveManifestBuiltinFile(builtinFolder, fileName);
355
+ } catch (IOException e) {
356
+ logger.error("Invalid manifest file path: " + fileName);
357
+ sendStatsAsync("manifest_path_fail", version + ":" + fileName);
358
+ hasError.set(true);
359
+ continue;
360
+ }
317
361
  String cacheBaseName = new File(isBrotli ? targetFileName : fileName).getName();
318
362
  File cacheFile = new File(cacheFolder, finalFileHash + "_" + cacheBaseName);
319
363
  final File legacyCacheFile = isBrotli ? new File(cacheFolder, finalFileHash + "_" + new File(fileName).getName()) : null;
320
- File builtinFile = new File(builtinFolder, fileName);
321
364
 
322
365
  // Ensure parent directories of the target file exist
323
366
  if (!Objects.requireNonNull(targetFile.getParentFile()).exists() && !targetFile.getParentFile().mkdirs()) {
@@ -63,6 +63,7 @@ public class DownloadWorkerManager {
63
63
  String appId,
64
64
  String pluginVersion,
65
65
  boolean isProd,
66
+ String installSource,
66
67
  String statsUrl,
67
68
  String deviceId,
68
69
  String versionBuild,
@@ -89,6 +90,7 @@ public class DownloadWorkerManager {
89
90
  .putString(DownloadService.PUBLIC_KEY, publicKey)
90
91
  .putString(DownloadService.APP_ID, appId)
91
92
  .putString(DownloadService.pluginVersion, pluginVersion)
93
+ .putString(DownloadService.INSTALL_SOURCE, installSource)
92
94
  .putString(DownloadService.STATS_URL, statsUrl)
93
95
  .putString(DownloadService.DEVICE_ID, deviceId)
94
96
  .putString(DownloadService.VERSION_BUILD, versionBuild)
@@ -17,8 +17,8 @@ public class ShakeDetector implements SensorEventListener {
17
17
  void onShakeDetected();
18
18
  }
19
19
 
20
- private static final float SHAKE_THRESHOLD = 12.0f; // Acceleration threshold for shake detection
21
- private static final int SHAKE_TIMEOUT = 500; // Minimum time between shake events (ms)
20
+ private static final float SHAKE_THRESHOLD = 16.0f; // Acceleration threshold for shake detection
21
+ private static final int SHAKE_TIMEOUT = 1000; // Minimum time between shake events (ms)
22
22
 
23
23
  private Listener listener;
24
24
  private SensorManager sensorManager;
@@ -34,7 +34,7 @@ public class ShakeDetector implements SensorEventListener {
34
34
  this.accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
35
35
 
36
36
  if (accelerometer != null) {
37
- sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_GAME);
37
+ sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_UI);
38
38
  }
39
39
  }
40
40