@capgo/capacitor-updater 6.14.26 → 6.14.29

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