@capgo/capacitor-updater 8.0.1 → 8.2.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.
Files changed (55) hide show
  1. package/CapgoCapacitorUpdater.podspec +7 -5
  2. package/Package.swift +9 -7
  3. package/README.md +984 -215
  4. package/android/build.gradle +24 -12
  5. package/android/proguard-rules.pro +22 -5
  6. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +110 -22
  7. package/android/src/main/java/ee/forgr/capacitor_updater/Callback.java +2 -2
  8. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +1316 -489
  9. package/android/src/main/java/ee/forgr/capacitor_updater/{CapacitorUpdater.java → CapgoUpdater.java} +662 -203
  10. package/android/src/main/java/ee/forgr/capacitor_updater/{CryptoCipherV2.java → CryptoCipher.java} +138 -33
  11. package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +0 -3
  12. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +260 -0
  13. package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +221 -0
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +497 -133
  15. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +80 -25
  16. package/android/src/main/java/ee/forgr/capacitor_updater/Logger.java +338 -0
  17. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +72 -0
  18. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +169 -0
  19. package/dist/docs.json +873 -154
  20. package/dist/esm/definitions.d.ts +881 -114
  21. package/dist/esm/definitions.js.map +1 -1
  22. package/dist/esm/history.d.ts +1 -0
  23. package/dist/esm/history.js +283 -0
  24. package/dist/esm/history.js.map +1 -0
  25. package/dist/esm/index.d.ts +1 -0
  26. package/dist/esm/index.js +1 -0
  27. package/dist/esm/index.js.map +1 -1
  28. package/dist/esm/web.d.ts +12 -1
  29. package/dist/esm/web.js +29 -2
  30. package/dist/esm/web.js.map +1 -1
  31. package/dist/plugin.cjs.js +311 -2
  32. package/dist/plugin.cjs.js.map +1 -1
  33. package/dist/plugin.js +311 -2
  34. package/dist/plugin.js.map +1 -1
  35. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +69 -0
  36. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +55 -0
  37. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleInfo.swift +37 -10
  38. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleStatus.swift +1 -1
  39. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1610 -0
  40. package/ios/{Plugin/CapacitorUpdater.swift → Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift} +541 -231
  41. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +286 -0
  42. package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +220 -0
  43. package/ios/Sources/CapacitorUpdaterPlugin/DeviceIdHelper.swift +120 -0
  44. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/InternalUtils.swift +54 -0
  45. package/ios/Sources/CapacitorUpdaterPlugin/Logger.swift +310 -0
  46. package/ios/Sources/CapacitorUpdaterPlugin/RSA.swift +274 -0
  47. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +112 -0
  48. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/UserDefaultsExtension.swift +0 -2
  49. package/package.json +21 -19
  50. package/ios/Plugin/CapacitorUpdaterPlugin.swift +0 -975
  51. package/ios/Plugin/CryptoCipherV2.swift +0 -310
  52. /package/{LICENCE → LICENSE} +0 -0
  53. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayCondition.swift +0 -0
  54. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayUntilNext.swift +0 -0
  55. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/Info.plist +0 -0
@@ -10,14 +10,11 @@ import android.app.Activity;
10
10
  import android.content.Context;
11
11
  import android.content.SharedPreferences;
12
12
  import android.os.Build;
13
- import android.util.Log;
14
13
  import androidx.annotation.NonNull;
15
14
  import androidx.lifecycle.LifecycleOwner;
16
15
  import androidx.work.Data;
17
16
  import androidx.work.WorkInfo;
18
17
  import androidx.work.WorkManager;
19
- import com.getcapacitor.JSObject;
20
- import com.getcapacitor.plugin.WebView;
21
18
  import com.google.common.util.concurrent.Futures;
22
19
  import com.google.common.util.concurrent.ListenableFuture;
23
20
  import java.io.BufferedInputStream;
@@ -30,17 +27,28 @@ import java.io.IOException;
30
27
  import java.security.SecureRandom;
31
28
  import java.util.ArrayList;
32
29
  import java.util.Date;
30
+ import java.util.HashMap;
33
31
  import java.util.Iterator;
34
32
  import java.util.List;
33
+ import java.util.Map;
35
34
  import java.util.Objects;
35
+ import java.util.Set;
36
+ import java.util.concurrent.CompletableFuture;
37
+ import java.util.concurrent.ConcurrentHashMap;
38
+ import java.util.concurrent.ExecutorService;
39
+ import java.util.concurrent.Executors;
40
+ import java.util.concurrent.TimeUnit;
36
41
  import java.util.zip.ZipEntry;
37
42
  import java.util.zip.ZipInputStream;
38
43
  import okhttp3.*;
44
+ import okhttp3.HttpUrl;
39
45
  import org.json.JSONArray;
40
46
  import org.json.JSONException;
41
47
  import org.json.JSONObject;
42
48
 
43
- public class CapacitorUpdater {
49
+ public class CapgoUpdater {
50
+
51
+ private final Logger logger;
44
52
 
45
53
  private static final String AB = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
46
54
  private static final SecureRandom rnd = new SecureRandom();
@@ -55,15 +63,14 @@ public class CapacitorUpdater {
55
63
  public SharedPreferences.Editor editor;
56
64
  public SharedPreferences prefs;
57
65
 
58
- public OkHttpClient client;
59
-
60
66
  public File documentsDir;
61
67
  public Boolean directUpdate = false;
62
68
  public Activity activity;
63
- public String PLUGIN_VERSION = "";
69
+ public String pluginVersion = "";
64
70
  public String versionBuild = "";
65
71
  public String versionCode = "";
66
72
  public String versionOs = "";
73
+ public String CAP_SERVER_PATH = "";
67
74
 
68
75
  public String customId = "";
69
76
  public String statsUrl = "";
@@ -74,6 +81,22 @@ public class CapacitorUpdater {
74
81
  public String deviceID = "";
75
82
  public int timeout = 20000;
76
83
 
84
+ // Cached key ID calculated once from publicKey
85
+ private String cachedKeyId = "";
86
+
87
+ // Flag to track if we received a 429 response - stops requests until app restart
88
+ private static volatile boolean rateLimitExceeded = false;
89
+
90
+ // Flag to track if we've already sent the rate limit statistic - prevents infinite loop
91
+ private static volatile boolean rateLimitStatisticSent = false;
92
+
93
+ private final Map<String, CompletableFuture<BundleInfo>> downloadFutures = new ConcurrentHashMap<>();
94
+ private final ExecutorService io = Executors.newSingleThreadExecutor();
95
+
96
+ public CapgoUpdater(Logger logger) {
97
+ this.logger = logger;
98
+ }
99
+
77
100
  private final FilenameFilter filter = (f, name) -> {
78
101
  // ignore directories generated by mac os x
79
102
  return (!name.startsWith("__MACOSX") && !name.startsWith(".") && !name.startsWith(".DS_Store"));
@@ -117,14 +140,27 @@ public class CapacitorUpdater {
117
140
 
118
141
  void directUpdateFinish(final BundleInfo latest) {}
119
142
 
120
- void notifyListeners(final String id, final JSObject res) {}
143
+ void notifyListeners(final String id, final Map<String, Object> res) {}
121
144
 
122
- private String randomString() {
145
+ public String randomString() {
123
146
  final StringBuilder sb = new StringBuilder(10);
124
147
  for (int i = 0; i < 10; i++) sb.append(AB.charAt(rnd.nextInt(AB.length())));
125
148
  return sb.toString();
126
149
  }
127
150
 
151
+ public void setPublicKey(String publicKey) {
152
+ this.publicKey = publicKey;
153
+ if (!publicKey.isEmpty()) {
154
+ this.cachedKeyId = CryptoCipher.calcKeyId(publicKey);
155
+ } else {
156
+ this.cachedKeyId = "";
157
+ }
158
+ }
159
+
160
+ public String getKeyId() {
161
+ return this.cachedKeyId;
162
+ }
163
+
128
164
  private File unzip(final String id, final File zipFile, final String dest) throws IOException {
129
165
  final File targetDirectory = new File(this.documentsDir, dest);
130
166
  try (
@@ -142,7 +178,7 @@ public class CapacitorUpdater {
142
178
  ZipEntry entry;
143
179
  while ((entry = zis.getNextEntry()) != null) {
144
180
  if (entry.getName().contains("\\")) {
145
- Log.e(TAG, "unzip: Windows path is not supported, please use unix path as require by zip RFC: " + entry.getName());
181
+ logger.error("unzip: Windows path is not supported, please use unix path as require by zip RFC: " + entry.getName());
146
182
  this.sendStats("windows_path_fail");
147
183
  }
148
184
  final File file = new File(targetDirectory, entry.getName());
@@ -207,14 +243,14 @@ public class CapacitorUpdater {
207
243
 
208
244
  private void observeWorkProgress(Context context, String id) {
209
245
  if (!(context instanceof LifecycleOwner)) {
210
- Log.e(TAG, "Context is not a LifecycleOwner, cannot observe work progress");
246
+ logger.error("Context is not a LifecycleOwner, cannot observe work progress");
211
247
  return;
212
248
  }
213
249
 
214
250
  activity.runOnUiThread(() -> {
215
251
  WorkManager.getInstance(context)
216
252
  .getWorkInfosByTagLiveData(id)
217
- .observe((LifecycleOwner) context, workInfos -> {
253
+ .observe((LifecycleOwner) context, (workInfos) -> {
218
254
  if (workInfos == null || workInfos.isEmpty()) return;
219
255
 
220
256
  WorkInfo workInfo = workInfos.get(0);
@@ -226,6 +262,7 @@ public class CapacitorUpdater {
226
262
  notifyDownload(id, percent);
227
263
  break;
228
264
  case SUCCEEDED:
265
+ logger.info("Download succeeded: " + workInfo.getState());
229
266
  Data outputData = workInfo.getOutputData();
230
267
  String dest = outputData.getString(DownloadService.FILEDEST);
231
268
  String version = outputData.getString(DownloadService.VERSION);
@@ -233,35 +270,71 @@ public class CapacitorUpdater {
233
270
  String checksum = outputData.getString(DownloadService.CHECKSUM);
234
271
  boolean isManifest = outputData.getBoolean(DownloadService.IS_MANIFEST, false);
235
272
 
236
- boolean success = finishDownload(id, dest, version, sessionKey, checksum, true, isManifest);
237
- if (!success) {
238
- saveBundleInfo(
239
- id,
240
- new BundleInfo(id, version, BundleStatus.ERROR, new Date(System.currentTimeMillis()), "")
241
- );
242
- JSObject ret = new JSObject();
243
- ret.put("version", getCurrentBundle().getVersionName());
244
- ret.put("error", "finish_download_fail");
245
- sendStats("finish_download_fail", version);
246
- notifyListeners("downloadFailed", ret);
247
- }
273
+ io.execute(() -> {
274
+ boolean success = finishDownload(id, dest, version, sessionKey, checksum, true, isManifest);
275
+ BundleInfo resultBundle;
276
+ if (!success) {
277
+ logger.error("Finish download failed: " + version);
278
+ resultBundle = new BundleInfo(
279
+ id,
280
+ version,
281
+ BundleStatus.ERROR,
282
+ new Date(System.currentTimeMillis()),
283
+ ""
284
+ );
285
+ saveBundleInfo(id, resultBundle);
286
+ // Cleanup download tracking
287
+ DownloadWorkerManager.cancelBundleDownload(activity, id, version);
288
+ Map<String, Object> ret = new HashMap<>();
289
+ ret.put("version", version);
290
+ ret.put("error", "finish_download_fail");
291
+ sendStats("finish_download_fail", version);
292
+ notifyListeners("downloadFailed", ret);
293
+ } else {
294
+ // Successful download - cleanup tracking
295
+ DownloadWorkerManager.cancelBundleDownload(activity, id, version);
296
+ resultBundle = getBundleInfo(id);
297
+ }
298
+
299
+ // Complete the future if it exists
300
+ CompletableFuture<BundleInfo> future = downloadFutures.remove(id);
301
+ if (future != null) {
302
+ future.complete(resultBundle);
303
+ }
304
+ });
248
305
  break;
249
306
  case FAILED:
250
307
  Data failedData = workInfo.getOutputData();
251
308
  String error = failedData.getString(DownloadService.ERROR);
309
+ logger.error("Download failed: " + error + " " + workInfo.getState());
252
310
  String failedVersion = failedData.getString(DownloadService.VERSION);
253
- saveBundleInfo(
254
- id,
255
- new BundleInfo(id, failedVersion, BundleStatus.ERROR, new Date(System.currentTimeMillis()), "")
256
- );
257
- JSObject ret = new JSObject();
258
- ret.put("version", getCurrentBundle().getVersionName());
259
- if ("low_mem_fail".equals(error)) {
260
- sendStats("low_mem_fail", failedVersion);
261
- }
262
- ret.put("error", error != null ? error : "download_fail");
263
- sendStats("download_fail", failedVersion);
264
- notifyListeners("downloadFailed", ret);
311
+
312
+ io.execute(() -> {
313
+ BundleInfo failedBundle = new BundleInfo(
314
+ id,
315
+ failedVersion,
316
+ BundleStatus.ERROR,
317
+ new Date(System.currentTimeMillis()),
318
+ ""
319
+ );
320
+ saveBundleInfo(id, failedBundle);
321
+ // Cleanup download tracking for failed downloads
322
+ DownloadWorkerManager.cancelBundleDownload(activity, id, failedVersion);
323
+ Map<String, Object> ret = new HashMap<>();
324
+ ret.put("version", failedVersion);
325
+ if ("low_mem_fail".equals(error)) {
326
+ sendStats("low_mem_fail", failedVersion);
327
+ }
328
+ ret.put("error", error != null ? error : "download_fail");
329
+ sendStats("download_fail", failedVersion);
330
+ notifyListeners("downloadFailed", ret);
331
+
332
+ // Complete the future with error status
333
+ CompletableFuture<BundleInfo> failedFuture = downloadFutures.remove(id);
334
+ if (failedFuture != null) {
335
+ failedFuture.complete(failedBundle);
336
+ }
337
+ });
265
338
  break;
266
339
  }
267
340
  });
@@ -278,7 +351,7 @@ public class CapacitorUpdater {
278
351
  final JSONArray manifest
279
352
  ) {
280
353
  if (this.activity == null) {
281
- Log.e(TAG, "Activity is null, cannot observe work progress");
354
+ logger.error("Activity is null, cannot observe work progress");
282
355
  return;
283
356
  }
284
357
  observeWorkProgress(this.activity, id);
@@ -293,7 +366,18 @@ public class CapacitorUpdater {
293
366
  sessionKey,
294
367
  checksum,
295
368
  this.publicKey,
296
- manifest != null
369
+ manifest != null,
370
+ this.isEmulator(),
371
+ this.appId,
372
+ this.pluginVersion,
373
+ this.isProd(),
374
+ this.statsUrl,
375
+ this.deviceID,
376
+ this.versionBuild,
377
+ this.versionCode,
378
+ this.versionOs,
379
+ this.customId,
380
+ this.defaultChannel
297
381
  );
298
382
 
299
383
  if (manifest != null) {
@@ -311,6 +395,7 @@ public class CapacitorUpdater {
311
395
  Boolean isManifest
312
396
  ) {
313
397
  File downloaded = null;
398
+ File extractedDir = null;
314
399
  String checksum = "";
315
400
 
316
401
  try {
@@ -319,68 +404,94 @@ public class CapacitorUpdater {
319
404
 
320
405
  if (!isManifest) {
321
406
  String checksumDecrypted = Objects.requireNonNullElse(checksumRes, "");
407
+
408
+ // If public key is present but no checksum provided, refuse installation
409
+ if (!this.publicKey.isEmpty() && checksumDecrypted.isEmpty()) {
410
+ logger.error("Public key present but no checksum provided");
411
+ this.sendStats("checksum_required");
412
+ throw new IOException("Checksum required when public key is present: " + id);
413
+ }
414
+
322
415
  if (!sessionKey.isEmpty()) {
323
- CryptoCipherV2.decryptFile(downloaded, publicKey, sessionKey);
324
- checksumDecrypted = CryptoCipherV2.decryptChecksum(checksumRes, publicKey);
325
- checksum = CryptoCipherV2.calcChecksum(downloaded);
416
+ CryptoCipher.decryptFile(downloaded, publicKey, sessionKey);
417
+ checksumDecrypted = CryptoCipher.decryptChecksum(checksumRes, publicKey);
418
+ checksum = CryptoCipher.calcChecksum(downloaded);
419
+ } else {
420
+ checksum = CryptoCipher.calcChecksum(downloaded);
326
421
  }
422
+ CryptoCipher.logChecksumInfo("Calculated checksum", checksum);
423
+ CryptoCipher.logChecksumInfo("Expected checksum", checksumDecrypted);
327
424
  if ((!checksumDecrypted.isEmpty() || !this.publicKey.isEmpty()) && !checksumDecrypted.equals(checksum)) {
328
- Log.e(CapacitorUpdater.TAG, "Error checksum '" + checksumDecrypted + "' '" + checksum + "' '");
425
+ logger.error("Error checksum '" + checksumDecrypted + "' '" + checksum + "' '");
329
426
  this.sendStats("checksum_fail");
330
427
  throw new IOException("Checksum failed: " + id);
331
428
  }
332
429
  }
333
430
  // Remove the decryption for manifest downloads
334
- } catch (IOException e) {
431
+ } catch (Exception e) {
432
+ if (!isManifest) {
433
+ safeDelete(downloaded);
434
+ }
335
435
  final Boolean res = this.delete(id);
336
436
  if (!res) {
337
- Log.i(CapacitorUpdater.TAG, "Double error, cannot cleanup: " + version);
437
+ logger.info("Double error, cannot cleanup: " + version);
338
438
  }
339
439
 
340
- final JSObject ret = new JSObject();
341
- ret.put("version", CapacitorUpdater.this.getCurrentBundle().getVersionName());
440
+ final Map<String, Object> ret = new HashMap<>();
441
+ ret.put("version", version);
342
442
 
343
- CapacitorUpdater.this.notifyListeners("downloadFailed", ret);
344
- CapacitorUpdater.this.sendStats("download_fail");
443
+ CapgoUpdater.this.notifyListeners("downloadFailed", ret);
444
+ CapgoUpdater.this.sendStats("download_fail");
345
445
  return false;
346
446
  }
347
447
 
348
448
  try {
349
449
  if (!isManifest) {
350
- final File unzipped = this.unzip(id, downloaded, this.randomString());
450
+ extractedDir = this.unzip(id, downloaded, this.randomString());
351
451
  this.notifyDownload(id, 91);
352
452
  final String idName = bundleDirectory + "/" + id;
353
- this.flattenAssets(unzipped, idName);
453
+ this.flattenAssets(extractedDir, idName);
354
454
  } else {
355
455
  this.notifyDownload(id, 91);
356
456
  final String idName = bundleDirectory + "/" + id;
357
457
  this.flattenAssets(downloaded, idName);
358
458
  downloaded.delete();
359
459
  }
360
- this.notifyDownload(id, 100);
460
+ // Remove old bundle info and set new one
361
461
  this.saveBundleInfo(id, null);
362
462
  BundleInfo next = new BundleInfo(id, version, BundleStatus.PENDING, new Date(System.currentTimeMillis()), checksum);
363
463
  this.saveBundleInfo(id, next);
464
+ this.notifyDownload(id, 100);
364
465
 
365
- final JSObject ret = new JSObject();
366
- ret.put("bundle", next.toJSON());
367
- CapacitorUpdater.this.notifyListeners("updateAvailable", ret);
466
+ final Map<String, Object> ret = new HashMap<>();
467
+ ret.put("bundle", next.toJSONMap());
468
+ logger.info("updateAvailable: " + ret);
469
+ CapgoUpdater.this.notifyListeners("updateAvailable", ret);
470
+ logger.info("setNext: " + setNext);
368
471
  if (setNext) {
472
+ logger.info("directUpdate: " + this.directUpdate);
369
473
  if (this.directUpdate) {
370
- CapacitorUpdater.this.directUpdateFinish(next);
474
+ CapgoUpdater.this.directUpdateFinish(next);
371
475
  this.directUpdate = false;
372
476
  } else {
373
477
  this.setNextBundle(next.getId());
374
478
  }
375
479
  }
376
480
  } catch (IOException e) {
481
+ if (!isManifest) {
482
+ safeDelete(extractedDir);
483
+ safeDelete(downloaded);
484
+ }
377
485
  e.printStackTrace();
378
- final JSObject ret = new JSObject();
379
- ret.put("version", CapacitorUpdater.this.getCurrentBundle().getVersionName());
380
- CapacitorUpdater.this.notifyListeners("downloadFailed", ret);
381
- CapacitorUpdater.this.sendStats("download_fail");
486
+ final Map<String, Object> ret = new HashMap<>();
487
+ ret.put("version", version);
488
+ CapgoUpdater.this.notifyListeners("downloadFailed", ret);
489
+ CapgoUpdater.this.sendStats("download_fail");
382
490
  return false;
383
491
  }
492
+ if (!isManifest) {
493
+ safeDelete(downloaded);
494
+ }
384
495
  return true;
385
496
  }
386
497
 
@@ -398,9 +509,76 @@ public class CapacitorUpdater {
398
509
  }
399
510
  }
400
511
 
512
+ public void cleanupDeltaCache() {
513
+ if (this.activity == null) {
514
+ logger.warn("Activity is null, skipping delta cache cleanup");
515
+ return;
516
+ }
517
+ final File cacheFolder = new File(this.activity.getCacheDir(), "capgo_downloads");
518
+ if (!cacheFolder.exists()) {
519
+ return;
520
+ }
521
+ try {
522
+ this.deleteDirectory(cacheFolder);
523
+ logger.info("Cleaned up delta cache folder");
524
+ } catch (IOException e) {
525
+ logger.error("Failed to cleanup delta cache: " + e.getMessage());
526
+ }
527
+ }
528
+
529
+ public void cleanupDownloadDirectories(final Set<String> allowedIds) {
530
+ if (this.documentsDir == null) {
531
+ logger.warn("Documents directory is null, skipping download cleanup");
532
+ return;
533
+ }
534
+
535
+ final File bundleRoot = new File(this.documentsDir, bundleDirectory);
536
+ if (!bundleRoot.exists() || !bundleRoot.isDirectory()) {
537
+ return;
538
+ }
539
+
540
+ final File[] entries = bundleRoot.listFiles();
541
+ if (entries != null) {
542
+ for (final File entry : entries) {
543
+ if (!entry.isDirectory()) {
544
+ continue;
545
+ }
546
+
547
+ final String id = entry.getName();
548
+
549
+ if (allowedIds != null && allowedIds.contains(id)) {
550
+ continue;
551
+ }
552
+
553
+ try {
554
+ this.deleteDirectory(entry);
555
+ this.removeBundleInfo(id);
556
+ logger.info("Deleted orphan bundle directory: " + id);
557
+ } catch (IOException e) {
558
+ logger.error("Failed to delete orphan bundle directory: " + id + " " + e.getMessage());
559
+ }
560
+ }
561
+ }
562
+ }
563
+
564
+ private void safeDelete(final File target) {
565
+ if (target == null || !target.exists()) {
566
+ return;
567
+ }
568
+ try {
569
+ if (target.isDirectory()) {
570
+ this.deleteDirectory(target);
571
+ } else if (!target.delete()) {
572
+ logger.warn("Failed to delete file: " + target.getAbsolutePath());
573
+ }
574
+ } catch (IOException cleanupError) {
575
+ logger.warn("Cleanup failed for " + target.getAbsolutePath() + ": " + cleanupError.getMessage());
576
+ }
577
+ }
578
+
401
579
  private void setCurrentBundle(final File bundle) {
402
- this.editor.putString(WebView.CAP_SERVER_PATH, bundle.getPath());
403
- Log.i(TAG, "Current bundle set to: " + bundle);
580
+ this.editor.putString(this.CAP_SERVER_PATH, bundle.getPath());
581
+ logger.info("Current bundle set to: " + bundle);
404
582
  this.editor.commit();
405
583
  }
406
584
 
@@ -413,13 +591,21 @@ public class CapacitorUpdater {
413
591
  ) {
414
592
  final String id = this.randomString();
415
593
 
416
- // Check if version is already downloading
417
- if (this.activity != null && DownloadWorkerManager.isVersionDownloading(version)) {
418
- Log.i(TAG, "Version already downloading: " + version);
419
- return;
594
+ // Check if version is already downloading, but allow retry if previous download failed
595
+ if (this.activity != null && DownloadWorkerManager.isVersionDownloading(this.activity, version)) {
596
+ // Check if there's an existing bundle with error status that we can retry
597
+ BundleInfo existingBundle = this.getBundleInfoByName(version);
598
+ if (existingBundle != null && existingBundle.isErrorStatus()) {
599
+ // Cancel the failed download and allow retry
600
+ DownloadWorkerManager.cancelVersionDownload(this.activity, version);
601
+ logger.info("Retrying failed download for version: " + version);
602
+ } else {
603
+ logger.info("Version already downloading: " + version);
604
+ return;
605
+ }
420
606
  }
421
607
 
422
- this.saveBundleInfo(id, new BundleInfo(id, version, BundleStatus.DOWNLOADING, new Date(System.currentTimeMillis()), ""));
608
+ saveBundleInfo(id, new BundleInfo(id, version, BundleStatus.DOWNLOADING, new Date(System.currentTimeMillis()), ""));
423
609
  this.notifyDownload(id, 0);
424
610
  this.notifyDownload(id, 5);
425
611
 
@@ -427,42 +613,43 @@ public class CapacitorUpdater {
427
613
  }
428
614
 
429
615
  public BundleInfo download(final String url, final String version, final String sessionKey, final String checksum) throws IOException {
616
+ // Check for existing bundle with same version and clean up if in error state
617
+ BundleInfo existingBundle = this.getBundleInfoByName(version);
618
+ if (existingBundle != null && (existingBundle.isErrorStatus() || existingBundle.isDeleted())) {
619
+ logger.info("Found existing failed bundle for version " + version + ", deleting before retry");
620
+ this.delete(existingBundle.getId(), true);
621
+ }
622
+
430
623
  final String id = this.randomString();
431
- this.saveBundleInfo(id, new BundleInfo(id, version, BundleStatus.DOWNLOADING, new Date(System.currentTimeMillis()), ""));
624
+ saveBundleInfo(id, new BundleInfo(id, version, BundleStatus.DOWNLOADING, new Date(System.currentTimeMillis()), ""));
432
625
  this.notifyDownload(id, 0);
433
626
  this.notifyDownload(id, 5);
434
627
  final String dest = this.randomString();
435
628
 
436
- // Use the new WorkManager-based download
629
+ // Create a CompletableFuture to track download completion
630
+ CompletableFuture<BundleInfo> downloadFuture = new CompletableFuture<>();
631
+ downloadFutures.put(id, downloadFuture);
632
+
633
+ // Start the download
437
634
  this.download(id, url, dest, version, sessionKey, checksum, null);
438
635
 
439
- // Wait for completion
636
+ // Wait for completion without timeout
440
637
  try {
441
- ListenableFuture<List<WorkInfo>> future = WorkManager.getInstance(activity).getWorkInfosByTag(id);
442
-
443
- List<WorkInfo> workInfos = Futures.getChecked(future, IOException.class);
444
-
445
- if (workInfos != null && !workInfos.isEmpty()) {
446
- WorkInfo workInfo = workInfos.get(0);
447
- while (!workInfo.getState().isFinished()) {
448
- Thread.sleep(100);
449
- workInfos = Futures.getChecked(WorkManager.getInstance(activity).getWorkInfosByTag(id), IOException.class);
450
- if (workInfos != null && !workInfos.isEmpty()) {
451
- workInfo = workInfos.get(0);
452
- }
453
- }
454
-
455
- if (workInfo.getState() != WorkInfo.State.SUCCEEDED) {
456
- Data outputData = workInfo.getOutputData();
457
- String error = outputData.getString(DownloadService.ERROR);
458
- throw new IOException(error != null ? error : "Download failed: " + workInfo.getState());
459
- }
638
+ BundleInfo result = downloadFuture.get();
639
+ if (result.isErrorStatus()) {
640
+ throw new IOException("Download failed with status: " + result.getStatus());
460
641
  }
461
- return getBundleInfo(id);
642
+ return result;
462
643
  } catch (Exception e) {
463
- Log.e(TAG, "Error waiting for download", e);
464
- saveBundleInfo(id, new BundleInfo(id, version, BundleStatus.ERROR, new Date(System.currentTimeMillis()), ""));
465
- throw new IOException("Error waiting for download: " + e.getMessage());
644
+ // Clean up on failure
645
+ downloadFutures.remove(id);
646
+ logger.error("Error waiting for download: " + e.getMessage());
647
+ BundleInfo errorBundle = new BundleInfo(id, version, BundleStatus.ERROR, new Date(System.currentTimeMillis()), "");
648
+ saveBundleInfo(id, errorBundle);
649
+ if (e instanceof IOException) {
650
+ throw (IOException) e;
651
+ }
652
+ throw new IOException("Error waiting for download: " + e.getMessage(), e);
466
653
  }
467
654
  }
468
655
 
@@ -470,14 +657,14 @@ public class CapacitorUpdater {
470
657
  if (!rawList) {
471
658
  final List<BundleInfo> res = new ArrayList<>();
472
659
  final File destHot = new File(this.documentsDir, bundleDirectory);
473
- Log.d(TAG, "list File : " + destHot.getPath());
660
+ logger.debug("list File : " + destHot.getPath());
474
661
  if (destHot.exists()) {
475
662
  for (final File i : Objects.requireNonNull(destHot.listFiles())) {
476
663
  final String id = i.getName();
477
664
  res.add(this.getBundleInfo(id));
478
665
  }
479
666
  } else {
480
- Log.i(TAG, "No versions available to list" + destHot);
667
+ logger.info("No versions available to list" + destHot);
481
668
  }
482
669
  return res;
483
670
  } else {
@@ -496,12 +683,12 @@ public class CapacitorUpdater {
496
683
  public Boolean delete(final String id, final Boolean removeInfo) throws IOException {
497
684
  final BundleInfo deleted = this.getBundleInfo(id);
498
685
  if (deleted.isBuiltin() || this.getCurrentBundleId().equals(id)) {
499
- Log.e(TAG, "Cannot delete " + id);
686
+ logger.error("Cannot delete " + id);
500
687
  return false;
501
688
  }
502
689
  final BundleInfo next = this.getNextBundle();
503
690
  if (next != null && !next.isDeleted() && !next.isErrorStatus() && next.getId().equals(id)) {
504
- Log.e(TAG, "Cannot delete the next bundle" + id);
691
+ logger.error("Cannot delete the next bundle" + id);
505
692
  return false;
506
693
  }
507
694
  // Cancel download for this version if active
@@ -518,7 +705,7 @@ public class CapacitorUpdater {
518
705
  }
519
706
  return true;
520
707
  }
521
- Log.e(TAG, "bundle removed: " + deleted.getVersionName());
708
+ logger.error("bundle removed: " + deleted.getVersionName());
522
709
  // perhaps we did not find the bundle in the files, but if the user requested a delete, we delete
523
710
  if (removeInfo) {
524
711
  this.removeBundleInfo(id);
@@ -532,7 +719,7 @@ public class CapacitorUpdater {
532
719
  return this.delete(id, true);
533
720
  } catch (IOException e) {
534
721
  e.printStackTrace();
535
- Log.i(CapacitorUpdater.TAG, "Failed to delete bundle (" + id + ")" + "\nError:\n" + e.toString());
722
+ logger.info("Failed to delete bundle (" + id + ")" + "\nError:\n" + e.toString());
536
723
  return false;
537
724
  }
538
725
  }
@@ -558,7 +745,7 @@ public class CapacitorUpdater {
558
745
  return true;
559
746
  }
560
747
  final File bundle = this.getBundleDirectory(id);
561
- Log.i(TAG, "Setting next active bundle: " + id);
748
+ logger.info("Setting next active bundle: " + id);
562
749
  if (this.bundleExists(id)) {
563
750
  var currentBundleName = this.getCurrentBundle().getVersionName();
564
751
  this.setCurrentBundle(bundle);
@@ -574,7 +761,7 @@ public class CapacitorUpdater {
574
761
  public void autoReset() {
575
762
  final BundleInfo currentBundle = this.getCurrentBundle();
576
763
  if (!currentBundle.isBuiltin() && !this.bundleExists(currentBundle.getId())) {
577
- Log.i(TAG, "Folder at bundle path does not exist. Triggering reset.");
764
+ logger.info("Folder at bundle path does not exist. Triggering reset.");
578
765
  this.reset();
579
766
  }
580
767
  }
@@ -586,12 +773,17 @@ public class CapacitorUpdater {
586
773
  public void setSuccess(final BundleInfo bundle, Boolean autoDeletePrevious) {
587
774
  this.setBundleStatus(bundle.getId(), BundleStatus.SUCCESS);
588
775
  final BundleInfo fallback = this.getFallbackBundle();
589
- Log.d(CapacitorUpdater.TAG, "Fallback bundle is: " + fallback);
590
- Log.i(CapacitorUpdater.TAG, "Version successfully loaded: " + bundle.getVersionName());
591
- if (autoDeletePrevious && !fallback.isBuiltin()) {
776
+ logger.debug("Fallback bundle is: " + fallback);
777
+ logger.info("Version successfully loaded: " + bundle.getVersionName());
778
+ // Only attempt to delete when the fallback is a different bundle than the
779
+ // currently loaded one. Otherwise we spam logs with "Cannot delete <id>"
780
+ // because delete() protects the current bundle from removal.
781
+ if (autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != null && !fallback.getId().equals(bundle.getId())) {
592
782
  final Boolean res = this.delete(fallback.getId());
593
783
  if (res) {
594
- Log.i(CapacitorUpdater.TAG, "Deleted previous bundle: " + fallback.getVersionName());
784
+ logger.info("Deleted previous bundle: " + fallback.getVersionName());
785
+ } else {
786
+ logger.debug("Skip deleting previous bundle (same as current or protected): " + fallback.getId());
595
787
  }
596
788
  }
597
789
  this.setFallbackBundle(bundle);
@@ -602,7 +794,7 @@ public class CapacitorUpdater {
602
794
  }
603
795
 
604
796
  public void reset(final boolean internal) {
605
- Log.d(CapacitorUpdater.TAG, "reset: " + internal);
797
+ logger.debug("reset: " + internal);
606
798
  var currentBundleName = this.getCurrentBundle().getVersionName();
607
799
  this.setCurrentBundle(new File("public"));
608
800
  this.setFallbackBundle(null);
@@ -626,26 +818,85 @@ public class CapacitorUpdater {
626
818
  json.put("version_code", this.versionCode);
627
819
  json.put("version_os", this.versionOs);
628
820
  json.put("version_name", this.getCurrentBundle().getVersionName());
629
- json.put("plugin_version", this.PLUGIN_VERSION);
821
+ json.put("plugin_version", this.pluginVersion);
630
822
  json.put("is_emulator", this.isEmulator());
631
823
  json.put("is_prod", this.isProd());
632
824
  json.put("defaultChannel", this.defaultChannel);
825
+
826
+ // Add encryption key ID if encryption is enabled (use cached value)
827
+ if (!this.cachedKeyId.isEmpty()) {
828
+ json.put("key_id", this.cachedKeyId);
829
+ }
830
+
633
831
  return json;
634
832
  }
635
833
 
834
+ /**
835
+ * Check if a 429 (Too Many Requests) response was received and set the flag
836
+ */
837
+ private boolean checkAndHandleRateLimitResponse(Response response) {
838
+ if (response.code() == 429) {
839
+ // Send a statistic about the rate limit BEFORE setting the flag
840
+ // Only send once to prevent infinite loop if the stat request itself gets rate limited
841
+ if (!rateLimitExceeded && !rateLimitStatisticSent) {
842
+ rateLimitStatisticSent = true;
843
+ sendRateLimitStatistic();
844
+ }
845
+ rateLimitExceeded = true;
846
+ logger.warn("Rate limit exceeded (429). Stopping all stats and channel requests until app restart.");
847
+ return true;
848
+ }
849
+ return false;
850
+ }
851
+
852
+ /**
853
+ * Send a synchronous statistic about rate limiting
854
+ */
855
+ private void sendRateLimitStatistic() {
856
+ String statsUrl = this.statsUrl;
857
+ if (statsUrl == null || statsUrl.isEmpty()) {
858
+ return;
859
+ }
860
+
861
+ try {
862
+ BundleInfo current = this.getCurrentBundle();
863
+ JSONObject json = this.createInfoObject();
864
+ json.put("version_name", current.getVersionName());
865
+ json.put("old_version_name", "");
866
+ json.put("action", "rate_limit_reached");
867
+
868
+ Request request = new Request.Builder()
869
+ .url(statsUrl)
870
+ .post(RequestBody.create(json.toString(), MediaType.get("application/json")))
871
+ .build();
872
+
873
+ // Send synchronously to ensure it goes out before the flag is set
874
+ // User-Agent header is automatically added by DownloadService.sharedClient interceptor
875
+ try (Response response = DownloadService.sharedClient.newCall(request).execute()) {
876
+ if (response.isSuccessful()) {
877
+ logger.info("Rate limit statistic sent");
878
+ } else {
879
+ logger.error("Error sending rate limit statistic: " + response.code());
880
+ }
881
+ }
882
+ } catch (final Exception e) {
883
+ logger.error("Failed to send rate limit statistic: " + e.getMessage());
884
+ }
885
+ }
886
+
636
887
  private void makeJsonRequest(String url, JSONObject jsonBody, Callback callback) {
637
888
  MediaType JSON = MediaType.get("application/json; charset=utf-8");
638
889
  RequestBody body = RequestBody.create(jsonBody.toString(), JSON);
639
890
 
640
891
  Request request = new Request.Builder().url(url).post(body).build();
641
892
 
642
- client
893
+ DownloadService.sharedClient
643
894
  .newCall(request)
644
895
  .enqueue(
645
896
  new okhttp3.Callback() {
646
897
  @Override
647
898
  public void onFailure(@NonNull Call call, @NonNull IOException e) {
648
- JSObject retError = new JSObject();
899
+ Map<String, Object> retError = new HashMap<>();
649
900
  retError.put("message", "Request failed: " + e.getMessage());
650
901
  retError.put("error", "network_error");
651
902
  callback.callback(retError);
@@ -654,10 +905,22 @@ public class CapacitorUpdater {
654
905
  @Override
655
906
  public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
656
907
  try (ResponseBody responseBody = response.body()) {
908
+ final int statusCode = response.code();
909
+ // Check for 429 rate limit
910
+ if (checkAndHandleRateLimitResponse(response)) {
911
+ Map<String, Object> retError = new HashMap<>();
912
+ retError.put("message", "Rate limit exceeded");
913
+ retError.put("error", "rate_limit_exceeded");
914
+ retError.put("statusCode", statusCode);
915
+ callback.callback(retError);
916
+ return;
917
+ }
918
+
657
919
  if (!response.isSuccessful()) {
658
- JSObject retError = new JSObject();
920
+ Map<String, Object> retError = new HashMap<>();
659
921
  retError.put("message", "Server error: " + response.code());
660
922
  retError.put("error", "response_error");
923
+ retError.put("statusCode", statusCode);
661
924
  callback.callback(retError);
662
925
  return;
663
926
  }
@@ -665,7 +928,23 @@ public class CapacitorUpdater {
665
928
  assert responseBody != null;
666
929
  String responseData = responseBody.string();
667
930
  JSONObject jsonResponse = new JSONObject(responseData);
668
- JSObject ret = new JSObject();
931
+
932
+ // Check for server-side errors first
933
+ if (jsonResponse.has("error")) {
934
+ Map<String, Object> retError = new HashMap<>();
935
+ retError.put("error", jsonResponse.getString("error"));
936
+ if (jsonResponse.has("message")) {
937
+ retError.put("message", jsonResponse.getString("message"));
938
+ } else {
939
+ retError.put("message", "server did not provide a message");
940
+ }
941
+ retError.put("statusCode", statusCode);
942
+ callback.callback(retError);
943
+ return;
944
+ }
945
+
946
+ Map<String, Object> ret = new HashMap<>();
947
+ ret.put("statusCode", statusCode);
669
948
 
670
949
  Iterator<String> keys = jsonResponse.keys();
671
950
  while (keys.hasNext()) {
@@ -680,7 +959,7 @@ public class CapacitorUpdater {
680
959
  }
681
960
  callback.callback(ret);
682
961
  } catch (JSONException e) {
683
- JSObject retError = new JSObject();
962
+ Map<String, Object> retError = new HashMap<>();
684
963
  retError.put("message", "JSON parse error: " + e.getMessage());
685
964
  retError.put("error", "parse_error");
686
965
  callback.callback(retError);
@@ -698,24 +977,68 @@ public class CapacitorUpdater {
698
977
  json.put("defaultChannel", channel);
699
978
  }
700
979
  } catch (JSONException e) {
701
- Log.e(TAG, "Error getLatest JSONException", e);
702
- final JSObject retError = new JSObject();
980
+ logger.error("Error getLatest JSONException " + e.getMessage());
981
+ final Map<String, Object> retError = new HashMap<>();
703
982
  retError.put("message", "Cannot get info: " + e);
704
983
  retError.put("error", "json_error");
705
984
  callback.callback(retError);
706
985
  return;
707
986
  }
708
987
 
709
- Log.i(CapacitorUpdater.TAG, "Auto-update parameters: " + json);
988
+ logger.info("Auto-update parameters: " + json);
710
989
 
711
990
  makeJsonRequest(updateUrl, json, callback);
712
991
  }
713
992
 
714
- public void unsetChannel(final Callback callback) {
993
+ public void unsetChannel(
994
+ final SharedPreferences.Editor editor,
995
+ final String defaultChannelKey,
996
+ final String configDefaultChannel,
997
+ final Callback callback
998
+ ) {
999
+ // Clear persisted defaultChannel and revert to config value
1000
+ editor.remove(defaultChannelKey);
1001
+ editor.apply();
1002
+ this.defaultChannel = configDefaultChannel;
1003
+ logger.info("Persisted defaultChannel cleared, reverted to config value: " + configDefaultChannel);
1004
+
1005
+ Map<String, Object> ret = new HashMap<>();
1006
+ ret.put("status", "ok");
1007
+ ret.put("message", "Channel override removed");
1008
+ callback.callback(ret);
1009
+ }
1010
+
1011
+ public void setChannel(
1012
+ final String channel,
1013
+ final SharedPreferences.Editor editor,
1014
+ final String defaultChannelKey,
1015
+ final boolean allowSetDefaultChannel,
1016
+ final Callback callback
1017
+ ) {
1018
+ // Check if setting defaultChannel is allowed
1019
+ if (!allowSetDefaultChannel) {
1020
+ logger.error("setChannel is disabled by allowSetDefaultChannel config");
1021
+ final Map<String, Object> retError = new HashMap<>();
1022
+ retError.put("message", "setChannel is disabled by configuration");
1023
+ retError.put("error", "disabled_by_config");
1024
+ callback.callback(retError);
1025
+ return;
1026
+ }
1027
+
1028
+ // Check if rate limit was exceeded
1029
+ if (rateLimitExceeded) {
1030
+ logger.debug("Skipping setChannel due to rate limit (429). Requests will resume after app restart.");
1031
+ final Map<String, Object> retError = new HashMap<>();
1032
+ retError.put("message", "Rate limit exceeded");
1033
+ retError.put("error", "rate_limit_exceeded");
1034
+ callback.callback(retError);
1035
+ return;
1036
+ }
1037
+
715
1038
  String channelUrl = this.channelUrl;
716
1039
  if (channelUrl == null || channelUrl.isEmpty()) {
717
- Log.e(TAG, "Channel URL is not set");
718
- final JSObject retError = new JSObject();
1040
+ logger.error("Channel URL is not set");
1041
+ final Map<String, Object> retError = new HashMap<>();
719
1042
  retError.put("message", "channelUrl missing");
720
1043
  retError.put("error", "missing_config");
721
1044
  callback.callback(retError);
@@ -724,9 +1047,56 @@ public class CapacitorUpdater {
724
1047
  JSONObject json;
725
1048
  try {
726
1049
  json = this.createInfoObject();
1050
+ json.put("channel", channel);
727
1051
  } catch (JSONException e) {
728
- Log.e(TAG, "Error unsetChannel JSONException", e);
729
- final JSObject retError = new JSObject();
1052
+ logger.error("Error setChannel JSONException " + e.getMessage());
1053
+ final Map<String, Object> retError = new HashMap<>();
1054
+ retError.put("message", "Cannot get info: " + e);
1055
+ retError.put("error", "json_error");
1056
+ callback.callback(retError);
1057
+ return;
1058
+ }
1059
+
1060
+ makeJsonRequest(channelUrl, json, (res) -> {
1061
+ if (res.containsKey("error")) {
1062
+ callback.callback(res);
1063
+ } else {
1064
+ // Success - persist defaultChannel
1065
+ this.defaultChannel = channel;
1066
+ editor.putString(defaultChannelKey, channel);
1067
+ editor.apply();
1068
+ logger.info("defaultChannel persisted locally: " + channel);
1069
+ callback.callback(res);
1070
+ }
1071
+ });
1072
+ }
1073
+
1074
+ public void getChannel(final Callback callback) {
1075
+ // Check if rate limit was exceeded
1076
+ if (rateLimitExceeded) {
1077
+ logger.debug("Skipping getChannel due to rate limit (429). Requests will resume after app restart.");
1078
+ final Map<String, Object> retError = new HashMap<>();
1079
+ retError.put("message", "Rate limit exceeded");
1080
+ retError.put("error", "rate_limit_exceeded");
1081
+ callback.callback(retError);
1082
+ return;
1083
+ }
1084
+
1085
+ String channelUrl = this.channelUrl;
1086
+ if (channelUrl == null || channelUrl.isEmpty()) {
1087
+ logger.error("Channel URL is not set");
1088
+ final Map<String, Object> retError = new HashMap<>();
1089
+ retError.put("message", "Channel URL is not set");
1090
+ retError.put("error", "missing_config");
1091
+ callback.callback(retError);
1092
+ return;
1093
+ }
1094
+ JSONObject json;
1095
+ try {
1096
+ json = this.createInfoObject();
1097
+ } catch (JSONException e) {
1098
+ logger.error("Error getChannel JSONException " + e.getMessage());
1099
+ final Map<String, Object> retError = new HashMap<>();
730
1100
  retError.put("message", "Cannot get info: " + e);
731
1101
  retError.put("error", "json_error");
732
1102
  callback.callback(retError);
@@ -735,16 +1105,16 @@ public class CapacitorUpdater {
735
1105
 
736
1106
  Request request = new Request.Builder()
737
1107
  .url(channelUrl)
738
- .delete(RequestBody.create(json.toString(), MediaType.get("application/json")))
1108
+ .put(RequestBody.create(json.toString(), MediaType.get("application/json")))
739
1109
  .build();
740
1110
 
741
- client
1111
+ DownloadService.sharedClient
742
1112
  .newCall(request)
743
1113
  .enqueue(
744
1114
  new okhttp3.Callback() {
745
1115
  @Override
746
1116
  public void onFailure(@NonNull Call call, @NonNull IOException e) {
747
- JSObject retError = new JSObject();
1117
+ Map<String, Object> retError = new HashMap<>();
748
1118
  retError.put("message", "Request failed: " + e.getMessage());
749
1119
  retError.put("error", "network_error");
750
1120
  callback.callback(retError);
@@ -753,8 +1123,30 @@ public class CapacitorUpdater {
753
1123
  @Override
754
1124
  public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
755
1125
  try (ResponseBody responseBody = response.body()) {
1126
+ // Check for 429 rate limit
1127
+ if (checkAndHandleRateLimitResponse(response)) {
1128
+ Map<String, Object> retError = new HashMap<>();
1129
+ retError.put("message", "Rate limit exceeded");
1130
+ retError.put("error", "rate_limit_exceeded");
1131
+ callback.callback(retError);
1132
+ return;
1133
+ }
1134
+
1135
+ if (response.code() == 400) {
1136
+ assert responseBody != null;
1137
+ String data = responseBody.string();
1138
+ if (data.contains("channel_not_found") && !defaultChannel.isEmpty()) {
1139
+ Map<String, Object> ret = new HashMap<>();
1140
+ ret.put("channel", defaultChannel);
1141
+ ret.put("status", "default");
1142
+ logger.info("Channel get to \"" + ret);
1143
+ callback.callback(ret);
1144
+ return;
1145
+ }
1146
+ }
1147
+
756
1148
  if (!response.isSuccessful()) {
757
- JSObject retError = new JSObject();
1149
+ Map<String, Object> retError = new HashMap<>();
758
1150
  retError.put("message", "Server error: " + response.code());
759
1151
  retError.put("error", "response_error");
760
1152
  callback.callback(retError);
@@ -764,7 +1156,21 @@ public class CapacitorUpdater {
764
1156
  assert responseBody != null;
765
1157
  String responseData = responseBody.string();
766
1158
  JSONObject jsonResponse = new JSONObject(responseData);
767
- JSObject ret = new JSObject();
1159
+
1160
+ // Check for server-side errors first
1161
+ if (jsonResponse.has("error")) {
1162
+ Map<String, Object> retError = new HashMap<>();
1163
+ retError.put("error", jsonResponse.getString("error"));
1164
+ if (jsonResponse.has("message")) {
1165
+ retError.put("message", jsonResponse.getString("message"));
1166
+ } else {
1167
+ retError.put("message", "server did not provide a message");
1168
+ }
1169
+ callback.callback(retError);
1170
+ return;
1171
+ }
1172
+
1173
+ Map<String, Object> ret = new HashMap<>();
768
1174
 
769
1175
  Iterator<String> keys = jsonResponse.keys();
770
1176
  while (keys.hasNext()) {
@@ -773,10 +1179,10 @@ public class CapacitorUpdater {
773
1179
  ret.put(key, jsonResponse.get(key));
774
1180
  }
775
1181
  }
776
- Log.i(TAG, "Channel unset");
1182
+ logger.info("Channel get to \"" + ret);
777
1183
  callback.callback(ret);
778
1184
  } catch (JSONException e) {
779
- JSObject retError = new JSObject();
1185
+ Map<String, Object> retError = new HashMap<>();
780
1186
  retError.put("message", "JSON parse error: " + e.getMessage());
781
1187
  retError.put("error", "parse_error");
782
1188
  callback.callback(retError);
@@ -786,66 +1192,63 @@ public class CapacitorUpdater {
786
1192
  );
787
1193
  }
788
1194
 
789
- public void setChannel(final String channel, final Callback callback) {
790
- String channelUrl = this.channelUrl;
791
- if (channelUrl == null || channelUrl.isEmpty()) {
792
- Log.e(TAG, "Channel URL is not set");
793
- final JSObject retError = new JSObject();
794
- retError.put("message", "channelUrl missing");
795
- retError.put("error", "missing_config");
1195
+ public void listChannels(final Callback callback) {
1196
+ // Check if rate limit was exceeded
1197
+ if (rateLimitExceeded) {
1198
+ logger.debug("Skipping listChannels due to rate limit (429). Requests will resume after app restart.");
1199
+ final Map<String, Object> retError = new HashMap<>();
1200
+ retError.put("message", "Rate limit exceeded");
1201
+ retError.put("error", "rate_limit_exceeded");
796
1202
  callback.callback(retError);
797
1203
  return;
798
1204
  }
799
- JSONObject json;
800
- try {
801
- json = this.createInfoObject();
802
- json.put("channel", channel);
803
- } catch (JSONException e) {
804
- Log.e(TAG, "Error setChannel JSONException", e);
805
- final JSObject retError = new JSObject();
806
- retError.put("message", "Cannot get info: " + e);
807
- retError.put("error", "json_error");
808
- callback.callback(retError);
809
- return;
810
- }
811
-
812
- makeJsonRequest(channelUrl, json, callback);
813
- }
814
1205
 
815
- public void getChannel(final Callback callback) {
816
1206
  String channelUrl = this.channelUrl;
817
1207
  if (channelUrl == null || channelUrl.isEmpty()) {
818
- Log.e(TAG, "Channel URL is not set");
819
- final JSObject retError = new JSObject();
1208
+ logger.error("Channel URL is not set");
1209
+ final Map<String, Object> retError = new HashMap<>();
820
1210
  retError.put("message", "Channel URL is not set");
821
1211
  retError.put("error", "missing_config");
822
1212
  callback.callback(retError);
823
1213
  return;
824
1214
  }
1215
+
825
1216
  JSONObject json;
826
1217
  try {
827
1218
  json = this.createInfoObject();
828
1219
  } catch (JSONException e) {
829
- Log.e(TAG, "Error getChannel JSONException", e);
830
- final JSObject retError = new JSObject();
1220
+ logger.error("Error creating info object: " + e.getMessage());
1221
+ final Map<String, Object> retError = new HashMap<>();
831
1222
  retError.put("message", "Cannot get info: " + e);
832
1223
  retError.put("error", "json_error");
833
1224
  callback.callback(retError);
834
1225
  return;
835
1226
  }
836
1227
 
837
- Request request = new Request.Builder()
838
- .url(channelUrl)
839
- .put(RequestBody.create(json.toString(), MediaType.get("application/json")))
840
- .build();
1228
+ // Build URL with query parameters from JSON
1229
+ HttpUrl.Builder urlBuilder = HttpUrl.parse(channelUrl).newBuilder();
1230
+ try {
1231
+ Iterator<String> keys = json.keys();
1232
+ while (keys.hasNext()) {
1233
+ String key = keys.next();
1234
+ Object value = json.get(key);
1235
+ if (value != null) {
1236
+ urlBuilder.addQueryParameter(key, value.toString());
1237
+ }
1238
+ }
1239
+ } catch (JSONException e) {
1240
+ logger.error("Error adding query parameters: " + e.getMessage());
1241
+ }
1242
+
1243
+ Request request = new Request.Builder().url(urlBuilder.build()).get().build();
841
1244
 
842
- client
1245
+ DownloadService.sharedClient
843
1246
  .newCall(request)
844
1247
  .enqueue(
845
1248
  new okhttp3.Callback() {
846
1249
  @Override
847
1250
  public void onFailure(@NonNull Call call, @NonNull IOException e) {
848
- JSObject retError = new JSObject();
1251
+ Map<String, Object> retError = new HashMap<>();
849
1252
  retError.put("message", "Request failed: " + e.getMessage());
850
1253
  retError.put("error", "network_error");
851
1254
  callback.callback(retError);
@@ -854,21 +1257,17 @@ public class CapacitorUpdater {
854
1257
  @Override
855
1258
  public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
856
1259
  try (ResponseBody responseBody = response.body()) {
857
- if (response.code() == 400) {
858
- assert responseBody != null;
859
- String data = responseBody.string();
860
- if (data.contains("channel_not_found") && !defaultChannel.isEmpty()) {
861
- JSObject ret = new JSObject();
862
- ret.put("channel", defaultChannel);
863
- ret.put("status", "default");
864
- Log.i(TAG, "Channel get to \"" + ret);
865
- callback.callback(ret);
866
- return;
867
- }
1260
+ // Check for 429 rate limit
1261
+ if (checkAndHandleRateLimitResponse(response)) {
1262
+ Map<String, Object> retError = new HashMap<>();
1263
+ retError.put("message", "Rate limit exceeded");
1264
+ retError.put("error", "rate_limit_exceeded");
1265
+ callback.callback(retError);
1266
+ return;
868
1267
  }
869
1268
 
870
1269
  if (!response.isSuccessful()) {
871
- JSObject retError = new JSObject();
1270
+ Map<String, Object> retError = new HashMap<>();
872
1271
  retError.put("message", "Server error: " + response.code());
873
1272
  retError.put("error", "response_error");
874
1273
  callback.callback(retError);
@@ -876,24 +1275,57 @@ public class CapacitorUpdater {
876
1275
  }
877
1276
 
878
1277
  assert responseBody != null;
879
- String responseData = responseBody.string();
880
- JSONObject jsonResponse = new JSONObject(responseData);
881
- JSObject ret = new JSObject();
1278
+ String data = responseBody.string();
1279
+
1280
+ try {
1281
+ Map<String, Object> ret = new HashMap<>();
1282
+
1283
+ try {
1284
+ // Try to parse as direct array first
1285
+ JSONArray channelsJson = new JSONArray(data);
1286
+ List<Map<String, Object>> channelsList = new ArrayList<>();
1287
+
1288
+ for (int i = 0; i < channelsJson.length(); i++) {
1289
+ JSONObject channelJson = channelsJson.getJSONObject(i);
1290
+ Map<String, Object> channel = new HashMap<>();
1291
+ channel.put("id", channelJson.optString("id", ""));
1292
+ channel.put("name", channelJson.optString("name", ""));
1293
+ channel.put("public", channelJson.optBoolean("public", false));
1294
+ channel.put("allow_self_set", channelJson.optBoolean("allow_self_set", false));
1295
+ channelsList.add(channel);
1296
+ }
882
1297
 
883
- Iterator<String> keys = jsonResponse.keys();
884
- while (keys.hasNext()) {
885
- String key = keys.next();
886
- if (jsonResponse.has(key)) {
887
- ret.put(key, jsonResponse.get(key));
1298
+ // Wrap in channels object for JS API
1299
+ ret.put("channels", channelsList);
1300
+
1301
+ logger.info("Channels listed successfully");
1302
+ callback.callback(ret);
1303
+ } catch (JSONException arrayException) {
1304
+ // If not an array, try to parse as error object
1305
+ try {
1306
+ JSONObject json = new JSONObject(data);
1307
+ if (json.has("error")) {
1308
+ Map<String, Object> retError = new HashMap<>();
1309
+ retError.put("error", json.getString("error"));
1310
+ if (json.has("message")) {
1311
+ retError.put("message", json.getString("message"));
1312
+ } else {
1313
+ retError.put("message", "server did not provide a message");
1314
+ }
1315
+ callback.callback(retError);
1316
+ return;
1317
+ }
1318
+ } catch (JSONException objException) {
1319
+ // If neither array nor object, throw parse error
1320
+ throw arrayException;
1321
+ }
888
1322
  }
1323
+ } catch (JSONException e) {
1324
+ Map<String, Object> retError = new HashMap<>();
1325
+ retError.put("message", "JSON parse error: " + e.getMessage());
1326
+ retError.put("error", "parse_error");
1327
+ callback.callback(retError);
889
1328
  }
890
- Log.i(TAG, "Channel get to \"" + ret);
891
- callback.callback(ret);
892
- } catch (JSONException e) {
893
- JSObject retError = new JSObject();
894
- retError.put("message", "JSON parse error: " + e.getMessage());
895
- retError.put("error", "parse_error");
896
- callback.callback(retError);
897
1329
  }
898
1330
  }
899
1331
  }
@@ -909,6 +1341,12 @@ public class CapacitorUpdater {
909
1341
  }
910
1342
 
911
1343
  public void sendStats(final String action, final String versionName, final String oldVersionName) {
1344
+ // Check if rate limit was exceeded
1345
+ if (rateLimitExceeded) {
1346
+ logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.");
1347
+ return;
1348
+ }
1349
+
912
1350
  String statsUrl = this.statsUrl;
913
1351
  if (statsUrl == null || statsUrl.isEmpty()) {
914
1352
  return;
@@ -920,7 +1358,7 @@ public class CapacitorUpdater {
920
1358
  json.put("old_version_name", oldVersionName);
921
1359
  json.put("action", action);
922
1360
  } catch (JSONException e) {
923
- Log.e(TAG, "Error sendStats JSONException", e);
1361
+ logger.error("Error sendStats JSONException " + e.getMessage());
924
1362
  return;
925
1363
  }
926
1364
 
@@ -929,21 +1367,28 @@ public class CapacitorUpdater {
929
1367
  .post(RequestBody.create(json.toString(), MediaType.get("application/json")))
930
1368
  .build();
931
1369
 
932
- client
1370
+ DownloadService.sharedClient
933
1371
  .newCall(request)
934
1372
  .enqueue(
935
1373
  new okhttp3.Callback() {
936
1374
  @Override
937
1375
  public void onFailure(@NonNull Call call, @NonNull IOException e) {
938
- Log.e(TAG, "Failed to send stats: " + e.getMessage());
1376
+ logger.error("Failed to send stats: " + e.getMessage());
939
1377
  }
940
1378
 
941
1379
  @Override
942
1380
  public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
943
- if (response.isSuccessful()) {
944
- Log.i(TAG, "Stats send for \"" + action + "\", version " + versionName);
945
- } else {
946
- Log.e(TAG, "Error sending stats: " + response.code());
1381
+ try (ResponseBody responseBody = response.body()) {
1382
+ // Check for 429 rate limit
1383
+ if (checkAndHandleRateLimitResponse(response)) {
1384
+ return;
1385
+ }
1386
+
1387
+ if (response.isSuccessful()) {
1388
+ logger.info("Stats send for \"" + action + "\", version " + versionName);
1389
+ } else {
1390
+ logger.error("Error sending stats: " + response.code());
1391
+ }
947
1392
  }
948
1393
  }
949
1394
  }
@@ -963,13 +1408,26 @@ public class CapacitorUpdater {
963
1408
  } else {
964
1409
  try {
965
1410
  String stored = this.prefs.getString(trueId + INFO_SUFFIX, "");
966
- result = BundleInfo.fromJSON(stored);
1411
+ if (stored.isEmpty()) {
1412
+ result = new BundleInfo(trueId, null, BundleStatus.PENDING, "", "");
1413
+ } else {
1414
+ result = BundleInfo.fromJSON(stored);
1415
+ }
967
1416
  } catch (JSONException e) {
968
- Log.e(TAG, "Failed to parse info for bundle [" + trueId + "] ", e);
969
- result = new BundleInfo(trueId, null, BundleStatus.PENDING, "", "");
1417
+ logger.error(
1418
+ "Failed to parse info for bundle [" +
1419
+ trueId +
1420
+ "] stored value: '" +
1421
+ this.prefs.getString(trueId + INFO_SUFFIX, "") +
1422
+ "' error: " +
1423
+ e.getMessage()
1424
+ );
1425
+ // Clear corrupted data
1426
+ this.editor.remove(trueId + INFO_SUFFIX);
1427
+ this.editor.commit();
1428
+ result = new BundleInfo(trueId, null, BundleStatus.ERROR, "", "");
970
1429
  }
971
1430
  }
972
- // Log.d(TAG, "Returning info [" + trueId + "] " + result);
973
1431
  return result;
974
1432
  }
975
1433
 
@@ -989,17 +1447,18 @@ public class CapacitorUpdater {
989
1447
 
990
1448
  public void saveBundleInfo(final String id, final BundleInfo info) {
991
1449
  if (id == null || (info != null && (info.isBuiltin() || info.isUnknown()))) {
992
- Log.d(TAG, "Not saving info for bundle: [" + id + "] " + info);
1450
+ logger.debug("Not saving info for bundle: [" + id + "] " + info);
993
1451
  return;
994
1452
  }
995
1453
 
996
1454
  if (info == null) {
997
- Log.d(TAG, "Removing info for bundle [" + id + "]");
1455
+ logger.debug("Removing info for bundle [" + id + "]");
998
1456
  this.editor.remove(id + INFO_SUFFIX);
999
1457
  } else {
1000
1458
  final BundleInfo update = info.setId(id);
1001
- Log.d(TAG, "Storing info for bundle [" + id + "] " + update.toString());
1002
- this.editor.putString(id + INFO_SUFFIX, update.toString());
1459
+ String jsonString = update.toString();
1460
+ logger.debug("Storing info for bundle [" + id + "] " + update.getClass().getName() + " -> " + jsonString);
1461
+ this.editor.putString(id + INFO_SUFFIX, jsonString);
1003
1462
  }
1004
1463
  this.editor.commit();
1005
1464
  }
@@ -1007,7 +1466,7 @@ public class CapacitorUpdater {
1007
1466
  private void setBundleStatus(final String id, final BundleStatus status) {
1008
1467
  if (id != null && status != null) {
1009
1468
  BundleInfo info = this.getBundleInfo(id);
1010
- Log.d(TAG, "Setting status for bundle [" + id + "] to " + status);
1469
+ logger.debug("Setting status for bundle [" + id + "] to " + status);
1011
1470
  this.saveBundleInfo(id, info.setStatus(status));
1012
1471
  }
1013
1472
  }
@@ -1026,7 +1485,7 @@ public class CapacitorUpdater {
1026
1485
  }
1027
1486
 
1028
1487
  public String getCurrentBundlePath() {
1029
- String path = this.prefs.getString(WebView.CAP_SERVER_PATH, "public");
1488
+ String path = this.prefs.getString(this.CAP_SERVER_PATH, "public");
1030
1489
  if (path.trim().isEmpty()) {
1031
1490
  return "public";
1032
1491
  }