@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.
- package/CapgoCapacitorUpdater.podspec +0 -2
- package/Package.swift +4 -5
- package/README.md +53 -48
- package/android/build.gradle +6 -6
- package/android/src/main/java/ee/forgr/capacitor_updater/AppLifecycleObserver.java +29 -2
- package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +7 -3
- package/android/src/main/java/ee/forgr/capacitor_updater/BundleStatus.java +1 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +427 -116
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1108 -182
- package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +102 -31
- package/android/src/main/java/ee/forgr/capacitor_updater/DataManager.java +23 -7
- package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +2 -2
- package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +11 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +513 -201
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +103 -3
- package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +1 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +115 -133
- package/dist/docs.json +32 -8
- package/dist/esm/definitions.d.ts +41 -17
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +124 -0
- package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +9 -1
- package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +3 -0
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +806 -92
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoRawRsa.swift +36 -0
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1020 -270
- package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +49 -31
- package/ios/Sources/CapacitorUpdaterPlugin/RSA.swift +73 -251
- package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +44 -20
- package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +28 -0
- package/package.json +13 -8
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
363
|
-
|
|
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 (
|
|
376
|
-
|
|
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(
|
|
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
|
-
//
|
|
467
|
-
|
|
468
|
-
|
|
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[
|
|
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 (!
|
|
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
|
-
//
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
) {
|
|
651
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
680
|
-
|
|
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: " +
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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(
|
|
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
|
-
|
|
939
|
+
try {
|
|
700
940
|
if (isBrotli) {
|
|
701
|
-
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
writeFileAtomic(finalTargetFile, fis, null);
|
|
943
|
+
try (FileInputStream fis = new FileInputStream(source)) {
|
|
944
|
+
writeFileAtomic(finalTargetFile, fis, expectedHash);
|
|
732
945
|
}
|
|
733
946
|
}
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
int bytesCount = 0;
|
|
993
|
+
static boolean shouldAppendHttpBody(int statusCode, long existingBytes) {
|
|
994
|
+
return existingBytes > 0 && statusCode == HttpURLConnection.HTTP_PARTIAL;
|
|
995
|
+
}
|
|
786
996
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
831
|
-
logger.error("Error: Malformed data for " + fileName);
|
|
832
|
-
throw new IOException("Malformed data structure");
|
|
1074
|
+
return;
|
|
833
1075
|
}
|
|
834
1076
|
}
|
|
835
1077
|
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
853
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
864
|
-
File tempFile =
|
|
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
|
-
//
|
|
868
|
-
|
|
869
|
-
|
|
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
|
-
|
|
873
|
-
|
|
874
|
-
String actualChecksum = CryptoCipher.calcChecksum(tempFile);
|
|
1159
|
+
if (digest != null) {
|
|
1160
|
+
String actualChecksum = CryptoCipher.digestToHex(digest);
|
|
875
1161
|
if (!expectedChecksum.equalsIgnoreCase(actualChecksum)) {
|
|
876
|
-
|
|
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
|
-
|
|
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
|
}
|