@capgo/capacitor-updater 8.0.1 → 8.1.0

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