@capgo/capacitor-updater 6.41.1 → 6.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 = "6.41.1";
87
+ private final String pluginVersion = "6.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;
@@ -93,6 +96,12 @@ public class CapgoUpdater {
93
96
  // Flag to track if we've already sent the rate limit statistic - prevents infinite loop
94
97
  private static volatile boolean rateLimitStatisticSent = false;
95
98
 
99
+ // Stats batching - queue events and send max once per second
100
+ private final List<JSONObject> statsQueue = new CopyOnWriteArrayList<>();
101
+ private final ScheduledExecutorService statsScheduler = Executors.newSingleThreadScheduledExecutor();
102
+ private ScheduledFuture<?> statsFlushTask = null;
103
+ private static final long STATS_FLUSH_INTERVAL_MS = 1000;
104
+
96
105
  private final Map<String, CompletableFuture<BundleInfo>> downloadFutures = new ConcurrentHashMap<>();
97
106
  private final ExecutorService io = Executors.newSingleThreadExecutor();
98
107
 
@@ -155,12 +164,25 @@ public class CapgoUpdater {
155
164
  }
156
165
 
157
166
  public void setPublicKey(String publicKey) {
158
- this.publicKey = publicKey;
159
- if (!publicKey.isEmpty()) {
160
- this.cachedKeyId = CryptoCipher.calcKeyId(publicKey);
161
- } else {
167
+ // Empty string means no encryption - proceed normally
168
+ if (publicKey == null || publicKey.isEmpty()) {
169
+ this.publicKey = "";
162
170
  this.cachedKeyId = "";
171
+ return;
163
172
  }
173
+
174
+ // Non-empty: must be a valid RSA key or crash
175
+ try {
176
+ CryptoCipher.stringToPublicKey(publicKey);
177
+ } catch (Exception e) {
178
+ throw new RuntimeException(
179
+ "Invalid public key in capacitor.config.json: failed to parse RSA key. Remove the key or provide a valid PEM-formatted RSA public key.",
180
+ e
181
+ );
182
+ }
183
+
184
+ this.publicKey = publicKey;
185
+ this.cachedKeyId = CryptoCipher.calcKeyId(publicKey);
164
186
  }
165
187
 
166
188
  public String getKeyId() {
@@ -241,13 +263,90 @@ public class CapgoUpdater {
241
263
  }
242
264
  if (entries.length == 1 && !"index.html".equals(entries[0])) {
243
265
  final File child = new File(sourceFile, entries[0]);
244
- child.renameTo(destinationFile);
266
+ if (!child.renameTo(destinationFile)) {
267
+ throw new IOException("Failed to move bundle contents: " + child.getPath() + " -> " + destinationFile.getPath());
268
+ }
245
269
  } else {
246
- sourceFile.renameTo(destinationFile);
270
+ if (!sourceFile.renameTo(destinationFile)) {
271
+ throw new IOException("Failed to move bundle contents: " + sourceFile.getPath() + " -> " + destinationFile.getPath());
272
+ }
247
273
  }
248
274
  sourceFile.delete();
249
275
  }
250
276
 
277
+ private void cacheBundleFilesAsync(final String id) {
278
+ io.execute(() -> cacheBundleFiles(id));
279
+ }
280
+
281
+ private void cacheBundleFiles(final String id) {
282
+ if (this.activity == null) {
283
+ logger.debug("Skip delta cache population: activity is null");
284
+ return;
285
+ }
286
+
287
+ final File bundleDir = this.getBundleDirectory(id);
288
+ if (!bundleDir.exists()) {
289
+ logger.debug("Skip delta cache population: bundle dir missing");
290
+ return;
291
+ }
292
+
293
+ final File cacheDir = new File(this.activity.getCacheDir(), "capgo_downloads");
294
+ if (cacheDir.exists() && !cacheDir.isDirectory()) {
295
+ logger.debug("Skip delta cache population: cache dir is not a directory");
296
+ return;
297
+ }
298
+ if (!cacheDir.exists() && !cacheDir.mkdirs()) {
299
+ logger.debug("Skip delta cache population: failed to create cache dir");
300
+ return;
301
+ }
302
+
303
+ final List<File> files = new ArrayList<>();
304
+ collectFiles(bundleDir, files);
305
+ for (File file : files) {
306
+ final String checksum = CryptoCipher.calcChecksum(file);
307
+ if (checksum.isEmpty()) {
308
+ continue;
309
+ }
310
+ final String cacheName = checksum + "_" + file.getName();
311
+ final File cacheFile = new File(cacheDir, cacheName);
312
+ if (cacheFile.exists()) {
313
+ continue;
314
+ }
315
+ try {
316
+ copyFile(file, cacheFile);
317
+ } catch (IOException e) {
318
+ logger.debug("Delta cache copy failed: " + file.getPath());
319
+ }
320
+ }
321
+ }
322
+
323
+ private void collectFiles(final File dir, final List<File> files) {
324
+ final File[] entries = dir.listFiles();
325
+ if (entries == null) {
326
+ return;
327
+ }
328
+ for (File entry : entries) {
329
+ if (!this.filter.accept(dir, entry.getName())) {
330
+ continue;
331
+ }
332
+ if (entry.isDirectory()) {
333
+ collectFiles(entry, files);
334
+ } else if (entry.isFile()) {
335
+ files.add(entry);
336
+ }
337
+ }
338
+ }
339
+
340
+ private void copyFile(final File source, final File dest) throws IOException {
341
+ try (final FileInputStream input = new FileInputStream(source); final FileOutputStream output = new FileOutputStream(dest)) {
342
+ final byte[] buffer = new byte[1024 * 1024];
343
+ int length;
344
+ while ((length = input.read(buffer)) != -1) {
345
+ output.write(buffer, 0, length);
346
+ }
347
+ }
348
+ }
349
+
251
350
  private void observeWorkProgress(Context context, String id) {
252
351
  if (!(context instanceof LifecycleOwner)) {
253
352
  logger.error("Context is not a LifecycleOwner, cannot observe work progress");
@@ -465,6 +564,7 @@ public class CapgoUpdater {
465
564
  this.notifyDownload(id, 91);
466
565
  final String idName = bundleDirectory + "/" + id;
467
566
  this.flattenAssets(extractedDir, idName);
567
+ this.cacheBundleFilesAsync(id);
468
568
  } else {
469
569
  this.notifyDownload(id, 91);
470
570
  final String idName = bundleDirectory + "/" + id;
@@ -1148,6 +1248,13 @@ public class CapgoUpdater {
1148
1248
  makeJsonRequest(channelUrl, json, (res) -> {
1149
1249
  if (res.containsKey("error")) {
1150
1250
  callback.callback(res);
1251
+ } else if (Boolean.TRUE.equals(res.get("unset"))) {
1252
+ // Server requested to unset channel (public channel was requested)
1253
+ // Clear persisted defaultChannel and revert to config value
1254
+ editor.remove(defaultChannelKey);
1255
+ editor.apply();
1256
+ logger.info("Public channel requested, channel override removed");
1257
+ callback.callback(res);
1151
1258
  } else {
1152
1259
  // Success - persist defaultChannel
1153
1260
  this.defaultChannel = channel;
@@ -1442,30 +1549,74 @@ public class CapgoUpdater {
1442
1549
  if (statsUrl == null || statsUrl.isEmpty()) {
1443
1550
  return;
1444
1551
  }
1552
+
1445
1553
  JSONObject json;
1446
1554
  try {
1447
1555
  json = this.createInfoObject();
1448
1556
  json.put("version_name", versionName);
1449
1557
  json.put("old_version_name", oldVersionName);
1450
1558
  json.put("action", action);
1559
+ json.put("timestamp", System.currentTimeMillis());
1451
1560
  } catch (JSONException e) {
1452
1561
  logger.error("Error preparing stats");
1453
1562
  logger.debug("JSONException: " + e.getMessage());
1454
1563
  return;
1455
1564
  }
1456
1565
 
1566
+ statsQueue.add(json);
1567
+ ensureStatsTimerStarted();
1568
+ }
1569
+
1570
+ private synchronized void ensureStatsTimerStarted() {
1571
+ if (statsFlushTask == null || statsFlushTask.isCancelled() || statsFlushTask.isDone()) {
1572
+ statsFlushTask = statsScheduler.scheduleAtFixedRate(
1573
+ this::flushStatsQueue,
1574
+ STATS_FLUSH_INTERVAL_MS,
1575
+ STATS_FLUSH_INTERVAL_MS,
1576
+ TimeUnit.MILLISECONDS
1577
+ );
1578
+ }
1579
+ }
1580
+
1581
+ private void flushStatsQueue() {
1582
+ if (statsQueue.isEmpty()) {
1583
+ return;
1584
+ }
1585
+
1586
+ String statsUrl = this.statsUrl;
1587
+ if (statsUrl == null || statsUrl.isEmpty()) {
1588
+ statsQueue.clear();
1589
+ return;
1590
+ }
1591
+
1592
+ // Copy and clear the queue atomically using synchronized block
1593
+ List<JSONObject> eventsToSend;
1594
+ synchronized (statsQueue) {
1595
+ if (statsQueue.isEmpty()) {
1596
+ return;
1597
+ }
1598
+ eventsToSend = new ArrayList<>(statsQueue);
1599
+ statsQueue.clear();
1600
+ }
1601
+
1602
+ JSONArray jsonArray = new JSONArray();
1603
+ for (JSONObject event : eventsToSend) {
1604
+ jsonArray.put(event);
1605
+ }
1606
+
1457
1607
  Request request = new Request.Builder()
1458
1608
  .url(statsUrl)
1459
- .post(RequestBody.create(json.toString(), MediaType.get("application/json")))
1609
+ .post(RequestBody.create(jsonArray.toString(), MediaType.get("application/json")))
1460
1610
  .build();
1461
1611
 
1612
+ final int eventCount = eventsToSend.size();
1462
1613
  DownloadService.sharedClient
1463
1614
  .newCall(request)
1464
1615
  .enqueue(
1465
1616
  new okhttp3.Callback() {
1466
1617
  @Override
1467
1618
  public void onFailure(@NonNull Call call, @NonNull IOException e) {
1468
- logger.error("Failed to send stats");
1619
+ logger.error("Failed to send stats batch");
1469
1620
  logger.debug("Error: " + e.getMessage());
1470
1621
  }
1471
1622
 
@@ -1478,10 +1629,10 @@ public class CapgoUpdater {
1478
1629
  }
1479
1630
 
1480
1631
  if (response.isSuccessful()) {
1481
- logger.info("Stats sent successfully");
1482
- logger.debug("Action: " + action + ", Version: " + versionName);
1632
+ logger.info("Stats batch sent successfully");
1633
+ logger.debug("Sent " + eventCount + " events");
1483
1634
  } else {
1484
- logger.error("Error sending stats");
1635
+ logger.error("Error sending stats batch");
1485
1636
  logger.debug("Response code: " + response.code());
1486
1637
  }
1487
1638
  }
@@ -1615,4 +1766,30 @@ public class CapgoUpdater {
1615
1766
  this.editor.commit();
1616
1767
  return true;
1617
1768
  }
1769
+
1770
+ /**
1771
+ * Shuts down the stats scheduler and flushes any pending stats.
1772
+ * Should be called when the plugin is destroyed to prevent resource leaks.
1773
+ */
1774
+ public void shutdown() {
1775
+ // Cancel the scheduled task
1776
+ if (statsFlushTask != null) {
1777
+ statsFlushTask.cancel(false);
1778
+ statsFlushTask = null;
1779
+ }
1780
+
1781
+ // Flush any remaining stats before shutdown
1782
+ flushStatsQueue();
1783
+
1784
+ // Shutdown the scheduler
1785
+ statsScheduler.shutdown();
1786
+ try {
1787
+ if (!statsScheduler.awaitTermination(2, TimeUnit.SECONDS)) {
1788
+ statsScheduler.shutdownNow();
1789
+ }
1790
+ } catch (InterruptedException e) {
1791
+ statsScheduler.shutdownNow();
1792
+ Thread.currentThread().interrupt();
1793
+ }
1794
+ }
1618
1795
  }
@@ -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;