@capgo/capacitor-updater 6.14.26 → 6.14.33

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