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