@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.
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +1 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +72 -23
- package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +129 -23
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +306 -115
- package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +119 -0
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +249 -160
- package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +41 -29
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
365
|
-
|
|
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 (
|
|
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 (!
|
|
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
|
-
//
|
|
631
|
-
|
|
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 =
|
|
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
|
-
|
|
664
|
-
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
|
|
762
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
if (
|
|
821
|
-
|
|
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
|
-
|
|
826
|
-
|
|
827
|
-
return
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
848
|
-
logger.error("Error: Malformed data for " + fileName);
|
|
849
|
-
throw new IOException("Malformed data structure");
|
|
984
|
+
return;
|
|
850
985
|
}
|
|
851
986
|
}
|
|
852
987
|
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
870
|
-
|
|
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
|
|
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
|
|
1041
|
+
* Atomically write a stream to a file using the RAM-ladder IO buffer.
|
|
879
1042
|
*/
|
|
880
|
-
|
|
881
|
-
File tempFile =
|
|
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
|
-
//
|
|
885
|
-
|
|
886
|
-
|
|
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
|
-
|
|
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
|
}
|