@capgo/capacitor-updater 5.50.2 → 5.51.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CapgoCapacitorUpdater.podspec +1 -1
- package/Package.swift +3 -2
- package/README.md +53 -48
- package/android/build.gradle +1 -0
- 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 +452 -139
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1354 -412
- 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/DownloadService.java +540 -224
- 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 +131 -145
- 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 +787 -92
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1014 -268
- package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +49 -31
- package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +44 -20
- package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +28 -0
- package/package.json +12 -7
|
@@ -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/" +
|
|
@@ -116,13 +164,15 @@ public class DownloadService extends Worker {
|
|
|
116
164
|
}
|
|
117
165
|
|
|
118
166
|
StringBuilder sanitized = new StringBuilder();
|
|
119
|
-
value
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
167
|
+
value
|
|
168
|
+
.codePoints()
|
|
169
|
+
.forEach((cp) -> {
|
|
170
|
+
boolean isVisibleAscii = cp >= 0x20 && cp <= 0x7E;
|
|
171
|
+
boolean isIso88591 = cp >= 0xA0 && cp <= 0xFF;
|
|
172
|
+
if (isVisibleAscii || isIso88591) {
|
|
173
|
+
sanitized.appendCodePoint(cp);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
126
176
|
|
|
127
177
|
String result = sanitized.toString().trim();
|
|
128
178
|
return result.isEmpty() ? "unknown" : result;
|
|
@@ -144,6 +194,7 @@ public class DownloadService extends Worker {
|
|
|
144
194
|
|
|
145
195
|
// Clean up old temporary files on service initialization
|
|
146
196
|
cleanupOldTempFiles(getApplicationContext().getCacheDir());
|
|
197
|
+
cleanupOldTempFiles(new File(getApplicationContext().getCacheDir(), "capgo_downloads"));
|
|
147
198
|
}
|
|
148
199
|
|
|
149
200
|
private void setProgress(int percent) {
|
|
@@ -174,7 +225,134 @@ public class DownloadService extends Worker {
|
|
|
174
225
|
}
|
|
175
226
|
|
|
176
227
|
static File resolveManifestBuiltinFile(final File builtinFolder, final String fileName) throws IOException {
|
|
177
|
-
|
|
228
|
+
final boolean isBrotli = fileName.endsWith(".br");
|
|
229
|
+
final String resolvedName = isBrotli ? fileName.substring(0, fileName.length() - 3) : fileName;
|
|
230
|
+
return CapgoUpdater.resolvePathInsideDirectory(builtinFolder, resolvedName);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** APK web assets live in assets/public/; strip .br so store files match. */
|
|
234
|
+
static String resolveBuiltinAssetPath(final String fileName) throws IOException {
|
|
235
|
+
final File base = new File("/capgo-builtin-assets");
|
|
236
|
+
final File resolved = resolveManifestBuiltinFile(base, fileName);
|
|
237
|
+
final String basePath = base.getCanonicalPath();
|
|
238
|
+
final String resolvedPath = resolved.getCanonicalPath();
|
|
239
|
+
final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
|
|
240
|
+
if (!resolvedPath.startsWith(normalizedBasePath)) {
|
|
241
|
+
throw new IOException("Invalid manifest file path: " + fileName);
|
|
242
|
+
}
|
|
243
|
+
return "public/" + resolvedPath.substring(normalizedBasePath.length()).replace(File.separatorChar, '/');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
static boolean copyStreamIfChecksumMatches(final InputStream input, final File dest, final String expectedHash) throws IOException {
|
|
247
|
+
if (expectedHash == null || expectedHash.isEmpty()) {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
final File parent = dest.getParentFile();
|
|
251
|
+
if (parent == null) {
|
|
252
|
+
throw new IOException("Destination has no parent: " + dest.getAbsolutePath());
|
|
253
|
+
}
|
|
254
|
+
if (!parent.exists() && !parent.mkdirs()) {
|
|
255
|
+
throw new IOException("Failed to create parent directory: " + parent.getAbsolutePath());
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
final MessageDigest digest;
|
|
259
|
+
try {
|
|
260
|
+
digest = MessageDigest.getInstance("SHA-256");
|
|
261
|
+
} catch (java.security.NoSuchAlgorithmException e) {
|
|
262
|
+
throw new IOException("SHA-256 algorithm not available", e);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
final File tempFile = File.createTempFile("capgo_asset_", ".tmp", parent);
|
|
266
|
+
try {
|
|
267
|
+
try (FileOutputStream outStream = new FileOutputStream(tempFile)) {
|
|
268
|
+
final byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
|
|
269
|
+
int length;
|
|
270
|
+
while ((length = input.read(buffer)) != -1) {
|
|
271
|
+
digest.update(buffer, 0, length);
|
|
272
|
+
outStream.write(buffer, 0, length);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (!expectedHash.equalsIgnoreCase(sha256Hex(digest))) {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
return replaceFile(tempFile, dest);
|
|
279
|
+
} finally {
|
|
280
|
+
deleteQuietly(tempFile);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private static String sha256Hex(final MessageDigest digest) {
|
|
285
|
+
final byte[] hash = digest.digest();
|
|
286
|
+
final StringBuilder hexString = new StringBuilder(hash.length * 2);
|
|
287
|
+
for (final byte b : hash) {
|
|
288
|
+
final String hex = Integer.toHexString(0xff & b);
|
|
289
|
+
if (hex.length() == 1) {
|
|
290
|
+
hexString.append('0');
|
|
291
|
+
}
|
|
292
|
+
hexString.append(hex);
|
|
293
|
+
}
|
|
294
|
+
return hexString.toString();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private static void deleteQuietly(final File file) {
|
|
298
|
+
if (file.exists() && !file.delete()) {
|
|
299
|
+
file.deleteOnExit();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
static boolean replaceFile(final File tempFile, final File dest) {
|
|
304
|
+
if (tempFile.renameTo(dest)) {
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
final File parent = dest.getParentFile();
|
|
308
|
+
if (parent == null) {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
final File backup = new File(parent, ".capgo_bak_" + UUID.randomUUID());
|
|
312
|
+
deleteQuietly(backup);
|
|
313
|
+
if (dest.exists() && !dest.renameTo(backup)) {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
if (!tempFile.renameTo(dest)) {
|
|
317
|
+
if (backup.exists()) {
|
|
318
|
+
backup.renameTo(dest);
|
|
319
|
+
}
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
deleteQuietly(backup);
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
static boolean rememberManifestTarget(final Set<String> seenTargets, final File targetFile) throws IOException {
|
|
327
|
+
return seenTargets.add(targetFile.getCanonicalPath());
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
static boolean tryCopyBuiltinAsset(final AssetManager assets, final String fileName, final File dest, final String expectedHash) {
|
|
331
|
+
if (assets == null || fileName == null || dest == null) {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
final String assetPath = resolveBuiltinAssetPath(fileName);
|
|
336
|
+
try (InputStream in = assets.open(assetPath)) {
|
|
337
|
+
return copyStreamIfChecksumMatches(in, dest, expectedHash);
|
|
338
|
+
}
|
|
339
|
+
} catch (IOException e) {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
static boolean builtinAssetMatches(final AssetManager assets, final String fileName, final String expectedHash) {
|
|
345
|
+
if (assets == null || fileName == null || expectedHash == null || expectedHash.isEmpty()) {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
final String assetPath = resolveBuiltinAssetPath(fileName);
|
|
350
|
+
try (InputStream in = assets.open(assetPath)) {
|
|
351
|
+
return expectedHash.equalsIgnoreCase(CryptoCipher.calcChecksum(in));
|
|
352
|
+
}
|
|
353
|
+
} catch (IOException e) {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
178
356
|
}
|
|
179
357
|
|
|
180
358
|
private String getInputString(String key, String fallback) {
|
|
@@ -199,10 +377,12 @@ public class DownloadService extends Worker {
|
|
|
199
377
|
logger.debug("doWork isManifest: " + isManifest);
|
|
200
378
|
|
|
201
379
|
if (isManifest) {
|
|
202
|
-
JSONArray manifest = DataManager.getInstance().getAndClearManifest();
|
|
380
|
+
JSONArray manifest = DataManager.getInstance().getAndClearManifest(id);
|
|
203
381
|
if (manifest != null) {
|
|
204
|
-
handleManifestDownload(id, documentsDir, dest, version, sessionKey, publicKey, manifest
|
|
382
|
+
handleManifestDownload(id, documentsDir, dest, version, sessionKey, publicKey, manifest);
|
|
205
383
|
return createSuccessResult(dest, version, sessionKey, checksum, true);
|
|
384
|
+
} else if (isStopped()) {
|
|
385
|
+
return createFailureResult("download_cancelled");
|
|
206
386
|
} else {
|
|
207
387
|
logger.error("Manifest is null");
|
|
208
388
|
return createFailureResult("Manifest is null");
|
|
@@ -255,26 +435,27 @@ public class DownloadService extends Worker {
|
|
|
255
435
|
.post(RequestBody.create(json.toString(), MediaType.get("application/json")))
|
|
256
436
|
.build();
|
|
257
437
|
|
|
258
|
-
sharedClient
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
438
|
+
sharedClient
|
|
439
|
+
.newCall(request)
|
|
440
|
+
.enqueue(
|
|
441
|
+
new Callback() {
|
|
442
|
+
@Override
|
|
443
|
+
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
|
444
|
+
if (logger != null) {
|
|
445
|
+
logger.error("Failed to send stats: " + e.getMessage());
|
|
446
|
+
}
|
|
264
447
|
}
|
|
265
|
-
}
|
|
266
448
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
449
|
+
@Override
|
|
450
|
+
public void onResponse(@NonNull Call call, @NonNull Response response) {
|
|
451
|
+
try (ResponseBody body = response.body()) {
|
|
452
|
+
// nothing else to do, just closing body
|
|
453
|
+
} catch (Exception ignored) {} finally {
|
|
454
|
+
response.close();
|
|
455
|
+
}
|
|
274
456
|
}
|
|
275
457
|
}
|
|
276
|
-
|
|
277
|
-
);
|
|
458
|
+
);
|
|
278
459
|
} catch (Exception e) {
|
|
279
460
|
if (logger != null) {
|
|
280
461
|
logger.error("sendStatsAsync error: " + e.getMessage());
|
|
@@ -289,7 +470,7 @@ public class DownloadService extends Worker {
|
|
|
289
470
|
String version,
|
|
290
471
|
String sessionKey,
|
|
291
472
|
String publicKey,
|
|
292
|
-
|
|
473
|
+
JSONArray manifest
|
|
293
474
|
) {
|
|
294
475
|
try {
|
|
295
476
|
logger.debug("handleManifestDownload");
|
|
@@ -297,15 +478,16 @@ public class DownloadService extends Worker {
|
|
|
297
478
|
// Send stats for manifest download start
|
|
298
479
|
sendStatsAsync("download_manifest_start", version);
|
|
299
480
|
|
|
300
|
-
JSONArray manifest = new JSONArray(manifestString);
|
|
301
481
|
File destFolder = new File(documentsDir, dest);
|
|
302
482
|
File cacheFolder = new File(getApplicationContext().getCacheDir(), "capgo_downloads");
|
|
303
483
|
File builtinFolder = new File(getApplicationContext().getFilesDir(), "public");
|
|
484
|
+
AssetManager assets = getApplicationContext().getAssets();
|
|
304
485
|
|
|
305
486
|
// Ensure directories are created
|
|
306
487
|
if (!destFolder.exists() && !destFolder.mkdirs()) {
|
|
307
488
|
throw new IOException("Failed to create destination directory: " + destFolder.getAbsolutePath());
|
|
308
489
|
}
|
|
490
|
+
cleanupOrphanedAssetTemps(destFolder);
|
|
309
491
|
if (!cacheFolder.exists() && !cacheFolder.mkdirs()) {
|
|
310
492
|
throw new IOException("Failed to create cache directory: " + cacheFolder.getAbsolutePath());
|
|
311
493
|
}
|
|
@@ -314,10 +496,9 @@ public class DownloadService extends Worker {
|
|
|
314
496
|
final AtomicLong completedFiles = new AtomicLong(0);
|
|
315
497
|
final AtomicBoolean hasError = new AtomicBoolean(false);
|
|
316
498
|
|
|
317
|
-
|
|
318
|
-
int threadCount = Math.min(64, Math.max(32, totalFiles));
|
|
319
|
-
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
|
499
|
+
ExecutorService executor = Executors.newFixedThreadPool(Math.min(MANIFEST_MAX_CONCURRENT_FILES, Math.max(1, totalFiles)));
|
|
320
500
|
List<Future<?>> futures = new ArrayList<>();
|
|
501
|
+
final Set<String> seenTargets = new HashSet<>();
|
|
321
502
|
|
|
322
503
|
for (int i = 0; i < totalFiles; i++) {
|
|
323
504
|
JSONObject entry = manifest.getJSONObject(i);
|
|
@@ -352,6 +533,12 @@ public class DownloadService extends Worker {
|
|
|
352
533
|
try {
|
|
353
534
|
targetFile = resolveManifestTargetFile(destFolder, fileName);
|
|
354
535
|
builtinFile = resolveManifestBuiltinFile(builtinFolder, fileName);
|
|
536
|
+
if (!rememberManifestTarget(seenTargets, targetFile)) {
|
|
537
|
+
logger.error("Duplicate manifest target path: " + fileName);
|
|
538
|
+
sendStatsAsync("manifest_path_fail", version + ":" + fileName);
|
|
539
|
+
hasError.set(true);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
355
542
|
} catch (IOException e) {
|
|
356
543
|
logger.error("Invalid manifest file path: " + fileName);
|
|
357
544
|
sendStatsAsync("manifest_path_fail", version + ":" + fileName);
|
|
@@ -359,8 +546,12 @@ public class DownloadService extends Worker {
|
|
|
359
546
|
continue;
|
|
360
547
|
}
|
|
361
548
|
String cacheBaseName = new File(isBrotli ? targetFileName : fileName).getName();
|
|
362
|
-
File cacheFile =
|
|
363
|
-
|
|
549
|
+
final File cacheFile = CapgoUpdater.isSafeCacheHash(finalFileHash)
|
|
550
|
+
? new File(cacheFolder, finalFileHash + "_" + cacheBaseName)
|
|
551
|
+
: null;
|
|
552
|
+
final File legacyCacheFile = isBrotli && cacheFile != null
|
|
553
|
+
? new File(cacheFolder, finalFileHash + "_" + new File(fileName).getName())
|
|
554
|
+
: null;
|
|
364
555
|
|
|
365
556
|
// Ensure parent directories of the target file exist
|
|
366
557
|
if (!Objects.requireNonNull(targetFile.getParentFile()).exists() && !targetFile.getParentFile().mkdirs()) {
|
|
@@ -372,8 +563,9 @@ public class DownloadService extends Worker {
|
|
|
372
563
|
final boolean finalIsBrotli = isBrotli;
|
|
373
564
|
Future<?> future = executor.submit(() -> {
|
|
374
565
|
try {
|
|
375
|
-
if (
|
|
376
|
-
|
|
566
|
+
if (tryCopyBuiltinAsset(assets, fileName, targetFile, finalFileHash)) {
|
|
567
|
+
logger.debug("using builtin asset " + fileName);
|
|
568
|
+
} else if (tryCopyBuiltinFile(builtinFile, targetFile, finalFileHash)) {
|
|
377
569
|
logger.debug("using builtin file " + fileName);
|
|
378
570
|
} else if (
|
|
379
571
|
tryCopyFromCache(cacheFile, targetFile, finalFileHash) ||
|
|
@@ -381,7 +573,16 @@ public class DownloadService extends Worker {
|
|
|
381
573
|
) {
|
|
382
574
|
logger.debug("already cached " + fileName);
|
|
383
575
|
} else {
|
|
384
|
-
downloadAndVerify(
|
|
576
|
+
downloadAndVerify(
|
|
577
|
+
downloadUrl,
|
|
578
|
+
targetFile,
|
|
579
|
+
cacheFile,
|
|
580
|
+
finalFileHash,
|
|
581
|
+
sessionKey,
|
|
582
|
+
publicKey,
|
|
583
|
+
finalIsBrotli,
|
|
584
|
+
fileName
|
|
585
|
+
);
|
|
385
586
|
}
|
|
386
587
|
|
|
387
588
|
long completed = completedFiles.incrementAndGet();
|
|
@@ -463,9 +664,11 @@ public class DownloadService extends Worker {
|
|
|
463
664
|
URL u = new URL(url);
|
|
464
665
|
httpConn = (HttpURLConnection) u.openConnection();
|
|
465
666
|
|
|
466
|
-
//
|
|
467
|
-
|
|
468
|
-
|
|
667
|
+
// Zip can stall longer than a JSON API call; keep a floor so
|
|
668
|
+
// responseTimeout cannot shrink large-bundle downloads.
|
|
669
|
+
int zipTimeoutMs = Math.max(httpTimeoutMs, 60_000);
|
|
670
|
+
httpConn.setConnectTimeout(zipTimeoutMs);
|
|
671
|
+
httpConn.setReadTimeout(zipTimeoutMs);
|
|
469
672
|
|
|
470
673
|
// Reading progress file (if exist)
|
|
471
674
|
long downloadedBytes = 0;
|
|
@@ -515,7 +718,7 @@ public class DownloadService extends Worker {
|
|
|
515
718
|
writer = null;
|
|
516
719
|
}
|
|
517
720
|
|
|
518
|
-
byte[] buffer = new byte[
|
|
721
|
+
byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
|
|
519
722
|
int lastNotifiedPercent = 0;
|
|
520
723
|
int bytesRead;
|
|
521
724
|
|
|
@@ -620,17 +823,13 @@ public class DownloadService extends Worker {
|
|
|
620
823
|
* This handles the race condition where OS can delete cache files between exists() check and copy.
|
|
621
824
|
*/
|
|
622
825
|
private boolean tryCopyFromCache(File source, File dest, String expectedHash) {
|
|
623
|
-
// First quick check - if file doesn't exist, don't bother
|
|
624
|
-
if (!
|
|
826
|
+
// First quick check - if file doesn't exist or was truncated, don't bother
|
|
827
|
+
if (!CapgoUpdater.isReusableCacheFile(source, expectedHash)) {
|
|
625
828
|
return false;
|
|
626
829
|
}
|
|
627
830
|
|
|
628
|
-
//
|
|
629
|
-
|
|
630
|
-
return false;
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
// Try to copy - if it fails (file deleted by OS between check and copy), return false
|
|
831
|
+
// Hash is in the cache file name and was verified when written.
|
|
832
|
+
// Re-hashing here would re-read every reused file on low-RAM devices.
|
|
634
833
|
try {
|
|
635
834
|
copyFile(source, dest);
|
|
636
835
|
return true;
|
|
@@ -642,13 +841,38 @@ public class DownloadService extends Worker {
|
|
|
642
841
|
}
|
|
643
842
|
|
|
644
843
|
private void copyFile(File source, File dest) throws IOException {
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
) {
|
|
651
|
-
|
|
844
|
+
copyFileChannel(source, dest);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
static void copyFileChannel(final File source, final File dest) throws IOException {
|
|
848
|
+
final File parent = dest.getParentFile();
|
|
849
|
+
if (parent != null && !parent.exists() && !parent.mkdirs()) {
|
|
850
|
+
throw new IOException("Failed to create parent directory: " + parent.getAbsolutePath());
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
final File tempFile = File.createTempFile("capgo-", ".tmp", parent);
|
|
854
|
+
try {
|
|
855
|
+
try (
|
|
856
|
+
FileInputStream inStream = new FileInputStream(source);
|
|
857
|
+
FileOutputStream outStream = new FileOutputStream(tempFile);
|
|
858
|
+
FileChannel inChannel = inStream.getChannel();
|
|
859
|
+
FileChannel outChannel = outStream.getChannel()
|
|
860
|
+
) {
|
|
861
|
+
long size = inChannel.size();
|
|
862
|
+
long pos = 0;
|
|
863
|
+
while (pos < size) {
|
|
864
|
+
long transferred = inChannel.transferTo(pos, size - pos, outChannel);
|
|
865
|
+
if (transferred <= 0) {
|
|
866
|
+
throw new IOException("Failed to copy file: " + source.getAbsolutePath());
|
|
867
|
+
}
|
|
868
|
+
pos += transferred;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
CryptoCipher.replaceFile(tempFile, dest);
|
|
872
|
+
} finally {
|
|
873
|
+
if (tempFile.exists()) {
|
|
874
|
+
tempFile.delete();
|
|
875
|
+
}
|
|
652
876
|
}
|
|
653
877
|
}
|
|
654
878
|
|
|
@@ -659,229 +883,298 @@ public class DownloadService extends Worker {
|
|
|
659
883
|
String expectedHash,
|
|
660
884
|
String sessionKey,
|
|
661
885
|
String publicKey,
|
|
662
|
-
boolean isBrotli
|
|
886
|
+
boolean isBrotli,
|
|
887
|
+
String relativeName
|
|
663
888
|
) throws Exception {
|
|
664
889
|
logger.debug("downloadAndVerify " + downloadUrl);
|
|
665
890
|
|
|
666
|
-
Request request = new Request.Builder().url(downloadUrl).build();
|
|
667
|
-
|
|
668
|
-
// targetFile is already the final destination without .br extension
|
|
669
891
|
File finalTargetFile = targetFile;
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
);
|
|
677
|
-
|
|
892
|
+
File cacheFolder = new File(getApplicationContext().getCacheDir(), "capgo_downloads");
|
|
893
|
+
if (!cacheFolder.exists() && !cacheFolder.mkdirs()) {
|
|
894
|
+
throw new IOException("Failed to create cache directory: " + cacheFolder.getAbsolutePath());
|
|
895
|
+
}
|
|
896
|
+
File partial = manifestPartialFile(cacheFolder, expectedHash, relativeName);
|
|
897
|
+
File workFile = null;
|
|
898
|
+
boolean keepPartial = partial.isFile();
|
|
678
899
|
try {
|
|
679
|
-
|
|
680
|
-
|
|
900
|
+
long existing = partial.isFile() ? partial.length() : 0;
|
|
901
|
+
Request.Builder builder = new Request.Builder().url(downloadUrl);
|
|
902
|
+
if (existing > 0) {
|
|
903
|
+
builder.header("Range", "bytes=" + existing + "-");
|
|
904
|
+
}
|
|
905
|
+
try (Response response = sharedClient.newCall(builder.build()).execute()) {
|
|
906
|
+
int code = response.code();
|
|
907
|
+
if (code == 416 && existing > 0) {
|
|
908
|
+
logger.debug("Range not satisfiable, using existing partial " + partial.getName());
|
|
909
|
+
keepPartial = true;
|
|
910
|
+
} else if (code != HttpURLConnection.HTTP_OK && code != HttpURLConnection.HTTP_PARTIAL) {
|
|
681
911
|
sendStatsAsync("download_manifest_file_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
|
|
682
|
-
throw new IOException("Unexpected response code: " +
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
912
|
+
throw new IOException("Unexpected response code: " + code);
|
|
913
|
+
} else {
|
|
914
|
+
ResponseBody responseBody = response.body();
|
|
915
|
+
if (responseBody == null) {
|
|
916
|
+
throw new IOException("Response body is null");
|
|
917
|
+
}
|
|
918
|
+
try {
|
|
919
|
+
writeHttpBody(partial, responseBody.byteStream(), code, existing);
|
|
920
|
+
keepPartial = true;
|
|
921
|
+
} catch (Exception e) {
|
|
922
|
+
keepPartial = true;
|
|
923
|
+
throw e;
|
|
924
|
+
}
|
|
689
925
|
}
|
|
926
|
+
}
|
|
690
927
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
928
|
+
boolean needDecrypt = publicKey != null && !publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty();
|
|
929
|
+
File source = partial;
|
|
930
|
+
if (needDecrypt) {
|
|
931
|
+
workFile = new File(cacheFolder, "work_" + UUID.randomUUID() + "_" + targetFile.getName() + ".tmp");
|
|
932
|
+
copyFile(partial, workFile);
|
|
933
|
+
try {
|
|
695
934
|
logger.debug("Decrypting file " + targetFile.getName());
|
|
696
|
-
CryptoCipher.decryptFile(
|
|
935
|
+
CryptoCipher.decryptFile(workFile, publicKey, sessionKey);
|
|
936
|
+
source = workFile;
|
|
937
|
+
} catch (Exception e) {
|
|
938
|
+
keepPartial = false;
|
|
939
|
+
throw e;
|
|
697
940
|
}
|
|
941
|
+
}
|
|
698
942
|
|
|
699
|
-
|
|
943
|
+
try {
|
|
700
944
|
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
|
-
}
|
|
945
|
+
decompressBrotli(source, finalTargetFile, targetFile.getName(), expectedHash);
|
|
728
946
|
} else {
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
writeFileAtomic(finalTargetFile, fis, null);
|
|
947
|
+
try (FileInputStream fis = new FileInputStream(source)) {
|
|
948
|
+
writeFileAtomic(finalTargetFile, fis, expectedHash);
|
|
732
949
|
}
|
|
733
950
|
}
|
|
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();
|
|
951
|
+
} catch (IOException e) {
|
|
952
|
+
String msg = e.getMessage();
|
|
953
|
+
if (msg != null && msg.contains("Checksum verification failed")) {
|
|
749
954
|
sendStatsAsync("download_manifest_checksum_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
955
|
+
keepPartial = false;
|
|
956
|
+
} else if (isBrotli && msg != null && msg.toLowerCase(java.util.Locale.US).contains("brotli")) {
|
|
957
|
+
sendStatsAsync("download_manifest_brotli_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
|
|
958
|
+
keepPartial = false;
|
|
959
|
+
}
|
|
960
|
+
throw e;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
CryptoCipher.logChecksumInfo("Calculated checksum", expectedHash);
|
|
964
|
+
CryptoCipher.logChecksumInfo("Expected checksum", expectedHash);
|
|
965
|
+
|
|
966
|
+
if (cacheFile != null) {
|
|
967
|
+
try (FileInputStream fis = new FileInputStream(finalTargetFile)) {
|
|
968
|
+
writeFileAtomic(cacheFile, fis, null);
|
|
760
969
|
}
|
|
761
970
|
}
|
|
971
|
+
keepPartial = false;
|
|
762
972
|
} catch (Exception e) {
|
|
763
|
-
throw new IOException("Error in downloadAndVerify: " + e.getMessage());
|
|
973
|
+
throw new IOException("Error in downloadAndVerify: " + e.getMessage(), e);
|
|
764
974
|
} finally {
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
975
|
+
if (workFile != null && workFile.exists() && !workFile.delete()) {
|
|
976
|
+
logger.debug("Failed to delete decrypt work file");
|
|
977
|
+
}
|
|
978
|
+
if (!keepPartial && partial.exists() && !partial.delete()) {
|
|
979
|
+
logger.debug("Failed to delete manifest partial " + partial.getName());
|
|
768
980
|
}
|
|
769
981
|
}
|
|
770
982
|
}
|
|
771
983
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
984
|
+
static String safePartialToken(String fileName) {
|
|
985
|
+
return CryptoCipher.shortPathKey(fileName);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
static File manifestPartialFile(File cacheDir, String hash, String fileName) {
|
|
989
|
+
String token = safePartialToken(fileName);
|
|
990
|
+
if (CapgoUpdater.isSafeCacheHash(hash) && hash.length() == 64) {
|
|
991
|
+
return new File(cacheDir, "partial_" + hash + "_" + token + ".tmp");
|
|
779
992
|
}
|
|
993
|
+
String digest = CryptoCipher.shortPathKey((hash == null ? "" : hash) + "\0" + (fileName == null ? "" : fileName));
|
|
994
|
+
return new File(cacheDir, "partial_" + digest + "_" + token + ".tmp");
|
|
780
995
|
}
|
|
781
996
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
int bytesCount = 0;
|
|
997
|
+
static boolean shouldAppendHttpBody(int statusCode, long existingBytes) {
|
|
998
|
+
return existingBytes > 0 && statusCode == HttpURLConnection.HTTP_PARTIAL;
|
|
999
|
+
}
|
|
786
1000
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
1001
|
+
static void writeHttpBody(File dest, InputStream body, int statusCode, long existingBytes) throws IOException {
|
|
1002
|
+
boolean append = shouldAppendHttpBody(statusCode, existingBytes);
|
|
1003
|
+
byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
|
|
1004
|
+
try (FileOutputStream fos = new FileOutputStream(dest, append)) {
|
|
1005
|
+
int n;
|
|
1006
|
+
long written = append ? existingBytes : 0;
|
|
1007
|
+
while ((n = body.read(buffer)) != -1) {
|
|
1008
|
+
fos.write(buffer, 0, n);
|
|
1009
|
+
written += n;
|
|
1010
|
+
if (written % (1024 * 1024) == 0) {
|
|
1011
|
+
fos.flush();
|
|
1012
|
+
}
|
|
790
1013
|
}
|
|
1014
|
+
fos.flush();
|
|
791
1015
|
}
|
|
1016
|
+
}
|
|
792
1017
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
1018
|
+
static boolean tryCopyBuiltinFile(final File builtinFile, final File dest, final String expectedHash) {
|
|
1019
|
+
if (builtinFile == null || dest == null || expectedHash == null || expectedHash.isEmpty() || !builtinFile.isFile()) {
|
|
1020
|
+
return false;
|
|
1021
|
+
}
|
|
1022
|
+
try {
|
|
1023
|
+
if (!expectedHash.equalsIgnoreCase(CryptoCipher.calcChecksum(builtinFile))) {
|
|
1024
|
+
return false;
|
|
1025
|
+
}
|
|
1026
|
+
copyFileChannel(builtinFile, dest);
|
|
1027
|
+
return true;
|
|
1028
|
+
} catch (IOException e) {
|
|
1029
|
+
return false;
|
|
797
1030
|
}
|
|
798
|
-
return sb.toString();
|
|
799
1031
|
}
|
|
800
1032
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
logger.error("Error: Null data received for " + fileName);
|
|
805
|
-
throw new IOException("Null data received");
|
|
806
|
-
}
|
|
1033
|
+
static void decompressBrotli(File input, File output, String fileName) throws IOException {
|
|
1034
|
+
decompressBrotli(input, output, fileName, null);
|
|
1035
|
+
}
|
|
807
1036
|
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1037
|
+
static void decompressBrotli(File input, File output, String fileName, String expectedChecksum) throws IOException {
|
|
1038
|
+
File parent = output.getParentFile();
|
|
1039
|
+
if (parent != null) {
|
|
1040
|
+
parent.mkdirs();
|
|
1041
|
+
}
|
|
1042
|
+
long length = input.length();
|
|
1043
|
+
if (length == 0) {
|
|
1044
|
+
writeFileAtomic(output, new ByteArrayInputStream(new byte[0]), expectedChecksum);
|
|
1045
|
+
return;
|
|
811
1046
|
}
|
|
812
1047
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
1048
|
+
byte[] head = new byte[(int) Math.min(3, length)];
|
|
1049
|
+
byte last = 0;
|
|
1050
|
+
try (RandomAccessFile raf = new RandomAccessFile(input, "r")) {
|
|
1051
|
+
raf.readFully(head);
|
|
1052
|
+
if (length >= 1) {
|
|
1053
|
+
raf.seek(length - 1);
|
|
1054
|
+
last = raf.readByte();
|
|
1055
|
+
}
|
|
816
1056
|
}
|
|
817
1057
|
|
|
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
|
-
}
|
|
1058
|
+
if (length == 3 && head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06) {
|
|
1059
|
+
writeFileAtomic(output, new ByteArrayInputStream(new byte[0]), expectedChecksum);
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
825
1062
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1063
|
+
if (length > 3 && last == 0x03) {
|
|
1064
|
+
boolean emptyWrapper = head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06;
|
|
1065
|
+
boolean qualityZeroWrapper = head[0] == 0x0b && head[1] == 0x02 && head[2] == (byte) 0x80;
|
|
1066
|
+
if (emptyWrapper || qualityZeroWrapper) {
|
|
1067
|
+
try (FileInputStream fis = new FileInputStream(input)) {
|
|
1068
|
+
long skipped = 0;
|
|
1069
|
+
while (skipped < 3) {
|
|
1070
|
+
long n = fis.skip(3 - skipped);
|
|
1071
|
+
if (n <= 0) {
|
|
1072
|
+
break;
|
|
1073
|
+
}
|
|
1074
|
+
skipped += n;
|
|
1075
|
+
}
|
|
1076
|
+
writeFileAtomic(output, new BoundedInputStream(fis, length - 4), expectedChecksum);
|
|
829
1077
|
}
|
|
830
|
-
|
|
831
|
-
logger.error("Error: Malformed data for " + fileName);
|
|
832
|
-
throw new IOException("Malformed data structure");
|
|
1078
|
+
return;
|
|
833
1079
|
}
|
|
834
1080
|
}
|
|
835
1081
|
|
|
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();
|
|
1082
|
+
try (FileInputStream fis = new FileInputStream(input); BrotliInputStream brotliInputStream = new BrotliInputStream(fis)) {
|
|
1083
|
+
writeFileAtomic(output, brotliInputStream, expectedChecksum);
|
|
848
1084
|
} catch (IOException e) {
|
|
849
1085
|
logger.error("Error: Brotli process failed for " + fileName + ". Status: " + e.getMessage());
|
|
850
|
-
// Add hex dump for debugging
|
|
851
1086
|
StringBuilder hexDump = new StringBuilder();
|
|
852
|
-
|
|
853
|
-
|
|
1087
|
+
try (FileInputStream peek = new FileInputStream(input)) {
|
|
1088
|
+
byte[] prefix = new byte[(int) Math.min(32, length)];
|
|
1089
|
+
int n = peek.read(prefix);
|
|
1090
|
+
for (int i = 0; i < n; i++) {
|
|
1091
|
+
hexDump.append(String.format("%02x ", prefix[i]));
|
|
1092
|
+
}
|
|
854
1093
|
}
|
|
855
|
-
logger.error("Error: Raw data (" + fileName + "): " + hexDump
|
|
856
|
-
throw e;
|
|
1094
|
+
logger.error("Error: Raw data (" + fileName + "): " + hexDump);
|
|
1095
|
+
throw new IOException("Brotli process failed for " + fileName + ": " + e.getMessage(), e);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
private static final class BoundedInputStream extends FilterInputStream {
|
|
1100
|
+
|
|
1101
|
+
private long remaining;
|
|
1102
|
+
|
|
1103
|
+
BoundedInputStream(InputStream in, long remaining) {
|
|
1104
|
+
super(in);
|
|
1105
|
+
this.remaining = remaining;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
@Override
|
|
1109
|
+
public int read() throws IOException {
|
|
1110
|
+
if (remaining <= 0) {
|
|
1111
|
+
return -1;
|
|
1112
|
+
}
|
|
1113
|
+
int value = super.read();
|
|
1114
|
+
if (value >= 0) {
|
|
1115
|
+
remaining--;
|
|
1116
|
+
}
|
|
1117
|
+
return value;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
@Override
|
|
1121
|
+
public int read(byte[] b, int off, int len) throws IOException {
|
|
1122
|
+
if (remaining <= 0) {
|
|
1123
|
+
return -1;
|
|
1124
|
+
}
|
|
1125
|
+
int capped = (int) Math.min(len, remaining);
|
|
1126
|
+
int n = super.read(b, off, capped);
|
|
1127
|
+
if (n > 0) {
|
|
1128
|
+
remaining -= n;
|
|
1129
|
+
}
|
|
1130
|
+
return n;
|
|
857
1131
|
}
|
|
858
1132
|
}
|
|
859
1133
|
|
|
860
1134
|
/**
|
|
861
|
-
* Atomically write
|
|
1135
|
+
* Atomically write a stream to a file using the 256 KiB IO buffer.
|
|
1136
|
+
* When expectedChecksum is set, SHA-256 is hashed during the write.
|
|
862
1137
|
*/
|
|
863
|
-
|
|
864
|
-
File tempFile =
|
|
1138
|
+
static void writeFileAtomic(File targetFile, InputStream inputStream, String expectedChecksum) throws IOException {
|
|
1139
|
+
File tempFile = File.createTempFile("capgo-", ".tmp", targetFile.getParentFile());
|
|
865
1140
|
|
|
866
1141
|
try {
|
|
867
|
-
//
|
|
868
|
-
|
|
869
|
-
|
|
1142
|
+
// Okio's default segment is 8 KiB. Copy with 256 KiB so 8 MiB wrapper unwraps
|
|
1143
|
+
// are not 1000 tiny writes.
|
|
1144
|
+
byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
|
|
1145
|
+
MessageDigest digest = null;
|
|
1146
|
+
if (expectedChecksum != null && !expectedChecksum.isEmpty()) {
|
|
1147
|
+
try {
|
|
1148
|
+
digest = MessageDigest.getInstance("SHA-256");
|
|
1149
|
+
} catch (java.security.NoSuchAlgorithmException e) {
|
|
1150
|
+
throw new IOException("SHA-256 algorithm not available", e);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
try (FileOutputStream fos = new FileOutputStream(tempFile)) {
|
|
1154
|
+
int n;
|
|
1155
|
+
while ((n = inputStream.read(buffer)) != -1) {
|
|
1156
|
+
if (digest != null) {
|
|
1157
|
+
digest.update(buffer, 0, n);
|
|
1158
|
+
}
|
|
1159
|
+
fos.write(buffer, 0, n);
|
|
1160
|
+
}
|
|
870
1161
|
}
|
|
871
1162
|
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
String actualChecksum = CryptoCipher.calcChecksum(tempFile);
|
|
1163
|
+
if (digest != null) {
|
|
1164
|
+
String actualChecksum = CryptoCipher.digestToHex(digest);
|
|
875
1165
|
if (!expectedChecksum.equalsIgnoreCase(actualChecksum)) {
|
|
876
|
-
|
|
877
|
-
throw new IOException("Checksum verification failed");
|
|
1166
|
+
throw new IOException("Checksum verification failed expected: " + expectedChecksum + " calculated: " + actualChecksum);
|
|
878
1167
|
}
|
|
879
1168
|
}
|
|
880
1169
|
|
|
881
|
-
// Atomic rename (on same filesystem)
|
|
882
|
-
|
|
1170
|
+
// Atomic rename (on same filesystem). renameTo works on API 24; Files.move does not.
|
|
1171
|
+
CryptoCipher.replaceFile(tempFile, targetFile);
|
|
1172
|
+
} catch (IOException e) {
|
|
1173
|
+
if (tempFile.exists()) {
|
|
1174
|
+
tempFile.delete();
|
|
1175
|
+
}
|
|
1176
|
+
throw e;
|
|
883
1177
|
} catch (Exception e) {
|
|
884
|
-
// Clean up temp file on error
|
|
885
1178
|
if (tempFile.exists()) {
|
|
886
1179
|
tempFile.delete();
|
|
887
1180
|
}
|
|
@@ -907,4 +1200,27 @@ public class DownloadService extends Worker {
|
|
|
907
1200
|
}
|
|
908
1201
|
}
|
|
909
1202
|
}
|
|
1203
|
+
|
|
1204
|
+
private void cleanupOrphanedAssetTemps(final File directory) {
|
|
1205
|
+
if (directory == null || !directory.isDirectory()) {
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
final File[] children = directory.listFiles();
|
|
1209
|
+
if (children == null) {
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
final long oneHourAgo = System.currentTimeMillis() - 3600000;
|
|
1213
|
+
for (final File child : children) {
|
|
1214
|
+
if (child.isDirectory()) {
|
|
1215
|
+
cleanupOrphanedAssetTemps(child);
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
final String name = child.getName();
|
|
1219
|
+
final boolean orphanedAssetTemp = name.startsWith("capgo_asset_") && name.endsWith(".tmp");
|
|
1220
|
+
final boolean orphanedBackup = name.startsWith(".capgo_bak_");
|
|
1221
|
+
if ((orphanedAssetTemp || orphanedBackup) && child.lastModified() < oneHourAgo) {
|
|
1222
|
+
child.delete();
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
910
1226
|
}
|