@capgo/capacitor-updater 5.50.1 → 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.
Files changed (28) hide show
  1. package/CapgoCapacitorUpdater.podspec +1 -1
  2. package/Package.swift +3 -2
  3. package/README.md +53 -48
  4. package/android/build.gradle +1 -0
  5. package/android/src/main/java/ee/forgr/capacitor_updater/AppLifecycleObserver.java +29 -2
  6. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +7 -3
  7. package/android/src/main/java/ee/forgr/capacitor_updater/BundleStatus.java +1 -0
  8. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +476 -151
  9. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1354 -412
  10. package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +102 -31
  11. package/android/src/main/java/ee/forgr/capacitor_updater/DataManager.java +23 -7
  12. package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +2 -2
  13. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +540 -224
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +103 -3
  15. package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +1 -1
  16. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +131 -145
  17. package/dist/docs.json +32 -8
  18. package/dist/esm/definitions.d.ts +41 -17
  19. package/dist/esm/definitions.js.map +1 -1
  20. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +124 -0
  21. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +9 -1
  22. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +3 -0
  23. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +787 -92
  24. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1014 -268
  25. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +49 -31
  26. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +44 -20
  27. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +28 -0
  28. 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.codePoints().forEach((cp) -> {
120
- boolean isVisibleAscii = cp >= 0x20 && cp <= 0x7E;
121
- boolean isIso88591 = cp >= 0xA0 && cp <= 0xFF;
122
- if (isVisibleAscii || isIso88591) {
123
- sanitized.appendCodePoint(cp);
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
- return CapgoUpdater.resolvePathInsideDirectory(builtinFolder, fileName);
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.toString());
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.newCall(request).enqueue(
259
- new Callback() {
260
- @Override
261
- public void onFailure(@NonNull Call call, @NonNull IOException e) {
262
- if (logger != null) {
263
- logger.error("Failed to send stats: " + e.getMessage());
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
- @Override
268
- public void onResponse(@NonNull Call call, @NonNull Response response) {
269
- try (ResponseBody body = response.body()) {
270
- // nothing else to do, just closing body
271
- } catch (Exception ignored) {
272
- } finally {
273
- response.close();
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
- String manifestString
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
- // Use more threads for I/O-bound operations
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 = new File(cacheFolder, finalFileHash + "_" + cacheBaseName);
363
- final File legacyCacheFile = isBrotli ? new File(cacheFolder, finalFileHash + "_" + new File(fileName).getName()) : null;
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 (builtinFile.exists() && verifyChecksum(builtinFile, finalFileHash)) {
376
- copyFile(builtinFile, targetFile);
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(downloadUrl, targetFile, cacheFile, finalFileHash, sessionKey, publicKey, finalIsBrotli);
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
- // Set reasonable timeouts
467
- httpConn.setConnectTimeout(30000); // 30 seconds
468
- httpConn.setReadTimeout(60000); // 60 seconds
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[8192]; // Larger buffer for better performance
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 (!source.exists()) {
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
- // Verify checksum before copy
629
- if (!verifyChecksum(source, expectedHash)) {
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
- try (
646
- FileInputStream inStream = new FileInputStream(source);
647
- FileOutputStream outStream = new FileOutputStream(dest);
648
- FileChannel inChannel = inStream.getChannel();
649
- FileChannel outChannel = outStream.getChannel()
650
- ) {
651
- inChannel.transferTo(0, inChannel.size(), outChannel);
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
- // Create a temporary file for the compressed data with a unique name to avoid race conditions
672
- // between threads processing files with the same basename in different directories
673
- File compressedFile = new File(
674
- getApplicationContext().getCacheDir(),
675
- "temp_" + java.util.UUID.randomUUID().toString() + "_" + targetFile.getName() + ".tmp"
676
- );
677
-
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
- try (Response response = sharedClient.newCall(request).execute()) {
680
- if (!response.isSuccessful()) {
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: " + response.code());
683
- }
684
-
685
- // Download compressed file atomically
686
- ResponseBody responseBody = response.body();
687
- if (responseBody == null) {
688
- throw new IOException("Response body is null");
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
- // Use OkIO for atomic write
692
- writeFileAtomic(compressedFile, responseBody.byteStream(), null);
693
-
694
- if (publicKey != null && !publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
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(compressedFile, publicKey, sessionKey);
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
- // Only decompress if file has .br extension
943
+ try {
700
944
  if (isBrotli) {
701
- // Use new decompression method with atomic write
702
- try (FileInputStream fis = new FileInputStream(compressedFile)) {
703
- byte[] compressedData = new byte[(int) compressedFile.length()];
704
- int offset = 0;
705
- int bytesRead;
706
- while (
707
- offset < compressedData.length &&
708
- (bytesRead = fis.read(compressedData, offset, compressedData.length - offset)) != -1
709
- ) {
710
- offset += bytesRead;
711
- }
712
- byte[] decompressedData;
713
- try {
714
- decompressedData = decompressBrotli(compressedData, targetFile.getName());
715
- } catch (IOException e) {
716
- sendStatsAsync(
717
- "download_manifest_brotli_fail",
718
- getInputData().getString(VERSION) + ":" + finalTargetFile.getName()
719
- );
720
- throw e;
721
- }
722
-
723
- // Write decompressed data atomically
724
- try (java.io.ByteArrayInputStream bais = new java.io.ByteArrayInputStream(decompressedData)) {
725
- writeFileAtomic(finalTargetFile, bais, null);
726
- }
727
- }
945
+ decompressBrotli(source, finalTargetFile, targetFile.getName(), expectedHash);
728
946
  } else {
729
- // Just copy the file without decompression using atomic operation
730
- try (FileInputStream fis = new FileInputStream(compressedFile)) {
731
- writeFileAtomic(finalTargetFile, fis, null);
947
+ try (FileInputStream fis = new FileInputStream(source)) {
948
+ writeFileAtomic(finalTargetFile, fis, expectedHash);
732
949
  }
733
950
  }
734
-
735
- // Delete the compressed file
736
- compressedFile.delete();
737
- String calculatedHash = CryptoCipher.calcChecksum(finalTargetFile);
738
- CryptoCipher.logChecksumInfo("Calculated checksum", calculatedHash);
739
- CryptoCipher.logChecksumInfo("Expected checksum", expectedHash);
740
-
741
- // Verify checksum
742
- if (calculatedHash.equalsIgnoreCase(expectedHash)) {
743
- // Only cache if checksum is correct - use atomic copy
744
- try (FileInputStream fis = new FileInputStream(finalTargetFile)) {
745
- writeFileAtomic(cacheFile, fis, expectedHash);
746
- }
747
- } else {
748
- finalTargetFile.delete();
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
- throw new IOException(
751
- "Checksum verification failed for: " +
752
- downloadUrl +
753
- " " +
754
- targetFile.getName() +
755
- " expected: " +
756
- expectedHash +
757
- " calculated: " +
758
- calculatedHash
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
- // Always cleanup the compressed temp file if it still exists
766
- if (compressedFile.exists()) {
767
- compressedFile.delete();
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
- private boolean verifyChecksum(File file, String expectedHash) {
773
- try {
774
- String actualHash = calculateFileHash(file);
775
- return actualHash.equalsIgnoreCase(expectedHash);
776
- } catch (Exception e) {
777
- e.printStackTrace();
778
- return false;
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
- private String calculateFileHash(File file) throws Exception {
783
- MessageDigest digest = MessageDigest.getInstance("SHA-256");
784
- byte[] byteArray = new byte[1024];
785
- int bytesCount = 0;
997
+ static boolean shouldAppendHttpBody(int statusCode, long existingBytes) {
998
+ return existingBytes > 0 && statusCode == HttpURLConnection.HTTP_PARTIAL;
999
+ }
786
1000
 
787
- try (FileInputStream fis = new FileInputStream(file)) {
788
- while ((bytesCount = fis.read(byteArray)) != -1) {
789
- digest.update(byteArray, 0, bytesCount);
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
- byte[] bytes = digest.digest();
794
- StringBuilder sb = new StringBuilder();
795
- for (byte aByte : bytes) {
796
- sb.append(Integer.toString((aByte & 0xff) + 0x100, 16).substring(1));
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
- private byte[] decompressBrotli(byte[] data, String fileName) throws IOException {
802
- // Validate input
803
- if (data == null) {
804
- logger.error("Error: Null data received for " + fileName);
805
- throw new IOException("Null data received");
806
- }
1033
+ static void decompressBrotli(File input, File output, String fileName) throws IOException {
1034
+ decompressBrotli(input, output, fileName, null);
1035
+ }
807
1036
 
808
- // Handle empty files
809
- if (data.length == 0) {
810
- return new byte[0];
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
- // Handle the special EMPTY_BROTLI_STREAM case
814
- if (data.length == 3 && data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06) {
815
- return new byte[0];
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
- // For small files, check if it's a minimal Brotli wrapper
819
- if (data.length > 3) {
820
- try {
821
- // Handle our minimal wrapper pattern
822
- if (data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 && data[data.length - 1] == 0x03) {
823
- return Arrays.copyOfRange(data, 3, data.length - 1);
824
- }
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
- // Handle brotli.compress minimal wrapper (quality 0)
827
- if (data[0] == 0x0b && data[1] == 0x02 && data[2] == (byte) 0x80 && data[data.length - 1] == 0x03) {
828
- return Arrays.copyOfRange(data, 3, data.length - 1);
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
- } catch (ArrayIndexOutOfBoundsException e) {
831
- logger.error("Error: Malformed data for " + fileName);
832
- throw new IOException("Malformed data structure");
1078
+ return;
833
1079
  }
834
1080
  }
835
1081
 
836
- // For all other cases, try standard decompression
837
- try (
838
- ByteArrayInputStream bis = new ByteArrayInputStream(data);
839
- BrotliInputStream brotliInputStream = new BrotliInputStream(bis);
840
- ByteArrayOutputStream bos = new ByteArrayOutputStream()
841
- ) {
842
- byte[] buffer = new byte[8192];
843
- int len;
844
- while ((len = brotliInputStream.read(buffer)) != -1) {
845
- bos.write(buffer, 0, len);
846
- }
847
- return bos.toByteArray();
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
- for (int i = 0; i < Math.min(32, data.length); i++) {
853
- hexDump.append(String.format("%02x ", data[i]));
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.toString());
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 data to a file using OkIO
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
- private void writeFileAtomic(File targetFile, InputStream inputStream, String expectedChecksum) throws IOException {
864
- File tempFile = new File(targetFile.getParent(), targetFile.getName() + ".tmp");
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
- // Write to temp file first using OkIO
868
- try (BufferedSink sink = Okio.buffer(Okio.sink(tempFile)); BufferedSource source = Okio.buffer(Okio.source(inputStream))) {
869
- sink.writeAll(source);
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
- // Verify checksum if provided
873
- if (expectedChecksum != null && !expectedChecksum.isEmpty()) {
874
- String actualChecksum = CryptoCipher.calcChecksum(tempFile);
1163
+ if (digest != null) {
1164
+ String actualChecksum = CryptoCipher.digestToHex(digest);
875
1165
  if (!expectedChecksum.equalsIgnoreCase(actualChecksum)) {
876
- tempFile.delete();
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
- Files.move(tempFile.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
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
  }