@capgo/capacitor-updater 8.51.8 → 8.51.10

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.
@@ -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,6 +76,8 @@ 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
+ // Match HTTP dispatcher so 64 workers actually fetch in parallel (HTTP/2 multiplexes).
80
+ private static final int MANIFEST_MAX_CONCURRENT_FILES = 64;
81
81
  private static final String UPDATE_FILE = "update.dat";
82
82
 
83
83
  // Shared OkHttpClient to prevent resource leaks
@@ -88,7 +88,11 @@ public class DownloadService extends Worker {
88
88
 
89
89
  // Initialize shared client with User-Agent interceptor
90
90
  static {
91
+ Dispatcher dispatcher = new Dispatcher();
92
+ dispatcher.setMaxRequests(MANIFEST_MAX_CONCURRENT_FILES);
93
+ dispatcher.setMaxRequestsPerHost(MANIFEST_MAX_CONCURRENT_FILES);
91
94
  sharedClient = new OkHttpClient.Builder()
95
+ .dispatcher(dispatcher)
92
96
  .protocols(Arrays.asList(Protocol.HTTP_2, Protocol.HTTP_1_1))
93
97
  .addInterceptor((chain) -> {
94
98
  Request originalRequest = chain.request();
@@ -179,6 +183,131 @@ public class DownloadService extends Worker {
179
183
  return CapgoUpdater.resolvePathInsideDirectory(builtinFolder, resolvedName);
180
184
  }
181
185
 
186
+ /** APK web assets live in assets/public/; strip .br so store files match. */
187
+ static String resolveBuiltinAssetPath(final String fileName) throws IOException {
188
+ final File base = new File("/capgo-builtin-assets");
189
+ final File resolved = resolveManifestBuiltinFile(base, fileName);
190
+ final String basePath = base.getCanonicalPath();
191
+ final String resolvedPath = resolved.getCanonicalPath();
192
+ final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
193
+ if (!resolvedPath.startsWith(normalizedBasePath)) {
194
+ throw new IOException("Invalid manifest file path: " + fileName);
195
+ }
196
+ return "public/" + resolvedPath.substring(normalizedBasePath.length()).replace(File.separatorChar, '/');
197
+ }
198
+
199
+ static boolean copyStreamIfChecksumMatches(final InputStream input, final File dest, final String expectedHash) throws IOException {
200
+ if (expectedHash == null || expectedHash.isEmpty()) {
201
+ return false;
202
+ }
203
+ final File parent = dest.getParentFile();
204
+ if (parent == null) {
205
+ throw new IOException("Destination has no parent: " + dest.getAbsolutePath());
206
+ }
207
+ if (!parent.exists() && !parent.mkdirs()) {
208
+ throw new IOException("Failed to create parent directory: " + parent.getAbsolutePath());
209
+ }
210
+
211
+ final MessageDigest digest;
212
+ try {
213
+ digest = MessageDigest.getInstance("SHA-256");
214
+ } catch (java.security.NoSuchAlgorithmException e) {
215
+ throw new IOException("SHA-256 algorithm not available", e);
216
+ }
217
+
218
+ final File tempFile = File.createTempFile("capgo_asset_", ".tmp", parent);
219
+ try {
220
+ try (FileOutputStream outStream = new FileOutputStream(tempFile)) {
221
+ final byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
222
+ int length;
223
+ while ((length = input.read(buffer)) != -1) {
224
+ digest.update(buffer, 0, length);
225
+ outStream.write(buffer, 0, length);
226
+ }
227
+ }
228
+ if (!expectedHash.equalsIgnoreCase(sha256Hex(digest))) {
229
+ return false;
230
+ }
231
+ return replaceFile(tempFile, dest);
232
+ } finally {
233
+ deleteQuietly(tempFile);
234
+ }
235
+ }
236
+
237
+ private static String sha256Hex(final MessageDigest digest) {
238
+ final byte[] hash = digest.digest();
239
+ final StringBuilder hexString = new StringBuilder(hash.length * 2);
240
+ for (final byte b : hash) {
241
+ final String hex = Integer.toHexString(0xff & b);
242
+ if (hex.length() == 1) {
243
+ hexString.append('0');
244
+ }
245
+ hexString.append(hex);
246
+ }
247
+ return hexString.toString();
248
+ }
249
+
250
+ private static void deleteQuietly(final File file) {
251
+ if (file.exists() && !file.delete()) {
252
+ file.deleteOnExit();
253
+ }
254
+ }
255
+
256
+ static boolean replaceFile(final File tempFile, final File dest) {
257
+ if (tempFile.renameTo(dest)) {
258
+ return true;
259
+ }
260
+ final File parent = dest.getParentFile();
261
+ if (parent == null) {
262
+ return false;
263
+ }
264
+ final File backup = new File(parent, ".capgo_bak_" + UUID.randomUUID());
265
+ deleteQuietly(backup);
266
+ if (dest.exists() && !dest.renameTo(backup)) {
267
+ return false;
268
+ }
269
+ if (!tempFile.renameTo(dest)) {
270
+ if (backup.exists()) {
271
+ backup.renameTo(dest);
272
+ }
273
+ return false;
274
+ }
275
+ deleteQuietly(backup);
276
+ return true;
277
+ }
278
+
279
+ static boolean rememberManifestTarget(final Set<String> seenTargets, final File targetFile) throws IOException {
280
+ return seenTargets.add(targetFile.getCanonicalPath());
281
+ }
282
+
283
+ static boolean tryCopyBuiltinAsset(final AssetManager assets, final String fileName, final File dest, final String expectedHash) {
284
+ if (assets == null || fileName == null || dest == null) {
285
+ return false;
286
+ }
287
+ try {
288
+ final String assetPath = resolveBuiltinAssetPath(fileName);
289
+ try (InputStream in = assets.open(assetPath)) {
290
+ return copyStreamIfChecksumMatches(in, dest, expectedHash);
291
+ }
292
+ } catch (IOException e) {
293
+ return false;
294
+ }
295
+ }
296
+
297
+ static boolean builtinAssetMatches(final AssetManager assets, final String fileName, final String expectedHash) {
298
+ if (assets == null || fileName == null || expectedHash == null || expectedHash.isEmpty()) {
299
+ return false;
300
+ }
301
+ try {
302
+ final String assetPath = resolveBuiltinAssetPath(fileName);
303
+ try (InputStream in = assets.open(assetPath)) {
304
+ return expectedHash.equalsIgnoreCase(CryptoCipher.calcChecksum(in));
305
+ }
306
+ } catch (IOException e) {
307
+ return false;
308
+ }
309
+ }
310
+
182
311
  private String getInputString(String key, String fallback) {
183
312
  String value = getInputData().getString(key);
184
313
  return value != null ? value : fallback;
@@ -203,7 +332,7 @@ public class DownloadService extends Worker {
203
332
  if (isManifest) {
204
333
  JSONArray manifest = DataManager.getInstance().getAndClearManifest();
205
334
  if (manifest != null) {
206
- handleManifestDownload(id, documentsDir, dest, version, sessionKey, publicKey, manifest.toString());
335
+ handleManifestDownload(id, documentsDir, dest, version, sessionKey, publicKey, manifest);
207
336
  return createSuccessResult(dest, version, sessionKey, checksum, true);
208
337
  } else {
209
338
  logger.error("Manifest is null");
@@ -291,7 +420,7 @@ public class DownloadService extends Worker {
291
420
  String version,
292
421
  String sessionKey,
293
422
  String publicKey,
294
- String manifestString
423
+ JSONArray manifest
295
424
  ) {
296
425
  try {
297
426
  logger.debug("handleManifestDownload");
@@ -299,15 +428,16 @@ public class DownloadService extends Worker {
299
428
  // Send stats for manifest download start
300
429
  sendStatsAsync("download_manifest_start", version);
301
430
 
302
- JSONArray manifest = new JSONArray(manifestString);
303
431
  File destFolder = new File(documentsDir, dest);
304
432
  File cacheFolder = new File(getApplicationContext().getCacheDir(), "capgo_downloads");
305
433
  File builtinFolder = new File(getApplicationContext().getFilesDir(), "public");
434
+ AssetManager assets = getApplicationContext().getAssets();
306
435
 
307
436
  // Ensure directories are created
308
437
  if (!destFolder.exists() && !destFolder.mkdirs()) {
309
438
  throw new IOException("Failed to create destination directory: " + destFolder.getAbsolutePath());
310
439
  }
440
+ cleanupOrphanedAssetTemps(destFolder);
311
441
  if (!cacheFolder.exists() && !cacheFolder.mkdirs()) {
312
442
  throw new IOException("Failed to create cache directory: " + cacheFolder.getAbsolutePath());
313
443
  }
@@ -316,10 +446,9 @@ public class DownloadService extends Worker {
316
446
  final AtomicLong completedFiles = new AtomicLong(0);
317
447
  final AtomicBoolean hasError = new AtomicBoolean(false);
318
448
 
319
- // Use more threads for I/O-bound operations
320
- int threadCount = Math.min(64, Math.max(32, totalFiles));
321
- ExecutorService executor = Executors.newFixedThreadPool(threadCount);
449
+ ExecutorService executor = Executors.newFixedThreadPool(Math.min(MANIFEST_MAX_CONCURRENT_FILES, Math.max(1, totalFiles)));
322
450
  List<Future<?>> futures = new ArrayList<>();
451
+ final Set<String> seenTargets = new HashSet<>();
323
452
 
324
453
  for (int i = 0; i < totalFiles; i++) {
325
454
  JSONObject entry = manifest.getJSONObject(i);
@@ -354,6 +483,12 @@ public class DownloadService extends Worker {
354
483
  try {
355
484
  targetFile = resolveManifestTargetFile(destFolder, fileName);
356
485
  builtinFile = resolveManifestBuiltinFile(builtinFolder, fileName);
486
+ if (!rememberManifestTarget(seenTargets, targetFile)) {
487
+ logger.error("Duplicate manifest target path: " + fileName);
488
+ sendStatsAsync("manifest_path_fail", version + ":" + fileName);
489
+ hasError.set(true);
490
+ continue;
491
+ }
357
492
  } catch (IOException e) {
358
493
  logger.error("Invalid manifest file path: " + fileName);
359
494
  sendStatsAsync("manifest_path_fail", version + ":" + fileName);
@@ -361,8 +496,11 @@ public class DownloadService extends Worker {
361
496
  continue;
362
497
  }
363
498
  String cacheBaseName = new File(isBrotli ? targetFileName : fileName).getName();
364
- File cacheFile = new File(cacheFolder, finalFileHash + "_" + cacheBaseName);
365
- final File legacyCacheFile = isBrotli ? new File(cacheFolder, finalFileHash + "_" + new File(fileName).getName()) : null;
499
+ final File cacheFile = CapgoUpdater.isSafeCacheHash(finalFileHash)
500
+ ? new File(cacheFolder, finalFileHash + "_" + cacheBaseName)
501
+ : null;
502
+ final File legacyCacheFile =
503
+ isBrotli && cacheFile != null ? new File(cacheFolder, finalFileHash + "_" + new File(fileName).getName()) : null;
366
504
 
367
505
  // Ensure parent directories of the target file exist
368
506
  if (!Objects.requireNonNull(targetFile.getParentFile()).exists() && !targetFile.getParentFile().mkdirs()) {
@@ -374,7 +512,9 @@ public class DownloadService extends Worker {
374
512
  final boolean finalIsBrotli = isBrotli;
375
513
  Future<?> future = executor.submit(() -> {
376
514
  try {
377
- if (builtinFile.exists() && verifyChecksum(builtinFile, finalFileHash)) {
515
+ if (tryCopyBuiltinAsset(assets, fileName, targetFile, finalFileHash)) {
516
+ logger.debug("using builtin asset " + fileName);
517
+ } else if (builtinFile.exists() && verifyChecksum(builtinFile, finalFileHash)) {
378
518
  copyFile(builtinFile, targetFile);
379
519
  logger.debug("using builtin file " + fileName);
380
520
  } else if (
@@ -622,17 +762,13 @@ public class DownloadService extends Worker {
622
762
  * This handles the race condition where OS can delete cache files between exists() check and copy.
623
763
  */
624
764
  private boolean tryCopyFromCache(File source, File dest, String expectedHash) {
625
- // First quick check - if file doesn't exist, don't bother
626
- if (!source.exists()) {
765
+ // First quick check - if file doesn't exist or was truncated, don't bother
766
+ if (!CapgoUpdater.isReusableCacheFile(source, expectedHash)) {
627
767
  return false;
628
768
  }
629
769
 
630
- // Verify checksum before copy
631
- if (!verifyChecksum(source, expectedHash)) {
632
- return false;
633
- }
634
-
635
- // Try to copy - if it fails (file deleted by OS between check and copy), return false
770
+ // Hash is in the cache file name and was verified when written.
771
+ // Re-hashing here would re-read every reused file on low-RAM devices.
636
772
  try {
637
773
  copyFile(source, dest);
638
774
  return true;
@@ -649,23 +785,29 @@ public class DownloadService extends Worker {
649
785
  throw new IOException("Failed to create parent directory: " + parent.getAbsolutePath());
650
786
  }
651
787
 
652
- final File tempFile = new File(parent, dest.getName() + ".capgo_tmp");
653
- try (
654
- FileInputStream inStream = new FileInputStream(source);
655
- FileOutputStream outStream = new FileOutputStream(tempFile);
656
- FileChannel inChannel = inStream.getChannel();
657
- FileChannel outChannel = outStream.getChannel()
658
- ) {
659
- inChannel.transferTo(0, inChannel.size(), outChannel);
660
- }
661
-
788
+ final File tempFile = File.createTempFile("capgo-", ".tmp", parent);
662
789
  try {
663
- Files.move(tempFile.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING);
664
- } catch (IOException e) {
790
+ try (
791
+ FileInputStream inStream = new FileInputStream(source);
792
+ FileOutputStream outStream = new FileOutputStream(tempFile);
793
+ FileChannel inChannel = inStream.getChannel();
794
+ FileChannel outChannel = outStream.getChannel()
795
+ ) {
796
+ long size = inChannel.size();
797
+ long pos = 0;
798
+ while (pos < size) {
799
+ long transferred = inChannel.transferTo(pos, size - pos, outChannel);
800
+ if (transferred <= 0) {
801
+ throw new IOException("Failed to copy file: " + source.getAbsolutePath());
802
+ }
803
+ pos += transferred;
804
+ }
805
+ }
806
+ CryptoCipher.replaceFile(tempFile, dest);
807
+ } finally {
665
808
  if (tempFile.exists()) {
666
809
  tempFile.delete();
667
810
  }
668
- throw e;
669
811
  }
670
812
  }
671
813
 
@@ -715,35 +857,16 @@ public class DownloadService extends Worker {
715
857
 
716
858
  // Only decompress if file has .br extension
717
859
  if (isBrotli) {
718
- // Use new decompression method with atomic write
719
- try (FileInputStream fis = new FileInputStream(compressedFile)) {
720
- byte[] compressedData = new byte[(int) compressedFile.length()];
721
- int offset = 0;
722
- int bytesRead;
723
- while (
724
- offset < compressedData.length &&
725
- (bytesRead = fis.read(compressedData, offset, compressedData.length - offset)) != -1
726
- ) {
727
- offset += bytesRead;
728
- }
729
- byte[] decompressedData;
730
- try {
731
- decompressedData = decompressBrotli(compressedData, targetFile.getName());
732
- } catch (IOException e) {
733
- sendStatsAsync(
734
- "download_manifest_brotli_fail",
735
- getInputData().getString(VERSION) + ":" + finalTargetFile.getName()
736
- );
737
- throw e;
738
- }
739
-
740
- // Write decompressed data atomically
741
- try (java.io.ByteArrayInputStream bais = new java.io.ByteArrayInputStream(decompressedData)) {
742
- writeFileAtomic(finalTargetFile, bais, null);
743
- }
860
+ try {
861
+ decompressBrotli(compressedFile, finalTargetFile, targetFile.getName());
862
+ } catch (IOException e) {
863
+ sendStatsAsync(
864
+ "download_manifest_brotli_fail",
865
+ getInputData().getString(VERSION) + ":" + finalTargetFile.getName()
866
+ );
867
+ throw e;
744
868
  }
745
869
  } else {
746
- // Just copy the file without decompression using atomic operation
747
870
  try (FileInputStream fis = new FileInputStream(compressedFile)) {
748
871
  writeFileAtomic(finalTargetFile, fis, null);
749
872
  }
@@ -758,8 +881,10 @@ public class DownloadService extends Worker {
758
881
  // Verify checksum
759
882
  if (calculatedHash.equalsIgnoreCase(expectedHash)) {
760
883
  // Only cache if checksum is correct - use atomic copy
761
- try (FileInputStream fis = new FileInputStream(finalTargetFile)) {
762
- writeFileAtomic(cacheFile, fis, expectedHash);
884
+ if (cacheFile != null) {
885
+ try (FileInputStream fis = new FileInputStream(finalTargetFile)) {
886
+ writeFileAtomic(cacheFile, fis, null);
887
+ }
763
888
  }
764
889
  } else {
765
890
  finalTargetFile.delete();
@@ -815,75 +940,118 @@ public class DownloadService extends Worker {
815
940
  return sb.toString();
816
941
  }
817
942
 
818
- private byte[] decompressBrotli(byte[] data, String fileName) throws IOException {
819
- // Validate input
820
- if (data == null) {
821
- logger.error("Error: Null data received for " + fileName);
822
- throw new IOException("Null data received");
943
+ static void decompressBrotli(File input, File output, String fileName) throws IOException {
944
+ File parent = output.getParentFile();
945
+ if (parent != null) {
946
+ parent.mkdirs();
823
947
  }
824
-
825
- // Handle empty files
826
- if (data.length == 0) {
827
- return new byte[0];
948
+ long length = input.length();
949
+ if (length == 0) {
950
+ writeFileAtomic(output, new ByteArrayInputStream(new byte[0]), null);
951
+ return;
828
952
  }
829
953
 
830
- // Handle the special EMPTY_BROTLI_STREAM case
831
- if (data.length == 3 && data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06) {
832
- return new byte[0];
954
+ byte[] head = new byte[(int) Math.min(3, length)];
955
+ byte last = 0;
956
+ try (RandomAccessFile raf = new RandomAccessFile(input, "r")) {
957
+ raf.readFully(head);
958
+ if (length >= 1) {
959
+ raf.seek(length - 1);
960
+ last = raf.readByte();
961
+ }
833
962
  }
834
963
 
835
- // For small files, check if it's a minimal Brotli wrapper
836
- if (data.length > 3) {
837
- try {
838
- // Handle our minimal wrapper pattern
839
- if (data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 && data[data.length - 1] == 0x03) {
840
- return Arrays.copyOfRange(data, 3, data.length - 1);
841
- }
964
+ if (length == 3 && head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06) {
965
+ writeFileAtomic(output, new ByteArrayInputStream(new byte[0]), null);
966
+ return;
967
+ }
842
968
 
843
- // Handle brotli.compress minimal wrapper (quality 0)
844
- if (data[0] == 0x0b && data[1] == 0x02 && data[2] == (byte) 0x80 && data[data.length - 1] == 0x03) {
845
- return Arrays.copyOfRange(data, 3, data.length - 1);
969
+ if (length > 3 && last == 0x03) {
970
+ boolean emptyWrapper = head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06;
971
+ boolean qualityZeroWrapper = head[0] == 0x0b && head[1] == 0x02 && head[2] == (byte) 0x80;
972
+ if (emptyWrapper || qualityZeroWrapper) {
973
+ try (FileInputStream fis = new FileInputStream(input)) {
974
+ long skipped = 0;
975
+ while (skipped < 3) {
976
+ long n = fis.skip(3 - skipped);
977
+ if (n <= 0) {
978
+ break;
979
+ }
980
+ skipped += n;
981
+ }
982
+ writeFileAtomic(output, new BoundedInputStream(fis, length - 4), null);
846
983
  }
847
- } catch (ArrayIndexOutOfBoundsException e) {
848
- logger.error("Error: Malformed data for " + fileName);
849
- throw new IOException("Malformed data structure");
984
+ return;
850
985
  }
851
986
  }
852
987
 
853
- // For all other cases, try standard decompression
854
- try (
855
- ByteArrayInputStream bis = new ByteArrayInputStream(data);
856
- BrotliInputStream brotliInputStream = new BrotliInputStream(bis);
857
- ByteArrayOutputStream bos = new ByteArrayOutputStream()
858
- ) {
859
- byte[] buffer = new byte[8192];
860
- int len;
861
- while ((len = brotliInputStream.read(buffer)) != -1) {
862
- bos.write(buffer, 0, len);
863
- }
864
- return bos.toByteArray();
988
+ try (FileInputStream fis = new FileInputStream(input); BrotliInputStream brotliInputStream = new BrotliInputStream(fis)) {
989
+ writeFileAtomic(output, brotliInputStream, null);
865
990
  } catch (IOException e) {
866
991
  logger.error("Error: Brotli process failed for " + fileName + ". Status: " + e.getMessage());
867
- // Add hex dump for debugging
868
992
  StringBuilder hexDump = new StringBuilder();
869
- for (int i = 0; i < Math.min(32, data.length); i++) {
870
- hexDump.append(String.format("%02x ", data[i]));
993
+ try (FileInputStream peek = new FileInputStream(input)) {
994
+ byte[] prefix = new byte[(int) Math.min(32, length)];
995
+ int n = peek.read(prefix);
996
+ for (int i = 0; i < n; i++) {
997
+ hexDump.append(String.format("%02x ", prefix[i]));
998
+ }
871
999
  }
872
- logger.error("Error: Raw data (" + fileName + "): " + hexDump.toString());
1000
+ logger.error("Error: Raw data (" + fileName + "): " + hexDump);
873
1001
  throw e;
874
1002
  }
875
1003
  }
876
1004
 
1005
+ private static final class BoundedInputStream extends FilterInputStream {
1006
+
1007
+ private long remaining;
1008
+
1009
+ BoundedInputStream(InputStream in, long remaining) {
1010
+ super(in);
1011
+ this.remaining = remaining;
1012
+ }
1013
+
1014
+ @Override
1015
+ public int read() throws IOException {
1016
+ if (remaining <= 0) {
1017
+ return -1;
1018
+ }
1019
+ int value = super.read();
1020
+ if (value >= 0) {
1021
+ remaining--;
1022
+ }
1023
+ return value;
1024
+ }
1025
+
1026
+ @Override
1027
+ public int read(byte[] b, int off, int len) throws IOException {
1028
+ if (remaining <= 0) {
1029
+ return -1;
1030
+ }
1031
+ int capped = (int) Math.min(len, remaining);
1032
+ int n = super.read(b, off, capped);
1033
+ if (n > 0) {
1034
+ remaining -= n;
1035
+ }
1036
+ return n;
1037
+ }
1038
+ }
1039
+
877
1040
  /**
878
- * Atomically write data to a file using OkIO
1041
+ * Atomically write a stream to a file using the RAM-ladder IO buffer.
879
1042
  */
880
- private void writeFileAtomic(File targetFile, InputStream inputStream, String expectedChecksum) throws IOException {
881
- File tempFile = new File(targetFile.getParent(), targetFile.getName() + ".tmp");
1043
+ static void writeFileAtomic(File targetFile, InputStream inputStream, String expectedChecksum) throws IOException {
1044
+ File tempFile = File.createTempFile("capgo-", ".tmp", targetFile.getParentFile());
882
1045
 
883
1046
  try {
884
- // Write to temp file first using OkIO
885
- try (BufferedSink sink = Okio.buffer(Okio.sink(tempFile)); BufferedSource source = Okio.buffer(Okio.source(inputStream))) {
886
- sink.writeAll(source);
1047
+ // Okio's default segment is 8KB. Copy with the RAM-ladder buffer so
1048
+ // 8MB wrapper unwraps are not 1000 tiny writes.
1049
+ byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
1050
+ try (FileOutputStream fos = new FileOutputStream(tempFile)) {
1051
+ int n;
1052
+ while ((n = inputStream.read(buffer)) != -1) {
1053
+ fos.write(buffer, 0, n);
1054
+ }
887
1055
  }
888
1056
 
889
1057
  // Verify checksum if provided
@@ -895,8 +1063,8 @@ public class DownloadService extends Worker {
895
1063
  }
896
1064
  }
897
1065
 
898
- // Atomic rename (on same filesystem)
899
- Files.move(tempFile.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
1066
+ // Atomic rename (on same filesystem). renameTo works on API 24; Files.move does not.
1067
+ CryptoCipher.replaceFile(tempFile, targetFile);
900
1068
  } catch (Exception e) {
901
1069
  // Clean up temp file on error
902
1070
  if (tempFile.exists()) {
@@ -924,4 +1092,27 @@ public class DownloadService extends Worker {
924
1092
  }
925
1093
  }
926
1094
  }
1095
+
1096
+ private void cleanupOrphanedAssetTemps(final File directory) {
1097
+ if (directory == null || !directory.isDirectory()) {
1098
+ return;
1099
+ }
1100
+ final File[] children = directory.listFiles();
1101
+ if (children == null) {
1102
+ return;
1103
+ }
1104
+ final long oneHourAgo = System.currentTimeMillis() - 3600000;
1105
+ for (final File child : children) {
1106
+ if (child.isDirectory()) {
1107
+ cleanupOrphanedAssetTemps(child);
1108
+ continue;
1109
+ }
1110
+ final String name = child.getName();
1111
+ final boolean orphanedAssetTemp = name.startsWith("capgo_asset_") && name.endsWith(".tmp");
1112
+ final boolean orphanedBackup = name.startsWith(".capgo_bak_");
1113
+ if ((orphanedAssetTemp || orphanedBackup) && child.lastModified() < oneHourAgo) {
1114
+ child.delete();
1115
+ }
1116
+ }
1117
+ }
927
1118
  }