@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.
Files changed (36) hide show
  1. package/dist/esm/definitions.d.ts +116 -86
  2. package/dist/esm/definitions.js +2 -2
  3. package/dist/esm/definitions.js.map +1 -1
  4. package/dist/esm/exec.js +11 -1
  5. package/dist/esm/exec.js.map +1 -1
  6. package/dist/esm/history.js +7 -7
  7. package/dist/esm/history.js.map +1 -1
  8. package/dist/plugin.cjs.js +13 -3
  9. package/dist/plugin.cjs.js.map +1 -1
  10. package/dist/plugin.js +13 -3
  11. package/dist/plugin.js.map +1 -1
  12. package/package.json +5 -3
  13. package/plugin.xml +9 -9
  14. package/src/android/CordovaUpdaterPlugin.java +173 -5
  15. package/src/android/app/capgo/cordova/updater/CapgoUpdater.java +313 -55
  16. package/src/android/app/capgo/cordova/updater/CryptoCipher.java +51 -18
  17. package/src/android/app/capgo/cordova/updater/DelayUpdateUtils.java +0 -1
  18. package/src/android/app/capgo/cordova/updater/DownloadService.java +402 -89
  19. package/src/android/app/capgo/cordova/updater/Version.java +312 -0
  20. package/src/android/capgo-cordova-updater.gradle +3 -5
  21. package/src/android/test/app/capgo/cordova/updater/BackgroundDownloadSettlementTest.java +29 -0
  22. package/src/android/test/app/capgo/cordova/updater/CapacitorUpdaterUnitTest.java.skip +2 -12
  23. package/src/android/test/app/capgo/cordova/updater/DelayUpdateUtilsTest.java +0 -1
  24. package/src/android/test/app/capgo/cordova/updater/RsaContractTest.java +137 -0
  25. package/src/android/test/app/capgo/cordova/updater/SecurityHardeningTest.java +197 -0
  26. package/src/android/test/app/capgo/cordova/updater/SessionKeyRequiredTest.java +344 -0
  27. package/src/ios/CapgoSemanticVersion.swift +181 -0
  28. package/src/ios/CapgoUpdater.swift +427 -114
  29. package/src/ios/CordovaUpdaterPlugin.swift +171 -40
  30. package/src/ios/CryptoCipher.swift +41 -38
  31. package/src/ios/DelayUpdateUtils.swift +19 -45
  32. package/src/ios/Logger.swift +81 -5
  33. package/src/ios/RedirectPolicyDelegate.swift +53 -0
  34. package/src/ios/WebViewStatsReporter.swift +5 -0
  35. package/src/ios/ZipArchiveReader.swift +263 -0
  36. 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.URL;
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(CryptoCipher.digestToHex(digest))) {
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() && sessionKey != null && !sessionKey.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
- // Ensure parent directories of the target file exist
540
- if (!Objects.requireNonNull(targetFile.getParentFile()).exists() && !targetFile.getParentFile().mkdirs()) {
541
- logger.error("Failed to create parent directory for: " + targetFile.getAbsolutePath());
542
- hasError.set(true);
543
- continue;
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
- final boolean finalIsBrotli = isBrotli;
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
- finalIsBrotli,
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
- HttpURLConnection httpConn = null;
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
- URL u = new URL(url);
648
- httpConn = (HttpURLConnection) u.openConnection();
649
-
650
- // Zip can stall longer than a JSON API call; keep a floor so
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 (downloadedBytes > 0) {
680
- httpConn.setRequestProperty("Range", "bytes=" + downloadedBytes + "-");
844
+ if (isStopped()) {
845
+ throw new DownloadRetryException("download_stopped");
681
846
  }
682
847
 
683
- int responseCode = httpConn.getResponseCode();
848
+ response = executeZipRequest(zipUrl, downloadedBytes);
849
+ int responseCode = response.code();
684
850
 
685
851
  if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_PARTIAL) {
686
- long contentLength = httpConn.getContentLength() + downloadedBytes;
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 = httpConn.getInputStream();
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
- byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
705
- int lastNotifiedPercent = 0;
706
- int bytesRead;
707
-
708
- while ((bytesRead = inputStream.read(buffer)) != -1) {
709
- outputStream.write(buffer, 0, bytesRead);
710
- downloadedBytes += bytesRead;
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
- } finally {
747
- // Ensure all resources are closed
748
- if (outputStream != null) {
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 (httpConn != null) {
955
+ if (response != null) {
781
956
  try {
782
- httpConn.disconnect();
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
- String contentRange = response.header("Content-Range");
905
- if (contentRange == null || !contentRange.startsWith("bytes " + existing + "-")) {
906
- logger.debug("Unexpected Content-Range, restarting download of " + partial.getName());
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, resumeFrom);
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() && sessionKey != null && !sessionKey.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 ((n = body.read(buffer)) != -1) {
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
  }