@capgo/capacitor-updater 6.14.25 → 6.14.29

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 (54) hide show
  1. package/CapgoCapacitorUpdater.podspec +3 -2
  2. package/Package.swift +2 -2
  3. package/README.md +341 -74
  4. package/android/build.gradle +20 -8
  5. package/android/proguard-rules.pro +22 -5
  6. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +52 -16
  7. package/android/src/main/java/ee/forgr/capacitor_updater/Callback.java +2 -2
  8. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +1196 -514
  9. package/android/src/main/java/ee/forgr/capacitor_updater/{CapacitorUpdater.java → CapgoUpdater.java} +522 -154
  10. package/android/src/main/java/ee/forgr/capacitor_updater/{CryptoCipher.java → CryptoCipherV1.java} +17 -9
  11. package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipherV2.java +15 -26
  12. package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +0 -3
  13. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +260 -0
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +300 -119
  15. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +63 -25
  16. package/android/src/main/java/ee/forgr/capacitor_updater/Logger.java +338 -0
  17. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +72 -0
  18. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +169 -0
  19. package/dist/docs.json +652 -63
  20. package/dist/esm/definitions.d.ts +265 -15
  21. package/dist/esm/definitions.js.map +1 -1
  22. package/dist/esm/history.d.ts +1 -0
  23. package/dist/esm/history.js +283 -0
  24. package/dist/esm/history.js.map +1 -0
  25. package/dist/esm/index.d.ts +1 -0
  26. package/dist/esm/index.js +1 -0
  27. package/dist/esm/index.js.map +1 -1
  28. package/dist/esm/web.d.ts +12 -1
  29. package/dist/esm/web.js +29 -2
  30. package/dist/esm/web.js.map +1 -1
  31. package/dist/plugin.cjs.js +311 -2
  32. package/dist/plugin.cjs.js.map +1 -1
  33. package/dist/plugin.js +311 -2
  34. package/dist/plugin.js.map +1 -1
  35. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/AES.swift +6 -3
  36. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1575 -0
  37. package/ios/{Plugin/CapacitorUpdater.swift → Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift} +365 -139
  38. package/ios/{Plugin/CryptoCipher.swift → Sources/CapacitorUpdaterPlugin/CryptoCipherV1.swift} +13 -6
  39. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/CryptoCipherV2.swift +33 -27
  40. package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +220 -0
  41. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/InternalUtils.swift +47 -0
  42. package/ios/Sources/CapacitorUpdaterPlugin/Logger.swift +310 -0
  43. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/RSA.swift +1 -0
  44. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +112 -0
  45. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/UserDefaultsExtension.swift +0 -2
  46. package/package.json +20 -16
  47. package/ios/Plugin/CapacitorUpdaterPlugin.swift +0 -1031
  48. /package/{LICENCE → LICENSE} +0 -0
  49. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BigInt.swift +0 -0
  50. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleInfo.swift +0 -0
  51. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleStatus.swift +0 -0
  52. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayCondition.swift +0 -0
  53. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayUntilNext.swift +0 -0
  54. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/Info.plist +0 -0
@@ -6,7 +6,6 @@
6
6
  package ee.forgr.capacitor_updater;
7
7
 
8
8
  import android.content.Context;
9
- import android.util.Log;
10
9
  import androidx.annotation.NonNull;
11
10
  import androidx.work.Data;
12
11
  import androidx.work.Worker;
@@ -17,6 +16,7 @@ import java.net.HttpURLConnection;
17
16
  import java.net.URL;
18
17
  import java.nio.channels.FileChannel;
19
18
  import java.nio.file.Files;
19
+ import java.nio.file.StandardCopyOption;
20
20
  import java.security.MessageDigest;
21
21
  import java.util.ArrayList;
22
22
  import java.util.Arrays;
@@ -28,18 +28,29 @@ import java.util.concurrent.Future;
28
28
  import java.util.concurrent.TimeUnit;
29
29
  import java.util.concurrent.atomic.AtomicBoolean;
30
30
  import java.util.concurrent.atomic.AtomicLong;
31
+ import okhttp3.Interceptor;
31
32
  import okhttp3.OkHttpClient;
32
33
  import okhttp3.Protocol;
33
34
  import okhttp3.Request;
34
35
  import okhttp3.Response;
35
36
  import okhttp3.ResponseBody;
37
+ import okio.Buffer;
38
+ import okio.BufferedSink;
39
+ import okio.BufferedSource;
40
+ import okio.Okio;
41
+ import okio.Source;
36
42
  import org.brotli.dec.BrotliInputStream;
37
43
  import org.json.JSONArray;
38
44
  import org.json.JSONObject;
39
45
 
40
46
  public class DownloadService extends Worker {
41
47
 
42
- public static final String TAG = "Capacitor-updater";
48
+ private static Logger logger;
49
+
50
+ public static void setLogger(Logger loggerInstance) {
51
+ logger = loggerInstance;
52
+ }
53
+
43
54
  public static final String URL = "URL";
44
55
  public static final String ID = "id";
45
56
  public static final String PERCENT = "percent";
@@ -51,12 +62,51 @@ public class DownloadService extends Worker {
51
62
  public static final String CHECKSUM = "checksum";
52
63
  public static final String PUBLIC_KEY = "publickey";
53
64
  public static final String IS_MANIFEST = "is_manifest";
65
+ public static final String APP_ID = "app_id";
66
+ public static final String PLUGIN_VERSION = "plugin_version";
54
67
  private static final String UPDATE_FILE = "update.dat";
55
68
 
56
- private final OkHttpClient client = new OkHttpClient.Builder().protocols(Arrays.asList(Protocol.HTTP_2, Protocol.HTTP_1_1)).build();
69
+ // Shared OkHttpClient to prevent resource leaks
70
+ protected static OkHttpClient sharedClient;
71
+ private static String currentAppId = "unknown";
72
+ private static String currentPluginVersion = "unknown";
73
+ private static String currentVersionOs = "unknown";
74
+
75
+ // Initialize shared client with User-Agent interceptor
76
+ static {
77
+ sharedClient = new OkHttpClient.Builder()
78
+ .protocols(Arrays.asList(Protocol.HTTP_2, Protocol.HTTP_1_1))
79
+ .addInterceptor((chain) -> {
80
+ Request originalRequest = chain.request();
81
+ String userAgent =
82
+ "CapacitorUpdater/" +
83
+ (currentPluginVersion != null ? currentPluginVersion : "unknown") +
84
+ " (" +
85
+ (currentAppId != null ? currentAppId : "unknown") +
86
+ ") android/" +
87
+ (currentVersionOs != null ? currentVersionOs : "unknown");
88
+ Request requestWithUserAgent = originalRequest.newBuilder().header("User-Agent", userAgent).build();
89
+ return chain.proceed(requestWithUserAgent);
90
+ })
91
+ .build();
92
+ }
93
+
94
+ // Method to update User-Agent values
95
+ public static void updateUserAgent(String appId, String pluginVersion, String versionOs) {
96
+ currentAppId = appId != null ? appId : "unknown";
97
+ currentPluginVersion = pluginVersion != null ? pluginVersion : "unknown";
98
+ currentVersionOs = versionOs != null ? versionOs : "unknown";
99
+ logger.debug(
100
+ "Updated User-Agent: CapacitorUpdater/" + currentPluginVersion + " (" + currentAppId + ") android/" + currentVersionOs
101
+ );
102
+ }
57
103
 
58
104
  public DownloadService(@NonNull Context context, @NonNull WorkerParameters params) {
59
105
  super(context, params);
106
+ // Use shared client - no need to create new instances
107
+
108
+ // Clean up old temporary files on service initialization
109
+ cleanupOldTempFiles(getApplicationContext().getCacheDir());
60
110
  }
61
111
 
62
112
  private void setProgress(int percent) {
@@ -94,7 +144,7 @@ public class DownloadService extends Worker {
94
144
  String publicKey = getInputData().getString(PUBLIC_KEY);
95
145
  boolean isManifest = getInputData().getBoolean(IS_MANIFEST, false);
96
146
 
97
- Log.d(TAG, "doWork isManifest: " + isManifest);
147
+ logger.debug("doWork isManifest: " + isManifest);
98
148
 
99
149
  if (isManifest) {
100
150
  JSONArray manifest = DataManager.getInstance().getAndClearManifest();
@@ -102,7 +152,7 @@ public class DownloadService extends Worker {
102
152
  handleManifestDownload(id, documentsDir, dest, version, sessionKey, publicKey, manifest.toString());
103
153
  return createSuccessResult(dest, version, sessionKey, checksum, true);
104
154
  } else {
105
- Log.e(TAG, "Manifest is null");
155
+ logger.error("Manifest is null");
106
156
  return createFailureResult("Manifest is null");
107
157
  }
108
158
  } else {
@@ -134,7 +184,7 @@ public class DownloadService extends Worker {
134
184
  String manifestString
135
185
  ) {
136
186
  try {
137
- Log.d(TAG, "handleManifestDownload");
187
+ logger.debug("handleManifestDownload");
138
188
  JSONArray manifest = new JSONArray(manifestString);
139
189
  File destFolder = new File(documentsDir, dest);
140
190
  File cacheFolder = new File(getApplicationContext().getCacheDir(), "capgo_downloads");
@@ -167,7 +217,7 @@ public class DownloadService extends Worker {
167
217
  try {
168
218
  fileHash = CryptoCipherV2.decryptChecksum(fileHash, publicKey);
169
219
  } catch (Exception e) {
170
- Log.e(TAG, "Error decrypting checksum for " + fileName, e);
220
+ logger.error("Error decrypting checksum for " + fileName + "fileHash: " + fileHash);
171
221
  hasError.set(true);
172
222
  continue;
173
223
  }
@@ -180,7 +230,7 @@ public class DownloadService extends Worker {
180
230
 
181
231
  // Ensure parent directories of the target file exist
182
232
  if (!Objects.requireNonNull(targetFile.getParentFile()).exists() && !targetFile.getParentFile().mkdirs()) {
183
- Log.e(TAG, "Failed to create parent directory for: " + targetFile.getAbsolutePath());
233
+ logger.error("Failed to create parent directory for: " + targetFile.getAbsolutePath());
184
234
  hasError.set(true);
185
235
  continue;
186
236
  }
@@ -189,10 +239,10 @@ public class DownloadService extends Worker {
189
239
  try {
190
240
  if (builtinFile.exists() && verifyChecksum(builtinFile, finalFileHash)) {
191
241
  copyFile(builtinFile, targetFile);
192
- Log.d(TAG, "using builtin file " + fileName);
242
+ logger.debug("using builtin file " + fileName);
193
243
  } else if (cacheFile.exists() && verifyChecksum(cacheFile, finalFileHash)) {
194
244
  copyFile(cacheFile, targetFile);
195
- Log.d(TAG, "already cached " + fileName);
245
+ logger.debug("already cached " + fileName);
196
246
  } else {
197
247
  downloadAndVerify(downloadUrl, targetFile, cacheFile, finalFileHash, sessionKey, publicKey);
198
248
  }
@@ -201,7 +251,7 @@ public class DownloadService extends Worker {
201
251
  int percent = calcTotalPercent(completed, totalFiles);
202
252
  setProgress(percent);
203
253
  } catch (Exception e) {
204
- Log.e(TAG, "Error processing file: " + fileName, e);
254
+ logger.error("Error processing file: " + fileName + " " + e.getMessage());
205
255
  hasError.set(true);
206
256
  }
207
257
  });
@@ -213,7 +263,7 @@ public class DownloadService extends Worker {
213
263
  try {
214
264
  future.get();
215
265
  } catch (Exception e) {
216
- Log.e(TAG, "Error waiting for download", e);
266
+ logger.error("Error waiting for download " + e.getMessage());
217
267
  hasError.set(true);
218
268
  }
219
269
  }
@@ -229,11 +279,11 @@ public class DownloadService extends Worker {
229
279
  }
230
280
 
231
281
  if (hasError.get()) {
232
- Log.e(TAG, "One or more files failed to download");
282
+ logger.error("One or more files failed to download");
233
283
  throw new IOException("One or more files failed to download");
234
284
  }
235
285
  } catch (Exception e) {
236
- Log.e(TAG, "Error in handleManifestDownload", e);
286
+ logger.error("Error in handleManifestDownload " + e.getMessage());
237
287
  throw new RuntimeException(e.getLocalizedMessage());
238
288
  }
239
289
  }
@@ -248,93 +298,156 @@ public class DownloadService extends Worker {
248
298
  String checksum
249
299
  ) {
250
300
  File target = new File(documentsDir, dest);
251
- File infoFile = new File(documentsDir, UPDATE_FILE); // The file where the download progress (how much byte
252
- // downloaded) is stored
253
- File tempFile = new File(documentsDir, "temp" + ".tmp"); // Temp file, where the downloaded data is stored
301
+ File infoFile = new File(documentsDir, UPDATE_FILE);
302
+ File tempFile = new File(documentsDir, "temp" + ".tmp");
303
+
304
+ // Check available disk space before starting
305
+ long availableSpace = target.getParentFile().getUsableSpace();
306
+ long estimatedSize = 50 * 1024 * 1024; // 50MB default estimate
307
+ if (availableSpace < estimatedSize * 2) {
308
+ throw new RuntimeException("insufficient_disk_space");
309
+ }
310
+
311
+ HttpURLConnection httpConn = null;
312
+ InputStream inputStream = null;
313
+ FileOutputStream outputStream = null;
314
+ BufferedReader reader = null;
315
+ BufferedWriter writer = null;
316
+
254
317
  try {
255
318
  URL u = new URL(url);
256
- HttpURLConnection httpConn = null;
257
- try {
258
- httpConn = (HttpURLConnection) u.openConnection();
319
+ httpConn = (HttpURLConnection) u.openConnection();
259
320
 
260
- // Reading progress file (if exist)
261
- long downloadedBytes = 0;
321
+ // Set reasonable timeouts
322
+ httpConn.setConnectTimeout(30000); // 30 seconds
323
+ httpConn.setReadTimeout(60000); // 60 seconds
262
324
 
263
- if (infoFile.exists() && tempFile.exists()) {
264
- try (BufferedReader reader = new BufferedReader(new FileReader(infoFile))) {
265
- String updateVersion = reader.readLine();
266
- if (!updateVersion.equals(version)) {
267
- clearDownloadData(documentsDir);
268
- } else {
269
- downloadedBytes = tempFile.length();
270
- }
325
+ // Reading progress file (if exist)
326
+ long downloadedBytes = 0;
327
+
328
+ if (infoFile.exists() && tempFile.exists()) {
329
+ try {
330
+ reader = new BufferedReader(new FileReader(infoFile));
331
+ String updateVersion = reader.readLine();
332
+ if (updateVersion != null && !updateVersion.equals(version)) {
333
+ clearDownloadData(documentsDir);
334
+ } else {
335
+ downloadedBytes = tempFile.length();
336
+ }
337
+ } finally {
338
+ if (reader != null) {
339
+ try {
340
+ reader.close();
341
+ } catch (Exception ignored) {}
271
342
  }
272
- } else {
273
- clearDownloadData(documentsDir);
274
343
  }
344
+ } else {
345
+ clearDownloadData(documentsDir);
346
+ }
347
+
348
+ if (downloadedBytes > 0) {
349
+ httpConn.setRequestProperty("Range", "bytes=" + downloadedBytes + "-");
350
+ }
351
+
352
+ int responseCode = httpConn.getResponseCode();
353
+
354
+ if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_PARTIAL) {
355
+ long contentLength = httpConn.getContentLength() + downloadedBytes;
275
356
 
276
- if (downloadedBytes > 0) {
277
- httpConn.setRequestProperty("Range", "bytes=" + downloadedBytes + "-");
357
+ // Check if we have enough space for the actual file
358
+ if (contentLength > 0 && availableSpace < contentLength * 2) {
359
+ throw new RuntimeException("insufficient_disk_space");
278
360
  }
279
361
 
280
- int responseCode = httpConn.getResponseCode();
362
+ try {
363
+ inputStream = httpConn.getInputStream();
364
+ outputStream = new FileOutputStream(tempFile, downloadedBytes > 0);
365
+
366
+ if (downloadedBytes == 0) {
367
+ writer = new BufferedWriter(new FileWriter(infoFile));
368
+ writer.write(String.valueOf(version));
369
+ writer.close();
370
+ writer = null;
371
+ }
281
372
 
282
- if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_PARTIAL) {
283
- long contentLength = httpConn.getContentLength() + downloadedBytes;
373
+ byte[] buffer = new byte[8192]; // Larger buffer for better performance
374
+ int lastNotifiedPercent = 0;
375
+ int bytesRead;
284
376
 
285
- try (
286
- InputStream inputStream = httpConn.getInputStream();
287
- FileOutputStream outputStream = new FileOutputStream(tempFile, downloadedBytes > 0)
288
- ) {
289
- if (downloadedBytes == 0) {
290
- try (BufferedWriter writer = new BufferedWriter(new FileWriter(infoFile))) {
291
- writer.write(String.valueOf(version));
292
- }
293
- }
294
- // Updating the info file
295
- try (BufferedWriter writer = new BufferedWriter(new FileWriter(infoFile))) {
296
- writer.write(String.valueOf(version));
377
+ while ((bytesRead = inputStream.read(buffer)) != -1) {
378
+ outputStream.write(buffer, 0, bytesRead);
379
+ downloadedBytes += bytesRead;
380
+
381
+ // Flush every 1MB to ensure progress is saved
382
+ if (downloadedBytes % (1024 * 1024) == 0) {
383
+ outputStream.flush();
297
384
  }
298
385
 
299
- int bytesRead = -1;
300
- byte[] buffer = new byte[4096];
301
- int lastNotifiedPercent = 0;
302
- while ((bytesRead = inputStream.read(buffer)) != -1) {
303
- outputStream.write(buffer, 0, bytesRead);
304
- downloadedBytes += bytesRead;
305
- // Saving progress (flushing every 100 Ko)
306
- if (downloadedBytes % 102400 == 0) {
307
- outputStream.flush();
308
- }
309
- // Computing percentage
310
- int percent = calcTotalPercent(downloadedBytes, contentLength);
311
- while (lastNotifiedPercent + 10 <= percent) {
312
- lastNotifiedPercent += 10;
313
- // Artificial delay using CPU-bound calculation to take ~5 seconds
314
- double result = 0;
315
- setProgress(lastNotifiedPercent);
316
- }
386
+ // Computing percentage
387
+ int percent = calcTotalPercent(downloadedBytes, contentLength);
388
+ if (percent >= lastNotifiedPercent + 10) {
389
+ lastNotifiedPercent = (percent / 10) * 10;
390
+ setProgress(lastNotifiedPercent);
317
391
  }
392
+ }
393
+
394
+ // Final flush
395
+ outputStream.flush();
396
+ outputStream.close();
397
+ outputStream = null;
318
398
 
319
- outputStream.close();
320
- inputStream.close();
399
+ inputStream.close();
400
+ inputStream = null;
321
401
 
322
- // Rename the temp file with the final name (dest)
323
- tempFile.renameTo(new File(documentsDir, dest));
324
- infoFile.delete();
402
+ // Rename the temp file with the final name (dest)
403
+ if (!tempFile.renameTo(new File(documentsDir, dest))) {
404
+ throw new RuntimeException("Failed to rename temp file to final destination");
325
405
  }
326
- } else {
327
406
  infoFile.delete();
407
+ } catch (OutOfMemoryError e) {
408
+ logger.error("Out of memory during download: " + e.getMessage());
409
+ // Try to free some memory
410
+ System.gc();
411
+ throw new RuntimeException("low_mem_fail");
412
+ } finally {
413
+ // Ensure all resources are closed
414
+ if (outputStream != null) {
415
+ try {
416
+ outputStream.close();
417
+ } catch (Exception ignored) {}
418
+ }
419
+ if (inputStream != null) {
420
+ try {
421
+ inputStream.close();
422
+ } catch (Exception ignored) {}
423
+ }
424
+ if (writer != null) {
425
+ try {
426
+ writer.close();
427
+ } catch (Exception ignored) {}
428
+ }
328
429
  }
329
- } finally {
330
- if (httpConn != null) {
331
- httpConn.disconnect();
332
- }
430
+ } else {
431
+ infoFile.delete();
432
+ throw new RuntimeException("HTTP error: " + responseCode);
333
433
  }
334
434
  } catch (OutOfMemoryError e) {
435
+ logger.error("Critical memory error: " + e.getMessage());
436
+ System.gc(); // Suggest garbage collection
335
437
  throw new RuntimeException("low_mem_fail");
438
+ } catch (SecurityException e) {
439
+ logger.error("Security error during download: " + e.getMessage());
440
+ throw new RuntimeException("security_error: " + e.getMessage());
336
441
  } catch (Exception e) {
337
- throw new RuntimeException(e.getLocalizedMessage());
442
+ logger.error("Download error: " + e.getMessage());
443
+ throw new RuntimeException(e.getMessage());
444
+ } finally {
445
+ // Ensure connection is closed
446
+ if (httpConn != null) {
447
+ try {
448
+ httpConn.disconnect();
449
+ } catch (Exception ignored) {}
450
+ }
338
451
  }
339
452
  }
340
453
 
@@ -347,7 +460,7 @@ public class DownloadService extends Worker {
347
460
  infoFile.createNewFile();
348
461
  tempFile.createNewFile();
349
462
  } catch (IOException e) {
350
- Log.e(TAG, "Error in clearDownloadData", e);
463
+ logger.error("Error in clearDownloadData " + e.getMessage());
351
464
  // not a fatal error, so we don't throw an exception
352
465
  }
353
466
  }
@@ -373,62 +486,81 @@ public class DownloadService extends Worker {
373
486
  String sessionKey,
374
487
  String publicKey
375
488
  ) throws Exception {
376
- Log.d(TAG, "downloadAndVerify " + downloadUrl);
489
+ logger.debug("downloadAndVerify " + downloadUrl);
377
490
 
378
491
  Request request = new Request.Builder().url(downloadUrl).build();
379
492
 
493
+ // Check if file is a Brotli file
494
+ boolean isBrotli = targetFile.getName().endsWith(".br");
495
+
496
+ // Create final target file with .br extension removed if it's a Brotli file
497
+ File finalTargetFile = isBrotli
498
+ ? new File(targetFile.getParentFile(), targetFile.getName().substring(0, targetFile.getName().length() - 3))
499
+ : targetFile;
500
+
380
501
  // Create a temporary file for the compressed data
381
- File compressedFile = new File(getApplicationContext().getCacheDir(), "temp_" + targetFile.getName() + ".br");
502
+ File compressedFile = new File(getApplicationContext().getCacheDir(), "temp_" + targetFile.getName() + ".tmp");
382
503
 
383
- try (Response response = client.newCall(request).execute()) {
504
+ try (Response response = sharedClient.newCall(request).execute()) {
384
505
  if (!response.isSuccessful()) {
385
506
  throw new IOException("Unexpected response code: " + response.code());
386
507
  }
387
508
 
388
- // Download compressed file
389
- try (ResponseBody responseBody = response.body(); FileOutputStream compressedFos = new FileOutputStream(compressedFile)) {
390
- if (responseBody == null) {
391
- throw new IOException("Response body is null");
392
- }
393
-
394
- byte[] buffer = new byte[8192];
395
- int bytesRead;
396
- try (InputStream inputStream = responseBody.byteStream()) {
397
- while ((bytesRead = inputStream.read(buffer)) != -1) {
398
- compressedFos.write(buffer, 0, bytesRead);
399
- }
400
- }
509
+ // Download compressed file atomically
510
+ ResponseBody responseBody = response.body();
511
+ if (responseBody == null) {
512
+ throw new IOException("Response body is null");
401
513
  }
402
514
 
515
+ // Use OkIO for atomic write
516
+ writeFileAtomic(compressedFile, responseBody.byteStream(), null);
517
+
403
518
  if (!publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
404
- Log.d(CapacitorUpdater.TAG + " DLSrv", "Decrypting file " + targetFile.getName());
519
+ logger.debug("Decrypting file " + targetFile.getName());
405
520
  CryptoCipherV2.decryptFile(compressedFile, publicKey, sessionKey);
406
521
  }
407
522
 
408
- // Use new decompression method
409
- byte[] compressedData = Files.readAllBytes(compressedFile.toPath());
410
- byte[] decompressedData = decompressBrotli(compressedData, targetFile.getName());
411
- Files.write(targetFile.toPath(), decompressedData);
523
+ // Only decompress if file has .br extension
524
+ if (isBrotli) {
525
+ // Use new decompression method with atomic write
526
+ try (FileInputStream fis = new FileInputStream(compressedFile)) {
527
+ byte[] compressedData = new byte[(int) compressedFile.length()];
528
+ fis.read(compressedData);
529
+ byte[] decompressedData = decompressBrotli(compressedData, targetFile.getName());
530
+
531
+ // Write decompressed data atomically
532
+ try (java.io.ByteArrayInputStream bais = new java.io.ByteArrayInputStream(decompressedData)) {
533
+ writeFileAtomic(finalTargetFile, bais, null);
534
+ }
535
+ }
536
+ } else {
537
+ // Just copy the file without decompression using atomic operation
538
+ try (FileInputStream fis = new FileInputStream(compressedFile)) {
539
+ writeFileAtomic(finalTargetFile, fis, null);
540
+ }
541
+ }
412
542
 
413
543
  // Delete the compressed file
414
544
  compressedFile.delete();
415
- String calculatedHash = CryptoCipherV2.calcChecksum(targetFile);
545
+ String calculatedHash = CryptoCipherV2.calcChecksum(finalTargetFile);
416
546
 
417
547
  // Verify checksum
418
548
  if (calculatedHash.equals(expectedHash)) {
419
- // Only cache if checksum is correct
420
- copyFile(targetFile, cacheFile);
549
+ // Only cache if checksum is correct - use atomic copy
550
+ try (FileInputStream fis = new FileInputStream(finalTargetFile)) {
551
+ writeFileAtomic(cacheFile, fis, expectedHash);
552
+ }
421
553
  } else {
422
- targetFile.delete();
554
+ finalTargetFile.delete();
423
555
  throw new IOException(
424
556
  "Checksum verification failed for: " +
425
- downloadUrl +
426
- " " +
427
- targetFile.getName() +
428
- " expected: " +
429
- expectedHash +
430
- " calculated: " +
431
- calculatedHash
557
+ downloadUrl +
558
+ " " +
559
+ targetFile.getName() +
560
+ " expected: " +
561
+ expectedHash +
562
+ " calculated: " +
563
+ calculatedHash
432
564
  );
433
565
  }
434
566
  } catch (Exception e) {
@@ -448,14 +580,14 @@ public class DownloadService extends Worker {
448
580
 
449
581
  private String calculateFileHash(File file) throws Exception {
450
582
  MessageDigest digest = MessageDigest.getInstance("SHA-256");
451
- FileInputStream fis = new FileInputStream(file);
452
583
  byte[] byteArray = new byte[1024];
453
584
  int bytesCount = 0;
454
585
 
455
- while ((bytesCount = fis.read(byteArray)) != -1) {
456
- digest.update(byteArray, 0, bytesCount);
586
+ try (FileInputStream fis = new FileInputStream(file)) {
587
+ while ((bytesCount = fis.read(byteArray)) != -1) {
588
+ digest.update(byteArray, 0, bytesCount);
589
+ }
457
590
  }
458
- fis.close();
459
591
 
460
592
  byte[] bytes = digest.digest();
461
593
  StringBuilder sb = new StringBuilder();
@@ -468,7 +600,7 @@ public class DownloadService extends Worker {
468
600
  private byte[] decompressBrotli(byte[] data, String fileName) throws IOException {
469
601
  // Validate input
470
602
  if (data == null) {
471
- Log.e(TAG, "Error: Null data received for " + fileName);
603
+ logger.error("Error: Null data received for " + fileName);
472
604
  throw new IOException("Null data received");
473
605
  }
474
606
 
@@ -495,7 +627,7 @@ public class DownloadService extends Worker {
495
627
  return Arrays.copyOfRange(data, 3, data.length - 1);
496
628
  }
497
629
  } catch (ArrayIndexOutOfBoundsException e) {
498
- Log.e(TAG, "Error: Malformed data for " + fileName);
630
+ logger.error("Error: Malformed data for " + fileName);
499
631
  throw new IOException("Malformed data structure");
500
632
  }
501
633
  }
@@ -513,14 +645,63 @@ public class DownloadService extends Worker {
513
645
  }
514
646
  return bos.toByteArray();
515
647
  } catch (IOException e) {
516
- Log.e(TAG, "Error: Brotli process failed for " + fileName + ". Status: " + e.getMessage());
648
+ logger.error("Error: Brotli process failed for " + fileName + ". Status: " + e.getMessage());
517
649
  // Add hex dump for debugging
518
650
  StringBuilder hexDump = new StringBuilder();
519
651
  for (int i = 0; i < Math.min(32, data.length); i++) {
520
652
  hexDump.append(String.format("%02x ", data[i]));
521
653
  }
522
- Log.e(TAG, "Error: Raw data (" + fileName + "): " + hexDump.toString());
654
+ logger.error("Error: Raw data (" + fileName + "): " + hexDump.toString());
523
655
  throw e;
524
656
  }
525
657
  }
658
+
659
+ /**
660
+ * Atomically write data to a file using OkIO
661
+ */
662
+ private void writeFileAtomic(File targetFile, InputStream inputStream, String expectedChecksum) throws IOException {
663
+ File tempFile = new File(targetFile.getParent(), targetFile.getName() + ".tmp");
664
+
665
+ try {
666
+ // Write to temp file first using OkIO
667
+ try (BufferedSink sink = Okio.buffer(Okio.sink(tempFile)); BufferedSource source = Okio.buffer(Okio.source(inputStream))) {
668
+ sink.writeAll(source);
669
+ }
670
+
671
+ // Verify checksum if provided
672
+ if (expectedChecksum != null && !expectedChecksum.isEmpty()) {
673
+ String actualChecksum = CryptoCipherV2.calcChecksum(tempFile);
674
+ if (!expectedChecksum.equalsIgnoreCase(actualChecksum)) {
675
+ tempFile.delete();
676
+ throw new IOException("Checksum verification failed");
677
+ }
678
+ }
679
+
680
+ // Atomic rename (on same filesystem)
681
+ Files.move(tempFile.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
682
+ } catch (Exception e) {
683
+ // Clean up temp file on error
684
+ if (tempFile.exists()) {
685
+ tempFile.delete();
686
+ }
687
+ throw new IOException("Failed to write file atomically: " + e.getMessage(), e);
688
+ }
689
+ }
690
+
691
+ /**
692
+ * Clean up old temporary files
693
+ */
694
+ private void cleanupOldTempFiles(File directory) {
695
+ if (directory == null || !directory.exists()) return;
696
+
697
+ File[] tempFiles = directory.listFiles((dir, name) -> name.endsWith(".tmp"));
698
+ if (tempFiles != null) {
699
+ long oneHourAgo = System.currentTimeMillis() - 3600000;
700
+ for (File tempFile : tempFiles) {
701
+ if (tempFile.lastModified() < oneHourAgo) {
702
+ tempFile.delete();
703
+ }
704
+ }
705
+ }
706
+ }
526
707
  }