@capgo/capacitor-updater 7.50.2 → 7.51.16

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 (32) hide show
  1. package/CapgoCapacitorUpdater.podspec +0 -2
  2. package/Package.swift +4 -5
  3. package/README.md +53 -48
  4. package/android/build.gradle +6 -6
  5. package/android/src/main/java/ee/forgr/capacitor_updater/AppLifecycleObserver.java +29 -2
  6. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +7 -3
  7. package/android/src/main/java/ee/forgr/capacitor_updater/BundleStatus.java +1 -0
  8. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +427 -116
  9. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1108 -182
  10. package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +102 -31
  11. package/android/src/main/java/ee/forgr/capacitor_updater/DataManager.java +23 -7
  12. package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +2 -2
  13. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +11 -0
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +513 -201
  15. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +103 -3
  16. package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +1 -1
  17. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +115 -133
  18. package/dist/docs.json +32 -8
  19. package/dist/esm/definitions.d.ts +41 -17
  20. package/dist/esm/definitions.js.map +1 -1
  21. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +124 -0
  22. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +9 -1
  23. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +3 -0
  24. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +806 -92
  25. package/ios/Sources/CapacitorUpdaterPlugin/CapgoRawRsa.swift +36 -0
  26. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1020 -270
  27. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +49 -31
  28. package/ios/Sources/CapacitorUpdaterPlugin/RSA.swift +73 -251
  29. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +44 -20
  30. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +28 -0
  31. package/package.json +13 -8
  32. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +0 -39
@@ -6,6 +6,7 @@
6
6
  package ee.forgr.capacitor_updater;
7
7
 
8
8
  import android.content.Context;
9
+ import android.content.res.AssetManager;
9
10
  import androidx.annotation.NonNull;
10
11
  import androidx.work.Data;
11
12
  import androidx.work.Worker;
@@ -15,13 +16,14 @@ import java.io.FileInputStream;
15
16
  import java.net.HttpURLConnection;
16
17
  import java.net.URL;
17
18
  import java.nio.channels.FileChannel;
18
- import java.nio.file.Files;
19
- import java.nio.file.StandardCopyOption;
20
19
  import java.security.MessageDigest;
21
20
  import java.util.ArrayList;
22
21
  import java.util.Arrays;
22
+ import java.util.HashSet;
23
23
  import java.util.List;
24
24
  import java.util.Objects;
25
+ import java.util.Set;
26
+ import java.util.UUID;
25
27
  import java.util.concurrent.ExecutorService;
26
28
  import java.util.concurrent.Executors;
27
29
  import java.util.concurrent.Future;
@@ -30,6 +32,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
30
32
  import java.util.concurrent.atomic.AtomicLong;
31
33
  import okhttp3.Call;
32
34
  import okhttp3.Callback;
35
+ import okhttp3.Dispatcher;
33
36
  import okhttp3.Interceptor;
34
37
  import okhttp3.MediaType;
35
38
  import okhttp3.OkHttpClient;
@@ -38,11 +41,6 @@ import okhttp3.Request;
38
41
  import okhttp3.RequestBody;
39
42
  import okhttp3.Response;
40
43
  import okhttp3.ResponseBody;
41
- import okio.Buffer;
42
- import okio.BufferedSink;
43
- import okio.BufferedSource;
44
- import okio.Okio;
45
- import okio.Source;
46
44
  import org.brotli.dec.BrotliInputStream;
47
45
  import org.json.JSONArray;
48
46
  import org.json.JSONObject;
@@ -78,18 +76,33 @@ public class DownloadService extends Worker {
78
76
  public static final String DEFAULT_CHANNEL = "default_channel";
79
77
  public static final String IS_PROD = "is_prod";
80
78
  public static final String IS_EMULATOR = "is_emulator";
79
+ // HTTP + decode share one pool. Cap per host by CPU: 8 on 4 cores, 16 on 8 cores.
80
+ // Keep the global cap at 64 so API calls on another host are not starved by manifest downloads.
81
+ private static final int MANIFEST_MAX_CONCURRENT_FILES = manifestMaxConcurrentFiles();
82
+ private static final int SHARED_MAX_REQUESTS = 64;
81
83
  private static final String UPDATE_FILE = "update.dat";
82
84
 
83
85
  // Shared OkHttpClient to prevent resource leaks
84
- 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;
85
91
  private static String currentAppId = "unknown";
86
92
  private static String currentPluginVersion = "unknown";
87
93
  private static String currentVersionOs = "unknown";
88
94
 
89
95
  // Initialize shared client with User-Agent interceptor
90
96
  static {
97
+ Dispatcher dispatcher = new Dispatcher();
98
+ dispatcher.setMaxRequests(SHARED_MAX_REQUESTS);
99
+ dispatcher.setMaxRequestsPerHost(MANIFEST_MAX_CONCURRENT_FILES);
91
100
  sharedClient = new OkHttpClient.Builder()
101
+ .dispatcher(dispatcher)
92
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)
93
106
  .addInterceptor((chain) -> {
94
107
  Request originalRequest = chain.request();
95
108
  String userAgent = buildUserAgent(currentAppId, currentPluginVersion, currentVersionOs);
@@ -99,6 +112,41 @@ public class DownloadService extends Worker {
99
112
  .build();
100
113
  }
101
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
+
141
+ static int manifestMaxConcurrentFiles() {
142
+ return manifestMaxConcurrentFiles(Runtime.getRuntime().availableProcessors());
143
+ }
144
+
145
+ static int manifestMaxConcurrentFiles(int processors) {
146
+ int cores = Math.max(1, processors);
147
+ return Math.min(64, Math.max(8, cores * 2));
148
+ }
149
+
102
150
  static String buildUserAgent(String appId, String pluginVersion, String versionOs) {
103
151
  return (
104
152
  "CapacitorUpdater/" +
@@ -144,6 +192,7 @@ public class DownloadService extends Worker {
144
192
 
145
193
  // Clean up old temporary files on service initialization
146
194
  cleanupOldTempFiles(getApplicationContext().getCacheDir());
195
+ cleanupOldTempFiles(new File(getApplicationContext().getCacheDir(), "capgo_downloads"));
147
196
  }
148
197
 
149
198
  private void setProgress(int percent) {
@@ -174,7 +223,134 @@ public class DownloadService extends Worker {
174
223
  }
175
224
 
176
225
  static File resolveManifestBuiltinFile(final File builtinFolder, final String fileName) throws IOException {
177
- return CapgoUpdater.resolvePathInsideDirectory(builtinFolder, fileName);
226
+ final boolean isBrotli = fileName.endsWith(".br");
227
+ final String resolvedName = isBrotli ? fileName.substring(0, fileName.length() - 3) : fileName;
228
+ return CapgoUpdater.resolvePathInsideDirectory(builtinFolder, resolvedName);
229
+ }
230
+
231
+ /** APK web assets live in assets/public/; strip .br so store files match. */
232
+ static String resolveBuiltinAssetPath(final String fileName) throws IOException {
233
+ final File base = new File("/capgo-builtin-assets");
234
+ final File resolved = resolveManifestBuiltinFile(base, fileName);
235
+ final String basePath = base.getCanonicalPath();
236
+ final String resolvedPath = resolved.getCanonicalPath();
237
+ final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
238
+ if (!resolvedPath.startsWith(normalizedBasePath)) {
239
+ throw new IOException("Invalid manifest file path: " + fileName);
240
+ }
241
+ return "public/" + resolvedPath.substring(normalizedBasePath.length()).replace(File.separatorChar, '/');
242
+ }
243
+
244
+ static boolean copyStreamIfChecksumMatches(final InputStream input, final File dest, final String expectedHash) throws IOException {
245
+ if (expectedHash == null || expectedHash.isEmpty()) {
246
+ return false;
247
+ }
248
+ final File parent = dest.getParentFile();
249
+ if (parent == null) {
250
+ throw new IOException("Destination has no parent: " + dest.getAbsolutePath());
251
+ }
252
+ if (!parent.exists() && !parent.mkdirs()) {
253
+ throw new IOException("Failed to create parent directory: " + parent.getAbsolutePath());
254
+ }
255
+
256
+ final MessageDigest digest;
257
+ try {
258
+ digest = MessageDigest.getInstance("SHA-256");
259
+ } catch (java.security.NoSuchAlgorithmException e) {
260
+ throw new IOException("SHA-256 algorithm not available", e);
261
+ }
262
+
263
+ final File tempFile = File.createTempFile("capgo_asset_", ".tmp", parent);
264
+ try {
265
+ try (FileOutputStream outStream = new FileOutputStream(tempFile)) {
266
+ final byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
267
+ int length;
268
+ while ((length = input.read(buffer)) != -1) {
269
+ digest.update(buffer, 0, length);
270
+ outStream.write(buffer, 0, length);
271
+ }
272
+ }
273
+ if (!expectedHash.equalsIgnoreCase(sha256Hex(digest))) {
274
+ return false;
275
+ }
276
+ return replaceFile(tempFile, dest);
277
+ } finally {
278
+ deleteQuietly(tempFile);
279
+ }
280
+ }
281
+
282
+ private static String sha256Hex(final MessageDigest digest) {
283
+ final byte[] hash = digest.digest();
284
+ final StringBuilder hexString = new StringBuilder(hash.length * 2);
285
+ for (final byte b : hash) {
286
+ final String hex = Integer.toHexString(0xff & b);
287
+ if (hex.length() == 1) {
288
+ hexString.append('0');
289
+ }
290
+ hexString.append(hex);
291
+ }
292
+ return hexString.toString();
293
+ }
294
+
295
+ private static void deleteQuietly(final File file) {
296
+ if (file.exists() && !file.delete()) {
297
+ file.deleteOnExit();
298
+ }
299
+ }
300
+
301
+ static boolean replaceFile(final File tempFile, final File dest) {
302
+ if (tempFile.renameTo(dest)) {
303
+ return true;
304
+ }
305
+ final File parent = dest.getParentFile();
306
+ if (parent == null) {
307
+ return false;
308
+ }
309
+ final File backup = new File(parent, ".capgo_bak_" + UUID.randomUUID());
310
+ deleteQuietly(backup);
311
+ if (dest.exists() && !dest.renameTo(backup)) {
312
+ return false;
313
+ }
314
+ if (!tempFile.renameTo(dest)) {
315
+ if (backup.exists()) {
316
+ backup.renameTo(dest);
317
+ }
318
+ return false;
319
+ }
320
+ deleteQuietly(backup);
321
+ return true;
322
+ }
323
+
324
+ static boolean rememberManifestTarget(final Set<String> seenTargets, final File targetFile) throws IOException {
325
+ return seenTargets.add(targetFile.getCanonicalPath());
326
+ }
327
+
328
+ static boolean tryCopyBuiltinAsset(final AssetManager assets, final String fileName, final File dest, final String expectedHash) {
329
+ if (assets == null || fileName == null || dest == null) {
330
+ return false;
331
+ }
332
+ try {
333
+ final String assetPath = resolveBuiltinAssetPath(fileName);
334
+ try (InputStream in = assets.open(assetPath)) {
335
+ return copyStreamIfChecksumMatches(in, dest, expectedHash);
336
+ }
337
+ } catch (IOException e) {
338
+ return false;
339
+ }
340
+ }
341
+
342
+ static boolean builtinAssetMatches(final AssetManager assets, final String fileName, final String expectedHash) {
343
+ if (assets == null || fileName == null || expectedHash == null || expectedHash.isEmpty()) {
344
+ return false;
345
+ }
346
+ try {
347
+ final String assetPath = resolveBuiltinAssetPath(fileName);
348
+ try (InputStream in = assets.open(assetPath)) {
349
+ return expectedHash.equalsIgnoreCase(CryptoCipher.calcChecksum(in));
350
+ }
351
+ } catch (IOException e) {
352
+ return false;
353
+ }
178
354
  }
179
355
 
180
356
  private String getInputString(String key, String fallback) {
@@ -199,10 +375,12 @@ public class DownloadService extends Worker {
199
375
  logger.debug("doWork isManifest: " + isManifest);
200
376
 
201
377
  if (isManifest) {
202
- JSONArray manifest = DataManager.getInstance().getAndClearManifest();
378
+ JSONArray manifest = DataManager.getInstance().getAndClearManifest(id);
203
379
  if (manifest != null) {
204
- handleManifestDownload(id, documentsDir, dest, version, sessionKey, publicKey, manifest.toString());
380
+ handleManifestDownload(id, documentsDir, dest, version, sessionKey, publicKey, manifest);
205
381
  return createSuccessResult(dest, version, sessionKey, checksum, true);
382
+ } else if (isStopped()) {
383
+ return createFailureResult("download_cancelled");
206
384
  } else {
207
385
  logger.error("Manifest is null");
208
386
  return createFailureResult("Manifest is null");
@@ -289,7 +467,7 @@ public class DownloadService extends Worker {
289
467
  String version,
290
468
  String sessionKey,
291
469
  String publicKey,
292
- String manifestString
470
+ JSONArray manifest
293
471
  ) {
294
472
  try {
295
473
  logger.debug("handleManifestDownload");
@@ -297,15 +475,16 @@ public class DownloadService extends Worker {
297
475
  // Send stats for manifest download start
298
476
  sendStatsAsync("download_manifest_start", version);
299
477
 
300
- JSONArray manifest = new JSONArray(manifestString);
301
478
  File destFolder = new File(documentsDir, dest);
302
479
  File cacheFolder = new File(getApplicationContext().getCacheDir(), "capgo_downloads");
303
480
  File builtinFolder = new File(getApplicationContext().getFilesDir(), "public");
481
+ AssetManager assets = getApplicationContext().getAssets();
304
482
 
305
483
  // Ensure directories are created
306
484
  if (!destFolder.exists() && !destFolder.mkdirs()) {
307
485
  throw new IOException("Failed to create destination directory: " + destFolder.getAbsolutePath());
308
486
  }
487
+ cleanupOrphanedAssetTemps(destFolder);
309
488
  if (!cacheFolder.exists() && !cacheFolder.mkdirs()) {
310
489
  throw new IOException("Failed to create cache directory: " + cacheFolder.getAbsolutePath());
311
490
  }
@@ -314,10 +493,9 @@ public class DownloadService extends Worker {
314
493
  final AtomicLong completedFiles = new AtomicLong(0);
315
494
  final AtomicBoolean hasError = new AtomicBoolean(false);
316
495
 
317
- // Use more threads for I/O-bound operations
318
- int threadCount = Math.min(64, Math.max(32, totalFiles));
319
- ExecutorService executor = Executors.newFixedThreadPool(threadCount);
496
+ ExecutorService executor = Executors.newFixedThreadPool(Math.min(MANIFEST_MAX_CONCURRENT_FILES, Math.max(1, totalFiles)));
320
497
  List<Future<?>> futures = new ArrayList<>();
498
+ final Set<String> seenTargets = new HashSet<>();
321
499
 
322
500
  for (int i = 0; i < totalFiles; i++) {
323
501
  JSONObject entry = manifest.getJSONObject(i);
@@ -352,6 +530,12 @@ public class DownloadService extends Worker {
352
530
  try {
353
531
  targetFile = resolveManifestTargetFile(destFolder, fileName);
354
532
  builtinFile = resolveManifestBuiltinFile(builtinFolder, fileName);
533
+ if (!rememberManifestTarget(seenTargets, targetFile)) {
534
+ logger.error("Duplicate manifest target path: " + fileName);
535
+ sendStatsAsync("manifest_path_fail", version + ":" + fileName);
536
+ hasError.set(true);
537
+ continue;
538
+ }
355
539
  } catch (IOException e) {
356
540
  logger.error("Invalid manifest file path: " + fileName);
357
541
  sendStatsAsync("manifest_path_fail", version + ":" + fileName);
@@ -359,8 +543,11 @@ public class DownloadService extends Worker {
359
543
  continue;
360
544
  }
361
545
  String cacheBaseName = new File(isBrotli ? targetFileName : fileName).getName();
362
- File cacheFile = new File(cacheFolder, finalFileHash + "_" + cacheBaseName);
363
- final File legacyCacheFile = isBrotli ? new File(cacheFolder, finalFileHash + "_" + new File(fileName).getName()) : null;
546
+ final File cacheFile = CapgoUpdater.isSafeCacheHash(finalFileHash)
547
+ ? new File(cacheFolder, finalFileHash + "_" + cacheBaseName)
548
+ : null;
549
+ final File legacyCacheFile =
550
+ isBrotli && cacheFile != null ? new File(cacheFolder, finalFileHash + "_" + new File(fileName).getName()) : null;
364
551
 
365
552
  // Ensure parent directories of the target file exist
366
553
  if (!Objects.requireNonNull(targetFile.getParentFile()).exists() && !targetFile.getParentFile().mkdirs()) {
@@ -372,8 +559,9 @@ public class DownloadService extends Worker {
372
559
  final boolean finalIsBrotli = isBrotli;
373
560
  Future<?> future = executor.submit(() -> {
374
561
  try {
375
- if (builtinFile.exists() && verifyChecksum(builtinFile, finalFileHash)) {
376
- copyFile(builtinFile, targetFile);
562
+ if (tryCopyBuiltinAsset(assets, fileName, targetFile, finalFileHash)) {
563
+ logger.debug("using builtin asset " + fileName);
564
+ } else if (tryCopyBuiltinFile(builtinFile, targetFile, finalFileHash)) {
377
565
  logger.debug("using builtin file " + fileName);
378
566
  } else if (
379
567
  tryCopyFromCache(cacheFile, targetFile, finalFileHash) ||
@@ -381,7 +569,16 @@ public class DownloadService extends Worker {
381
569
  ) {
382
570
  logger.debug("already cached " + fileName);
383
571
  } else {
384
- downloadAndVerify(downloadUrl, targetFile, cacheFile, finalFileHash, sessionKey, publicKey, finalIsBrotli);
572
+ downloadAndVerify(
573
+ downloadUrl,
574
+ targetFile,
575
+ cacheFile,
576
+ finalFileHash,
577
+ sessionKey,
578
+ publicKey,
579
+ finalIsBrotli,
580
+ fileName
581
+ );
385
582
  }
386
583
 
387
584
  long completed = completedFiles.incrementAndGet();
@@ -463,9 +660,11 @@ public class DownloadService extends Worker {
463
660
  URL u = new URL(url);
464
661
  httpConn = (HttpURLConnection) u.openConnection();
465
662
 
466
- // Set reasonable timeouts
467
- httpConn.setConnectTimeout(30000); // 30 seconds
468
- 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);
469
668
 
470
669
  // Reading progress file (if exist)
471
670
  long downloadedBytes = 0;
@@ -515,7 +714,7 @@ public class DownloadService extends Worker {
515
714
  writer = null;
516
715
  }
517
716
 
518
- byte[] buffer = new byte[8192]; // Larger buffer for better performance
717
+ byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
519
718
  int lastNotifiedPercent = 0;
520
719
  int bytesRead;
521
720
 
@@ -620,17 +819,13 @@ public class DownloadService extends Worker {
620
819
  * This handles the race condition where OS can delete cache files between exists() check and copy.
621
820
  */
622
821
  private boolean tryCopyFromCache(File source, File dest, String expectedHash) {
623
- // First quick check - if file doesn't exist, don't bother
624
- if (!source.exists()) {
625
- return false;
626
- }
627
-
628
- // Verify checksum before copy
629
- if (!verifyChecksum(source, expectedHash)) {
822
+ // First quick check - if file doesn't exist or was truncated, don't bother
823
+ if (!CapgoUpdater.isReusableCacheFile(source, expectedHash)) {
630
824
  return false;
631
825
  }
632
826
 
633
- // Try to copy - if it fails (file deleted by OS between check and copy), return false
827
+ // Hash is in the cache file name and was verified when written.
828
+ // Re-hashing here would re-read every reused file on low-RAM devices.
634
829
  try {
635
830
  copyFile(source, dest);
636
831
  return true;
@@ -642,13 +837,38 @@ public class DownloadService extends Worker {
642
837
  }
643
838
 
644
839
  private void copyFile(File source, File dest) throws IOException {
645
- try (
646
- FileInputStream inStream = new FileInputStream(source);
647
- FileOutputStream outStream = new FileOutputStream(dest);
648
- FileChannel inChannel = inStream.getChannel();
649
- FileChannel outChannel = outStream.getChannel()
650
- ) {
651
- inChannel.transferTo(0, inChannel.size(), outChannel);
840
+ copyFileChannel(source, dest);
841
+ }
842
+
843
+ static void copyFileChannel(final File source, final File dest) throws IOException {
844
+ final File parent = dest.getParentFile();
845
+ if (parent != null && !parent.exists() && !parent.mkdirs()) {
846
+ throw new IOException("Failed to create parent directory: " + parent.getAbsolutePath());
847
+ }
848
+
849
+ final File tempFile = File.createTempFile("capgo-", ".tmp", parent);
850
+ try {
851
+ try (
852
+ FileInputStream inStream = new FileInputStream(source);
853
+ FileOutputStream outStream = new FileOutputStream(tempFile);
854
+ FileChannel inChannel = inStream.getChannel();
855
+ FileChannel outChannel = outStream.getChannel()
856
+ ) {
857
+ long size = inChannel.size();
858
+ long pos = 0;
859
+ while (pos < size) {
860
+ long transferred = inChannel.transferTo(pos, size - pos, outChannel);
861
+ if (transferred <= 0) {
862
+ throw new IOException("Failed to copy file: " + source.getAbsolutePath());
863
+ }
864
+ pos += transferred;
865
+ }
866
+ }
867
+ CryptoCipher.replaceFile(tempFile, dest);
868
+ } finally {
869
+ if (tempFile.exists()) {
870
+ tempFile.delete();
871
+ }
652
872
  }
653
873
  }
654
874
 
@@ -659,229 +879,298 @@ public class DownloadService extends Worker {
659
879
  String expectedHash,
660
880
  String sessionKey,
661
881
  String publicKey,
662
- boolean isBrotli
882
+ boolean isBrotli,
883
+ String relativeName
663
884
  ) throws Exception {
664
885
  logger.debug("downloadAndVerify " + downloadUrl);
665
886
 
666
- Request request = new Request.Builder().url(downloadUrl).build();
667
-
668
- // targetFile is already the final destination without .br extension
669
887
  File finalTargetFile = targetFile;
670
-
671
- // Create a temporary file for the compressed data with a unique name to avoid race conditions
672
- // between threads processing files with the same basename in different directories
673
- File compressedFile = new File(
674
- getApplicationContext().getCacheDir(),
675
- "temp_" + java.util.UUID.randomUUID().toString() + "_" + targetFile.getName() + ".tmp"
676
- );
677
-
888
+ File cacheFolder = new File(getApplicationContext().getCacheDir(), "capgo_downloads");
889
+ if (!cacheFolder.exists() && !cacheFolder.mkdirs()) {
890
+ throw new IOException("Failed to create cache directory: " + cacheFolder.getAbsolutePath());
891
+ }
892
+ File partial = manifestPartialFile(cacheFolder, expectedHash, relativeName);
893
+ File workFile = null;
894
+ boolean keepPartial = partial.isFile();
678
895
  try {
679
- try (Response response = sharedClient.newCall(request).execute()) {
680
- if (!response.isSuccessful()) {
896
+ long existing = partial.isFile() ? partial.length() : 0;
897
+ Request.Builder builder = new Request.Builder().url(downloadUrl);
898
+ if (existing > 0) {
899
+ builder.header("Range", "bytes=" + existing + "-");
900
+ }
901
+ try (Response response = sharedClient.newCall(builder.build()).execute()) {
902
+ int code = response.code();
903
+ if (code == 416 && existing > 0) {
904
+ logger.debug("Range not satisfiable, using existing partial " + partial.getName());
905
+ keepPartial = true;
906
+ } else if (code != HttpURLConnection.HTTP_OK && code != HttpURLConnection.HTTP_PARTIAL) {
681
907
  sendStatsAsync("download_manifest_file_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
682
- throw new IOException("Unexpected response code: " + response.code());
683
- }
684
-
685
- // Download compressed file atomically
686
- ResponseBody responseBody = response.body();
687
- if (responseBody == null) {
688
- throw new IOException("Response body is null");
908
+ throw new IOException("Unexpected response code: " + code);
909
+ } else {
910
+ ResponseBody responseBody = response.body();
911
+ if (responseBody == null) {
912
+ throw new IOException("Response body is null");
913
+ }
914
+ try {
915
+ writeHttpBody(partial, responseBody.byteStream(), code, existing);
916
+ keepPartial = true;
917
+ } catch (Exception e) {
918
+ keepPartial = true;
919
+ throw e;
920
+ }
689
921
  }
922
+ }
690
923
 
691
- // Use OkIO for atomic write
692
- writeFileAtomic(compressedFile, responseBody.byteStream(), null);
693
-
694
- if (publicKey != null && !publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
924
+ boolean needDecrypt = publicKey != null && !publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty();
925
+ File source = partial;
926
+ if (needDecrypt) {
927
+ workFile = new File(cacheFolder, "work_" + UUID.randomUUID() + "_" + targetFile.getName() + ".tmp");
928
+ copyFile(partial, workFile);
929
+ try {
695
930
  logger.debug("Decrypting file " + targetFile.getName());
696
- CryptoCipher.decryptFile(compressedFile, publicKey, sessionKey);
931
+ CryptoCipher.decryptFile(workFile, publicKey, sessionKey);
932
+ source = workFile;
933
+ } catch (Exception e) {
934
+ keepPartial = false;
935
+ throw e;
697
936
  }
937
+ }
698
938
 
699
- // Only decompress if file has .br extension
939
+ try {
700
940
  if (isBrotli) {
701
- // Use new decompression method with atomic write
702
- try (FileInputStream fis = new FileInputStream(compressedFile)) {
703
- byte[] compressedData = new byte[(int) compressedFile.length()];
704
- int offset = 0;
705
- int bytesRead;
706
- while (
707
- offset < compressedData.length &&
708
- (bytesRead = fis.read(compressedData, offset, compressedData.length - offset)) != -1
709
- ) {
710
- offset += bytesRead;
711
- }
712
- byte[] decompressedData;
713
- try {
714
- decompressedData = decompressBrotli(compressedData, targetFile.getName());
715
- } catch (IOException e) {
716
- sendStatsAsync(
717
- "download_manifest_brotli_fail",
718
- getInputData().getString(VERSION) + ":" + finalTargetFile.getName()
719
- );
720
- throw e;
721
- }
722
-
723
- // Write decompressed data atomically
724
- try (java.io.ByteArrayInputStream bais = new java.io.ByteArrayInputStream(decompressedData)) {
725
- writeFileAtomic(finalTargetFile, bais, null);
726
- }
727
- }
941
+ decompressBrotli(source, finalTargetFile, targetFile.getName(), expectedHash);
728
942
  } else {
729
- // Just copy the file without decompression using atomic operation
730
- try (FileInputStream fis = new FileInputStream(compressedFile)) {
731
- writeFileAtomic(finalTargetFile, fis, null);
943
+ try (FileInputStream fis = new FileInputStream(source)) {
944
+ writeFileAtomic(finalTargetFile, fis, expectedHash);
732
945
  }
733
946
  }
734
-
735
- // Delete the compressed file
736
- compressedFile.delete();
737
- String calculatedHash = CryptoCipher.calcChecksum(finalTargetFile);
738
- CryptoCipher.logChecksumInfo("Calculated checksum", calculatedHash);
739
- CryptoCipher.logChecksumInfo("Expected checksum", expectedHash);
740
-
741
- // Verify checksum
742
- if (calculatedHash.equalsIgnoreCase(expectedHash)) {
743
- // Only cache if checksum is correct - use atomic copy
744
- try (FileInputStream fis = new FileInputStream(finalTargetFile)) {
745
- writeFileAtomic(cacheFile, fis, expectedHash);
746
- }
747
- } else {
748
- finalTargetFile.delete();
947
+ } catch (IOException e) {
948
+ String msg = e.getMessage();
949
+ if (msg != null && msg.contains("Checksum verification failed")) {
749
950
  sendStatsAsync("download_manifest_checksum_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
750
- throw new IOException(
751
- "Checksum verification failed for: " +
752
- downloadUrl +
753
- " " +
754
- targetFile.getName() +
755
- " expected: " +
756
- expectedHash +
757
- " calculated: " +
758
- calculatedHash
759
- );
951
+ keepPartial = false;
952
+ } else if (isBrotli && msg != null && msg.toLowerCase(java.util.Locale.US).contains("brotli")) {
953
+ sendStatsAsync("download_manifest_brotli_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
954
+ keepPartial = false;
955
+ }
956
+ throw e;
957
+ }
958
+
959
+ CryptoCipher.logChecksumInfo("Calculated checksum", expectedHash);
960
+ CryptoCipher.logChecksumInfo("Expected checksum", expectedHash);
961
+
962
+ if (cacheFile != null) {
963
+ try (FileInputStream fis = new FileInputStream(finalTargetFile)) {
964
+ writeFileAtomic(cacheFile, fis, null);
760
965
  }
761
966
  }
967
+ keepPartial = false;
762
968
  } catch (Exception e) {
763
- throw new IOException("Error in downloadAndVerify: " + e.getMessage());
969
+ throw new IOException("Error in downloadAndVerify: " + e.getMessage(), e);
764
970
  } finally {
765
- // Always cleanup the compressed temp file if it still exists
766
- if (compressedFile.exists()) {
767
- compressedFile.delete();
971
+ if (workFile != null && workFile.exists() && !workFile.delete()) {
972
+ logger.debug("Failed to delete decrypt work file");
973
+ }
974
+ if (!keepPartial && partial.exists() && !partial.delete()) {
975
+ logger.debug("Failed to delete manifest partial " + partial.getName());
768
976
  }
769
977
  }
770
978
  }
771
979
 
772
- private boolean verifyChecksum(File file, String expectedHash) {
773
- try {
774
- String actualHash = calculateFileHash(file);
775
- return actualHash.equalsIgnoreCase(expectedHash);
776
- } catch (Exception e) {
777
- e.printStackTrace();
778
- return false;
980
+ static String safePartialToken(String fileName) {
981
+ return CryptoCipher.shortPathKey(fileName);
982
+ }
983
+
984
+ static File manifestPartialFile(File cacheDir, String hash, String fileName) {
985
+ String token = safePartialToken(fileName);
986
+ if (CapgoUpdater.isSafeCacheHash(hash) && hash.length() == 64) {
987
+ return new File(cacheDir, "partial_" + hash + "_" + token + ".tmp");
779
988
  }
989
+ String digest = CryptoCipher.shortPathKey((hash == null ? "" : hash) + "\0" + (fileName == null ? "" : fileName));
990
+ return new File(cacheDir, "partial_" + digest + "_" + token + ".tmp");
780
991
  }
781
992
 
782
- private String calculateFileHash(File file) throws Exception {
783
- MessageDigest digest = MessageDigest.getInstance("SHA-256");
784
- byte[] byteArray = new byte[1024];
785
- int bytesCount = 0;
993
+ static boolean shouldAppendHttpBody(int statusCode, long existingBytes) {
994
+ return existingBytes > 0 && statusCode == HttpURLConnection.HTTP_PARTIAL;
995
+ }
786
996
 
787
- try (FileInputStream fis = new FileInputStream(file)) {
788
- while ((bytesCount = fis.read(byteArray)) != -1) {
789
- digest.update(byteArray, 0, bytesCount);
997
+ static void writeHttpBody(File dest, InputStream body, int statusCode, long existingBytes) throws IOException {
998
+ boolean append = shouldAppendHttpBody(statusCode, existingBytes);
999
+ byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
1000
+ try (FileOutputStream fos = new FileOutputStream(dest, append)) {
1001
+ int n;
1002
+ long written = append ? existingBytes : 0;
1003
+ while ((n = body.read(buffer)) != -1) {
1004
+ fos.write(buffer, 0, n);
1005
+ written += n;
1006
+ if (written % (1024 * 1024) == 0) {
1007
+ fos.flush();
1008
+ }
790
1009
  }
1010
+ fos.flush();
791
1011
  }
1012
+ }
792
1013
 
793
- byte[] bytes = digest.digest();
794
- StringBuilder sb = new StringBuilder();
795
- for (byte aByte : bytes) {
796
- sb.append(Integer.toString((aByte & 0xff) + 0x100, 16).substring(1));
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()) {
1016
+ return false;
1017
+ }
1018
+ try {
1019
+ if (!expectedHash.equalsIgnoreCase(CryptoCipher.calcChecksum(builtinFile))) {
1020
+ return false;
1021
+ }
1022
+ copyFileChannel(builtinFile, dest);
1023
+ return true;
1024
+ } catch (IOException e) {
1025
+ return false;
797
1026
  }
798
- return sb.toString();
799
1027
  }
800
1028
 
801
- private byte[] decompressBrotli(byte[] data, String fileName) throws IOException {
802
- // Validate input
803
- if (data == null) {
804
- logger.error("Error: Null data received for " + fileName);
805
- throw new IOException("Null data received");
806
- }
1029
+ static void decompressBrotli(File input, File output, String fileName) throws IOException {
1030
+ decompressBrotli(input, output, fileName, null);
1031
+ }
807
1032
 
808
- // Handle empty files
809
- if (data.length == 0) {
810
- return new byte[0];
1033
+ static void decompressBrotli(File input, File output, String fileName, String expectedChecksum) throws IOException {
1034
+ File parent = output.getParentFile();
1035
+ if (parent != null) {
1036
+ parent.mkdirs();
1037
+ }
1038
+ long length = input.length();
1039
+ if (length == 0) {
1040
+ writeFileAtomic(output, new ByteArrayInputStream(new byte[0]), expectedChecksum);
1041
+ return;
811
1042
  }
812
1043
 
813
- // Handle the special EMPTY_BROTLI_STREAM case
814
- if (data.length == 3 && data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06) {
815
- return new byte[0];
1044
+ byte[] head = new byte[(int) Math.min(3, length)];
1045
+ byte last = 0;
1046
+ try (RandomAccessFile raf = new RandomAccessFile(input, "r")) {
1047
+ raf.readFully(head);
1048
+ if (length >= 1) {
1049
+ raf.seek(length - 1);
1050
+ last = raf.readByte();
1051
+ }
816
1052
  }
817
1053
 
818
- // For small files, check if it's a minimal Brotli wrapper
819
- if (data.length > 3) {
820
- try {
821
- // Handle our minimal wrapper pattern
822
- if (data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 && data[data.length - 1] == 0x03) {
823
- return Arrays.copyOfRange(data, 3, data.length - 1);
824
- }
1054
+ if (length == 3 && head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06) {
1055
+ writeFileAtomic(output, new ByteArrayInputStream(new byte[0]), expectedChecksum);
1056
+ return;
1057
+ }
825
1058
 
826
- // Handle brotli.compress minimal wrapper (quality 0)
827
- if (data[0] == 0x0b && data[1] == 0x02 && data[2] == (byte) 0x80 && data[data.length - 1] == 0x03) {
828
- return Arrays.copyOfRange(data, 3, data.length - 1);
1059
+ if (length > 3 && last == 0x03) {
1060
+ boolean emptyWrapper = head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06;
1061
+ boolean qualityZeroWrapper = head[0] == 0x0b && head[1] == 0x02 && head[2] == (byte) 0x80;
1062
+ if (emptyWrapper || qualityZeroWrapper) {
1063
+ try (FileInputStream fis = new FileInputStream(input)) {
1064
+ long skipped = 0;
1065
+ while (skipped < 3) {
1066
+ long n = fis.skip(3 - skipped);
1067
+ if (n <= 0) {
1068
+ break;
1069
+ }
1070
+ skipped += n;
1071
+ }
1072
+ writeFileAtomic(output, new BoundedInputStream(fis, length - 4), expectedChecksum);
829
1073
  }
830
- } catch (ArrayIndexOutOfBoundsException e) {
831
- logger.error("Error: Malformed data for " + fileName);
832
- throw new IOException("Malformed data structure");
1074
+ return;
833
1075
  }
834
1076
  }
835
1077
 
836
- // For all other cases, try standard decompression
837
- try (
838
- ByteArrayInputStream bis = new ByteArrayInputStream(data);
839
- BrotliInputStream brotliInputStream = new BrotliInputStream(bis);
840
- ByteArrayOutputStream bos = new ByteArrayOutputStream()
841
- ) {
842
- byte[] buffer = new byte[8192];
843
- int len;
844
- while ((len = brotliInputStream.read(buffer)) != -1) {
845
- bos.write(buffer, 0, len);
846
- }
847
- return bos.toByteArray();
1078
+ try (FileInputStream fis = new FileInputStream(input); BrotliInputStream brotliInputStream = new BrotliInputStream(fis)) {
1079
+ writeFileAtomic(output, brotliInputStream, expectedChecksum);
848
1080
  } catch (IOException e) {
849
1081
  logger.error("Error: Brotli process failed for " + fileName + ". Status: " + e.getMessage());
850
- // Add hex dump for debugging
851
1082
  StringBuilder hexDump = new StringBuilder();
852
- for (int i = 0; i < Math.min(32, data.length); i++) {
853
- hexDump.append(String.format("%02x ", data[i]));
1083
+ try (FileInputStream peek = new FileInputStream(input)) {
1084
+ byte[] prefix = new byte[(int) Math.min(32, length)];
1085
+ int n = peek.read(prefix);
1086
+ for (int i = 0; i < n; i++) {
1087
+ hexDump.append(String.format("%02x ", prefix[i]));
1088
+ }
854
1089
  }
855
- logger.error("Error: Raw data (" + fileName + "): " + hexDump.toString());
856
- throw e;
1090
+ logger.error("Error: Raw data (" + fileName + "): " + hexDump);
1091
+ throw new IOException("Brotli process failed for " + fileName + ": " + e.getMessage(), e);
1092
+ }
1093
+ }
1094
+
1095
+ private static final class BoundedInputStream extends FilterInputStream {
1096
+
1097
+ private long remaining;
1098
+
1099
+ BoundedInputStream(InputStream in, long remaining) {
1100
+ super(in);
1101
+ this.remaining = remaining;
1102
+ }
1103
+
1104
+ @Override
1105
+ public int read() throws IOException {
1106
+ if (remaining <= 0) {
1107
+ return -1;
1108
+ }
1109
+ int value = super.read();
1110
+ if (value >= 0) {
1111
+ remaining--;
1112
+ }
1113
+ return value;
1114
+ }
1115
+
1116
+ @Override
1117
+ public int read(byte[] b, int off, int len) throws IOException {
1118
+ if (remaining <= 0) {
1119
+ return -1;
1120
+ }
1121
+ int capped = (int) Math.min(len, remaining);
1122
+ int n = super.read(b, off, capped);
1123
+ if (n > 0) {
1124
+ remaining -= n;
1125
+ }
1126
+ return n;
857
1127
  }
858
1128
  }
859
1129
 
860
1130
  /**
861
- * Atomically write data to a file using OkIO
1131
+ * Atomically write a stream to a file using the 256 KiB IO buffer.
1132
+ * When expectedChecksum is set, SHA-256 is hashed during the write.
862
1133
  */
863
- private void writeFileAtomic(File targetFile, InputStream inputStream, String expectedChecksum) throws IOException {
864
- File tempFile = new File(targetFile.getParent(), targetFile.getName() + ".tmp");
1134
+ static void writeFileAtomic(File targetFile, InputStream inputStream, String expectedChecksum) throws IOException {
1135
+ File tempFile = File.createTempFile("capgo-", ".tmp", targetFile.getParentFile());
865
1136
 
866
1137
  try {
867
- // Write to temp file first using OkIO
868
- try (BufferedSink sink = Okio.buffer(Okio.sink(tempFile)); BufferedSource source = Okio.buffer(Okio.source(inputStream))) {
869
- sink.writeAll(source);
1138
+ // Okio's default segment is 8 KiB. Copy with 256 KiB so 8 MiB wrapper unwraps
1139
+ // are not 1000 tiny writes.
1140
+ byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
1141
+ MessageDigest digest = null;
1142
+ if (expectedChecksum != null && !expectedChecksum.isEmpty()) {
1143
+ try {
1144
+ digest = MessageDigest.getInstance("SHA-256");
1145
+ } catch (java.security.NoSuchAlgorithmException e) {
1146
+ throw new IOException("SHA-256 algorithm not available", e);
1147
+ }
1148
+ }
1149
+ try (FileOutputStream fos = new FileOutputStream(tempFile)) {
1150
+ int n;
1151
+ while ((n = inputStream.read(buffer)) != -1) {
1152
+ if (digest != null) {
1153
+ digest.update(buffer, 0, n);
1154
+ }
1155
+ fos.write(buffer, 0, n);
1156
+ }
870
1157
  }
871
1158
 
872
- // Verify checksum if provided
873
- if (expectedChecksum != null && !expectedChecksum.isEmpty()) {
874
- String actualChecksum = CryptoCipher.calcChecksum(tempFile);
1159
+ if (digest != null) {
1160
+ String actualChecksum = CryptoCipher.digestToHex(digest);
875
1161
  if (!expectedChecksum.equalsIgnoreCase(actualChecksum)) {
876
- tempFile.delete();
877
- throw new IOException("Checksum verification failed");
1162
+ throw new IOException("Checksum verification failed expected: " + expectedChecksum + " calculated: " + actualChecksum);
878
1163
  }
879
1164
  }
880
1165
 
881
- // Atomic rename (on same filesystem)
882
- Files.move(tempFile.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
1166
+ // Atomic rename (on same filesystem). renameTo works on API 24; Files.move does not.
1167
+ CryptoCipher.replaceFile(tempFile, targetFile);
1168
+ } catch (IOException e) {
1169
+ if (tempFile.exists()) {
1170
+ tempFile.delete();
1171
+ }
1172
+ throw e;
883
1173
  } catch (Exception e) {
884
- // Clean up temp file on error
885
1174
  if (tempFile.exists()) {
886
1175
  tempFile.delete();
887
1176
  }
@@ -907,4 +1196,27 @@ public class DownloadService extends Worker {
907
1196
  }
908
1197
  }
909
1198
  }
1199
+
1200
+ private void cleanupOrphanedAssetTemps(final File directory) {
1201
+ if (directory == null || !directory.isDirectory()) {
1202
+ return;
1203
+ }
1204
+ final File[] children = directory.listFiles();
1205
+ if (children == null) {
1206
+ return;
1207
+ }
1208
+ final long oneHourAgo = System.currentTimeMillis() - 3600000;
1209
+ for (final File child : children) {
1210
+ if (child.isDirectory()) {
1211
+ cleanupOrphanedAssetTemps(child);
1212
+ continue;
1213
+ }
1214
+ final String name = child.getName();
1215
+ final boolean orphanedAssetTemp = name.startsWith("capgo_asset_") && name.endsWith(".tmp");
1216
+ final boolean orphanedBackup = name.startsWith(".capgo_bak_");
1217
+ if ((orphanedAssetTemp || orphanedBackup) && child.lastModified() < oneHourAgo) {
1218
+ child.delete();
1219
+ }
1220
+ }
1221
+ }
910
1222
  }