@capgo/capacitor-updater 8.46.3 → 8.47.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.
@@ -60,6 +60,7 @@ public class CapgoUpdater {
60
60
 
61
61
  private static final String FALLBACK_VERSION = "pastVersion";
62
62
  private static final String NEXT_VERSION = "nextVersion";
63
+ private static final String PREVIEW_FALLBACK_VERSION = "previewFallbackVersion";
63
64
  private static final String bundleDirectory = "versions";
64
65
  private static final String TEMP_UNZIP_PREFIX = "capgo_unzip_";
65
66
 
@@ -81,6 +82,7 @@ public class CapgoUpdater {
81
82
  public String channelUrl = "";
82
83
  public String defaultChannel = "";
83
84
  public String appId = "";
85
+ public boolean previewSession = false;
84
86
  public String publicKey = "";
85
87
  public String deviceID = "";
86
88
  public int timeout = 20000;
@@ -124,24 +126,32 @@ public class CapgoUpdater {
124
126
  }
125
127
 
126
128
  private boolean isEmulator() {
129
+ final String brand = String.valueOf(Build.BRAND);
130
+ final String device = String.valueOf(Build.DEVICE);
131
+ final String fingerprint = String.valueOf(Build.FINGERPRINT);
132
+ final String hardware = String.valueOf(Build.HARDWARE);
133
+ final String model = String.valueOf(Build.MODEL);
134
+ final String manufacturer = String.valueOf(Build.MANUFACTURER);
135
+ final String product = String.valueOf(Build.PRODUCT);
136
+
127
137
  return (
128
- (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) ||
129
- Build.FINGERPRINT.startsWith("generic") ||
130
- Build.FINGERPRINT.startsWith("unknown") ||
131
- Build.HARDWARE.contains("goldfish") ||
132
- Build.HARDWARE.contains("ranchu") ||
133
- Build.MODEL.contains("google_sdk") ||
134
- Build.MODEL.contains("Emulator") ||
135
- Build.MODEL.contains("Android SDK built for x86") ||
136
- Build.MANUFACTURER.contains("Genymotion") ||
137
- Build.PRODUCT.contains("sdk_google") ||
138
- Build.PRODUCT.contains("google_sdk") ||
139
- Build.PRODUCT.contains("sdk") ||
140
- Build.PRODUCT.contains("sdk_x86") ||
141
- Build.PRODUCT.contains("sdk_gphone64_arm64") ||
142
- Build.PRODUCT.contains("vbox86p") ||
143
- Build.PRODUCT.contains("emulator") ||
144
- Build.PRODUCT.contains("simulator")
138
+ (brand.startsWith("generic") && device.startsWith("generic")) ||
139
+ fingerprint.startsWith("generic") ||
140
+ fingerprint.startsWith("unknown") ||
141
+ hardware.contains("goldfish") ||
142
+ hardware.contains("ranchu") ||
143
+ model.contains("google_sdk") ||
144
+ model.contains("Emulator") ||
145
+ model.contains("Android SDK built for x86") ||
146
+ manufacturer.contains("Genymotion") ||
147
+ product.contains("sdk_google") ||
148
+ product.contains("google_sdk") ||
149
+ product.contains("sdk") ||
150
+ product.contains("sdk_x86") ||
151
+ product.contains("sdk_gphone64_arm64") ||
152
+ product.contains("vbox86p") ||
153
+ product.contains("emulator") ||
154
+ product.contains("simulator")
145
155
  );
146
156
  }
147
157
 
@@ -345,6 +355,137 @@ public class CapgoUpdater {
345
355
  }
346
356
  }
347
357
 
358
+ private boolean verifyChecksum(final File file, final String expectedHash) {
359
+ if (expectedHash == null || expectedHash.isEmpty() || file == null || !file.exists()) {
360
+ return false;
361
+ }
362
+ final String actualHash = CryptoCipher.calcChecksum(file);
363
+ return expectedHash.equalsIgnoreCase(actualHash);
364
+ }
365
+
366
+ private String resolveManifestFileHash(final JSONObject entry, final String sessionKey) {
367
+ String fileHash = entry.optString("file_hash", "");
368
+ if (fileHash.isEmpty()) {
369
+ return "";
370
+ }
371
+ if (this.publicKey != null && !this.publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
372
+ try {
373
+ fileHash = CryptoCipher.decryptChecksum(fileHash, this.publicKey);
374
+ } catch (Exception e) {
375
+ logger.error("Checksum decryption failed while checking missing manifest files");
376
+ logger.debug("File: " + entry.optString("file_name", "unknown") + ", Error: " + e.getMessage());
377
+ return "";
378
+ }
379
+ }
380
+ return fileHash;
381
+ }
382
+
383
+ private boolean isManifestEntryAvailableLocally(final JSONObject entry, final String sessionKey) {
384
+ final String fileName = entry.optString("file_name", "");
385
+ final String fileHash = resolveManifestFileHash(entry, sessionKey);
386
+ if (fileName.isEmpty() || fileHash.isEmpty() || this.activity == null) {
387
+ return false;
388
+ }
389
+
390
+ final File builtinFile = new File(this.activity.getFilesDir(), "public/" + fileName);
391
+ if (verifyChecksum(builtinFile, fileHash)) {
392
+ return true;
393
+ }
394
+
395
+ final boolean isBrotli = fileName.endsWith(".br");
396
+ final String fileNameWithoutPath = new File(fileName).getName();
397
+ final String cacheBaseName = isBrotli ? fileNameWithoutPath.substring(0, fileNameWithoutPath.length() - 3) : fileNameWithoutPath;
398
+ final File cacheFolder = new File(this.activity.getCacheDir(), "capgo_downloads");
399
+ final File cacheFile = new File(cacheFolder, fileHash + "_" + cacheBaseName);
400
+ if (verifyChecksum(cacheFile, fileHash)) {
401
+ return true;
402
+ }
403
+
404
+ if (isBrotli) {
405
+ final File legacyCacheFile = new File(cacheFolder, fileHash + "_" + fileNameWithoutPath);
406
+ return verifyChecksum(legacyCacheFile, fileHash);
407
+ }
408
+
409
+ return false;
410
+ }
411
+
412
+ public JSONArray getMissingBundleFiles(final JSONArray manifest, final String sessionKey) throws JSONException {
413
+ final JSONArray missing = new JSONArray();
414
+ for (int i = 0; i < manifest.length(); i++) {
415
+ final JSONObject entry = manifest.getJSONObject(i);
416
+ if (!isManifestEntryAvailableLocally(entry, sessionKey)) {
417
+ missing.put(entry);
418
+ }
419
+ }
420
+ return missing;
421
+ }
422
+
423
+ public JSONObject missingBundleFilesResult(final JSONArray manifest, final String sessionKey) throws JSONException {
424
+ final JSONArray missing = getMissingBundleFiles(manifest, sessionKey);
425
+ final JSONObject ret = new JSONObject();
426
+ ret.put("missing", missing);
427
+ ret.put("total", manifest.length());
428
+ ret.put("missingCount", missing.length());
429
+ ret.put("reusableCount", manifest.length() - missing.length());
430
+ return ret;
431
+ }
432
+
433
+ private String manifestSizeUrl(final String updateUrl) {
434
+ HttpUrl parsed = HttpUrl.parse(updateUrl);
435
+ if (parsed == null) {
436
+ return updateUrl;
437
+ }
438
+ return parsed.newBuilder().addPathSegment("manifest_size").query(null).build().toString();
439
+ }
440
+
441
+ private JSONObject unavailableBundleSizeResult(final JSONArray manifest, final String error) throws JSONException {
442
+ final JSONObject ret = new JSONObject();
443
+ final JSONArray files = new JSONArray();
444
+ for (int i = 0; i < manifest.length(); i++) {
445
+ final JSONObject entry = new JSONObject(manifest.getJSONObject(i).toString());
446
+ entry.put("error", error);
447
+ files.put(entry);
448
+ }
449
+ ret.put("totalSize", 0);
450
+ ret.put("knownFiles", 0);
451
+ ret.put("unknownFiles", manifest.length());
452
+ ret.put("files", files);
453
+ return ret;
454
+ }
455
+
456
+ public JSONObject getBundleDownloadSize(final String updateUrl, final String version, final JSONArray manifest) throws JSONException {
457
+ if (manifest.length() == 0) {
458
+ final JSONObject ret = new JSONObject();
459
+ ret.put("totalSize", 0);
460
+ ret.put("knownFiles", 0);
461
+ ret.put("unknownFiles", 0);
462
+ ret.put("files", new JSONArray());
463
+ return ret;
464
+ }
465
+
466
+ final JSONObject json = this.createInfoObject();
467
+ json.put("version", version != null ? version : "");
468
+ json.put("manifest", manifest);
469
+
470
+ Request request = new Request.Builder()
471
+ .url(manifestSizeUrl(updateUrl))
472
+ .post(RequestBody.create(json.toString(), MediaType.get("application/json; charset=utf-8")))
473
+ .build();
474
+
475
+ try (Response response = DownloadService.sharedClient.newCall(request).execute()) {
476
+ final ResponseBody responseBody = response.body();
477
+ final String responseData = responseBody != null ? responseBody.string() : "";
478
+ if (!response.isSuccessful() || responseData.isEmpty()) {
479
+ return unavailableBundleSizeResult(manifest, "response_error");
480
+ }
481
+ return new JSONObject(responseData);
482
+ } catch (IOException e) {
483
+ logger.error("Error getting bundle download size");
484
+ logger.debug("Error: " + e.getMessage());
485
+ return unavailableBundleSizeResult(manifest, "response_error");
486
+ }
487
+ }
488
+
348
489
  private void observeWorkProgress(Context context, String id, boolean setNext) {
349
490
  if (!(context instanceof LifecycleOwner)) {
350
491
  logger.error("Context is not a LifecycleOwner, cannot observe work progress");
@@ -941,6 +1082,17 @@ public class CapgoUpdater {
941
1082
  logger.debug("Bundle ID: " + id);
942
1083
  return false;
943
1084
  }
1085
+ final BundleInfo previewFallback = this.getPreviewFallbackBundle();
1086
+ if (
1087
+ previewFallback != null &&
1088
+ !previewFallback.isDeleted() &&
1089
+ !previewFallback.isErrorStatus() &&
1090
+ previewFallback.getId().equals(id)
1091
+ ) {
1092
+ logger.error("Cannot delete the preview fallback bundle");
1093
+ logger.debug("Bundle ID: " + id);
1094
+ return false;
1095
+ }
944
1096
  final BundleInfo next = this.getNextBundle();
945
1097
  if (next != null && !next.isDeleted() && !next.isErrorStatus() && next.getId().equals(id)) {
946
1098
  logger.error("Cannot delete the next bundle");
@@ -1081,6 +1233,21 @@ public class CapgoUpdater {
1081
1233
  return true;
1082
1234
  }
1083
1235
 
1236
+ boolean stagePreviewFallbackReload(final BundleInfo bundle) {
1237
+ if (bundle == null || bundle.isErrorStatus()) {
1238
+ return false;
1239
+ }
1240
+ if (bundle.isBuiltin()) {
1241
+ this.setCurrentBundle(new File("public"));
1242
+ return true;
1243
+ }
1244
+ if (!this.bundleExists(bundle.getId())) {
1245
+ return false;
1246
+ }
1247
+ this.setCurrentBundle(this.getBundleDirectory(bundle.getId()));
1248
+ return true;
1249
+ }
1250
+
1084
1251
  void finalizePendingReload(final BundleInfo bundle, final String previousBundleName) {
1085
1252
  if (bundle == null || bundle.isBuiltin()) {
1086
1253
  return;
@@ -1147,12 +1314,20 @@ public class CapgoUpdater {
1147
1314
  public void setSuccess(final BundleInfo bundle, Boolean autoDeletePrevious) {
1148
1315
  this.setBundleStatus(bundle.getId(), BundleStatus.SUCCESS);
1149
1316
  final BundleInfo fallback = this.getFallbackBundle();
1317
+ final BundleInfo previewFallback = this.getPreviewFallbackBundle();
1318
+ final boolean fallbackIsPreviewFallback = previewFallback != null && previewFallback.getId().equals(fallback.getId());
1150
1319
  logger.debug("Fallback bundle is: " + fallback);
1151
1320
  logger.info("Version successfully loaded: " + bundle.getVersionName());
1152
1321
  // Only attempt to delete when the fallback is a different bundle than the
1153
1322
  // currently loaded one. Otherwise we spam logs with "Cannot delete <id>"
1154
1323
  // because delete() protects the current bundle from removal.
1155
- if (autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != null && !fallback.getId().equals(bundle.getId())) {
1324
+ if (
1325
+ autoDeletePrevious &&
1326
+ !fallback.isBuiltin() &&
1327
+ fallback.getId() != null &&
1328
+ !fallback.getId().equals(bundle.getId()) &&
1329
+ !fallbackIsPreviewFallback
1330
+ ) {
1156
1331
  final Boolean res = this.delete(fallback.getId());
1157
1332
  if (res) {
1158
1333
  logger.info("Deleted previous bundle: " + fallback.getVersionName());
@@ -1175,10 +1350,14 @@ public class CapgoUpdater {
1175
1350
  }
1176
1351
 
1177
1352
  private JSONObject createInfoObject() throws JSONException {
1353
+ return this.createInfoObject(null);
1354
+ }
1355
+
1356
+ private JSONObject createInfoObject(final String appIdOverride) throws JSONException {
1178
1357
  JSONObject json = new JSONObject();
1179
1358
  json.put("platform", "android");
1180
1359
  json.put("device_id", this.deviceID);
1181
- json.put("app_id", this.appId);
1360
+ json.put("app_id", appIdOverride == null || appIdOverride.trim().isEmpty() ? this.appId : appIdOverride);
1182
1361
  json.put("custom_id", this.customId);
1183
1362
  json.put("version_build", this.versionBuild);
1184
1363
  json.put("version_code", this.versionCode);
@@ -1204,7 +1383,7 @@ public class CapgoUpdater {
1204
1383
  if (response.code() == 429) {
1205
1384
  // Send a statistic about the rate limit BEFORE setting the flag
1206
1385
  // Only send once to prevent infinite loop if the stat request itself gets rate limited
1207
- if (!rateLimitExceeded && !rateLimitStatisticSent) {
1386
+ if (!this.previewSession && !rateLimitExceeded && !rateLimitStatisticSent) {
1208
1387
  rateLimitStatisticSent = true;
1209
1388
  sendRateLimitStatistic();
1210
1389
  }
@@ -1362,9 +1541,13 @@ public class CapgoUpdater {
1362
1541
  }
1363
1542
 
1364
1543
  public void getLatest(final String updateUrl, final String channel, final Callback callback) {
1544
+ this.getLatest(updateUrl, channel, null, callback);
1545
+ }
1546
+
1547
+ public void getLatest(final String updateUrl, final String channel, final String appIdOverride, final Callback callback) {
1365
1548
  JSONObject json;
1366
1549
  try {
1367
- json = this.createInfoObject();
1550
+ json = this.createInfoObject(appIdOverride);
1368
1551
  if (channel != null && json != null) {
1369
1552
  json.put("defaultChannel", channel);
1370
1553
  }
@@ -1378,7 +1561,9 @@ public class CapgoUpdater {
1378
1561
  return;
1379
1562
  }
1380
1563
 
1381
- logger.info("Auto-update parameters: " + json);
1564
+ if (logger != null) {
1565
+ logger.info("Auto-update parameters: " + json);
1566
+ }
1382
1567
 
1383
1568
  makeJsonRequest(updateUrl, json, callback);
1384
1569
  }
@@ -1749,6 +1934,13 @@ public class CapgoUpdater {
1749
1934
  }
1750
1935
 
1751
1936
  public void sendStats(final String action, final String versionName, final String oldVersionName, final Map<String, String> metadata) {
1937
+ if (this.previewSession) {
1938
+ if (logger != null) {
1939
+ logger.debug("Skipping sendStats during preview session.");
1940
+ }
1941
+ return;
1942
+ }
1943
+
1752
1944
  // Check if rate limit was exceeded
1753
1945
  if (rateLimitExceeded) {
1754
1946
  logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.");
@@ -1971,6 +2163,31 @@ public class CapgoUpdater {
1971
2163
  return this.getBundleInfo(id);
1972
2164
  }
1973
2165
 
2166
+ public BundleInfo getPreviewFallbackBundle() {
2167
+ final String id = this.prefs.getString(PREVIEW_FALLBACK_VERSION, null);
2168
+ if (id == null) return null;
2169
+ final BundleInfo bundle = this.getBundleInfo(id);
2170
+ if (bundle.isErrorStatus() || (!bundle.isBuiltin() && !this.bundleExists(id))) {
2171
+ this.setPreviewFallbackBundle(null);
2172
+ return null;
2173
+ }
2174
+ return bundle;
2175
+ }
2176
+
2177
+ public boolean setPreviewFallbackBundle(final String fallback) {
2178
+ if (fallback == null) {
2179
+ this.editor.remove(PREVIEW_FALLBACK_VERSION);
2180
+ } else {
2181
+ final BundleInfo newBundle = this.getBundleInfo(fallback);
2182
+ if (newBundle.isErrorStatus() || (!newBundle.isBuiltin() && !this.bundleExists(fallback))) {
2183
+ return false;
2184
+ }
2185
+ this.editor.putString(PREVIEW_FALLBACK_VERSION, fallback);
2186
+ }
2187
+ this.editor.commit();
2188
+ return true;
2189
+ }
2190
+
1974
2191
  public boolean setNextBundle(final String next) {
1975
2192
  BundleInfo bundleToNotify = null;
1976
2193
  if (next == null) {
@@ -66,8 +66,9 @@ public class ShakeMenu implements ShakeDetector.Listener {
66
66
 
67
67
  isShowing = true;
68
68
 
69
- // Check if channel selector mode is enabled
70
- if (plugin.shakeChannelSelectorEnabled) {
69
+ if (plugin.hasActivePreviewSession()) {
70
+ showDefaultMenu();
71
+ } else if (plugin.shakeChannelSelectorEnabled) {
71
72
  showChannelSelector();
72
73
  } else {
73
74
  showDefaultMenu();
@@ -75,6 +76,98 @@ public class ShakeMenu implements ShakeDetector.Listener {
75
76
  }
76
77
 
77
78
  private void showDefaultMenu() {
79
+ activity.runOnUiThread(() -> {
80
+ try {
81
+ if (!plugin.hasActivePreviewSession()) {
82
+ showConfiguredDefaultMenu();
83
+ return;
84
+ }
85
+ String appName = activity.getPackageManager().getApplicationLabel(activity.getApplicationInfo()).toString();
86
+ String title = "Preview " + appName + " Menu";
87
+ String message = "Reload the current preview or leave the test app.";
88
+ String okButtonTitle = "Leave test app";
89
+ String reloadButtonTitle = "Reload app";
90
+ String cancelButtonTitle = "Close menu";
91
+
92
+ AlertDialog.Builder builder = new AlertDialog.Builder(activity);
93
+ builder.setTitle(title);
94
+ builder.setMessage(message);
95
+
96
+ builder.setPositiveButton(okButtonTitle, null);
97
+ builder.setNeutralButton(reloadButtonTitle, null);
98
+
99
+ // Cancel button
100
+ builder.setNegativeButton(
101
+ cancelButtonTitle,
102
+ new DialogInterface.OnClickListener() {
103
+ public void onClick(DialogInterface dialog, int id) {
104
+ logger.info("Shake menu cancelled");
105
+ dialog.dismiss();
106
+ isShowing = false;
107
+ }
108
+ }
109
+ );
110
+
111
+ AlertDialog dialog = builder.create();
112
+ dialog.setOnDismissListener((dialogInterface) -> isShowing = false);
113
+ dialog.show();
114
+ dialog
115
+ .getButton(AlertDialog.BUTTON_POSITIVE)
116
+ .setOnClickListener((view) -> {
117
+ setPreviewMenuButtonsEnabled(dialog, false);
118
+ new Thread(() -> {
119
+ try {
120
+ if (!plugin.leavePreviewSessionFromShakeMenu()) {
121
+ activity.runOnUiThread(() -> showError("Could not leave the test app."));
122
+ }
123
+ } catch (Exception e) {
124
+ logger.error("Error leaving test app: " + e.getMessage());
125
+ activity.runOnUiThread(() -> showError("Error leaving test app: " + e.getMessage()));
126
+ } finally {
127
+ activity.runOnUiThread(() -> {
128
+ dialog.dismiss();
129
+ isShowing = false;
130
+ });
131
+ }
132
+ })
133
+ .start();
134
+ });
135
+ dialog
136
+ .getButton(AlertDialog.BUTTON_NEUTRAL)
137
+ .setOnClickListener((view) -> {
138
+ setPreviewMenuButtonsEnabled(dialog, false);
139
+ new Thread(() -> {
140
+ try {
141
+ logger.info("Reloading webview");
142
+ if (!plugin.reloadPreviewSessionFromShakeMenu()) {
143
+ activity.runOnUiThread(() -> showError("Could not reload the test app."));
144
+ }
145
+ } catch (Exception e) {
146
+ logger.error("Error in Reload action: " + e.getMessage());
147
+ activity.runOnUiThread(() -> showError("Error reloading test app: " + e.getMessage()));
148
+ } finally {
149
+ activity.runOnUiThread(() -> {
150
+ dialog.dismiss();
151
+ isShowing = false;
152
+ });
153
+ }
154
+ })
155
+ .start();
156
+ });
157
+ } catch (Exception e) {
158
+ logger.error("Error showing shake menu: " + e.getMessage());
159
+ isShowing = false;
160
+ }
161
+ });
162
+ }
163
+
164
+ private void setPreviewMenuButtonsEnabled(AlertDialog dialog, boolean enabled) {
165
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(enabled);
166
+ dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setEnabled(enabled);
167
+ dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setEnabled(enabled);
168
+ }
169
+
170
+ private void showConfiguredDefaultMenu() {
78
171
  activity.runOnUiThread(() -> {
79
172
  try {
80
173
  String appName = activity.getPackageManager().getApplicationLabel(activity.getApplicationInfo()).toString();
@@ -120,7 +213,6 @@ public class ShakeMenu implements ShakeDetector.Listener {
120
213
  bridge.setServerAssetPath(path);
121
214
  }
122
215
 
123
- // Try to delete the current bundle
124
216
  try {
125
217
  updater.delete(current.getId());
126
218
  } catch (Exception err) {