@capgo/capacitor-updater 7.41.1 → 7.42.9

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.
@@ -7,7 +7,6 @@
7
7
  package ee.forgr.capacitor_updater;
8
8
 
9
9
  import android.app.Activity;
10
- import android.app.ActivityManager;
11
10
  import android.content.Context;
12
11
  import android.content.Intent;
13
12
  import android.content.SharedPreferences;
@@ -71,7 +70,7 @@ import org.json.JSONObject;
71
70
  @CapacitorPlugin(name = "CapacitorUpdater")
72
71
  public class CapacitorUpdaterPlugin extends Plugin {
73
72
 
74
- private final Logger logger = new Logger("CapgoUpdater");
73
+ private Logger logger;
75
74
 
76
75
  private static final String updateUrlDefault = "https://plugin.capgo.app/updates";
77
76
  private static final String statsUrlDefault = "https://plugin.capgo.app/stats";
@@ -85,7 +84,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
85
84
  private static final String[] BREAKING_EVENT_NAMES = { "breakingAvailable", "majorAvailable" };
86
85
  private static final String LAST_FAILED_BUNDLE_PREF_KEY = "CapacitorUpdater.lastFailedBundle";
87
86
 
88
- private final String pluginVersion = "7.41.1";
87
+ private final String pluginVersion = "7.42.9";
89
88
  private static final String DELAY_CONDITION_PREFERENCES = "";
90
89
 
91
90
  private SharedPreferences.Editor editor;
@@ -116,6 +115,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
116
115
  private Boolean allowManualBundleError = false;
117
116
  private Boolean allowSetDefaultChannel = true;
118
117
 
118
+ // Used for activity-based foreground/background detection on Android < 14
119
119
  private Boolean isPreviousMainActivity = true;
120
120
 
121
121
  private volatile Thread backgroundDownloadTask;
@@ -138,6 +138,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
138
138
  private FrameLayout splashscreenLoaderOverlay;
139
139
  private Runnable splashscreenTimeoutRunnable;
140
140
 
141
+ // App lifecycle observer using ProcessLifecycleOwner for reliable foreground/background detection
142
+ private AppLifecycleObserver appLifecycleObserver;
143
+
141
144
  // Play Store In-App Updates
142
145
  private AppUpdateManager appUpdateManager;
143
146
  private AppUpdateInfo cachedAppUpdateInfo;
@@ -216,6 +219,13 @@ public class CapacitorUpdaterPlugin extends Plugin {
216
219
  @Override
217
220
  public void load() {
218
221
  super.load();
222
+
223
+ // Initialize logger with osLogging config
224
+ // Default to true for both platforms to enable system logging by default
225
+ boolean osLogging = this.getConfig().getBoolean("osLogging", true);
226
+ Logger.Options loggerOptions = new Logger.Options(osLogging);
227
+ this.logger = new Logger("CapgoUpdater", loggerOptions);
228
+
219
229
  this.prefs = this.getContext().getSharedPreferences(WebView.WEBVIEW_PREFS_NAME, Activity.MODE_PRIVATE);
220
230
  this.editor = this.prefs.edit();
221
231
 
@@ -431,6 +441,31 @@ public class CapacitorUpdaterPlugin extends Plugin {
431
441
  this.delayUpdateUtils.checkCancelDelay(DelayUpdateUtils.CancelDelaySource.KILLED);
432
442
 
433
443
  this.checkForUpdateAfterDelay();
444
+
445
+ // On Android 14+ (API 34+), topActivity in RecentTaskInfo returns null due to
446
+ // security restrictions (StrandHogg task hijacking mitigations). Use ProcessLifecycleOwner
447
+ // for reliable app-level foreground/background detection on these versions.
448
+ // On older versions, we use the traditional activity lifecycle callbacks in handleOnStart/handleOnStop.
449
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
450
+ this.appLifecycleObserver = new AppLifecycleObserver(
451
+ new AppLifecycleObserver.AppLifecycleListener() {
452
+ @Override
453
+ public void onAppMovedToForeground() {
454
+ CapacitorUpdaterPlugin.this.appMovedToForeground();
455
+ }
456
+
457
+ @Override
458
+ public void onAppMovedToBackground() {
459
+ CapacitorUpdaterPlugin.this.appMovedToBackground();
460
+ }
461
+ },
462
+ logger
463
+ );
464
+ this.appLifecycleObserver.register();
465
+ logger.info("Using ProcessLifecycleOwner for foreground/background detection (Android 14+)");
466
+ } else {
467
+ logger.info("Using activity lifecycle callbacks for foreground/background detection (Android <14)");
468
+ }
434
469
  }
435
470
 
436
471
  private void semaphoreWait(Number waitTime) {
@@ -2081,6 +2116,9 @@ public class CapacitorUpdaterPlugin extends Plugin {
2081
2116
  }
2082
2117
 
2083
2118
  public void appMovedToBackground() {
2119
+ // Reset timeout flag at start of each background cycle
2120
+ this.autoSplashscreenTimedOut = false;
2121
+
2084
2122
  final BundleInfo current = CapacitorUpdaterPlugin.this.implementation.getCurrentBundle();
2085
2123
 
2086
2124
  // Show splashscreen FIRST, before any other background work to ensure launcher shows it
@@ -2129,16 +2167,22 @@ public class CapacitorUpdaterPlugin extends Plugin {
2129
2167
  }
2130
2168
  }
2131
2169
 
2170
+ /**
2171
+ * Check if the current activity is the main activity.
2172
+ * Used for activity-based foreground/background detection on Android < 14.
2173
+ * On Android 14+, topActivity returns null due to security restrictions, so we use
2174
+ * ProcessLifecycleOwner instead.
2175
+ */
2132
2176
  private boolean isMainActivity() {
2133
2177
  try {
2134
2178
  Context mContext = this.getContext();
2135
- ActivityManager activityManager = (ActivityManager) mContext.getSystemService(Context.ACTIVITY_SERVICE);
2136
- List<ActivityManager.AppTask> runningTasks = activityManager.getAppTasks();
2179
+ android.app.ActivityManager activityManager = (android.app.ActivityManager) mContext.getSystemService(Context.ACTIVITY_SERVICE);
2180
+ java.util.List<android.app.ActivityManager.AppTask> runningTasks = activityManager.getAppTasks();
2137
2181
  if (runningTasks.isEmpty()) {
2138
2182
  return false;
2139
2183
  }
2140
- ActivityManager.RecentTaskInfo runningTask = runningTasks.get(0).getTaskInfo();
2141
- String className = Objects.requireNonNull(runningTask.baseIntent.getComponent()).getClassName();
2184
+ android.app.ActivityManager.RecentTaskInfo runningTask = runningTasks.get(0).getTaskInfo();
2185
+ String className = java.util.Objects.requireNonNull(runningTask.baseIntent.getComponent()).getClassName();
2142
2186
  if (runningTask.topActivity == null) {
2143
2187
  return false;
2144
2188
  }
@@ -2152,12 +2196,17 @@ public class CapacitorUpdaterPlugin extends Plugin {
2152
2196
  @Override
2153
2197
  public void handleOnStart() {
2154
2198
  try {
2155
- if (isPreviousMainActivity) {
2156
- logger.info("handleOnStart: appMovedToForeground");
2157
- this.appMovedToForeground();
2158
- }
2159
2199
  logger.info("handleOnStart: onActivityStarted " + getActivity().getClass().getName());
2160
- isPreviousMainActivity = true;
2200
+
2201
+ // On Android < 14, use activity lifecycle for foreground detection
2202
+ // On Android 14+, ProcessLifecycleOwner handles this via AppLifecycleObserver
2203
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
2204
+ if (isPreviousMainActivity) {
2205
+ logger.info("handleOnStart: appMovedToForeground (Android <14 path)");
2206
+ this.appMovedToForeground();
2207
+ }
2208
+ isPreviousMainActivity = true;
2209
+ }
2161
2210
 
2162
2211
  // Initialize shake menu if enabled and activity is BridgeActivity
2163
2212
  if (shakeMenuEnabled && getActivity() instanceof com.getcapacitor.BridgeActivity && shakeMenu == null) {
@@ -2176,10 +2225,16 @@ public class CapacitorUpdaterPlugin extends Plugin {
2176
2225
  @Override
2177
2226
  public void handleOnStop() {
2178
2227
  try {
2179
- isPreviousMainActivity = isMainActivity();
2180
- if (isPreviousMainActivity) {
2181
- logger.info("handleOnStop: appMovedToBackground");
2182
- this.appMovedToBackground();
2228
+ logger.info("handleOnStop: onActivityStopped");
2229
+
2230
+ // On Android < 14, use activity lifecycle for background detection
2231
+ // On Android 14+, ProcessLifecycleOwner handles this via AppLifecycleObserver
2232
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
2233
+ isPreviousMainActivity = isMainActivity();
2234
+ if (isPreviousMainActivity) {
2235
+ logger.info("handleOnStop: appMovedToBackground (Android <14 path)");
2236
+ this.appMovedToBackground();
2237
+ }
2183
2238
  }
2184
2239
  } catch (Exception e) {
2185
2240
  logger.error("Failed to run handleOnStop: " + e.getMessage());
@@ -2604,6 +2659,17 @@ public class CapacitorUpdaterPlugin extends Plugin {
2604
2659
  logger.error("Failed to clean up shake menu: " + e.getMessage());
2605
2660
  }
2606
2661
  }
2662
+
2663
+ // Clean up app lifecycle observer
2664
+ if (appLifecycleObserver != null) {
2665
+ try {
2666
+ appLifecycleObserver.unregister();
2667
+ appLifecycleObserver = null;
2668
+ logger.info("AppLifecycleObserver cleaned up");
2669
+ } catch (Exception e) {
2670
+ logger.error("Failed to clean up AppLifecycleObserver: " + e.getMessage());
2671
+ }
2672
+ }
2607
2673
  } catch (Exception e) {
2608
2674
  logger.error("Failed to run handleOnDestroy: " + e.getMessage());
2609
2675
  }
@@ -35,8 +35,11 @@ import java.util.Objects;
35
35
  import java.util.Set;
36
36
  import java.util.concurrent.CompletableFuture;
37
37
  import java.util.concurrent.ConcurrentHashMap;
38
+ import java.util.concurrent.CopyOnWriteArrayList;
38
39
  import java.util.concurrent.ExecutorService;
39
40
  import java.util.concurrent.Executors;
41
+ import java.util.concurrent.ScheduledExecutorService;
42
+ import java.util.concurrent.ScheduledFuture;
40
43
  import java.util.concurrent.TimeUnit;
41
44
  import java.util.zip.ZipEntry;
42
45
  import java.util.zip.ZipInputStream;
@@ -91,6 +94,12 @@ public class CapgoUpdater {
91
94
  // Flag to track if we've already sent the rate limit statistic - prevents infinite loop
92
95
  private static volatile boolean rateLimitStatisticSent = false;
93
96
 
97
+ // Stats batching - queue events and send max once per second
98
+ private final List<JSONObject> statsQueue = new CopyOnWriteArrayList<>();
99
+ private final ScheduledExecutorService statsScheduler = Executors.newSingleThreadScheduledExecutor();
100
+ private ScheduledFuture<?> statsFlushTask = null;
101
+ private static final long STATS_FLUSH_INTERVAL_MS = 1000;
102
+
94
103
  private final Map<String, CompletableFuture<BundleInfo>> downloadFutures = new ConcurrentHashMap<>();
95
104
  private final ExecutorService io = Executors.newSingleThreadExecutor();
96
105
 
@@ -153,12 +162,25 @@ public class CapgoUpdater {
153
162
  }
154
163
 
155
164
  public void setPublicKey(String publicKey) {
156
- this.publicKey = publicKey;
157
- if (!publicKey.isEmpty()) {
158
- this.cachedKeyId = CryptoCipher.calcKeyId(publicKey);
159
- } else {
165
+ // Empty string means no encryption - proceed normally
166
+ if (publicKey == null || publicKey.isEmpty()) {
167
+ this.publicKey = "";
160
168
  this.cachedKeyId = "";
169
+ return;
161
170
  }
171
+
172
+ // Non-empty: must be a valid RSA key or crash
173
+ try {
174
+ CryptoCipher.stringToPublicKey(publicKey);
175
+ } catch (Exception e) {
176
+ throw new RuntimeException(
177
+ "Invalid public key in capacitor.config.json: failed to parse RSA key. Remove the key or provide a valid PEM-formatted RSA public key.",
178
+ e
179
+ );
180
+ }
181
+
182
+ this.publicKey = publicKey;
183
+ this.cachedKeyId = CryptoCipher.calcKeyId(publicKey);
162
184
  }
163
185
 
164
186
  public String getKeyId() {
@@ -239,13 +261,90 @@ public class CapgoUpdater {
239
261
  }
240
262
  if (entries.length == 1 && !"index.html".equals(entries[0])) {
241
263
  final File child = new File(sourceFile, entries[0]);
242
- child.renameTo(destinationFile);
264
+ if (!child.renameTo(destinationFile)) {
265
+ throw new IOException("Failed to move bundle contents: " + child.getPath() + " -> " + destinationFile.getPath());
266
+ }
243
267
  } else {
244
- sourceFile.renameTo(destinationFile);
268
+ if (!sourceFile.renameTo(destinationFile)) {
269
+ throw new IOException("Failed to move bundle contents: " + sourceFile.getPath() + " -> " + destinationFile.getPath());
270
+ }
245
271
  }
246
272
  sourceFile.delete();
247
273
  }
248
274
 
275
+ private void cacheBundleFilesAsync(final String id) {
276
+ io.execute(() -> cacheBundleFiles(id));
277
+ }
278
+
279
+ private void cacheBundleFiles(final String id) {
280
+ if (this.activity == null) {
281
+ logger.debug("Skip delta cache population: activity is null");
282
+ return;
283
+ }
284
+
285
+ final File bundleDir = this.getBundleDirectory(id);
286
+ if (!bundleDir.exists()) {
287
+ logger.debug("Skip delta cache population: bundle dir missing");
288
+ return;
289
+ }
290
+
291
+ final File cacheDir = new File(this.activity.getCacheDir(), "capgo_downloads");
292
+ if (cacheDir.exists() && !cacheDir.isDirectory()) {
293
+ logger.debug("Skip delta cache population: cache dir is not a directory");
294
+ return;
295
+ }
296
+ if (!cacheDir.exists() && !cacheDir.mkdirs()) {
297
+ logger.debug("Skip delta cache population: failed to create cache dir");
298
+ return;
299
+ }
300
+
301
+ final List<File> files = new ArrayList<>();
302
+ collectFiles(bundleDir, files);
303
+ for (File file : files) {
304
+ final String checksum = CryptoCipher.calcChecksum(file);
305
+ if (checksum.isEmpty()) {
306
+ continue;
307
+ }
308
+ final String cacheName = checksum + "_" + file.getName();
309
+ final File cacheFile = new File(cacheDir, cacheName);
310
+ if (cacheFile.exists()) {
311
+ continue;
312
+ }
313
+ try {
314
+ copyFile(file, cacheFile);
315
+ } catch (IOException e) {
316
+ logger.debug("Delta cache copy failed: " + file.getPath());
317
+ }
318
+ }
319
+ }
320
+
321
+ private void collectFiles(final File dir, final List<File> files) {
322
+ final File[] entries = dir.listFiles();
323
+ if (entries == null) {
324
+ return;
325
+ }
326
+ for (File entry : entries) {
327
+ if (!this.filter.accept(dir, entry.getName())) {
328
+ continue;
329
+ }
330
+ if (entry.isDirectory()) {
331
+ collectFiles(entry, files);
332
+ } else if (entry.isFile()) {
333
+ files.add(entry);
334
+ }
335
+ }
336
+ }
337
+
338
+ private void copyFile(final File source, final File dest) throws IOException {
339
+ try (final FileInputStream input = new FileInputStream(source); final FileOutputStream output = new FileOutputStream(dest)) {
340
+ final byte[] buffer = new byte[1024 * 1024];
341
+ int length;
342
+ while ((length = input.read(buffer)) != -1) {
343
+ output.write(buffer, 0, length);
344
+ }
345
+ }
346
+ }
347
+
249
348
  private void observeWorkProgress(Context context, String id) {
250
349
  if (!(context instanceof LifecycleOwner)) {
251
350
  logger.error("Context is not a LifecycleOwner, cannot observe work progress");
@@ -460,6 +559,7 @@ public class CapgoUpdater {
460
559
  this.notifyDownload(id, 91);
461
560
  final String idName = bundleDirectory + "/" + id;
462
561
  this.flattenAssets(extractedDir, idName);
562
+ this.cacheBundleFilesAsync(id);
463
563
  } else {
464
564
  this.notifyDownload(id, 91);
465
565
  final String idName = bundleDirectory + "/" + id;
@@ -1143,6 +1243,13 @@ public class CapgoUpdater {
1143
1243
  makeJsonRequest(channelUrl, json, (res) -> {
1144
1244
  if (res.containsKey("error")) {
1145
1245
  callback.callback(res);
1246
+ } else if (Boolean.TRUE.equals(res.get("unset"))) {
1247
+ // Server requested to unset channel (public channel was requested)
1248
+ // Clear persisted defaultChannel and revert to config value
1249
+ editor.remove(defaultChannelKey);
1250
+ editor.apply();
1251
+ logger.info("Public channel requested, channel override removed");
1252
+ callback.callback(res);
1146
1253
  } else {
1147
1254
  // Success - persist defaultChannel
1148
1255
  this.defaultChannel = channel;
@@ -1437,30 +1544,74 @@ public class CapgoUpdater {
1437
1544
  if (statsUrl == null || statsUrl.isEmpty()) {
1438
1545
  return;
1439
1546
  }
1547
+
1440
1548
  JSONObject json;
1441
1549
  try {
1442
1550
  json = this.createInfoObject();
1443
1551
  json.put("version_name", versionName);
1444
1552
  json.put("old_version_name", oldVersionName);
1445
1553
  json.put("action", action);
1554
+ json.put("timestamp", System.currentTimeMillis());
1446
1555
  } catch (JSONException e) {
1447
1556
  logger.error("Error preparing stats");
1448
1557
  logger.debug("JSONException: " + e.getMessage());
1449
1558
  return;
1450
1559
  }
1451
1560
 
1561
+ statsQueue.add(json);
1562
+ ensureStatsTimerStarted();
1563
+ }
1564
+
1565
+ private synchronized void ensureStatsTimerStarted() {
1566
+ if (statsFlushTask == null || statsFlushTask.isCancelled() || statsFlushTask.isDone()) {
1567
+ statsFlushTask = statsScheduler.scheduleAtFixedRate(
1568
+ this::flushStatsQueue,
1569
+ STATS_FLUSH_INTERVAL_MS,
1570
+ STATS_FLUSH_INTERVAL_MS,
1571
+ TimeUnit.MILLISECONDS
1572
+ );
1573
+ }
1574
+ }
1575
+
1576
+ private void flushStatsQueue() {
1577
+ if (statsQueue.isEmpty()) {
1578
+ return;
1579
+ }
1580
+
1581
+ String statsUrl = this.statsUrl;
1582
+ if (statsUrl == null || statsUrl.isEmpty()) {
1583
+ statsQueue.clear();
1584
+ return;
1585
+ }
1586
+
1587
+ // Copy and clear the queue atomically using synchronized block
1588
+ List<JSONObject> eventsToSend;
1589
+ synchronized (statsQueue) {
1590
+ if (statsQueue.isEmpty()) {
1591
+ return;
1592
+ }
1593
+ eventsToSend = new ArrayList<>(statsQueue);
1594
+ statsQueue.clear();
1595
+ }
1596
+
1597
+ JSONArray jsonArray = new JSONArray();
1598
+ for (JSONObject event : eventsToSend) {
1599
+ jsonArray.put(event);
1600
+ }
1601
+
1452
1602
  Request request = new Request.Builder()
1453
1603
  .url(statsUrl)
1454
- .post(RequestBody.create(json.toString(), MediaType.get("application/json")))
1604
+ .post(RequestBody.create(jsonArray.toString(), MediaType.get("application/json")))
1455
1605
  .build();
1456
1606
 
1607
+ final int eventCount = eventsToSend.size();
1457
1608
  DownloadService.sharedClient
1458
1609
  .newCall(request)
1459
1610
  .enqueue(
1460
1611
  new okhttp3.Callback() {
1461
1612
  @Override
1462
1613
  public void onFailure(@NonNull Call call, @NonNull IOException e) {
1463
- logger.error("Failed to send stats");
1614
+ logger.error("Failed to send stats batch");
1464
1615
  logger.debug("Error: " + e.getMessage());
1465
1616
  }
1466
1617
 
@@ -1473,10 +1624,10 @@ public class CapgoUpdater {
1473
1624
  }
1474
1625
 
1475
1626
  if (response.isSuccessful()) {
1476
- logger.info("Stats sent successfully");
1477
- logger.debug("Action: " + action + ", Version: " + versionName);
1627
+ logger.info("Stats batch sent successfully");
1628
+ logger.debug("Sent " + eventCount + " events");
1478
1629
  } else {
1479
- logger.error("Error sending stats");
1630
+ logger.error("Error sending stats batch");
1480
1631
  logger.debug("Response code: " + response.code());
1481
1632
  }
1482
1633
  }
@@ -1610,4 +1761,30 @@ public class CapgoUpdater {
1610
1761
  this.editor.commit();
1611
1762
  return true;
1612
1763
  }
1764
+
1765
+ /**
1766
+ * Shuts down the stats scheduler and flushes any pending stats.
1767
+ * Should be called when the plugin is destroyed to prevent resource leaks.
1768
+ */
1769
+ public void shutdown() {
1770
+ // Cancel the scheduled task
1771
+ if (statsFlushTask != null) {
1772
+ statsFlushTask.cancel(false);
1773
+ statsFlushTask = null;
1774
+ }
1775
+
1776
+ // Flush any remaining stats before shutdown
1777
+ flushStatsQueue();
1778
+
1779
+ // Shutdown the scheduler
1780
+ statsScheduler.shutdown();
1781
+ try {
1782
+ if (!statsScheduler.awaitTermination(2, TimeUnit.SECONDS)) {
1783
+ statsScheduler.shutdownNow();
1784
+ }
1785
+ } catch (InterruptedException e) {
1786
+ statsScheduler.shutdownNow();
1787
+ Thread.currentThread().interrupt();
1788
+ }
1789
+ }
1613
1790
  }
@@ -210,7 +210,7 @@ public class CryptoCipher {
210
210
  detectedFormat = "base64";
211
211
  }
212
212
  logger.debug(
213
- "Received encrypted checksum format: " +
213
+ "Received checksum format: " +
214
214
  detectedFormat +
215
215
  " (length: " +
216
216
  checksum.length() +
@@ -218,6 +218,18 @@ public class CryptoCipher {
218
218
  checksumBytes.length +
219
219
  " bytes)"
220
220
  );
221
+
222
+ // RSA-2048 encrypted data must be exactly 256 bytes
223
+ // If the checksum is not 256 bytes, the bundle was not encrypted properly
224
+ if (checksumBytes.length != 256) {
225
+ logger.error(
226
+ "Checksum is not RSA encrypted (size: " +
227
+ checksumBytes.length +
228
+ " bytes, expected 256 for RSA-2048). Bundle must be uploaded with encryption when public key is configured."
229
+ );
230
+ throw new IOException("Bundle checksum is not encrypted. Upload bundle with --key flag when encryption is configured.");
231
+ }
232
+
221
233
  PublicKey pKey = CryptoCipher.stringToPublicKey(publicKey);
222
234
  byte[] decryptedChecksum = CryptoCipher.decryptRSA(checksumBytes, pKey);
223
235
  // Return as hex string to match calcChecksum output format
@@ -288,10 +288,16 @@ public class DownloadService extends Worker {
288
288
  for (int i = 0; i < totalFiles; i++) {
289
289
  JSONObject entry = manifest.getJSONObject(i);
290
290
  String fileName = entry.getString("file_name");
291
- String fileHash = entry.getString("file_hash");
291
+ String fileHash = entry.optString("file_hash", "");
292
292
  String downloadUrl = entry.getString("download_url");
293
293
 
294
- if (!publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
294
+ if (fileHash.isEmpty()) {
295
+ logger.error("Missing file_hash for manifest entry: " + fileName);
296
+ hasError.set(true);
297
+ continue;
298
+ }
299
+
300
+ if (publicKey != null && !publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
295
301
  try {
296
302
  fileHash = CryptoCipher.decryptChecksum(fileHash, publicKey);
297
303
  } catch (Exception e) {
@@ -308,7 +314,9 @@ public class DownloadService extends Worker {
308
314
  String targetFileName = isBrotli ? fileName.substring(0, fileName.length() - 3) : fileName;
309
315
 
310
316
  File targetFile = new File(destFolder, targetFileName);
311
- File cacheFile = new File(cacheFolder, finalFileHash + "_" + new File(fileName).getName());
317
+ String cacheBaseName = new File(isBrotli ? targetFileName : fileName).getName();
318
+ File cacheFile = new File(cacheFolder, finalFileHash + "_" + cacheBaseName);
319
+ final File legacyCacheFile = isBrotli ? new File(cacheFolder, finalFileHash + "_" + new File(fileName).getName()) : null;
312
320
  File builtinFile = new File(builtinFolder, fileName);
313
321
 
314
322
  // Ensure parent directories of the target file exist
@@ -324,7 +332,10 @@ public class DownloadService extends Worker {
324
332
  if (builtinFile.exists() && verifyChecksum(builtinFile, finalFileHash)) {
325
333
  copyFile(builtinFile, targetFile);
326
334
  logger.debug("using builtin file " + fileName);
327
- } else if (tryCopyFromCache(cacheFile, targetFile, finalFileHash)) {
335
+ } else if (
336
+ tryCopyFromCache(cacheFile, targetFile, finalFileHash) ||
337
+ (legacyCacheFile != null && tryCopyFromCache(legacyCacheFile, targetFile, finalFileHash))
338
+ ) {
328
339
  logger.debug("already cached " + fileName);
329
340
  } else {
330
341
  downloadAndVerify(downloadUrl, targetFile, cacheFile, finalFileHash, sessionKey, publicKey, finalIsBrotli);
@@ -614,8 +625,12 @@ public class DownloadService extends Worker {
614
625
  // targetFile is already the final destination without .br extension
615
626
  File finalTargetFile = targetFile;
616
627
 
617
- // Create a temporary file for the compressed data
618
- File compressedFile = new File(getApplicationContext().getCacheDir(), "temp_" + targetFile.getName() + ".tmp");
628
+ // Create a temporary file for the compressed data with a unique name to avoid race conditions
629
+ // between threads processing files with the same basename in different directories
630
+ File compressedFile = new File(
631
+ getApplicationContext().getCacheDir(),
632
+ "temp_" + java.util.UUID.randomUUID().toString() + "_" + targetFile.getName() + ".tmp"
633
+ );
619
634
 
620
635
  try {
621
636
  try (Response response = sharedClient.newCall(request).execute()) {
@@ -633,7 +648,7 @@ public class DownloadService extends Worker {
633
648
  // Use OkIO for atomic write
634
649
  writeFileAtomic(compressedFile, responseBody.byteStream(), null);
635
650
 
636
- if (!publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
651
+ if (publicKey != null && !publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
637
652
  logger.debug("Decrypting file " + targetFile.getName());
638
653
  CryptoCipher.decryptFile(compressedFile, publicKey, sessionKey);
639
654
  }
@@ -643,7 +658,14 @@ public class DownloadService extends Worker {
643
658
  // Use new decompression method with atomic write
644
659
  try (FileInputStream fis = new FileInputStream(compressedFile)) {
645
660
  byte[] compressedData = new byte[(int) compressedFile.length()];
646
- fis.read(compressedData);
661
+ int offset = 0;
662
+ int bytesRead;
663
+ while (
664
+ offset < compressedData.length &&
665
+ (bytesRead = fis.read(compressedData, offset, compressedData.length - offset)) != -1
666
+ ) {
667
+ offset += bytesRead;
668
+ }
647
669
  byte[] decompressedData;
648
670
  try {
649
671
  decompressedData = decompressBrotli(compressedData, targetFile.getName());
@@ -674,7 +696,7 @@ public class DownloadService extends Worker {
674
696
  CryptoCipher.logChecksumInfo("Expected checksum", expectedHash);
675
697
 
676
698
  // Verify checksum
677
- if (calculatedHash.equals(expectedHash)) {
699
+ if (calculatedHash.equalsIgnoreCase(expectedHash)) {
678
700
  // Only cache if checksum is correct - use atomic copy
679
701
  try (FileInputStream fis = new FileInputStream(finalTargetFile)) {
680
702
  writeFileAtomic(cacheFile, fis, expectedHash);
@@ -707,7 +729,7 @@ public class DownloadService extends Worker {
707
729
  private boolean verifyChecksum(File file, String expectedHash) {
708
730
  try {
709
731
  String actualHash = calculateFileHash(file);
710
- return actualHash.equals(expectedHash);
732
+ return actualHash.equalsIgnoreCase(expectedHash);
711
733
  } catch (Exception e) {
712
734
  e.printStackTrace();
713
735
  return false;