@capgo/capacitor-updater 8.0.0 → 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 (64) hide show
  1. package/CapgoCapacitorUpdater.podspec +7 -5
  2. package/Package.swift +37 -0
  3. package/README.md +1461 -231
  4. package/android/build.gradle +29 -12
  5. package/android/proguard-rules.pro +45 -0
  6. package/android/src/main/AndroidManifest.xml +0 -1
  7. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +223 -195
  8. package/android/src/main/java/ee/forgr/capacitor_updater/BundleStatus.java +23 -23
  9. package/android/src/main/java/ee/forgr/capacitor_updater/Callback.java +13 -0
  10. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +2159 -1234
  11. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1507 -0
  12. package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +330 -121
  13. package/android/src/main/java/ee/forgr/capacitor_updater/DataManager.java +28 -0
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +43 -49
  15. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUntilNext.java +4 -4
  16. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +260 -0
  17. package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +221 -0
  18. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +808 -117
  19. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +156 -0
  20. package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +32 -0
  21. package/android/src/main/java/ee/forgr/capacitor_updater/Logger.java +338 -0
  22. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +72 -0
  23. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +169 -0
  24. package/dist/docs.json +2187 -625
  25. package/dist/esm/definitions.d.ts +1286 -249
  26. package/dist/esm/definitions.js.map +1 -1
  27. package/dist/esm/history.d.ts +1 -0
  28. package/dist/esm/history.js +283 -0
  29. package/dist/esm/history.js.map +1 -0
  30. package/dist/esm/index.d.ts +3 -2
  31. package/dist/esm/index.js +5 -4
  32. package/dist/esm/index.js.map +1 -1
  33. package/dist/esm/web.d.ts +36 -41
  34. package/dist/esm/web.js +94 -35
  35. package/dist/esm/web.js.map +1 -1
  36. package/dist/plugin.cjs.js +376 -35
  37. package/dist/plugin.cjs.js.map +1 -1
  38. package/dist/plugin.js +376 -35
  39. package/dist/plugin.js.map +1 -1
  40. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +69 -0
  41. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +55 -0
  42. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleInfo.swift +37 -10
  43. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleStatus.swift +1 -1
  44. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1605 -0
  45. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1526 -0
  46. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +267 -0
  47. package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +220 -0
  48. package/ios/Sources/CapacitorUpdaterPlugin/DeviceIdHelper.swift +120 -0
  49. package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +311 -0
  50. package/ios/Sources/CapacitorUpdaterPlugin/Logger.swift +310 -0
  51. package/ios/Sources/CapacitorUpdaterPlugin/RSA.swift +274 -0
  52. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +112 -0
  53. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/UserDefaultsExtension.swift +0 -2
  54. package/package.json +41 -35
  55. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdater.java +0 -1130
  56. package/ios/Plugin/CapacitorUpdater.swift +0 -858
  57. package/ios/Plugin/CapacitorUpdaterPlugin.h +0 -10
  58. package/ios/Plugin/CapacitorUpdaterPlugin.m +0 -27
  59. package/ios/Plugin/CapacitorUpdaterPlugin.swift +0 -675
  60. package/ios/Plugin/CryptoCipher.swift +0 -240
  61. /package/{LICENCE → LICENSE} +0 -0
  62. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayCondition.swift +0 -0
  63. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayUntilNext.swift +0 -0
  64. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/Info.plist +0 -0
@@ -0,0 +1,1507 @@
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
6
+
7
+ package ee.forgr.capacitor_updater;
8
+
9
+ import android.app.Activity;
10
+ import android.content.Context;
11
+ import android.content.SharedPreferences;
12
+ import android.os.Build;
13
+ import androidx.annotation.NonNull;
14
+ import androidx.lifecycle.LifecycleOwner;
15
+ import androidx.work.Data;
16
+ import androidx.work.WorkInfo;
17
+ import androidx.work.WorkManager;
18
+ import com.google.common.util.concurrent.Futures;
19
+ import com.google.common.util.concurrent.ListenableFuture;
20
+ import java.io.BufferedInputStream;
21
+ import java.io.File;
22
+ import java.io.FileInputStream;
23
+ import java.io.FileNotFoundException;
24
+ import java.io.FileOutputStream;
25
+ import java.io.FilenameFilter;
26
+ import java.io.IOException;
27
+ import java.security.SecureRandom;
28
+ import java.util.ArrayList;
29
+ import java.util.Date;
30
+ import java.util.HashMap;
31
+ import java.util.Iterator;
32
+ import java.util.List;
33
+ import java.util.Map;
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;
41
+ import java.util.zip.ZipEntry;
42
+ import java.util.zip.ZipInputStream;
43
+ import okhttp3.*;
44
+ import okhttp3.HttpUrl;
45
+ import org.json.JSONArray;
46
+ import org.json.JSONException;
47
+ import org.json.JSONObject;
48
+
49
+ public class CapgoUpdater {
50
+
51
+ private final Logger logger;
52
+
53
+ private static final String AB = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
54
+ private static final SecureRandom rnd = new SecureRandom();
55
+
56
+ private static final String INFO_SUFFIX = "_info";
57
+
58
+ private static final String FALLBACK_VERSION = "pastVersion";
59
+ private static final String NEXT_VERSION = "nextVersion";
60
+ private static final String bundleDirectory = "versions";
61
+
62
+ public static final String TAG = "Capacitor-updater";
63
+ public SharedPreferences.Editor editor;
64
+ public SharedPreferences prefs;
65
+
66
+ public File documentsDir;
67
+ public Boolean directUpdate = false;
68
+ public Activity activity;
69
+ public String pluginVersion = "";
70
+ public String versionBuild = "";
71
+ public String versionCode = "";
72
+ public String versionOs = "";
73
+ public String CAP_SERVER_PATH = "";
74
+
75
+ public String customId = "";
76
+ public String statsUrl = "";
77
+ public String channelUrl = "";
78
+ public String defaultChannel = "";
79
+ public String appId = "";
80
+ public String publicKey = "";
81
+ public String deviceID = "";
82
+ public int timeout = 20000;
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
+
97
+ private final FilenameFilter filter = (f, name) -> {
98
+ // ignore directories generated by mac os x
99
+ return (!name.startsWith("__MACOSX") && !name.startsWith(".") && !name.startsWith(".DS_Store"));
100
+ };
101
+
102
+ private boolean isProd() {
103
+ try {
104
+ return !Objects.requireNonNull(getClass().getPackage()).getName().contains(".debug");
105
+ } catch (Exception e) {
106
+ return true; // Default to production if we can't determine
107
+ }
108
+ }
109
+
110
+ private boolean isEmulator() {
111
+ return (
112
+ (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) ||
113
+ Build.FINGERPRINT.startsWith("generic") ||
114
+ Build.FINGERPRINT.startsWith("unknown") ||
115
+ Build.HARDWARE.contains("goldfish") ||
116
+ Build.HARDWARE.contains("ranchu") ||
117
+ Build.MODEL.contains("google_sdk") ||
118
+ Build.MODEL.contains("Emulator") ||
119
+ Build.MODEL.contains("Android SDK built for x86") ||
120
+ Build.MANUFACTURER.contains("Genymotion") ||
121
+ Build.PRODUCT.contains("sdk_google") ||
122
+ Build.PRODUCT.contains("google_sdk") ||
123
+ Build.PRODUCT.contains("sdk") ||
124
+ Build.PRODUCT.contains("sdk_x86") ||
125
+ Build.PRODUCT.contains("sdk_gphone64_arm64") ||
126
+ Build.PRODUCT.contains("vbox86p") ||
127
+ Build.PRODUCT.contains("emulator") ||
128
+ Build.PRODUCT.contains("simulator")
129
+ );
130
+ }
131
+
132
+ private int calcTotalPercent(final int percent, final int min, final int max) {
133
+ return (percent * (max - min)) / 100 + min;
134
+ }
135
+
136
+ void notifyDownload(final String id, final int percent) {}
137
+
138
+ void directUpdateFinish(final BundleInfo latest) {}
139
+
140
+ void notifyListeners(final String id, final Map<String, Object> res) {}
141
+
142
+ public String randomString() {
143
+ final StringBuilder sb = new StringBuilder(10);
144
+ for (int i = 0; i < 10; i++) sb.append(AB.charAt(rnd.nextInt(AB.length())));
145
+ return sb.toString();
146
+ }
147
+
148
+ private File unzip(final String id, final File zipFile, final String dest) throws IOException {
149
+ final File targetDirectory = new File(this.documentsDir, dest);
150
+ try (
151
+ final BufferedInputStream bis = new BufferedInputStream(new FileInputStream(zipFile));
152
+ final ZipInputStream zis = new ZipInputStream(bis)
153
+ ) {
154
+ int count;
155
+ final int bufferSize = 8192;
156
+ final byte[] buffer = new byte[bufferSize];
157
+ final long lengthTotal = zipFile.length();
158
+ long lengthRead = bufferSize;
159
+ int percent = 0;
160
+ this.notifyDownload(id, 75);
161
+
162
+ ZipEntry entry;
163
+ while ((entry = zis.getNextEntry()) != null) {
164
+ if (entry.getName().contains("\\")) {
165
+ logger.error("unzip: Windows path is not supported, please use unix path as require by zip RFC: " + entry.getName());
166
+ this.sendStats("windows_path_fail");
167
+ }
168
+ final File file = new File(targetDirectory, entry.getName());
169
+ final String canonicalPath = file.getCanonicalPath();
170
+ final String canonicalDir = targetDirectory.getCanonicalPath();
171
+ final File dir = entry.isDirectory() ? file : file.getParentFile();
172
+
173
+ if (!canonicalPath.startsWith(canonicalDir)) {
174
+ this.sendStats("canonical_path_fail");
175
+ throw new FileNotFoundException(
176
+ "SecurityException, Failed to ensure directory is the start path : " + canonicalDir + " of " + canonicalPath
177
+ );
178
+ }
179
+
180
+ assert dir != null;
181
+ if (!dir.isDirectory() && !dir.mkdirs()) {
182
+ this.sendStats("directory_path_fail");
183
+ throw new FileNotFoundException("Failed to ensure directory: " + dir.getAbsolutePath());
184
+ }
185
+
186
+ if (entry.isDirectory()) {
187
+ continue;
188
+ }
189
+
190
+ try (final FileOutputStream outputStream = new FileOutputStream(file)) {
191
+ while ((count = zis.read(buffer)) != -1) outputStream.write(buffer, 0, count);
192
+ }
193
+
194
+ final int newPercent = (int) ((lengthRead / (float) lengthTotal) * 100);
195
+ if (lengthTotal > 1 && newPercent != percent) {
196
+ percent = newPercent;
197
+ this.notifyDownload(id, this.calcTotalPercent(percent, 75, 90));
198
+ }
199
+
200
+ lengthRead += entry.getCompressedSize();
201
+ }
202
+ return targetDirectory;
203
+ } catch (IOException e) {
204
+ this.sendStats("unzip_fail");
205
+ throw new IOException("Failed to unzip: " + zipFile.getPath());
206
+ }
207
+ }
208
+
209
+ private void flattenAssets(final File sourceFile, final String dest) throws IOException {
210
+ if (!sourceFile.exists()) {
211
+ throw new FileNotFoundException("Source file not found: " + sourceFile.getPath());
212
+ }
213
+ final File destinationFile = new File(this.documentsDir, dest);
214
+ Objects.requireNonNull(destinationFile.getParentFile()).mkdirs();
215
+ final String[] entries = sourceFile.list(this.filter);
216
+ if (entries == null || entries.length == 0) {
217
+ throw new IOException("Source file was not a directory or was empty: " + sourceFile.getPath());
218
+ }
219
+ if (entries.length == 1 && !"index.html".equals(entries[0])) {
220
+ final File child = new File(sourceFile, entries[0]);
221
+ child.renameTo(destinationFile);
222
+ } else {
223
+ sourceFile.renameTo(destinationFile);
224
+ }
225
+ sourceFile.delete();
226
+ }
227
+
228
+ private void observeWorkProgress(Context context, String id) {
229
+ if (!(context instanceof LifecycleOwner)) {
230
+ logger.error("Context is not a LifecycleOwner, cannot observe work progress");
231
+ return;
232
+ }
233
+
234
+ activity.runOnUiThread(() -> {
235
+ WorkManager.getInstance(context)
236
+ .getWorkInfosByTagLiveData(id)
237
+ .observe((LifecycleOwner) context, (workInfos) -> {
238
+ if (workInfos == null || workInfos.isEmpty()) return;
239
+
240
+ WorkInfo workInfo = workInfos.get(0);
241
+ Data progress = workInfo.getProgress();
242
+
243
+ switch (workInfo.getState()) {
244
+ case RUNNING:
245
+ int percent = progress.getInt(DownloadService.PERCENT, 0);
246
+ notifyDownload(id, percent);
247
+ break;
248
+ case SUCCEEDED:
249
+ logger.info("Download succeeded: " + workInfo.getState());
250
+ Data outputData = workInfo.getOutputData();
251
+ String dest = outputData.getString(DownloadService.FILEDEST);
252
+ String version = outputData.getString(DownloadService.VERSION);
253
+ String sessionKey = outputData.getString(DownloadService.SESSIONKEY);
254
+ String checksum = outputData.getString(DownloadService.CHECKSUM);
255
+ boolean isManifest = outputData.getBoolean(DownloadService.IS_MANIFEST, false);
256
+
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
+ });
289
+ break;
290
+ case FAILED:
291
+ Data failedData = workInfo.getOutputData();
292
+ String error = failedData.getString(DownloadService.ERROR);
293
+ logger.error("Download failed: " + error + " " + workInfo.getState());
294
+ String failedVersion = failedData.getString(DownloadService.VERSION);
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
+ });
322
+ break;
323
+ }
324
+ });
325
+ });
326
+ }
327
+
328
+ private void download(
329
+ final String id,
330
+ final String url,
331
+ final String dest,
332
+ final String version,
333
+ final String sessionKey,
334
+ final String checksum,
335
+ final JSONArray manifest
336
+ ) {
337
+ if (this.activity == null) {
338
+ logger.error("Activity is null, cannot observe work progress");
339
+ return;
340
+ }
341
+ observeWorkProgress(this.activity, id);
342
+
343
+ DownloadWorkerManager.enqueueDownload(
344
+ this.activity,
345
+ url,
346
+ id,
347
+ this.documentsDir.getAbsolutePath(),
348
+ dest,
349
+ version,
350
+ sessionKey,
351
+ checksum,
352
+ this.publicKey,
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
365
+ );
366
+
367
+ if (manifest != null) {
368
+ DataManager.getInstance().setManifest(manifest);
369
+ }
370
+ }
371
+
372
+ public Boolean finishDownload(
373
+ String id,
374
+ String dest,
375
+ String version,
376
+ String sessionKey,
377
+ String checksumRes,
378
+ Boolean setNext,
379
+ Boolean isManifest
380
+ ) {
381
+ File downloaded = null;
382
+ File extractedDir = null;
383
+ String checksum = "";
384
+
385
+ try {
386
+ this.notifyDownload(id, 71);
387
+ downloaded = new File(this.documentsDir, dest);
388
+
389
+ if (!isManifest) {
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
+
399
+ if (!sessionKey.isEmpty()) {
400
+ CryptoCipher.decryptFile(downloaded, publicKey, sessionKey);
401
+ checksumDecrypted = CryptoCipher.decryptChecksum(checksumRes, publicKey);
402
+ checksum = CryptoCipher.calcChecksum(downloaded);
403
+ } else {
404
+ checksum = CryptoCipher.calcChecksum(downloaded);
405
+ }
406
+ CryptoCipher.logChecksumInfo("Calculated checksum", checksum);
407
+ CryptoCipher.logChecksumInfo("Expected checksum", checksumDecrypted);
408
+ if ((!checksumDecrypted.isEmpty() || !this.publicKey.isEmpty()) && !checksumDecrypted.equals(checksum)) {
409
+ logger.error("Error checksum '" + checksumDecrypted + "' '" + checksum + "' '");
410
+ this.sendStats("checksum_fail");
411
+ throw new IOException("Checksum failed: " + id);
412
+ }
413
+ }
414
+ // Remove the decryption for manifest downloads
415
+ } catch (Exception e) {
416
+ if (!isManifest) {
417
+ safeDelete(downloaded);
418
+ }
419
+ final Boolean res = this.delete(id);
420
+ if (!res) {
421
+ logger.info("Double error, cannot cleanup: " + version);
422
+ }
423
+
424
+ final Map<String, Object> ret = new HashMap<>();
425
+ ret.put("version", version);
426
+
427
+ CapgoUpdater.this.notifyListeners("downloadFailed", ret);
428
+ CapgoUpdater.this.sendStats("download_fail");
429
+ return false;
430
+ }
431
+
432
+ try {
433
+ if (!isManifest) {
434
+ extractedDir = this.unzip(id, downloaded, this.randomString());
435
+ this.notifyDownload(id, 91);
436
+ final String idName = bundleDirectory + "/" + id;
437
+ this.flattenAssets(extractedDir, idName);
438
+ } else {
439
+ this.notifyDownload(id, 91);
440
+ final String idName = bundleDirectory + "/" + id;
441
+ this.flattenAssets(downloaded, idName);
442
+ downloaded.delete();
443
+ }
444
+ // Remove old bundle info and set new one
445
+ this.saveBundleInfo(id, null);
446
+ BundleInfo next = new BundleInfo(id, version, BundleStatus.PENDING, new Date(System.currentTimeMillis()), checksum);
447
+ this.saveBundleInfo(id, next);
448
+ this.notifyDownload(id, 100);
449
+
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);
455
+ if (setNext) {
456
+ logger.info("directUpdate: " + this.directUpdate);
457
+ if (this.directUpdate) {
458
+ CapgoUpdater.this.directUpdateFinish(next);
459
+ this.directUpdate = false;
460
+ } else {
461
+ this.setNextBundle(next.getId());
462
+ }
463
+ }
464
+ } catch (IOException e) {
465
+ if (!isManifest) {
466
+ safeDelete(extractedDir);
467
+ safeDelete(downloaded);
468
+ }
469
+ e.printStackTrace();
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");
474
+ return false;
475
+ }
476
+ if (!isManifest) {
477
+ safeDelete(downloaded);
478
+ }
479
+ return true;
480
+ }
481
+
482
+ private void deleteDirectory(final File file) throws IOException {
483
+ if (file.isDirectory()) {
484
+ final File[] entries = file.listFiles();
485
+ if (entries != null) {
486
+ for (final File entry : entries) {
487
+ this.deleteDirectory(entry);
488
+ }
489
+ }
490
+ }
491
+ if (!file.delete()) {
492
+ throw new IOException("Failed to delete: " + file);
493
+ }
494
+ }
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
+
563
+ private void setCurrentBundle(final File bundle) {
564
+ this.editor.putString(this.CAP_SERVER_PATH, bundle.getPath());
565
+ logger.info("Current bundle set to: " + bundle);
566
+ this.editor.commit();
567
+ }
568
+
569
+ public void downloadBackground(
570
+ final String url,
571
+ final String version,
572
+ final String sessionKey,
573
+ final String checksum,
574
+ final JSONArray manifest
575
+ ) {
576
+ final String id = this.randomString();
577
+
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
+ }
590
+ }
591
+
592
+ saveBundleInfo(id, new BundleInfo(id, version, BundleStatus.DOWNLOADING, new Date(System.currentTimeMillis()), ""));
593
+ this.notifyDownload(id, 0);
594
+ this.notifyDownload(id, 5);
595
+
596
+ this.download(id, url, this.randomString(), version, sessionKey, checksum, manifest);
597
+ }
598
+
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
+
607
+ final String id = this.randomString();
608
+ saveBundleInfo(id, new BundleInfo(id, version, BundleStatus.DOWNLOADING, new Date(System.currentTimeMillis()), ""));
609
+ this.notifyDownload(id, 0);
610
+ this.notifyDownload(id, 5);
611
+ final String dest = this.randomString();
612
+
613
+ // Create a CompletableFuture to track download completion
614
+ CompletableFuture<BundleInfo> downloadFuture = new CompletableFuture<>();
615
+ downloadFutures.put(id, downloadFuture);
616
+
617
+ // Start the download
618
+ this.download(id, url, dest, version, sessionKey, checksum, null);
619
+
620
+ // Wait for completion without timeout
621
+ try {
622
+ BundleInfo result = downloadFuture.get();
623
+ if (result.isErrorStatus()) {
624
+ throw new IOException("Download failed with status: " + result.getStatus());
625
+ }
626
+ return result;
627
+ } catch (Exception e) {
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);
637
+ }
638
+ }
639
+
640
+ public List<BundleInfo> list(boolean rawList) {
641
+ if (!rawList) {
642
+ final List<BundleInfo> res = new ArrayList<>();
643
+ final File destHot = new File(this.documentsDir, bundleDirectory);
644
+ logger.debug("list File : " + destHot.getPath());
645
+ if (destHot.exists()) {
646
+ for (final File i : Objects.requireNonNull(destHot.listFiles())) {
647
+ final String id = i.getName();
648
+ res.add(this.getBundleInfo(id));
649
+ }
650
+ } else {
651
+ logger.info("No versions available to list" + destHot);
652
+ }
653
+ return res;
654
+ } else {
655
+ final List<BundleInfo> res = new ArrayList<>();
656
+ for (String value : this.prefs.getAll().keySet()) {
657
+ if (!value.matches("^[0-9A-Za-z]{10}_info$")) {
658
+ continue;
659
+ }
660
+
661
+ res.add(this.getBundleInfo(value.split("_")[0]));
662
+ }
663
+ return res;
664
+ }
665
+ }
666
+
667
+ public Boolean delete(final String id, final Boolean removeInfo) throws IOException {
668
+ final BundleInfo deleted = this.getBundleInfo(id);
669
+ if (deleted.isBuiltin() || this.getCurrentBundleId().equals(id)) {
670
+ logger.error("Cannot delete " + id);
671
+ return false;
672
+ }
673
+ final BundleInfo next = this.getNextBundle();
674
+ if (next != null && !next.isDeleted() && !next.isErrorStatus() && next.getId().equals(id)) {
675
+ logger.error("Cannot delete the next bundle" + id);
676
+ return false;
677
+ }
678
+ // Cancel download for this version if active
679
+ if (this.activity != null) {
680
+ DownloadWorkerManager.cancelVersionDownload(this.activity, deleted.getVersionName());
681
+ }
682
+ final File bundle = new File(this.documentsDir, bundleDirectory + "/" + id);
683
+ if (bundle.exists()) {
684
+ this.deleteDirectory(bundle);
685
+ if (!removeInfo) {
686
+ this.saveBundleInfo(id, deleted.setStatus(BundleStatus.DELETED));
687
+ } else {
688
+ this.removeBundleInfo(id);
689
+ }
690
+ return true;
691
+ }
692
+ logger.error("bundle removed: " + deleted.getVersionName());
693
+ // perhaps we did not find the bundle in the files, but if the user requested a delete, we delete
694
+ if (removeInfo) {
695
+ this.removeBundleInfo(id);
696
+ }
697
+ this.sendStats("delete", deleted.getVersionName());
698
+ return false;
699
+ }
700
+
701
+ public Boolean delete(final String id) {
702
+ try {
703
+ return this.delete(id, true);
704
+ } catch (IOException e) {
705
+ e.printStackTrace();
706
+ logger.info("Failed to delete bundle (" + id + ")" + "\nError:\n" + e.toString());
707
+ return false;
708
+ }
709
+ }
710
+
711
+ private File getBundleDirectory(final String id) {
712
+ return new File(this.documentsDir, bundleDirectory + "/" + id);
713
+ }
714
+
715
+ private boolean bundleExists(final String id) {
716
+ final File bundle = this.getBundleDirectory(id);
717
+ final BundleInfo bundleInfo = this.getBundleInfo(id);
718
+ return (bundle.isDirectory() && bundle.exists() && new File(bundle.getPath(), "/index.html").exists() && !bundleInfo.isDeleted());
719
+ }
720
+
721
+ public Boolean set(final BundleInfo bundle) {
722
+ return this.set(bundle.getId());
723
+ }
724
+
725
+ public Boolean set(final String id) {
726
+ final BundleInfo newBundle = this.getBundleInfo(id);
727
+ if (newBundle.isBuiltin()) {
728
+ this.reset();
729
+ return true;
730
+ }
731
+ final File bundle = this.getBundleDirectory(id);
732
+ logger.info("Setting next active bundle: " + id);
733
+ if (this.bundleExists(id)) {
734
+ var currentBundleName = this.getCurrentBundle().getVersionName();
735
+ this.setCurrentBundle(bundle);
736
+ this.setBundleStatus(id, BundleStatus.PENDING);
737
+ this.sendStats("set", newBundle.getVersionName(), currentBundleName);
738
+ return true;
739
+ }
740
+ this.setBundleStatus(id, BundleStatus.ERROR);
741
+ this.sendStats("set_fail", newBundle.getVersionName());
742
+ return false;
743
+ }
744
+
745
+ public void autoReset() {
746
+ final BundleInfo currentBundle = this.getCurrentBundle();
747
+ if (!currentBundle.isBuiltin() && !this.bundleExists(currentBundle.getId())) {
748
+ logger.info("Folder at bundle path does not exist. Triggering reset.");
749
+ this.reset();
750
+ }
751
+ }
752
+
753
+ public void reset() {
754
+ this.reset(false);
755
+ }
756
+
757
+ public void setSuccess(final BundleInfo bundle, Boolean autoDeletePrevious) {
758
+ this.setBundleStatus(bundle.getId(), BundleStatus.SUCCESS);
759
+ final BundleInfo fallback = this.getFallbackBundle();
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())) {
766
+ final Boolean res = this.delete(fallback.getId());
767
+ if (res) {
768
+ logger.info("Deleted previous bundle: " + fallback.getVersionName());
769
+ } else {
770
+ logger.debug("Skip deleting previous bundle (same as current or protected): " + fallback.getId());
771
+ }
772
+ }
773
+ this.setFallbackBundle(bundle);
774
+ }
775
+
776
+ public void setError(final BundleInfo bundle) {
777
+ this.setBundleStatus(bundle.getId(), BundleStatus.ERROR);
778
+ }
779
+
780
+ public void reset(final boolean internal) {
781
+ logger.debug("reset: " + internal);
782
+ var currentBundleName = this.getCurrentBundle().getVersionName();
783
+ this.setCurrentBundle(new File("public"));
784
+ this.setFallbackBundle(null);
785
+ this.setNextBundle(null);
786
+ // Cancel any ongoing downloads
787
+ if (this.activity != null) {
788
+ DownloadWorkerManager.cancelAllDownloads(this.activity);
789
+ }
790
+ if (!internal) {
791
+ this.sendStats("reset", this.getCurrentBundle().getVersionName(), currentBundleName);
792
+ }
793
+ }
794
+
795
+ private JSONObject createInfoObject() throws JSONException {
796
+ JSONObject json = new JSONObject();
797
+ json.put("platform", "android");
798
+ json.put("device_id", this.deviceID);
799
+ json.put("app_id", this.appId);
800
+ json.put("custom_id", this.customId);
801
+ json.put("version_build", this.versionBuild);
802
+ json.put("version_code", this.versionCode);
803
+ json.put("version_os", this.versionOs);
804
+ json.put("version_name", this.getCurrentBundle().getVersionName());
805
+ json.put("plugin_version", this.pluginVersion);
806
+ json.put("is_emulator", this.isEmulator());
807
+ json.put("is_prod", this.isProd());
808
+ json.put("defaultChannel", this.defaultChannel);
809
+ return json;
810
+ }
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
+
865
+ private void makeJsonRequest(String url, JSONObject jsonBody, Callback callback) {
866
+ MediaType JSON = MediaType.get("application/json; charset=utf-8");
867
+ RequestBody body = RequestBody.create(jsonBody.toString(), JSON);
868
+
869
+ Request request = new Request.Builder().url(url).post(body).build();
870
+
871
+ DownloadService.sharedClient
872
+ .newCall(request)
873
+ .enqueue(
874
+ new okhttp3.Callback() {
875
+ @Override
876
+ public void onFailure(@NonNull Call call, @NonNull IOException e) {
877
+ Map<String, Object> retError = new HashMap<>();
878
+ retError.put("message", "Request failed: " + e.getMessage());
879
+ retError.put("error", "network_error");
880
+ callback.callback(retError);
881
+ }
882
+
883
+ @Override
884
+ public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
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
+
897
+ if (!response.isSuccessful()) {
898
+ Map<String, Object> retError = new HashMap<>();
899
+ retError.put("message", "Server error: " + response.code());
900
+ retError.put("error", "response_error");
901
+ retError.put("statusCode", statusCode);
902
+ callback.callback(retError);
903
+ return;
904
+ }
905
+
906
+ assert responseBody != null;
907
+ String responseData = responseBody.string();
908
+ JSONObject jsonResponse = new JSONObject(responseData);
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);
926
+
927
+ Iterator<String> keys = jsonResponse.keys();
928
+ while (keys.hasNext()) {
929
+ String key = keys.next();
930
+ if (jsonResponse.has(key)) {
931
+ if ("session_key".equals(key)) {
932
+ ret.put("sessionKey", jsonResponse.get(key));
933
+ } else {
934
+ ret.put(key, jsonResponse.get(key));
935
+ }
936
+ }
937
+ }
938
+ callback.callback(ret);
939
+ } catch (JSONException e) {
940
+ Map<String, Object> retError = new HashMap<>();
941
+ retError.put("message", "JSON parse error: " + e.getMessage());
942
+ retError.put("error", "parse_error");
943
+ callback.callback(retError);
944
+ }
945
+ }
946
+ }
947
+ );
948
+ }
949
+
950
+ public void getLatest(final String updateUrl, final String channel, final Callback callback) {
951
+ JSONObject json;
952
+ try {
953
+ json = this.createInfoObject();
954
+ if (channel != null && json != null) {
955
+ json.put("defaultChannel", channel);
956
+ }
957
+ } catch (JSONException e) {
958
+ logger.error("Error getLatest JSONException " + e.getMessage());
959
+ final Map<String, Object> retError = new HashMap<>();
960
+ retError.put("message", "Cannot get info: " + e);
961
+ retError.put("error", "json_error");
962
+ callback.callback(retError);
963
+ return;
964
+ }
965
+
966
+ logger.info("Auto-update parameters: " + json);
967
+
968
+ makeJsonRequest(updateUrl, json, callback);
969
+ }
970
+
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
+
1016
+ String channelUrl = this.channelUrl;
1017
+ if (channelUrl == null || channelUrl.isEmpty()) {
1018
+ logger.error("Channel URL is not set");
1019
+ final Map<String, Object> retError = new HashMap<>();
1020
+ retError.put("message", "channelUrl missing");
1021
+ retError.put("error", "missing_config");
1022
+ callback.callback(retError);
1023
+ return;
1024
+ }
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;
1073
+ try {
1074
+ json = this.createInfoObject();
1075
+ } catch (JSONException e) {
1076
+ logger.error("Error getChannel JSONException " + e.getMessage());
1077
+ final Map<String, Object> retError = new HashMap<>();
1078
+ retError.put("message", "Cannot get info: " + e);
1079
+ retError.put("error", "json_error");
1080
+ callback.callback(retError);
1081
+ return;
1082
+ }
1083
+
1084
+ Request request = new Request.Builder()
1085
+ .url(channelUrl)
1086
+ .put(RequestBody.create(json.toString(), MediaType.get("application/json")))
1087
+ .build();
1088
+
1089
+ DownloadService.sharedClient
1090
+ .newCall(request)
1091
+ .enqueue(
1092
+ new okhttp3.Callback() {
1093
+ @Override
1094
+ public void onFailure(@NonNull Call call, @NonNull IOException e) {
1095
+ Map<String, Object> retError = new HashMap<>();
1096
+ retError.put("message", "Request failed: " + e.getMessage());
1097
+ retError.put("error", "network_error");
1098
+ callback.callback(retError);
1099
+ }
1100
+
1101
+ @Override
1102
+ public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
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
+
1126
+ if (!response.isSuccessful()) {
1127
+ Map<String, Object> retError = new HashMap<>();
1128
+ retError.put("message", "Server error: " + response.code());
1129
+ retError.put("error", "response_error");
1130
+ callback.callback(retError);
1131
+ return;
1132
+ }
1133
+
1134
+ assert responseBody != null;
1135
+ String responseData = responseBody.string();
1136
+ JSONObject jsonResponse = new JSONObject(responseData);
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<>();
1152
+
1153
+ Iterator<String> keys = jsonResponse.keys();
1154
+ while (keys.hasNext()) {
1155
+ String key = keys.next();
1156
+ if (jsonResponse.has(key)) {
1157
+ ret.put(key, jsonResponse.get(key));
1158
+ }
1159
+ }
1160
+ logger.info("Channel get to \"" + ret);
1161
+ callback.callback(ret);
1162
+ } catch (JSONException e) {
1163
+ Map<String, Object> retError = new HashMap<>();
1164
+ retError.put("message", "JSON parse error: " + e.getMessage());
1165
+ retError.put("error", "parse_error");
1166
+ callback.callback(retError);
1167
+ }
1168
+ }
1169
+ }
1170
+ );
1171
+ }
1172
+
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");
1180
+ callback.callback(retError);
1181
+ return;
1182
+ }
1183
+
1184
+ String channelUrl = this.channelUrl;
1185
+ if (channelUrl == null || channelUrl.isEmpty()) {
1186
+ logger.error("Channel URL is not set");
1187
+ final Map<String, Object> retError = new HashMap<>();
1188
+ retError.put("message", "Channel URL is not set");
1189
+ retError.put("error", "missing_config");
1190
+ callback.callback(retError);
1191
+ return;
1192
+ }
1193
+
1194
+ JSONObject json;
1195
+ try {
1196
+ json = this.createInfoObject();
1197
+ } catch (JSONException e) {
1198
+ logger.error("Error creating info object: " + e.getMessage());
1199
+ final Map<String, Object> retError = new HashMap<>();
1200
+ retError.put("message", "Cannot get info: " + e);
1201
+ retError.put("error", "json_error");
1202
+ callback.callback(retError);
1203
+ return;
1204
+ }
1205
+
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
+ }
1220
+
1221
+ Request request = new Request.Builder().url(urlBuilder.build()).get().build();
1222
+
1223
+ DownloadService.sharedClient
1224
+ .newCall(request)
1225
+ .enqueue(
1226
+ new okhttp3.Callback() {
1227
+ @Override
1228
+ public void onFailure(@NonNull Call call, @NonNull IOException e) {
1229
+ Map<String, Object> retError = new HashMap<>();
1230
+ retError.put("message", "Request failed: " + e.getMessage());
1231
+ retError.put("error", "network_error");
1232
+ callback.callback(retError);
1233
+ }
1234
+
1235
+ @Override
1236
+ public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
1237
+ try (ResponseBody responseBody = response.body()) {
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;
1245
+ }
1246
+
1247
+ if (!response.isSuccessful()) {
1248
+ Map<String, Object> retError = new HashMap<>();
1249
+ retError.put("message", "Server error: " + response.code());
1250
+ retError.put("error", "response_error");
1251
+ callback.callback(retError);
1252
+ return;
1253
+ }
1254
+
1255
+ assert responseBody != null;
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
+ }
1275
+
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
+ }
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);
1306
+ }
1307
+ }
1308
+ }
1309
+ }
1310
+ );
1311
+ }
1312
+
1313
+ public void sendStats(final String action) {
1314
+ this.sendStats(action, this.getCurrentBundle().getVersionName());
1315
+ }
1316
+
1317
+ public void sendStats(final String action, final String versionName) {
1318
+ this.sendStats(action, versionName, "");
1319
+ }
1320
+
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
+
1328
+ String statsUrl = this.statsUrl;
1329
+ if (statsUrl == null || statsUrl.isEmpty()) {
1330
+ return;
1331
+ }
1332
+ JSONObject json;
1333
+ try {
1334
+ json = this.createInfoObject();
1335
+ json.put("version_name", versionName);
1336
+ json.put("old_version_name", oldVersionName);
1337
+ json.put("action", action);
1338
+ } catch (JSONException e) {
1339
+ logger.error("Error sendStats JSONException " + e.getMessage());
1340
+ return;
1341
+ }
1342
+
1343
+ Request request = new Request.Builder()
1344
+ .url(statsUrl)
1345
+ .post(RequestBody.create(json.toString(), MediaType.get("application/json")))
1346
+ .build();
1347
+
1348
+ DownloadService.sharedClient
1349
+ .newCall(request)
1350
+ .enqueue(
1351
+ new okhttp3.Callback() {
1352
+ @Override
1353
+ public void onFailure(@NonNull Call call, @NonNull IOException e) {
1354
+ logger.error("Failed to send stats: " + e.getMessage());
1355
+ }
1356
+
1357
+ @Override
1358
+ public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
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
+ }
1370
+ }
1371
+ }
1372
+ }
1373
+ );
1374
+ }
1375
+
1376
+ public BundleInfo getBundleInfo(final String id) {
1377
+ String trueId = BundleInfo.VERSION_UNKNOWN;
1378
+ if (id != null) {
1379
+ trueId = id;
1380
+ }
1381
+ BundleInfo result;
1382
+ if (BundleInfo.ID_BUILTIN.equals(trueId)) {
1383
+ result = new BundleInfo(trueId, null, BundleStatus.SUCCESS, "", "");
1384
+ } else if (BundleInfo.VERSION_UNKNOWN.equals(trueId)) {
1385
+ result = new BundleInfo(trueId, null, BundleStatus.ERROR, "", "");
1386
+ } else {
1387
+ try {
1388
+ String stored = this.prefs.getString(trueId + INFO_SUFFIX, "");
1389
+ if (stored.isEmpty()) {
1390
+ result = new BundleInfo(trueId, null, BundleStatus.PENDING, "", "");
1391
+ } else {
1392
+ result = BundleInfo.fromJSON(stored);
1393
+ }
1394
+ } catch (JSONException e) {
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, "", "");
1407
+ }
1408
+ }
1409
+ return result;
1410
+ }
1411
+
1412
+ public BundleInfo getBundleInfoByName(final String versionName) {
1413
+ final List<BundleInfo> installed = this.list(false);
1414
+ for (final BundleInfo i : installed) {
1415
+ if (i.getVersionName().equals(versionName)) {
1416
+ return i;
1417
+ }
1418
+ }
1419
+ return null;
1420
+ }
1421
+
1422
+ private void removeBundleInfo(final String id) {
1423
+ this.saveBundleInfo(id, null);
1424
+ }
1425
+
1426
+ public void saveBundleInfo(final String id, final BundleInfo info) {
1427
+ if (id == null || (info != null && (info.isBuiltin() || info.isUnknown()))) {
1428
+ logger.debug("Not saving info for bundle: [" + id + "] " + info);
1429
+ return;
1430
+ }
1431
+
1432
+ if (info == null) {
1433
+ logger.debug("Removing info for bundle [" + id + "]");
1434
+ this.editor.remove(id + INFO_SUFFIX);
1435
+ } else {
1436
+ final BundleInfo update = info.setId(id);
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);
1440
+ }
1441
+ this.editor.commit();
1442
+ }
1443
+
1444
+ private void setBundleStatus(final String id, final BundleStatus status) {
1445
+ if (id != null && status != null) {
1446
+ BundleInfo info = this.getBundleInfo(id);
1447
+ logger.debug("Setting status for bundle [" + id + "] to " + status);
1448
+ this.saveBundleInfo(id, info.setStatus(status));
1449
+ }
1450
+ }
1451
+
1452
+ private String getCurrentBundleId() {
1453
+ if (this.isUsingBuiltin()) {
1454
+ return BundleInfo.ID_BUILTIN;
1455
+ } else {
1456
+ final String path = this.getCurrentBundlePath();
1457
+ return path.substring(path.lastIndexOf('/') + 1);
1458
+ }
1459
+ }
1460
+
1461
+ public BundleInfo getCurrentBundle() {
1462
+ return this.getBundleInfo(this.getCurrentBundleId());
1463
+ }
1464
+
1465
+ public String getCurrentBundlePath() {
1466
+ String path = this.prefs.getString(this.CAP_SERVER_PATH, "public");
1467
+ if (path.trim().isEmpty()) {
1468
+ return "public";
1469
+ }
1470
+ return path;
1471
+ }
1472
+
1473
+ public Boolean isUsingBuiltin() {
1474
+ return this.getCurrentBundlePath().equals("public");
1475
+ }
1476
+
1477
+ public BundleInfo getFallbackBundle() {
1478
+ final String id = this.prefs.getString(FALLBACK_VERSION, BundleInfo.ID_BUILTIN);
1479
+ return this.getBundleInfo(id);
1480
+ }
1481
+
1482
+ private void setFallbackBundle(final BundleInfo fallback) {
1483
+ this.editor.putString(FALLBACK_VERSION, fallback == null ? BundleInfo.ID_BUILTIN : fallback.getId());
1484
+ this.editor.commit();
1485
+ }
1486
+
1487
+ public BundleInfo getNextBundle() {
1488
+ final String id = this.prefs.getString(NEXT_VERSION, null);
1489
+ if (id == null) return null;
1490
+ return this.getBundleInfo(id);
1491
+ }
1492
+
1493
+ public boolean setNextBundle(final String next) {
1494
+ if (next == null) {
1495
+ this.editor.remove(NEXT_VERSION);
1496
+ } else {
1497
+ final BundleInfo newBundle = this.getBundleInfo(next);
1498
+ if (!newBundle.isBuiltin() && !this.bundleExists(next)) {
1499
+ return false;
1500
+ }
1501
+ this.editor.putString(NEXT_VERSION, next);
1502
+ this.setBundleStatus(next, BundleStatus.PENDING);
1503
+ }
1504
+ this.editor.commit();
1505
+ return true;
1506
+ }
1507
+ }