@capgo/capacitor-updater 8.51.14 → 8.51.15

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/Package.swift CHANGED
@@ -32,7 +32,8 @@ let package = Package(
32
32
  name: "CapacitorUpdaterPluginTests",
33
33
  dependencies: [
34
34
  "CapacitorUpdaterPlugin",
35
- .product(name: "Version", package: "Version")
35
+ .product(name: "Version", package: "Version"),
36
+ .product(name: "ZIPFoundation", package: "ZIPFoundation")
36
37
  ],
37
38
  path: "ios/Tests/CapacitorUpdaterPluginTests")
38
39
  ],
package/README.md CHANGED
@@ -303,7 +303,7 @@ CapacitorUpdater can be configured with these options:
303
303
  | Prop | Type | Description | Default | Since |
304
304
  | -------------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ------- |
305
305
  | **`appReadyTimeout`** | <code>number</code> | Configure the number of milliseconds the native plugin should wait before considering an update 'failed'. Only available for Android and iOS. | <code>10000 // (10 seconds)</code> | |
306
- | **`responseTimeout`** | <code>number</code> | Configure the number of seconds the native plugin should wait before considering API timeout. Only available for Android and iOS. | <code>20 // (20 second)</code> | |
306
+ | **`responseTimeout`** | <code>number</code> | Configure the number of seconds the native plugin should wait before considering an HTTP timeout. Applies to update checks and file downloads. On Android these are idle connect/read/write timeouts and do not cap total download time; on iOS the request timeout also bounds the total download duration. Only available for Android and iOS. | <code>20 // (20 second)</code> | |
307
307
  | **`autoDeleteFailed`** | <code>boolean</code> | Configure whether the plugin should use automatically delete failed bundles. Only available for Android and iOS. | <code>true</code> | |
308
308
  | **`autoDeletePrevious`** | <code>boolean</code> | Configure whether the plugin should use automatically delete previous bundles after a successful update. Only available for Android and iOS. | <code>true</code> | |
309
309
  | **`autoUpdate`** | <code>boolean \| 'always' \| 'off' \| 'atBackground' \| 'atInstall' \| 'onLaunch' \| 'onlyDownload'</code> | Configure how the plugin checks for, downloads, and applies live updates. The plugin checks for updates when the app moves to the foreground. When {@link periodCheckDelay} is greater than 0, it also checks on a repeating timer while the app stays open. Boolean values keep their existing behavior: - `true`: Same as `"atBackground"`. - `false`: Same as `"off"`. String values merge the previous Auto Update and Direct Update configuration: - `"off"`: Disable automatic update checks. - `"atBackground"`: Check and download automatically on each foreground check, then apply the update the next time the app moves to background. - `"atInstall"`: Apply immediately only after a fresh install or native app store update; otherwise use `"atBackground"` behavior. - `"onLaunch"`: Apply immediately only when the app is brought to the foreground from a killed state (cold start). After that first check, fall back to `"atBackground"` behavior. - `"always"`: Check on every foreground transition and apply immediately whenever an update is available. - `"onlyDownload"`: Check and download automatically, emit `updateAvailable`, and never set the next bundle or apply an update automatically. Only available for Android and iOS. | <code>true</code> | |
@@ -146,7 +146,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
146
146
  static final int APPLICATION_EXIT_REASON_USER_REQUESTED = 10;
147
147
  static final int APPLICATION_EXIT_REASON_DEPENDENCY_DIED = 12;
148
148
 
149
- private final String pluginVersion = "8.51.14";
149
+ private final String pluginVersion = "8.51.15";
150
150
  private static final String DELAY_CONDITION_PREFERENCES = "";
151
151
 
152
152
  private SharedPreferences.Editor editor;
@@ -881,7 +881,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
881
881
  this.autoSplashscreenLoader = this.getConfig().getBoolean("autoSplashscreenLoader", false);
882
882
  int splashscreenTimeoutValue = this.getConfig().getInt("autoSplashscreenTimeout", 10000);
883
883
  this.autoSplashscreenTimeout = Math.max(0, splashscreenTimeoutValue);
884
- this.implementation.timeout = this.getConfig().getInt("responseTimeout", 20) * 1000;
884
+ int responseTimeoutSeconds = this.getConfig().getInt("responseTimeout", 20);
885
+ long responseTimeoutMillis = responseTimeoutSeconds > 0 ? (long) responseTimeoutSeconds * 1000L : 20_000L;
886
+ this.implementation.timeout = (int) Math.min(Integer.MAX_VALUE, responseTimeoutMillis);
887
+ DownloadService.applyHttpTimeouts(this.implementation.timeout);
885
888
  this.shakeMenuEnabled = this.getConfig().getBoolean("shakeMenu", false);
886
889
  this.shakeChannelSelectorEnabled = this.getConfig().getBoolean("allowShakeChannelSelector", false);
887
890
  this.shakeMenuGesture = normalizedShakeMenuGesture(this.getConfig().getString("shakeMenuGesture", SHAKE_MENU_GESTURE_SHAKE));
@@ -4230,12 +4233,18 @@ public class CapacitorUpdaterPlugin extends Plugin {
4230
4233
  if (this.shouldBlockAutoUpdateForPreviewSession()) {
4231
4234
  return "preview_session";
4232
4235
  }
4233
- if (this.isDownloadStuckOrTimedOut()) {
4234
- logger.info("Download already in progress, skipping duplicate download request");
4235
- return "already_running";
4236
+ synchronized (this) {
4237
+ final Thread previousTask = this.backgroundDownloadTask;
4238
+ final Thread task = this.backgroundDownload();
4239
+ if (task == null) {
4240
+ return "unavailable";
4241
+ }
4242
+ if (previousTask != null && previousTask == task) {
4243
+ logger.info("Download already in progress, skipping duplicate download request");
4244
+ return "already_running";
4245
+ }
4246
+ return "queued";
4236
4247
  }
4237
- this.backgroundDownload();
4238
- return "queued";
4239
4248
  }
4240
4249
 
4241
4250
  @PluginMethod
@@ -4748,10 +4757,14 @@ public class CapacitorUpdaterPlugin extends Plugin {
4748
4757
  return true;
4749
4758
  }
4750
4759
 
4751
- private Thread backgroundDownload() {
4760
+ private synchronized Thread backgroundDownload() {
4752
4761
  if (this.shouldBlockAutoUpdateForPreviewSession()) {
4753
4762
  return null;
4754
4763
  }
4764
+ if (this.isDownloadStuckOrTimedOut()) {
4765
+ logger.info("Download already in progress, skipping duplicate download request");
4766
+ return this.backgroundDownloadTask;
4767
+ }
4755
4768
  final boolean plannedDirectUpdate = this.shouldUseDirectUpdate();
4756
4769
  final boolean initialDirectUpdateAllowed = this.isDirectUpdateCurrentlyAllowed(plannedDirectUpdate);
4757
4770
  final String messageUpdate = initialDirectUpdateAllowed
@@ -4760,8 +4773,6 @@ public class CapacitorUpdaterPlugin extends Plugin {
4760
4773
  ? "Update will occur next time app moves to background."
4761
4774
  : "Update will be downloaded and made available.";
4762
4775
  Thread newTask = startNewThread(() -> {
4763
- // Wait for cleanup to complete before starting download
4764
- waitForCleanupIfNeeded();
4765
4776
  if (CapacitorUpdaterPlugin.this.shouldBlockAutoUpdateForPreviewSession()) {
4766
4777
  CapacitorUpdaterPlugin.this.clearBackgroundDownloadState();
4767
4778
  return;
@@ -4774,7 +4785,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
4774
4785
  return;
4775
4786
  }
4776
4787
  JSObject jsRes = InternalUtils.mapToJSObject(res);
4777
- final BundleInfo current = CapacitorUpdaterPlugin.this.implementation.getCurrentBundle();
4788
+ final BundleInfo currentBeforeCleanup = CapacitorUpdaterPlugin.this.implementation.getCurrentBundle();
4778
4789
 
4779
4790
  // Handle network errors and other failures first
4780
4791
  if (jsRes.has("error") || jsRes.has("kind")) {
@@ -4782,8 +4793,15 @@ public class CapacitorUpdaterPlugin extends Plugin {
4782
4793
  String errorMessage = jsRes.has("message") ? jsRes.getString("message") : "server did not provide a message";
4783
4794
  int statusCode = jsRes.has("statusCode") ? jsRes.optInt("statusCode", 0) : 0;
4784
4795
  String kind = CapacitorUpdaterPlugin.this.getUpdateResponseKind(jsRes.has("kind") ? jsRes.getString("kind") : null);
4785
- String latestVersion = jsRes.has("version") ? jsRes.getString("version") : current.getVersionName();
4786
- CapacitorUpdaterPlugin.this.notifyUpdateCheckResult(kind, error, errorMessage, statusCode, latestVersion, current);
4796
+ String latestVersion = jsRes.has("version") ? jsRes.getString("version") : currentBeforeCleanup.getVersionName();
4797
+ CapacitorUpdaterPlugin.this.notifyUpdateCheckResult(
4798
+ kind,
4799
+ error,
4800
+ errorMessage,
4801
+ statusCode,
4802
+ latestVersion,
4803
+ currentBeforeCleanup
4804
+ );
4787
4805
  CapacitorUpdaterPlugin.this.notifyBreakingEventsIfNeeded(
4788
4806
  jsRes,
4789
4807
  jsRes.has("version") ? jsRes.getString("version") : ""
@@ -4803,7 +4821,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
4803
4821
  CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
4804
4822
  errorMessage,
4805
4823
  latestVersion,
4806
- current,
4824
+ currentBeforeCleanup,
4807
4825
  isFailure,
4808
4826
  plannedDirectUpdate,
4809
4827
  "download_fail",
@@ -4813,6 +4831,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
4813
4831
  return;
4814
4832
  }
4815
4833
  try {
4834
+ // File mutations wait here. getLatest already ran in parallel with cleanup.
4835
+ waitForCleanupIfNeeded();
4836
+ final BundleInfo current = CapacitorUpdaterPlugin.this.implementation.getCurrentBundle();
4816
4837
  final String latestVersionName = jsRes.getString("version");
4817
4838
 
4818
4839
  if ("builtin".equals(latestVersionName)) {
@@ -5061,8 +5082,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
5061
5082
  logger.error("error in update check " + e.getMessage());
5062
5083
  CapacitorUpdaterPlugin.this.endBackGroundTaskWithNotif(
5063
5084
  "Error in update check",
5064
- current.getVersionName(),
5065
- current,
5085
+ currentBeforeCleanup.getVersionName(),
5086
+ currentBeforeCleanup,
5066
5087
  true,
5067
5088
  plannedDirectUpdate
5068
5089
  );
@@ -315,14 +315,17 @@ public class CapgoUpdater {
315
315
  return this.cachedKeyId;
316
316
  }
317
317
 
318
- private File unzip(final String id, final File zipFile, final String dest) throws IOException {
318
+ File unzip(final String id, final File zipFile, final String dest) throws IOException {
319
+ return unzip(id, zipFile, dest, CryptoCipher.ioBufferBytes());
320
+ }
321
+
322
+ File unzip(final String id, final File zipFile, final String dest, final int bufferSize) throws IOException {
319
323
  final File targetDirectory = new File(this.documentsDir, dest);
320
324
  try (
321
325
  final BufferedInputStream bis = new BufferedInputStream(new FileInputStream(zipFile));
322
326
  final ZipInputStream zis = new ZipInputStream(bis)
323
327
  ) {
324
328
  int count;
325
- final int bufferSize = 8192;
326
329
  final byte[] buffer = new byte[bufferSize];
327
330
  final long lengthTotal = zipFile.length();
328
331
  long lengthRead = bufferSize;
@@ -760,6 +763,13 @@ public class CapgoUpdater {
760
763
  }
761
764
  });
762
765
  break;
766
+ case CANCELLED:
767
+ DataManager.getInstance().clearManifest(id);
768
+ CompletableFuture<BundleInfo> cancelledFuture = downloadFutures.remove(id);
769
+ if (cancelledFuture != null) {
770
+ cancelledFuture.cancel(true);
771
+ }
772
+ break;
763
773
  }
764
774
  });
765
775
  });
@@ -781,6 +791,10 @@ public class CapgoUpdater {
781
791
  }
782
792
  observeWorkProgress(this.activity, id, setNext);
783
793
 
794
+ if (manifest != null) {
795
+ DataManager.getInstance().setManifest(id, manifest);
796
+ }
797
+
784
798
  DownloadWorkerManager.enqueueDownload(
785
799
  this.activity,
786
800
  url,
@@ -805,10 +819,6 @@ public class CapgoUpdater {
805
819
  this.customId,
806
820
  this.defaultChannel
807
821
  );
808
-
809
- if (manifest != null) {
810
- DataManager.getInstance().setManifest(manifest);
811
- }
812
822
  }
813
823
 
814
824
  public Boolean finishDownload(
@@ -1398,7 +1408,10 @@ public class CapgoUpdater {
1398
1408
  BundleInfo existingBundle = this.getBundleInfoByName(version);
1399
1409
  if (existingBundle != null && existingBundle.isErrorStatus()) {
1400
1410
  // Cancel the failed download and allow retry
1401
- DownloadWorkerManager.cancelVersionDownload(this.activity, version);
1411
+ if (!DownloadWorkerManager.cancelVersionDownloadAndAwait(this.activity, version)) {
1412
+ logger.error("Failed to cancel previous download before retry");
1413
+ return;
1414
+ }
1402
1415
  logger.info("Retrying failed download for version: " + version);
1403
1416
  } else {
1404
1417
  logger.info("Version already downloading: " + version);
@@ -1419,7 +1432,9 @@ public class CapgoUpdater {
1419
1432
  BundleInfo existingBundle = this.getBundleInfoByName(version);
1420
1433
  if (existingBundle != null && (existingBundle.isErrorStatus() || existingBundle.isDeleted() || existingBundle.isDeleting())) {
1421
1434
  logger.info("Found existing failed bundle for version " + version + ", deleting before retry");
1422
- this.delete(existingBundle.getId(), true);
1435
+ if (!Boolean.TRUE.equals(this.delete(existingBundle.getId(), true))) {
1436
+ throw new IOException("Failed to delete existing bundle before retry");
1437
+ }
1423
1438
  }
1424
1439
 
1425
1440
  final String id = this.randomString();
@@ -1472,7 +1487,9 @@ public class CapgoUpdater {
1472
1487
  BundleInfo existingBundle = this.getBundleInfoByName(version);
1473
1488
  if (existingBundle != null && (existingBundle.isErrorStatus() || existingBundle.isDeleted() || existingBundle.isDeleting())) {
1474
1489
  logger.info("Found existing failed bundle for version " + version + ", deleting before retry");
1475
- this.delete(existingBundle.getId(), true);
1490
+ if (!Boolean.TRUE.equals(this.delete(existingBundle.getId(), true))) {
1491
+ throw new IOException("Failed to delete existing bundle before retry");
1492
+ }
1476
1493
  }
1477
1494
 
1478
1495
  final String id = this.randomString();
@@ -1587,7 +1604,10 @@ public class CapgoUpdater {
1587
1604
 
1588
1605
  // Cancel download for this version if active
1589
1606
  if (cancelActiveDownload && this.activity != null) {
1590
- DownloadWorkerManager.cancelVersionDownload(this.activity, deleted.getVersionName());
1607
+ if (!DownloadWorkerManager.cancelVersionDownloadAndAwait(this.activity, deleted.getVersionName())) {
1608
+ logger.error("Failed to cancel active download before delete");
1609
+ return false;
1610
+ }
1591
1611
  }
1592
1612
 
1593
1613
  if (bundle.exists()) {
@@ -1922,32 +1942,35 @@ public class CapgoUpdater {
1922
1942
  !fallbackIsPreviewFallback &&
1923
1943
  !previousIsNext;
1924
1944
  if (shouldDeletePrevious) {
1925
- // Cancel any in-flight download for the old version before the async boundary
1926
- // so a later download of the same version name is not cancelled mid-flight.
1927
- if (this.activity != null) {
1928
- DownloadWorkerManager.cancelVersionDownload(this.activity, previousFallbackVersion);
1929
- }
1930
- // Mark durable intent before fallback switch so a kill mid-flight still retries.
1931
1945
  if (!this.saveBundleInfo(previousFallbackId, fallback.setStatus(BundleStatus.DELETING))) {
1932
1946
  logger.error("Failed to persist DELETING for previous bundle; queueing durable retry");
1933
1947
  logger.debug("Bundle ID: " + previousFallbackId);
1934
1948
  this.enqueuePendingDelete(previousFallbackId);
1935
1949
  }
1936
1950
  }
1951
+ boolean deletePreviousAsync = shouldDeletePrevious;
1937
1952
  this.setFallbackBundle(bundle);
1938
- if (shouldDeletePrevious) {
1939
- new Thread(() -> {
1953
+ if (deletePreviousAsync) {
1954
+ final String asyncPreviousFallbackId = previousFallbackId;
1955
+ final String asyncPreviousFallbackVersion = previousFallbackVersion;
1956
+ io.execute(() -> {
1957
+ if (this.activity != null) {
1958
+ if (!DownloadWorkerManager.cancelVersionDownloadAndAwait(this.activity, asyncPreviousFallbackVersion)) {
1959
+ logger.error("Failed to cancel previous version download before delete");
1960
+ return;
1961
+ }
1962
+ }
1940
1963
  try {
1941
- final Boolean res = this.delete(previousFallbackId, true, false);
1964
+ final Boolean res = this.delete(asyncPreviousFallbackId, true, false);
1942
1965
  if (Boolean.TRUE.equals(res)) {
1943
- logger.info("Deleted previous bundle: " + previousFallbackVersion);
1966
+ logger.info("Deleted previous bundle: " + asyncPreviousFallbackVersion);
1944
1967
  } else {
1945
- logger.debug("Previous bundle delete incomplete, will retry: " + previousFallbackId);
1968
+ logger.debug("Previous bundle delete incomplete, will retry: " + asyncPreviousFallbackId);
1946
1969
  }
1947
1970
  } catch (final IOException e) {
1948
- logger.error("Failed to delete previous bundle: " + previousFallbackId + " " + e.getMessage());
1971
+ logger.error("Failed to delete previous bundle: " + asyncPreviousFallbackId + " " + e.getMessage());
1949
1972
  }
1950
- }, "CapgoUpdater-autoDeletePrevious").start();
1973
+ });
1951
1974
  }
1952
1975
  }
1953
1976
 
@@ -1,11 +1,13 @@
1
1
  package ee.forgr.capacitor_updater;
2
2
 
3
+ import java.util.HashMap;
4
+ import java.util.Map;
3
5
  import org.json.JSONArray;
4
6
 
5
7
  public class DataManager {
6
8
 
7
9
  private static DataManager instance;
8
- private JSONArray currentManifest;
10
+ private final Map<String, JSONArray> manifestsById = new HashMap<>();
9
11
 
10
12
  private DataManager() {}
11
13
 
@@ -16,13 +18,27 @@ public class DataManager {
16
18
  return instance;
17
19
  }
18
20
 
19
- public void setManifest(JSONArray manifest) {
20
- this.currentManifest = manifest;
21
+ public synchronized void setManifest(String downloadId, JSONArray manifest) {
22
+ if (downloadId == null || manifest == null) {
23
+ return;
24
+ }
25
+ this.manifestsById.put(downloadId, manifest);
26
+ }
27
+
28
+ public synchronized JSONArray getAndClearManifest(String downloadId) {
29
+ if (downloadId == null) {
30
+ return null;
31
+ }
32
+ return this.manifestsById.remove(downloadId);
33
+ }
34
+
35
+ public synchronized void clearManifest(String downloadId) {
36
+ if (downloadId != null) {
37
+ this.manifestsById.remove(downloadId);
38
+ }
21
39
  }
22
40
 
23
- public JSONArray getAndClearManifest() {
24
- JSONArray manifest = this.currentManifest;
25
- this.currentManifest = null;
26
- return manifest;
41
+ public synchronized void clearAllManifests() {
42
+ this.manifestsById.clear();
27
43
  }
28
44
  }
@@ -83,7 +83,11 @@ public class DownloadService extends Worker {
83
83
  private static final String UPDATE_FILE = "update.dat";
84
84
 
85
85
  // Shared OkHttpClient to prevent resource leaks
86
- protected static OkHttpClient sharedClient;
86
+ protected static volatile OkHttpClient sharedClient;
87
+ private static final Object HTTP_CLIENT_LOCK = new Object();
88
+ // Match CapgoUpdater.timeout / responseTimeout default (20s). OkHttp's 10s
89
+ // defaults were unused by the plugin config and aborted slow manifest GETs.
90
+ private static volatile int httpTimeoutMs = 20_000;
87
91
  private static String currentAppId = "unknown";
88
92
  private static String currentPluginVersion = "unknown";
89
93
  private static String currentVersionOs = "unknown";
@@ -96,6 +100,9 @@ public class DownloadService extends Worker {
96
100
  sharedClient = new OkHttpClient.Builder()
97
101
  .dispatcher(dispatcher)
98
102
  .protocols(Arrays.asList(Protocol.HTTP_2, Protocol.HTTP_1_1))
103
+ .connectTimeout(httpTimeoutMs, TimeUnit.MILLISECONDS)
104
+ .readTimeout(httpTimeoutMs, TimeUnit.MILLISECONDS)
105
+ .writeTimeout(httpTimeoutMs, TimeUnit.MILLISECONDS)
99
106
  .addInterceptor((chain) -> {
100
107
  Request originalRequest = chain.request();
101
108
  String userAgent = buildUserAgent(currentAppId, currentPluginVersion, currentVersionOs);
@@ -105,6 +112,32 @@ public class DownloadService extends Worker {
105
112
  .build();
106
113
  }
107
114
 
115
+ static int httpTimeoutMs() {
116
+ return httpTimeoutMs;
117
+ }
118
+
119
+ static void applyHttpTimeouts(int timeoutMs) {
120
+ // OkHttp treats 0 as infinite; keep plugin responseTimeout floor (20s default).
121
+ int ms = timeoutMs > 0 ? timeoutMs : 20_000;
122
+ synchronized (HTTP_CLIENT_LOCK) {
123
+ if (
124
+ sharedClient.connectTimeoutMillis() == ms &&
125
+ sharedClient.readTimeoutMillis() == ms &&
126
+ sharedClient.writeTimeoutMillis() == ms
127
+ ) {
128
+ httpTimeoutMs = ms;
129
+ return;
130
+ }
131
+ sharedClient = sharedClient
132
+ .newBuilder()
133
+ .connectTimeout(ms, TimeUnit.MILLISECONDS)
134
+ .readTimeout(ms, TimeUnit.MILLISECONDS)
135
+ .writeTimeout(ms, TimeUnit.MILLISECONDS)
136
+ .build();
137
+ httpTimeoutMs = ms;
138
+ }
139
+ }
140
+
108
141
  static int manifestMaxConcurrentFiles() {
109
142
  return manifestMaxConcurrentFiles(Runtime.getRuntime().availableProcessors());
110
143
  }
@@ -342,10 +375,12 @@ public class DownloadService extends Worker {
342
375
  logger.debug("doWork isManifest: " + isManifest);
343
376
 
344
377
  if (isManifest) {
345
- JSONArray manifest = DataManager.getInstance().getAndClearManifest();
378
+ JSONArray manifest = DataManager.getInstance().getAndClearManifest(id);
346
379
  if (manifest != null) {
347
380
  handleManifestDownload(id, documentsDir, dest, version, sessionKey, publicKey, manifest);
348
381
  return createSuccessResult(dest, version, sessionKey, checksum, true);
382
+ } else if (isStopped()) {
383
+ return createFailureResult("download_cancelled");
349
384
  } else {
350
385
  logger.error("Manifest is null");
351
386
  return createFailureResult("Manifest is null");
@@ -526,8 +561,7 @@ public class DownloadService extends Worker {
526
561
  try {
527
562
  if (tryCopyBuiltinAsset(assets, fileName, targetFile, finalFileHash)) {
528
563
  logger.debug("using builtin asset " + fileName);
529
- } else if (builtinFile.exists() && verifyChecksum(builtinFile, finalFileHash)) {
530
- copyFile(builtinFile, targetFile);
564
+ } else if (tryCopyBuiltinFile(builtinFile, targetFile, finalFileHash)) {
531
565
  logger.debug("using builtin file " + fileName);
532
566
  } else if (
533
567
  tryCopyFromCache(cacheFile, targetFile, finalFileHash) ||
@@ -626,9 +660,11 @@ public class DownloadService extends Worker {
626
660
  URL u = new URL(url);
627
661
  httpConn = (HttpURLConnection) u.openConnection();
628
662
 
629
- // Set reasonable timeouts
630
- httpConn.setConnectTimeout(30000); // 30 seconds
631
- httpConn.setReadTimeout(60000); // 60 seconds
663
+ // Zip can stall longer than a JSON API call; keep a floor so
664
+ // responseTimeout cannot shrink large-bundle downloads.
665
+ int zipTimeoutMs = Math.max(httpTimeoutMs, 60_000);
666
+ httpConn.setConnectTimeout(zipTimeoutMs);
667
+ httpConn.setReadTimeout(zipTimeoutMs);
632
668
 
633
669
  // Reading progress file (if exist)
634
670
  long downloadedBytes = 0;
@@ -678,7 +714,7 @@ public class DownloadService extends Worker {
678
714
  writer = null;
679
715
  }
680
716
 
681
- byte[] buffer = new byte[8192]; // Larger buffer for better performance
717
+ byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
682
718
  int lastNotifiedPercent = 0;
683
719
  int bytesRead;
684
720
 
@@ -801,6 +837,10 @@ public class DownloadService extends Worker {
801
837
  }
802
838
 
803
839
  private void copyFile(File source, File dest) throws IOException {
840
+ copyFileChannel(source, dest);
841
+ }
842
+
843
+ static void copyFileChannel(final File source, final File dest) throws IOException {
804
844
  final File parent = dest.getParentFile();
805
845
  if (parent != null && !parent.exists() && !parent.mkdirs()) {
806
846
  throw new IOException("Failed to create parent directory: " + parent.getAbsolutePath());
@@ -971,33 +1011,19 @@ public class DownloadService extends Worker {
971
1011
  }
972
1012
  }
973
1013
 
974
- private boolean verifyChecksum(File file, String expectedHash) {
975
- try {
976
- String actualHash = calculateFileHash(file);
977
- return actualHash.equalsIgnoreCase(expectedHash);
978
- } catch (Exception e) {
979
- e.printStackTrace();
1014
+ static boolean tryCopyBuiltinFile(final File builtinFile, final File dest, final String expectedHash) {
1015
+ if (builtinFile == null || dest == null || expectedHash == null || expectedHash.isEmpty() || !builtinFile.isFile()) {
980
1016
  return false;
981
1017
  }
982
- }
983
-
984
- private String calculateFileHash(File file) throws Exception {
985
- MessageDigest digest = MessageDigest.getInstance("SHA-256");
986
- byte[] byteArray = new byte[1024];
987
- int bytesCount = 0;
988
-
989
- try (FileInputStream fis = new FileInputStream(file)) {
990
- while ((bytesCount = fis.read(byteArray)) != -1) {
991
- digest.update(byteArray, 0, bytesCount);
1018
+ try {
1019
+ if (!expectedHash.equalsIgnoreCase(CryptoCipher.calcChecksum(builtinFile))) {
1020
+ return false;
992
1021
  }
1022
+ copyFileChannel(builtinFile, dest);
1023
+ return true;
1024
+ } catch (IOException e) {
1025
+ return false;
993
1026
  }
994
-
995
- byte[] bytes = digest.digest();
996
- StringBuilder sb = new StringBuilder();
997
- for (byte aByte : bytes) {
998
- sb.append(Integer.toString((aByte & 0xff) + 0x100, 16).substring(1));
999
- }
1000
- return sb.toString();
1001
1027
  }
1002
1028
 
1003
1029
  static void decompressBrotli(File input, File output, String fileName) throws IOException {