@capgo/cordova-updater 8.1.4 → 8.2.1
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/dist/esm/definitions.d.ts +116 -86
- package/dist/esm/definitions.js +2 -2
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/exec.js +11 -1
- package/dist/esm/exec.js.map +1 -1
- package/dist/esm/history.js +7 -7
- package/dist/esm/history.js.map +1 -1
- package/dist/plugin.cjs.js +13 -3
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +13 -3
- package/dist/plugin.js.map +1 -1
- package/package.json +5 -3
- package/plugin.xml +9 -9
- package/src/android/CordovaUpdaterPlugin.java +173 -5
- package/src/android/app/capgo/cordova/updater/CapgoUpdater.java +313 -55
- package/src/android/app/capgo/cordova/updater/CryptoCipher.java +51 -18
- package/src/android/app/capgo/cordova/updater/DelayUpdateUtils.java +0 -1
- package/src/android/app/capgo/cordova/updater/DownloadService.java +402 -89
- package/src/android/app/capgo/cordova/updater/Version.java +312 -0
- package/src/android/capgo-cordova-updater.gradle +3 -5
- package/src/android/test/app/capgo/cordova/updater/BackgroundDownloadSettlementTest.java +29 -0
- package/src/android/test/app/capgo/cordova/updater/CapacitorUpdaterUnitTest.java.skip +2 -12
- package/src/android/test/app/capgo/cordova/updater/DelayUpdateUtilsTest.java +0 -1
- package/src/android/test/app/capgo/cordova/updater/RsaContractTest.java +137 -0
- package/src/android/test/app/capgo/cordova/updater/SecurityHardeningTest.java +197 -0
- package/src/android/test/app/capgo/cordova/updater/SessionKeyRequiredTest.java +344 -0
- package/src/ios/CapgoSemanticVersion.swift +181 -0
- package/src/ios/CapgoUpdater.swift +427 -114
- package/src/ios/CordovaUpdaterPlugin.swift +171 -40
- package/src/ios/CryptoCipher.swift +41 -38
- package/src/ios/DelayUpdateUtils.swift +19 -45
- package/src/ios/Logger.swift +81 -5
- package/src/ios/RedirectPolicyDelegate.swift +53 -0
- package/src/ios/WebViewStatsReporter.swift +5 -0
- package/src/ios/ZipArchiveReader.swift +263 -0
- package/src/ios/ZipCentralDirectory.swift +283 -0
|
@@ -14,7 +14,8 @@ import androidx.work.WorkerParameters;
|
|
|
14
14
|
import java.io.*;
|
|
15
15
|
import java.io.FileInputStream;
|
|
16
16
|
import java.net.HttpURLConnection;
|
|
17
|
-
import java.net.
|
|
17
|
+
import java.net.MalformedURLException;
|
|
18
|
+
import java.net.ProtocolException;
|
|
18
19
|
import java.nio.channels.FileChannel;
|
|
19
20
|
import java.security.MessageDigest;
|
|
20
21
|
import java.util.ArrayList;
|
|
@@ -30,9 +31,13 @@ import java.util.concurrent.Future;
|
|
|
30
31
|
import java.util.concurrent.TimeUnit;
|
|
31
32
|
import java.util.concurrent.atomic.AtomicBoolean;
|
|
32
33
|
import java.util.concurrent.atomic.AtomicLong;
|
|
34
|
+
import java.util.function.BooleanSupplier;
|
|
35
|
+
import java.util.function.LongConsumer;
|
|
33
36
|
import okhttp3.Call;
|
|
34
37
|
import okhttp3.Callback;
|
|
38
|
+
import okhttp3.CookieJar;
|
|
35
39
|
import okhttp3.Dispatcher;
|
|
40
|
+
import okhttp3.HttpUrl;
|
|
36
41
|
import okhttp3.Interceptor;
|
|
37
42
|
import okhttp3.MediaType;
|
|
38
43
|
import okhttp3.OkHttpClient;
|
|
@@ -47,6 +52,49 @@ import org.json.JSONObject;
|
|
|
47
52
|
|
|
48
53
|
public class DownloadService extends Worker {
|
|
49
54
|
|
|
55
|
+
/** A refused HTTPS to HTTP redirect. Permanent for the given URL, so downloads must not retry it. */
|
|
56
|
+
static final class BlockedRedirectException extends ProtocolException {
|
|
57
|
+
|
|
58
|
+
BlockedRedirectException() {
|
|
59
|
+
super("Blocked HTTPS to HTTP redirect; set allowHttpsToHttpRedirect to true to allow it");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static final class DownloadRetryException extends RuntimeException {
|
|
64
|
+
|
|
65
|
+
DownloadRetryException(String message) {
|
|
66
|
+
super(message);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
DownloadRetryException(String message, Throwable cause) {
|
|
70
|
+
super(message, cause);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
static final class ZipWritePlan {
|
|
75
|
+
|
|
76
|
+
final int statusCode;
|
|
77
|
+
final long writeOffset;
|
|
78
|
+
|
|
79
|
+
ZipWritePlan(int statusCode, long writeOffset) {
|
|
80
|
+
this.statusCode = statusCode;
|
|
81
|
+
this.writeOffset = writeOffset;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
static final class ContentRangeInfo {
|
|
86
|
+
|
|
87
|
+
final long start;
|
|
88
|
+
final long end;
|
|
89
|
+
final long total;
|
|
90
|
+
|
|
91
|
+
ContentRangeInfo(long start, long end, long total) {
|
|
92
|
+
this.start = start;
|
|
93
|
+
this.end = end;
|
|
94
|
+
this.total = total;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
50
98
|
private static Logger logger;
|
|
51
99
|
|
|
52
100
|
public static void setLogger(Logger loggerInstance) {
|
|
@@ -88,6 +136,8 @@ public class DownloadService extends Worker {
|
|
|
88
136
|
// Match CapgoUpdater.timeout / responseTimeout default (20s). OkHttp's 10s
|
|
89
137
|
// defaults were unused by the plugin config and aborted slow manifest GETs.
|
|
90
138
|
private static volatile int httpTimeoutMs = 20_000;
|
|
139
|
+
// Off by default: a redirect must never downgrade updater traffic from HTTPS to plain HTTP.
|
|
140
|
+
private static volatile boolean allowHttpsToHttpRedirect = false;
|
|
91
141
|
private static String currentAppId = "unknown";
|
|
92
142
|
private static String currentPluginVersion = "unknown";
|
|
93
143
|
private static String currentVersionOs = "unknown";
|
|
@@ -100,6 +150,9 @@ public class DownloadService extends Worker {
|
|
|
100
150
|
sharedClient = new OkHttpClient.Builder()
|
|
101
151
|
.dispatcher(dispatcher)
|
|
102
152
|
.protocols(Arrays.asList(Protocol.HTTP_2, Protocol.HTTP_1_1))
|
|
153
|
+
// Never share cookies with Capgo endpoints: the app's java.net.CookieHandler default is
|
|
154
|
+
// Capacitor's WebView cookie manager, and it must not see (or be fed by) plugin traffic.
|
|
155
|
+
.cookieJar(CookieJar.NO_COOKIES)
|
|
103
156
|
.connectTimeout(httpTimeoutMs, TimeUnit.MILLISECONDS)
|
|
104
157
|
.readTimeout(httpTimeoutMs, TimeUnit.MILLISECONDS)
|
|
105
158
|
.writeTimeout(httpTimeoutMs, TimeUnit.MILLISECONDS)
|
|
@@ -109,9 +162,33 @@ public class DownloadService extends Worker {
|
|
|
109
162
|
Request requestWithUserAgent = originalRequest.newBuilder().header("User-Agent", userAgent).build();
|
|
110
163
|
return chain.proceed(requestWithUserAgent);
|
|
111
164
|
})
|
|
165
|
+
.addNetworkInterceptor((chain) -> {
|
|
166
|
+
Response response = chain.proceed(chain.request());
|
|
167
|
+
if (!allowHttpsToHttpRedirect && isHttpsToHttpRedirect(response)) {
|
|
168
|
+
response.close();
|
|
169
|
+
throw new BlockedRedirectException();
|
|
170
|
+
}
|
|
171
|
+
return response;
|
|
172
|
+
})
|
|
112
173
|
.build();
|
|
113
174
|
}
|
|
114
175
|
|
|
176
|
+
static void setAllowHttpsToHttpRedirect(boolean allow) {
|
|
177
|
+
allowHttpsToHttpRedirect = allow;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
static boolean isHttpsToHttpRedirect(Response response) {
|
|
181
|
+
if (!response.isRedirect() || !response.request().isHttps()) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
String location = response.header("Location");
|
|
185
|
+
if (location == null) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
HttpUrl target = response.request().url().resolve(location);
|
|
189
|
+
return target != null && !target.isHttps();
|
|
190
|
+
}
|
|
191
|
+
|
|
115
192
|
static int httpTimeoutMs() {
|
|
116
193
|
return httpTimeoutMs;
|
|
117
194
|
}
|
|
@@ -270,7 +347,7 @@ public class DownloadService extends Worker {
|
|
|
270
347
|
outStream.write(buffer, 0, length);
|
|
271
348
|
}
|
|
272
349
|
}
|
|
273
|
-
if (!expectedHash.equalsIgnoreCase(
|
|
350
|
+
if (!expectedHash.equalsIgnoreCase(sha256Hex(digest))) {
|
|
274
351
|
return false;
|
|
275
352
|
}
|
|
276
353
|
return replaceFile(tempFile, dest);
|
|
@@ -279,6 +356,19 @@ public class DownloadService extends Worker {
|
|
|
279
356
|
}
|
|
280
357
|
}
|
|
281
358
|
|
|
359
|
+
private static String sha256Hex(final MessageDigest digest) {
|
|
360
|
+
final byte[] hash = digest.digest();
|
|
361
|
+
final StringBuilder hexString = new StringBuilder(hash.length * 2);
|
|
362
|
+
for (final byte b : hash) {
|
|
363
|
+
final String hex = Integer.toHexString(0xff & b);
|
|
364
|
+
if (hex.length() == 1) {
|
|
365
|
+
hexString.append('0');
|
|
366
|
+
}
|
|
367
|
+
hexString.append(hex);
|
|
368
|
+
}
|
|
369
|
+
return hexString.toString();
|
|
370
|
+
}
|
|
371
|
+
|
|
282
372
|
private static void deleteQuietly(final File file) {
|
|
283
373
|
if (file.exists() && !file.delete()) {
|
|
284
374
|
file.deleteOnExit();
|
|
@@ -312,6 +402,38 @@ public class DownloadService extends Worker {
|
|
|
312
402
|
return seenTargets.add(targetFile.getCanonicalPath());
|
|
313
403
|
}
|
|
314
404
|
|
|
405
|
+
private static final class ManifestDownloadTask {
|
|
406
|
+
|
|
407
|
+
final String fileName;
|
|
408
|
+
final String finalFileHash;
|
|
409
|
+
final String downloadUrl;
|
|
410
|
+
final boolean isBrotli;
|
|
411
|
+
final File targetFile;
|
|
412
|
+
final File builtinFile;
|
|
413
|
+
final File cacheFile;
|
|
414
|
+
final File legacyCacheFile;
|
|
415
|
+
|
|
416
|
+
ManifestDownloadTask(
|
|
417
|
+
final String fileName,
|
|
418
|
+
final String finalFileHash,
|
|
419
|
+
final String downloadUrl,
|
|
420
|
+
final boolean isBrotli,
|
|
421
|
+
final File targetFile,
|
|
422
|
+
final File builtinFile,
|
|
423
|
+
final File cacheFile,
|
|
424
|
+
final File legacyCacheFile
|
|
425
|
+
) {
|
|
426
|
+
this.fileName = fileName;
|
|
427
|
+
this.finalFileHash = finalFileHash;
|
|
428
|
+
this.downloadUrl = downloadUrl;
|
|
429
|
+
this.isBrotli = isBrotli;
|
|
430
|
+
this.targetFile = targetFile;
|
|
431
|
+
this.builtinFile = builtinFile;
|
|
432
|
+
this.cacheFile = cacheFile;
|
|
433
|
+
this.legacyCacheFile = legacyCacheFile;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
315
437
|
static boolean tryCopyBuiltinAsset(final AssetManager assets, final String fileName, final File dest, final String expectedHash) {
|
|
316
438
|
if (assets == null || fileName == null || dest == null) {
|
|
317
439
|
return false;
|
|
@@ -373,9 +495,14 @@ public class DownloadService extends Worker {
|
|
|
373
495
|
return createFailureResult("Manifest is null");
|
|
374
496
|
}
|
|
375
497
|
} else {
|
|
376
|
-
handleSingleFileDownload(url, id, documentsDir, dest, version, sessionKey, checksum);
|
|
498
|
+
handleSingleFileDownload(url, id, documentsDir, dest, version, sessionKey, checksum, publicKey);
|
|
377
499
|
return createSuccessResult(dest, version, sessionKey, checksum, false);
|
|
378
500
|
}
|
|
501
|
+
} catch (DownloadRetryException e) {
|
|
502
|
+
if (logger != null) {
|
|
503
|
+
logger.debug("Download will retry: " + e.getMessage());
|
|
504
|
+
}
|
|
505
|
+
return Result.retry();
|
|
379
506
|
} catch (Exception e) {
|
|
380
507
|
return createFailureResult(e.getMessage());
|
|
381
508
|
}
|
|
@@ -459,6 +586,12 @@ public class DownloadService extends Worker {
|
|
|
459
586
|
try {
|
|
460
587
|
logger.debug("handleManifestDownload");
|
|
461
588
|
|
|
589
|
+
if (publicKey != null && !publicKey.isEmpty() && !CryptoCipher.isValidSessionKey(sessionKey)) {
|
|
590
|
+
logger.error("Public key present but no valid session key provided");
|
|
591
|
+
sendStatsAsync("session_key_required", version);
|
|
592
|
+
throw new IOException("Session key required when public key is present");
|
|
593
|
+
}
|
|
594
|
+
|
|
462
595
|
// Send stats for manifest download start
|
|
463
596
|
sendStatsAsync("download_manifest_start", version);
|
|
464
597
|
|
|
@@ -479,9 +612,7 @@ public class DownloadService extends Worker {
|
|
|
479
612
|
int totalFiles = manifest.length();
|
|
480
613
|
final AtomicLong completedFiles = new AtomicLong(0);
|
|
481
614
|
final AtomicBoolean hasError = new AtomicBoolean(false);
|
|
482
|
-
|
|
483
|
-
ExecutorService executor = Executors.newFixedThreadPool(Math.min(MANIFEST_MAX_CONCURRENT_FILES, Math.max(1, totalFiles)));
|
|
484
|
-
List<Future<?>> futures = new ArrayList<>();
|
|
615
|
+
final List<ManifestDownloadTask> tasks = new ArrayList<>();
|
|
485
616
|
final Set<String> seenTargets = new HashSet<>();
|
|
486
617
|
|
|
487
618
|
for (int i = 0; i < totalFiles; i++) {
|
|
@@ -496,7 +627,13 @@ public class DownloadService extends Worker {
|
|
|
496
627
|
continue;
|
|
497
628
|
}
|
|
498
629
|
|
|
499
|
-
if (publicKey != null && !publicKey.isEmpty()
|
|
630
|
+
if (publicKey != null && !publicKey.isEmpty()) {
|
|
631
|
+
if (!CryptoCipher.isValidSessionKey(sessionKey)) {
|
|
632
|
+
logger.error("Public key present but no valid session key provided");
|
|
633
|
+
sendStatsAsync("session_key_required", version);
|
|
634
|
+
hasError.set(true);
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
500
637
|
try {
|
|
501
638
|
fileHash = CryptoCipher.decryptChecksum(fileHash, publicKey);
|
|
502
639
|
} catch (Exception e) {
|
|
@@ -536,35 +673,56 @@ public class DownloadService extends Worker {
|
|
|
536
673
|
final File legacyCacheFile =
|
|
537
674
|
isBrotli && cacheFile != null ? new File(cacheFolder, finalFileHash + "_" + new File(fileName).getName()) : null;
|
|
538
675
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
676
|
+
tasks.add(
|
|
677
|
+
new ManifestDownloadTask(
|
|
678
|
+
fileName,
|
|
679
|
+
finalFileHash,
|
|
680
|
+
downloadUrl,
|
|
681
|
+
isBrotli,
|
|
682
|
+
targetFile,
|
|
683
|
+
builtinFile,
|
|
684
|
+
cacheFile,
|
|
685
|
+
legacyCacheFile
|
|
686
|
+
)
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (hasError.get()) {
|
|
691
|
+
throw new IOException("Manifest contains invalid or duplicate file paths");
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
for (final ManifestDownloadTask task : tasks) {
|
|
695
|
+
if (!Objects.requireNonNull(task.targetFile.getParentFile()).exists() && !task.targetFile.getParentFile().mkdirs()) {
|
|
696
|
+
logger.error("Failed to create parent directory for: " + task.targetFile.getAbsolutePath());
|
|
697
|
+
throw new IOException("Failed to create parent directory for: " + task.targetFile.getAbsolutePath());
|
|
544
698
|
}
|
|
699
|
+
}
|
|
545
700
|
|
|
546
|
-
|
|
701
|
+
ExecutorService executor = Executors.newFixedThreadPool(Math.min(MANIFEST_MAX_CONCURRENT_FILES, Math.max(1, totalFiles)));
|
|
702
|
+
List<Future<?>> futures = new ArrayList<>();
|
|
703
|
+
|
|
704
|
+
for (final ManifestDownloadTask task : tasks) {
|
|
547
705
|
Future<?> future = executor.submit(() -> {
|
|
548
706
|
try {
|
|
549
|
-
if (tryCopyBuiltinAsset(assets, fileName, targetFile, finalFileHash)) {
|
|
550
|
-
logger.debug("using builtin asset " + fileName);
|
|
551
|
-
} else if (tryCopyBuiltinFile(builtinFile, targetFile, finalFileHash)) {
|
|
552
|
-
logger.debug("using builtin file " + fileName);
|
|
707
|
+
if (tryCopyBuiltinAsset(assets, task.fileName, task.targetFile, task.finalFileHash)) {
|
|
708
|
+
logger.debug("using builtin asset " + task.fileName);
|
|
709
|
+
} else if (tryCopyBuiltinFile(task.builtinFile, task.targetFile, task.finalFileHash)) {
|
|
710
|
+
logger.debug("using builtin file " + task.fileName);
|
|
553
711
|
} else if (
|
|
554
|
-
tryCopyFromCache(cacheFile, targetFile, finalFileHash) ||
|
|
555
|
-
(legacyCacheFile != null && tryCopyFromCache(legacyCacheFile, targetFile, finalFileHash))
|
|
712
|
+
tryCopyFromCache(task.cacheFile, task.targetFile, task.finalFileHash) ||
|
|
713
|
+
(task.legacyCacheFile != null && tryCopyFromCache(task.legacyCacheFile, task.targetFile, task.finalFileHash))
|
|
556
714
|
) {
|
|
557
|
-
logger.debug("already cached " + fileName);
|
|
715
|
+
logger.debug("already cached " + task.fileName);
|
|
558
716
|
} else {
|
|
559
717
|
downloadAndVerify(
|
|
560
|
-
downloadUrl,
|
|
561
|
-
targetFile,
|
|
562
|
-
cacheFile,
|
|
563
|
-
finalFileHash,
|
|
718
|
+
task.downloadUrl,
|
|
719
|
+
task.targetFile,
|
|
720
|
+
task.cacheFile,
|
|
721
|
+
task.finalFileHash,
|
|
564
722
|
sessionKey,
|
|
565
723
|
publicKey,
|
|
566
|
-
|
|
567
|
-
fileName
|
|
724
|
+
task.isBrotli,
|
|
725
|
+
task.fileName
|
|
568
726
|
);
|
|
569
727
|
}
|
|
570
728
|
|
|
@@ -572,8 +730,8 @@ public class DownloadService extends Worker {
|
|
|
572
730
|
int percent = calcTotalPercent(completed, totalFiles);
|
|
573
731
|
setProgress(percent);
|
|
574
732
|
} catch (Exception e) {
|
|
575
|
-
logger.error("Error processing file: " + fileName + " " + e.getMessage());
|
|
576
|
-
sendStatsAsync("download_manifest_file_fail", version + ":" + fileName);
|
|
733
|
+
logger.error("Error processing file: " + task.fileName + " " + e.getMessage());
|
|
734
|
+
sendStatsAsync("download_manifest_file_fail", version + ":" + task.fileName);
|
|
577
735
|
hasError.set(true);
|
|
578
736
|
}
|
|
579
737
|
});
|
|
@@ -620,8 +778,20 @@ public class DownloadService extends Worker {
|
|
|
620
778
|
String dest,
|
|
621
779
|
String version,
|
|
622
780
|
String sessionKey,
|
|
623
|
-
String checksum
|
|
781
|
+
String checksum,
|
|
782
|
+
String publicKey
|
|
624
783
|
) {
|
|
784
|
+
if (publicKey != null && !publicKey.isEmpty() && !CryptoCipher.isValidSessionKey(sessionKey)) {
|
|
785
|
+
logger.error("Public key present but no valid session key provided");
|
|
786
|
+
sendStatsAsync("session_key_required", version);
|
|
787
|
+
throw new RuntimeException("Session key required when public key is present");
|
|
788
|
+
}
|
|
789
|
+
if (checksum == null || checksum.isEmpty()) {
|
|
790
|
+
logger.error("No checksum provided");
|
|
791
|
+
sendStatsAsync("checksum_required", version);
|
|
792
|
+
throw new RuntimeException("Checksum required");
|
|
793
|
+
}
|
|
794
|
+
|
|
625
795
|
// Send stats for zip download start
|
|
626
796
|
sendStatsAsync("download_zip_start", version);
|
|
627
797
|
|
|
@@ -637,21 +807,16 @@ public class DownloadService extends Worker {
|
|
|
637
807
|
throw new RuntimeException("insufficient_disk_space");
|
|
638
808
|
}
|
|
639
809
|
|
|
640
|
-
|
|
810
|
+
Response response = null;
|
|
641
811
|
InputStream inputStream = null;
|
|
642
|
-
FileOutputStream outputStream = null;
|
|
643
812
|
BufferedReader reader = null;
|
|
644
813
|
BufferedWriter writer = null;
|
|
645
814
|
|
|
646
815
|
try {
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
// responseTimeout cannot shrink large-bundle downloads.
|
|
652
|
-
int zipTimeoutMs = Math.max(httpTimeoutMs, 60_000);
|
|
653
|
-
httpConn.setConnectTimeout(zipTimeoutMs);
|
|
654
|
-
httpConn.setReadTimeout(zipTimeoutMs);
|
|
816
|
+
HttpUrl zipUrl = url != null ? HttpUrl.parse(url) : null;
|
|
817
|
+
if (zipUrl == null) {
|
|
818
|
+
throw new MalformedURLException("Expected an http or https URL");
|
|
819
|
+
}
|
|
655
820
|
|
|
656
821
|
// Reading progress file (if exist)
|
|
657
822
|
long downloadedBytes = 0;
|
|
@@ -676,14 +841,23 @@ public class DownloadService extends Worker {
|
|
|
676
841
|
clearDownloadData(documentsDir, id);
|
|
677
842
|
}
|
|
678
843
|
|
|
679
|
-
if (
|
|
680
|
-
|
|
844
|
+
if (isStopped()) {
|
|
845
|
+
throw new DownloadRetryException("download_stopped");
|
|
681
846
|
}
|
|
682
847
|
|
|
683
|
-
|
|
848
|
+
response = executeZipRequest(zipUrl, downloadedBytes);
|
|
849
|
+
int responseCode = response.code();
|
|
684
850
|
|
|
685
851
|
if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_PARTIAL) {
|
|
686
|
-
|
|
852
|
+
ResponseBody responseBody = response.body();
|
|
853
|
+
if (responseBody == null) {
|
|
854
|
+
throw new IOException("Response body is null");
|
|
855
|
+
}
|
|
856
|
+
ZipWritePlan writePlan = planZipResumeWrite(responseCode, downloadedBytes, response.header("Content-Range"));
|
|
857
|
+
long responseBodyLength = responseBody.contentLength();
|
|
858
|
+
long contentLength = shouldAppendHttpBody(writePlan.statusCode, writePlan.writeOffset)
|
|
859
|
+
? (responseBodyLength >= 0 ? responseBodyLength + writePlan.writeOffset : -1)
|
|
860
|
+
: responseBodyLength;
|
|
687
861
|
|
|
688
862
|
// Check if we have enough space for the actual file
|
|
689
863
|
if (contentLength > 0 && availableSpace < contentLength * 2) {
|
|
@@ -691,45 +865,37 @@ public class DownloadService extends Worker {
|
|
|
691
865
|
}
|
|
692
866
|
|
|
693
867
|
try {
|
|
694
|
-
inputStream =
|
|
695
|
-
outputStream = new FileOutputStream(tempFile, downloadedBytes > 0);
|
|
868
|
+
inputStream = responseBody.byteStream();
|
|
696
869
|
|
|
697
|
-
if (downloadedBytes == 0) {
|
|
870
|
+
if (writePlan.writeOffset == 0 && downloadedBytes == 0) {
|
|
698
871
|
writer = new BufferedWriter(new FileWriter(infoFile));
|
|
699
872
|
writer.write(String.valueOf(version));
|
|
700
873
|
writer.close();
|
|
701
874
|
writer = null;
|
|
702
875
|
}
|
|
703
876
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
// Flush every 1MB to ensure progress is saved
|
|
713
|
-
if (downloadedBytes % (1024 * 1024) == 0) {
|
|
714
|
-
outputStream.flush();
|
|
877
|
+
final int[] lastNotifiedPercent = { 0 };
|
|
878
|
+
final long totalContentLength = contentLength;
|
|
879
|
+
String contentRangeHeader = response.header("Content-Range");
|
|
880
|
+
writeHttpBody(tempFile, inputStream, writePlan.statusCode, writePlan.writeOffset, this::isStopped, (written) -> {
|
|
881
|
+
int percent = calcTotalPercent(written, totalContentLength);
|
|
882
|
+
if (percent >= lastNotifiedPercent[0] + 10) {
|
|
883
|
+
lastNotifiedPercent[0] = (percent / 10) * 10;
|
|
884
|
+
setProgress(lastNotifiedPercent[0]);
|
|
715
885
|
}
|
|
716
|
-
|
|
717
|
-
// Computing percentage
|
|
718
|
-
int percent = calcTotalPercent(downloadedBytes, contentLength);
|
|
719
|
-
if (percent >= lastNotifiedPercent + 10) {
|
|
720
|
-
lastNotifiedPercent = (percent / 10) * 10;
|
|
721
|
-
setProgress(lastNotifiedPercent);
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// Final flush
|
|
726
|
-
outputStream.flush();
|
|
727
|
-
outputStream.close();
|
|
728
|
-
outputStream = null;
|
|
886
|
+
});
|
|
729
887
|
|
|
730
888
|
inputStream.close();
|
|
731
889
|
inputStream = null;
|
|
732
890
|
|
|
891
|
+
validateZipDownloadComplete(
|
|
892
|
+
tempFile,
|
|
893
|
+
writePlan.statusCode,
|
|
894
|
+
contentRangeHeader,
|
|
895
|
+
writePlan.writeOffset,
|
|
896
|
+
responseBodyLength
|
|
897
|
+
);
|
|
898
|
+
|
|
733
899
|
// Rename the temp file with the final name (dest)
|
|
734
900
|
if (!tempFile.renameTo(new File(documentsDir, dest))) {
|
|
735
901
|
throw new RuntimeException("Failed to rename temp file to final destination");
|
|
@@ -743,13 +909,12 @@ public class DownloadService extends Worker {
|
|
|
743
909
|
// Try to free some memory
|
|
744
910
|
System.gc();
|
|
745
911
|
throw new RuntimeException("low_mem_fail");
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
try {
|
|
750
|
-
outputStream.close();
|
|
751
|
-
} catch (Exception ignored) {}
|
|
912
|
+
} catch (IOException e) {
|
|
913
|
+
if ("download_stopped".equals(e.getMessage())) {
|
|
914
|
+
throw new DownloadRetryException("download_stopped", e);
|
|
752
915
|
}
|
|
916
|
+
throw new DownloadRetryException(e.getMessage(), e);
|
|
917
|
+
} finally {
|
|
753
918
|
if (inputStream != null) {
|
|
754
919
|
try {
|
|
755
920
|
inputStream.close();
|
|
@@ -761,10 +926,14 @@ public class DownloadService extends Worker {
|
|
|
761
926
|
} catch (Exception ignored) {}
|
|
762
927
|
}
|
|
763
928
|
}
|
|
929
|
+
} else if (isRetryableHttpStatus(responseCode)) {
|
|
930
|
+
throw new DownloadRetryException("HTTP error: " + responseCode);
|
|
764
931
|
} else {
|
|
765
932
|
infoFile.delete();
|
|
766
933
|
throw new RuntimeException("HTTP error: " + responseCode);
|
|
767
934
|
}
|
|
935
|
+
} catch (DownloadRetryException e) {
|
|
936
|
+
throw e;
|
|
768
937
|
} catch (OutOfMemoryError e) {
|
|
769
938
|
logger.error("Critical memory error: " + e.getMessage());
|
|
770
939
|
System.gc(); // Suggest garbage collection
|
|
@@ -772,19 +941,141 @@ public class DownloadService extends Worker {
|
|
|
772
941
|
} catch (SecurityException e) {
|
|
773
942
|
logger.error("Security error during download: " + e.getMessage());
|
|
774
943
|
throw new RuntimeException("security_error: " + e.getMessage());
|
|
944
|
+
} catch (MalformedURLException e) {
|
|
945
|
+
logger.error("Invalid download URL: " + e.getMessage());
|
|
946
|
+
throw new RuntimeException("invalid_url: " + e.getMessage());
|
|
947
|
+
} catch (IOException e) {
|
|
948
|
+
logger.error("Download error: " + e.getMessage());
|
|
949
|
+
throw new DownloadRetryException(e.getMessage(), e);
|
|
775
950
|
} catch (Exception e) {
|
|
776
951
|
logger.error("Download error: " + e.getMessage());
|
|
777
952
|
throw new RuntimeException(e.getMessage());
|
|
778
953
|
} finally {
|
|
779
954
|
// Ensure connection is closed
|
|
780
|
-
if (
|
|
955
|
+
if (response != null) {
|
|
781
956
|
try {
|
|
782
|
-
|
|
957
|
+
response.close();
|
|
783
958
|
} catch (Exception ignored) {}
|
|
784
959
|
}
|
|
785
960
|
}
|
|
786
961
|
}
|
|
787
962
|
|
|
963
|
+
/**
|
|
964
|
+
* Zip bundle request on the shared client (no cookies, same User-Agent), resuming from
|
|
965
|
+
* {@code downloadedBytes} with a Range header. The caller closes the response.
|
|
966
|
+
*/
|
|
967
|
+
static Response executeZipRequest(HttpUrl url, long downloadedBytes) throws IOException {
|
|
968
|
+
// Zip can stall longer than a JSON API call; keep a floor so
|
|
969
|
+
// responseTimeout cannot shrink large-bundle downloads.
|
|
970
|
+
int zipTimeoutMs = Math.max(httpTimeoutMs, 60_000);
|
|
971
|
+
OkHttpClient zipClient = sharedClient
|
|
972
|
+
.newBuilder()
|
|
973
|
+
.connectTimeout(zipTimeoutMs, TimeUnit.MILLISECONDS)
|
|
974
|
+
.readTimeout(zipTimeoutMs, TimeUnit.MILLISECONDS)
|
|
975
|
+
.writeTimeout(zipTimeoutMs, TimeUnit.MILLISECONDS)
|
|
976
|
+
.build();
|
|
977
|
+
Request.Builder builder = new Request.Builder().url(url);
|
|
978
|
+
if (downloadedBytes > 0) {
|
|
979
|
+
builder.header("Range", "bytes=" + downloadedBytes + "-");
|
|
980
|
+
}
|
|
981
|
+
return zipClient.newCall(builder.build()).execute();
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
static boolean isRetryableHttpStatus(int responseCode) {
|
|
985
|
+
return responseCode >= 500 || responseCode == 408 || responseCode == 429;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
static long parseContentRangeStart(String contentRange) {
|
|
989
|
+
ContentRangeInfo range = parseContentRange(contentRange);
|
|
990
|
+
return range == null ? -1 : range.start;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
static ContentRangeInfo parseContentRange(String contentRange) {
|
|
994
|
+
if (contentRange == null || contentRange.isEmpty()) {
|
|
995
|
+
return null;
|
|
996
|
+
}
|
|
997
|
+
String trimmed = contentRange.trim();
|
|
998
|
+
if (!trimmed.startsWith("bytes ")) {
|
|
999
|
+
return null;
|
|
1000
|
+
}
|
|
1001
|
+
int slash = trimmed.indexOf('/');
|
|
1002
|
+
if (slash < 0) {
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
1005
|
+
int dash = trimmed.indexOf('-', 6);
|
|
1006
|
+
if (dash < 0 || dash >= slash) {
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
try {
|
|
1010
|
+
long start = Long.parseLong(trimmed.substring(6, dash).trim());
|
|
1011
|
+
long end = Long.parseLong(trimmed.substring(dash + 1, slash).trim());
|
|
1012
|
+
String totalPart = trimmed.substring(slash + 1).trim();
|
|
1013
|
+
long total = "*".equals(totalPart) ? -1 : Long.parseLong(totalPart);
|
|
1014
|
+
if (end < start) {
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
return new ContentRangeInfo(start, end, total);
|
|
1018
|
+
} catch (NumberFormatException e) {
|
|
1019
|
+
return null;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
static void validateZipDownloadComplete(
|
|
1024
|
+
File tempFile,
|
|
1025
|
+
int responseCode,
|
|
1026
|
+
String contentRangeHeader,
|
|
1027
|
+
long writeOffset,
|
|
1028
|
+
long responseBodyLength
|
|
1029
|
+
) {
|
|
1030
|
+
long fileLen = tempFile.length();
|
|
1031
|
+
if (responseCode == HttpURLConnection.HTTP_PARTIAL) {
|
|
1032
|
+
ContentRangeInfo range = parseContentRange(contentRangeHeader);
|
|
1033
|
+
if (range == null) {
|
|
1034
|
+
throw new DownloadRetryException("unknown_content_range_total");
|
|
1035
|
+
}
|
|
1036
|
+
if (range.total < 0) {
|
|
1037
|
+
long advertisedSpan = range.end - range.start + 1;
|
|
1038
|
+
long receivedSpan = fileLen - writeOffset;
|
|
1039
|
+
if (receivedSpan != advertisedSpan) {
|
|
1040
|
+
throw new DownloadRetryException("incomplete_content_range");
|
|
1041
|
+
}
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
if (range.start != writeOffset) {
|
|
1045
|
+
throw new DownloadRetryException("content_range_mismatch");
|
|
1046
|
+
}
|
|
1047
|
+
long advertisedSpan = range.end - range.start + 1;
|
|
1048
|
+
long receivedSpan = fileLen - writeOffset;
|
|
1049
|
+
if (receivedSpan != advertisedSpan) {
|
|
1050
|
+
throw new DownloadRetryException("incomplete_content_range");
|
|
1051
|
+
}
|
|
1052
|
+
if (fileLen != range.total) {
|
|
1053
|
+
throw new DownloadRetryException("incomplete_download");
|
|
1054
|
+
}
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
if (responseBodyLength >= 0 && fileLen != responseBodyLength) {
|
|
1058
|
+
throw new DownloadRetryException("incomplete_download");
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
static ZipWritePlan planZipResumeWrite(int responseCode, long downloadedBytes, String contentRange) {
|
|
1063
|
+
if (responseCode == HttpURLConnection.HTTP_PARTIAL) {
|
|
1064
|
+
long rangeStart = parseContentRangeStart(contentRange);
|
|
1065
|
+
if (rangeStart == downloadedBytes) {
|
|
1066
|
+
return new ZipWritePlan(HttpURLConnection.HTTP_PARTIAL, downloadedBytes);
|
|
1067
|
+
}
|
|
1068
|
+
if (rangeStart == 0) {
|
|
1069
|
+
return new ZipWritePlan(HttpURLConnection.HTTP_PARTIAL, 0);
|
|
1070
|
+
}
|
|
1071
|
+
throw new DownloadRetryException("invalid_content_range");
|
|
1072
|
+
}
|
|
1073
|
+
if (responseCode == HttpURLConnection.HTTP_OK && downloadedBytes > 0) {
|
|
1074
|
+
return new ZipWritePlan(HttpURLConnection.HTTP_OK, 0);
|
|
1075
|
+
}
|
|
1076
|
+
return new ZipWritePlan(responseCode, downloadedBytes);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
788
1079
|
private void clearDownloadData(String docDir, String id) {
|
|
789
1080
|
File tempFile = new File(docDir, "temp_" + id + ".tmp");
|
|
790
1081
|
File infoFile = new File(docDir, "update_" + id + ".dat");
|
|
@@ -899,18 +1190,13 @@ public class DownloadService extends Worker {
|
|
|
899
1190
|
throw new IOException("Response body is null");
|
|
900
1191
|
}
|
|
901
1192
|
try {
|
|
902
|
-
long resumeFrom = existing;
|
|
903
1193
|
if (code == HttpURLConnection.HTTP_PARTIAL && existing > 0) {
|
|
904
|
-
|
|
905
|
-
if (
|
|
906
|
-
|
|
907
|
-
resumeFrom = 0;
|
|
908
|
-
if (partial.exists() && !partial.delete()) {
|
|
909
|
-
logger.debug("Failed to delete partial before restart " + partial.getName());
|
|
910
|
-
}
|
|
1194
|
+
ContentRangeInfo range = parseContentRange(response.header("Content-Range"));
|
|
1195
|
+
if (range == null || range.start != existing) {
|
|
1196
|
+
throw new IOException("Content-Range does not match resume offset");
|
|
911
1197
|
}
|
|
912
1198
|
}
|
|
913
|
-
writeHttpBody(partial, responseBody.byteStream(), code,
|
|
1199
|
+
writeHttpBody(partial, responseBody.byteStream(), code, existing);
|
|
914
1200
|
keepPartial = true;
|
|
915
1201
|
} catch (Exception e) {
|
|
916
1202
|
keepPartial = true;
|
|
@@ -919,7 +1205,7 @@ public class DownloadService extends Worker {
|
|
|
919
1205
|
}
|
|
920
1206
|
}
|
|
921
1207
|
|
|
922
|
-
boolean needDecrypt = publicKey != null && !publicKey.isEmpty() &&
|
|
1208
|
+
boolean needDecrypt = publicKey != null && !publicKey.isEmpty() && CryptoCipher.isValidSessionKey(sessionKey);
|
|
923
1209
|
File source = partial;
|
|
924
1210
|
if (needDecrypt) {
|
|
925
1211
|
workFile = new File(cacheFolder, "work_" + UUID.randomUUID() + "_" + targetFile.getName() + ".tmp");
|
|
@@ -992,17 +1278,44 @@ public class DownloadService extends Worker {
|
|
|
992
1278
|
}
|
|
993
1279
|
|
|
994
1280
|
static void writeHttpBody(File dest, InputStream body, int statusCode, long existingBytes) throws IOException {
|
|
1281
|
+
writeHttpBody(dest, body, statusCode, existingBytes, null, null);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
static void writeHttpBody(
|
|
1285
|
+
File dest,
|
|
1286
|
+
InputStream body,
|
|
1287
|
+
int statusCode,
|
|
1288
|
+
long existingBytes,
|
|
1289
|
+
BooleanSupplier shouldStop,
|
|
1290
|
+
LongConsumer onProgress
|
|
1291
|
+
) throws IOException {
|
|
1292
|
+
if (shouldStop != null && shouldStop.getAsBoolean()) {
|
|
1293
|
+
throw new IOException("download_stopped");
|
|
1294
|
+
}
|
|
995
1295
|
boolean append = shouldAppendHttpBody(statusCode, existingBytes);
|
|
996
1296
|
byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
|
|
997
1297
|
try (FileOutputStream fos = new FileOutputStream(dest, append)) {
|
|
998
1298
|
int n;
|
|
999
1299
|
long written = append ? existingBytes : 0;
|
|
1000
|
-
while (
|
|
1300
|
+
while (true) {
|
|
1301
|
+
if (shouldStop != null && shouldStop.getAsBoolean()) {
|
|
1302
|
+
throw new IOException("download_stopped");
|
|
1303
|
+
}
|
|
1304
|
+
n = body.read(buffer);
|
|
1305
|
+
if (shouldStop != null && shouldStop.getAsBoolean()) {
|
|
1306
|
+
throw new IOException("download_stopped");
|
|
1307
|
+
}
|
|
1308
|
+
if (n == -1) {
|
|
1309
|
+
break;
|
|
1310
|
+
}
|
|
1001
1311
|
fos.write(buffer, 0, n);
|
|
1002
1312
|
written += n;
|
|
1003
1313
|
if (written % (1024 * 1024) == 0) {
|
|
1004
1314
|
fos.flush();
|
|
1005
1315
|
}
|
|
1316
|
+
if (onProgress != null) {
|
|
1317
|
+
onProgress.accept(written);
|
|
1318
|
+
}
|
|
1006
1319
|
}
|
|
1007
1320
|
fos.flush();
|
|
1008
1321
|
}
|