@capgo/capacitor-updater 7.9.1 → 7.9.2

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.
@@ -44,8 +44,7 @@ import java.util.concurrent.Semaphore;
44
44
  import java.util.concurrent.TimeUnit;
45
45
  import java.util.concurrent.TimeoutException;
46
46
  import java.util.concurrent.atomic.AtomicReference;
47
- import okhttp3.OkHttpClient;
48
- import okhttp3.Protocol;
47
+ // Removed OkHttpClient and Protocol imports - using shared client in DownloadService instead
49
48
  import org.json.JSONArray;
50
49
  import org.json.JSONException;
51
50
  import org.json.JSONObject;
@@ -59,7 +58,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
59
58
  private static final String statsUrlDefault = "https://plugin.capgo.app/stats";
60
59
  private static final String channelUrlDefault = "https://plugin.capgo.app/channel_self";
61
60
 
62
- private final String PLUGIN_VERSION = "7.9.1";
61
+ private final String PLUGIN_VERSION = "7.9.2";
63
62
  private static final String DELAY_CONDITION_PREFERENCES = "";
64
63
 
65
64
  private SharedPreferences.Editor editor;
@@ -153,12 +152,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
153
152
  this.implementation.CAP_SERVER_PATH = WebView.CAP_SERVER_PATH;
154
153
  this.implementation.PLUGIN_VERSION = this.PLUGIN_VERSION;
155
154
  this.implementation.versionCode = Integer.toString(pInfo.versionCode);
156
- this.implementation.client = new OkHttpClient.Builder()
157
- .protocols(Arrays.asList(Protocol.HTTP_2, Protocol.HTTP_1_1))
158
- .connectTimeout(this.implementation.timeout, TimeUnit.MILLISECONDS)
159
- .readTimeout(this.implementation.timeout, TimeUnit.MILLISECONDS)
160
- .writeTimeout(this.implementation.timeout, TimeUnit.MILLISECONDS)
161
- .build();
155
+ // Removed unused OkHttpClient creation - using shared client in DownloadService instead
162
156
  // Handle directUpdate configuration - support string values and backward compatibility
163
157
  String directUpdateConfig = this.getConfig().getString("directUpdate", null);
164
158
  if (directUpdateConfig != null) {
@@ -209,12 +203,16 @@ public class CapacitorUpdaterPlugin extends Plugin {
209
203
  this.implementation.appId = config.getString("appId", this.implementation.appId);
210
204
  this.implementation.appId = this.getConfig().getString("appId", this.implementation.appId);
211
205
  if (this.implementation.appId == null || this.implementation.appId.isEmpty()) {
212
- // crash the app
206
+ // crash the app on purpose it should not happen
213
207
  throw new RuntimeException(
214
208
  "appId is missing in capacitor.config.json or plugin config, and cannot be retrieved from the native app, please add it globally or in the plugin config"
215
209
  );
216
210
  }
217
211
  logger.info("appId: " + implementation.appId);
212
+
213
+ // Update User-Agent for shared OkHttpClient
214
+ DownloadService.updateUserAgent(this.implementation.appId, this.PLUGIN_VERSION);
215
+
218
216
  this.implementation.publicKey = this.getConfig().getString("publicKey", "");
219
217
  this.implementation.statsUrl = this.getConfig().getString("statsUrl", statsUrlDefault);
220
218
  this.implementation.channelUrl = this.getConfig().getString("channelUrl", channelUrlDefault);
@@ -263,9 +261,10 @@ public class CapacitorUpdaterPlugin extends Plugin {
263
261
  logger.info("semaphoreReady count " + semaphoreReady.getPhase());
264
262
  } catch (InterruptedException e) {
265
263
  logger.info("semaphoreWait InterruptedException");
266
- e.printStackTrace();
264
+ Thread.currentThread().interrupt(); // Restore interrupted status
267
265
  } catch (TimeoutException e) {
268
- throw new RuntimeException(e);
266
+ logger.error("Semaphore timeout: " + e.getMessage());
267
+ // Don't throw runtime exception, just log and continue
269
268
  }
270
269
  }
271
270
 
@@ -782,22 +781,37 @@ public class CapacitorUpdaterPlugin extends Plugin {
782
781
  Semaphore mainThreadSemaphore = new Semaphore(0);
783
782
  this.bridge.executeOnMainThread(() -> {
784
783
  try {
785
- url.set(new URL(this.bridge.getWebView().getUrl()));
784
+ if (this.bridge != null && this.bridge.getWebView() != null) {
785
+ String currentUrl = this.bridge.getWebView().getUrl();
786
+ if (currentUrl != null) {
787
+ url.set(new URL(currentUrl));
788
+ }
789
+ }
786
790
  } catch (Exception e) {
787
791
  logger.error("Error executing on main thread " + e.getMessage());
788
792
  }
789
793
  mainThreadSemaphore.release();
790
794
  });
791
- mainThreadSemaphore.acquire();
795
+
796
+ // Add timeout to prevent indefinite blocking
797
+ if (!mainThreadSemaphore.tryAcquire(10, TimeUnit.SECONDS)) {
798
+ logger.error("Timeout waiting for main thread operation");
799
+ }
792
800
  } else {
793
801
  try {
794
- url.set(new URL(this.bridge.getWebView().getUrl()));
802
+ if (this.bridge != null && this.bridge.getWebView() != null) {
803
+ String currentUrl = this.bridge.getWebView().getUrl();
804
+ if (currentUrl != null) {
805
+ url.set(new URL(currentUrl));
806
+ }
807
+ }
795
808
  } catch (Exception e) {
796
809
  logger.error("Error executing on main thread " + e.getMessage());
797
810
  }
798
811
  }
799
812
  } catch (InterruptedException e) {
800
813
  logger.error("Error waiting for main thread or getting the current URL from webview " + e.getMessage());
814
+ Thread.currentThread().interrupt(); // Restore interrupted status
801
815
  }
802
816
  }
803
817
 
@@ -839,7 +853,12 @@ public class CapacitorUpdaterPlugin extends Plugin {
839
853
  this.notifyListeners("appReloaded", new JSObject());
840
854
 
841
855
  // Wait for the reload to complete (until notifyAppReady is called)
842
- this.semaphoreWait(this.appReadyTimeout);
856
+ try {
857
+ this.semaphoreWait(this.appReadyTimeout);
858
+ } catch (Exception e) {
859
+ logger.error("Error waiting for app ready: " + e.getMessage());
860
+ return false;
861
+ }
843
862
 
844
863
  return true;
845
864
  }
@@ -32,6 +32,7 @@ import java.util.Iterator;
32
32
  import java.util.List;
33
33
  import java.util.Map;
34
34
  import java.util.Objects;
35
+ import java.util.concurrent.TimeUnit;
35
36
  import java.util.zip.ZipEntry;
36
37
  import java.util.zip.ZipInputStream;
37
38
  import okhttp3.*;
@@ -57,7 +58,7 @@ public class CapgoUpdater {
57
58
  public SharedPreferences.Editor editor;
58
59
  public SharedPreferences prefs;
59
60
 
60
- public OkHttpClient client;
61
+ private OkHttpClient client;
61
62
 
62
63
  public File documentsDir;
63
64
  public Boolean directUpdate = false;
@@ -79,6 +80,8 @@ public class CapgoUpdater {
79
80
 
80
81
  public CapgoUpdater(Logger logger) {
81
82
  this.logger = logger;
83
+ // Simple OkHttpClient - actual configuration happens in plugin
84
+ this.client = new OkHttpClient.Builder().connectTimeout(30, TimeUnit.SECONDS).readTimeout(30, TimeUnit.SECONDS).build();
82
85
  }
83
86
 
84
87
  private final FilenameFilter filter = (f, name) -> {
@@ -248,11 +251,16 @@ public class CapgoUpdater {
248
251
  id,
249
252
  new BundleInfo(id, version, BundleStatus.ERROR, new Date(System.currentTimeMillis()), "")
250
253
  );
254
+ // Cleanup download tracking
255
+ DownloadWorkerManager.cancelBundleDownload(activity, id, version);
251
256
  Map<String, Object> ret = new HashMap<>();
252
257
  ret.put("version", getCurrentBundle().getVersionName());
253
258
  ret.put("error", "finish_download_fail");
254
259
  sendStats("finish_download_fail", version);
255
260
  notifyListeners("downloadFailed", ret);
261
+ } else {
262
+ // Successful download - cleanup tracking
263
+ DownloadWorkerManager.cancelBundleDownload(activity, id, version);
256
264
  }
257
265
  break;
258
266
  case FAILED:
@@ -264,6 +272,8 @@ public class CapgoUpdater {
264
272
  id,
265
273
  new BundleInfo(id, failedVersion, BundleStatus.ERROR, new Date(System.currentTimeMillis()), "")
266
274
  );
275
+ // Cleanup download tracking for failed downloads
276
+ DownloadWorkerManager.cancelBundleDownload(activity, id, failedVersion);
267
277
  Map<String, Object> ret = new HashMap<>();
268
278
  ret.put("version", getCurrentBundle().getVersionName());
269
279
  if ("low_mem_fail".equals(error)) {
@@ -381,6 +391,7 @@ public class CapgoUpdater {
381
391
  downloaded.delete();
382
392
  }
383
393
  this.notifyDownload(id, 100);
394
+ // Remove old bundle info and set new one
384
395
  this.saveBundleInfo(id, null);
385
396
  BundleInfo next = new BundleInfo(id, version, BundleStatus.PENDING, new Date(System.currentTimeMillis()), checksum);
386
397
  this.saveBundleInfo(id, next);
@@ -440,7 +451,7 @@ public class CapgoUpdater {
440
451
  final String id = this.randomString();
441
452
 
442
453
  // Check if version is already downloading, but allow retry if previous download failed
443
- if (this.activity != null && DownloadWorkerManager.isVersionDownloading(version)) {
454
+ if (this.activity != null && DownloadWorkerManager.isVersionDownloading(this.activity, version)) {
444
455
  // Check if there's an existing bundle with error status that we can retry
445
456
  BundleInfo existingBundle = this.getBundleInfoByName(version);
446
457
  if (existingBundle != null && existingBundle.isErrorStatus()) {
@@ -453,7 +464,7 @@ public class CapgoUpdater {
453
464
  }
454
465
  }
455
466
 
456
- this.saveBundleInfo(id, new BundleInfo(id, version, BundleStatus.DOWNLOADING, new Date(System.currentTimeMillis()), ""));
467
+ saveBundleInfo(id, new BundleInfo(id, version, BundleStatus.DOWNLOADING, new Date(System.currentTimeMillis()), ""));
457
468
  this.notifyDownload(id, 0);
458
469
  this.notifyDownload(id, 5);
459
470
 
@@ -469,7 +480,7 @@ public class CapgoUpdater {
469
480
  }
470
481
 
471
482
  final String id = this.randomString();
472
- this.saveBundleInfo(id, new BundleInfo(id, version, BundleStatus.DOWNLOADING, new Date(System.currentTimeMillis()), ""));
483
+ saveBundleInfo(id, new BundleInfo(id, version, BundleStatus.DOWNLOADING, new Date(System.currentTimeMillis()), ""));
473
484
  this.notifyDownload(id, 0);
474
485
  this.notifyDownload(id, 5);
475
486
  final String dest = this.randomString();
@@ -16,6 +16,7 @@ import java.net.HttpURLConnection;
16
16
  import java.net.URL;
17
17
  import java.nio.channels.FileChannel;
18
18
  import java.nio.file.Files;
19
+ import java.nio.file.StandardCopyOption;
19
20
  import java.security.MessageDigest;
20
21
  import java.util.ArrayList;
21
22
  import java.util.Arrays;
@@ -33,6 +34,11 @@ 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;
@@ -60,29 +66,44 @@ public class DownloadService extends Worker {
60
66
  public static final String PLUGIN_VERSION = "plugin_version";
61
67
  private static final String UPDATE_FILE = "update.dat";
62
68
 
63
- private final OkHttpClient client;
69
+ // Shared OkHttpClient to prevent resource leaks
70
+ private static OkHttpClient sharedClient;
71
+ private static String currentAppId = "unknown";
72
+ private static String currentPluginVersion = "unknown";
64
73
 
65
- public DownloadService(@NonNull Context context, @NonNull WorkerParameters params) {
66
- super(context, params);
67
- // Get appId and plugin version from input data
68
- String appId = getInputData().getString(APP_ID);
69
- String pluginVersion = getInputData().getString(PLUGIN_VERSION);
70
-
71
- // Build user agent with appId and plugin version
72
- String userAgent =
73
- "CapacitorUpdater/" + (pluginVersion != null ? pluginVersion : "unknown") + " (" + (appId != null ? appId : "unknown") + ")";
74
-
75
- // Create OkHttpClient with custom user agent
76
- this.client = new OkHttpClient.Builder()
74
+ // Initialize shared client with User-Agent interceptor
75
+ static {
76
+ sharedClient = new OkHttpClient.Builder()
77
77
  .protocols(Arrays.asList(Protocol.HTTP_2, Protocol.HTTP_1_1))
78
78
  .addInterceptor(chain -> {
79
79
  Request originalRequest = chain.request();
80
+ String userAgent =
81
+ "CapacitorUpdater/" +
82
+ (currentPluginVersion != null ? currentPluginVersion : "unknown") +
83
+ " (" +
84
+ (currentAppId != null ? currentAppId : "unknown") +
85
+ ")";
80
86
  Request requestWithUserAgent = originalRequest.newBuilder().header("User-Agent", userAgent).build();
81
87
  return chain.proceed(requestWithUserAgent);
82
88
  })
83
89
  .build();
84
90
  }
85
91
 
92
+ // Method to update User-Agent values
93
+ public static void updateUserAgent(String appId, String pluginVersion) {
94
+ currentAppId = appId != null ? appId : "unknown";
95
+ currentPluginVersion = pluginVersion != null ? pluginVersion : "unknown";
96
+ logger.debug("Updated User-Agent: CapacitorUpdater/" + currentPluginVersion + " (" + currentAppId + ")");
97
+ }
98
+
99
+ public DownloadService(@NonNull Context context, @NonNull WorkerParameters params) {
100
+ super(context, params);
101
+ // Use shared client - no need to create new instances
102
+
103
+ // Clean up old temporary files on service initialization
104
+ cleanupOldTempFiles(getApplicationContext().getCacheDir());
105
+ }
106
+
86
107
  private void setProgress(int percent) {
87
108
  Data progress = new Data.Builder().putInt(PERCENT, percent).build();
88
109
  setProgressAsync(progress);
@@ -272,93 +293,156 @@ public class DownloadService extends Worker {
272
293
  String checksum
273
294
  ) {
274
295
  File target = new File(documentsDir, dest);
275
- File infoFile = new File(documentsDir, UPDATE_FILE); // The file where the download progress (how much byte
276
- // downloaded) is stored
277
- File tempFile = new File(documentsDir, "temp" + ".tmp"); // Temp file, where the downloaded data is stored
296
+ File infoFile = new File(documentsDir, UPDATE_FILE);
297
+ File tempFile = new File(documentsDir, "temp" + ".tmp");
298
+
299
+ // Check available disk space before starting
300
+ long availableSpace = target.getParentFile().getUsableSpace();
301
+ long estimatedSize = 50 * 1024 * 1024; // 50MB default estimate
302
+ if (availableSpace < estimatedSize * 2) {
303
+ throw new RuntimeException("insufficient_disk_space");
304
+ }
305
+
306
+ HttpURLConnection httpConn = null;
307
+ InputStream inputStream = null;
308
+ FileOutputStream outputStream = null;
309
+ BufferedReader reader = null;
310
+ BufferedWriter writer = null;
311
+
278
312
  try {
279
313
  URL u = new URL(url);
280
- HttpURLConnection httpConn = null;
281
- try {
282
- httpConn = (HttpURLConnection) u.openConnection();
314
+ httpConn = (HttpURLConnection) u.openConnection();
283
315
 
284
- // Reading progress file (if exist)
285
- long downloadedBytes = 0;
316
+ // Set reasonable timeouts
317
+ httpConn.setConnectTimeout(30000); // 30 seconds
318
+ httpConn.setReadTimeout(60000); // 60 seconds
286
319
 
287
- if (infoFile.exists() && tempFile.exists()) {
288
- try (BufferedReader reader = new BufferedReader(new FileReader(infoFile))) {
289
- String updateVersion = reader.readLine();
290
- if (updateVersion != null && !updateVersion.equals(version)) {
291
- clearDownloadData(documentsDir);
292
- } else {
293
- downloadedBytes = tempFile.length();
294
- }
320
+ // Reading progress file (if exist)
321
+ long downloadedBytes = 0;
322
+
323
+ if (infoFile.exists() && tempFile.exists()) {
324
+ try {
325
+ reader = new BufferedReader(new FileReader(infoFile));
326
+ String updateVersion = reader.readLine();
327
+ if (updateVersion != null && !updateVersion.equals(version)) {
328
+ clearDownloadData(documentsDir);
329
+ } else {
330
+ downloadedBytes = tempFile.length();
331
+ }
332
+ } finally {
333
+ if (reader != null) {
334
+ try {
335
+ reader.close();
336
+ } catch (Exception ignored) {}
295
337
  }
296
- } else {
297
- clearDownloadData(documentsDir);
298
338
  }
339
+ } else {
340
+ clearDownloadData(documentsDir);
341
+ }
342
+
343
+ if (downloadedBytes > 0) {
344
+ httpConn.setRequestProperty("Range", "bytes=" + downloadedBytes + "-");
345
+ }
299
346
 
300
- if (downloadedBytes > 0) {
301
- httpConn.setRequestProperty("Range", "bytes=" + downloadedBytes + "-");
347
+ int responseCode = httpConn.getResponseCode();
348
+
349
+ if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_PARTIAL) {
350
+ long contentLength = httpConn.getContentLength() + downloadedBytes;
351
+
352
+ // Check if we have enough space for the actual file
353
+ if (contentLength > 0 && availableSpace < contentLength * 2) {
354
+ throw new RuntimeException("insufficient_disk_space");
302
355
  }
303
356
 
304
- int responseCode = httpConn.getResponseCode();
357
+ try {
358
+ inputStream = httpConn.getInputStream();
359
+ outputStream = new FileOutputStream(tempFile, downloadedBytes > 0);
360
+
361
+ if (downloadedBytes == 0) {
362
+ writer = new BufferedWriter(new FileWriter(infoFile));
363
+ writer.write(String.valueOf(version));
364
+ writer.close();
365
+ writer = null;
366
+ }
305
367
 
306
- if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_PARTIAL) {
307
- long contentLength = httpConn.getContentLength() + downloadedBytes;
368
+ byte[] buffer = new byte[8192]; // Larger buffer for better performance
369
+ int lastNotifiedPercent = 0;
370
+ int bytesRead;
308
371
 
309
- try (
310
- InputStream inputStream = httpConn.getInputStream();
311
- FileOutputStream outputStream = new FileOutputStream(tempFile, downloadedBytes > 0)
312
- ) {
313
- if (downloadedBytes == 0) {
314
- try (BufferedWriter writer = new BufferedWriter(new FileWriter(infoFile))) {
315
- writer.write(String.valueOf(version));
316
- }
317
- }
318
- // Updating the info file
319
- try (BufferedWriter writer = new BufferedWriter(new FileWriter(infoFile))) {
320
- writer.write(String.valueOf(version));
372
+ while ((bytesRead = inputStream.read(buffer)) != -1) {
373
+ outputStream.write(buffer, 0, bytesRead);
374
+ downloadedBytes += bytesRead;
375
+
376
+ // Flush every 1MB to ensure progress is saved
377
+ if (downloadedBytes % (1024 * 1024) == 0) {
378
+ outputStream.flush();
321
379
  }
322
380
 
323
- int bytesRead = -1;
324
- byte[] buffer = new byte[4096];
325
- int lastNotifiedPercent = 0;
326
- while ((bytesRead = inputStream.read(buffer)) != -1) {
327
- outputStream.write(buffer, 0, bytesRead);
328
- downloadedBytes += bytesRead;
329
- // Saving progress (flushing every 100 Ko)
330
- if (downloadedBytes % 102400 == 0) {
331
- outputStream.flush();
332
- }
333
- // Computing percentage
334
- int percent = calcTotalPercent(downloadedBytes, contentLength);
335
- while (lastNotifiedPercent + 10 <= percent) {
336
- lastNotifiedPercent += 10;
337
- // Artificial delay using CPU-bound calculation to take ~5 seconds
338
- double result = 0;
339
- setProgress(lastNotifiedPercent);
340
- }
381
+ // Computing percentage
382
+ int percent = calcTotalPercent(downloadedBytes, contentLength);
383
+ if (percent >= lastNotifiedPercent + 10) {
384
+ lastNotifiedPercent = (percent / 10) * 10;
385
+ setProgress(lastNotifiedPercent);
341
386
  }
387
+ }
342
388
 
343
- outputStream.close();
344
- inputStream.close();
389
+ // Final flush
390
+ outputStream.flush();
391
+ outputStream.close();
392
+ outputStream = null;
345
393
 
346
- // Rename the temp file with the final name (dest)
347
- tempFile.renameTo(new File(documentsDir, dest));
348
- infoFile.delete();
394
+ inputStream.close();
395
+ inputStream = null;
396
+
397
+ // Rename the temp file with the final name (dest)
398
+ if (!tempFile.renameTo(new File(documentsDir, dest))) {
399
+ throw new RuntimeException("Failed to rename temp file to final destination");
349
400
  }
350
- } else {
351
401
  infoFile.delete();
402
+ } catch (OutOfMemoryError e) {
403
+ logger.error("Out of memory during download: " + e.getMessage());
404
+ // Try to free some memory
405
+ System.gc();
406
+ throw new RuntimeException("low_mem_fail");
407
+ } finally {
408
+ // Ensure all resources are closed
409
+ if (outputStream != null) {
410
+ try {
411
+ outputStream.close();
412
+ } catch (Exception ignored) {}
413
+ }
414
+ if (inputStream != null) {
415
+ try {
416
+ inputStream.close();
417
+ } catch (Exception ignored) {}
418
+ }
419
+ if (writer != null) {
420
+ try {
421
+ writer.close();
422
+ } catch (Exception ignored) {}
423
+ }
352
424
  }
353
- } finally {
354
- if (httpConn != null) {
355
- httpConn.disconnect();
356
- }
425
+ } else {
426
+ infoFile.delete();
427
+ throw new RuntimeException("HTTP error: " + responseCode);
357
428
  }
358
429
  } catch (OutOfMemoryError e) {
430
+ logger.error("Critical memory error: " + e.getMessage());
431
+ System.gc(); // Suggest garbage collection
359
432
  throw new RuntimeException("low_mem_fail");
433
+ } catch (SecurityException e) {
434
+ logger.error("Security error during download: " + e.getMessage());
435
+ throw new RuntimeException("security_error: " + e.getMessage());
360
436
  } catch (Exception e) {
361
- throw new RuntimeException(e.getLocalizedMessage());
437
+ logger.error("Download error: " + e.getMessage());
438
+ throw new RuntimeException(e.getMessage());
439
+ } finally {
440
+ // Ensure connection is closed
441
+ if (httpConn != null) {
442
+ try {
443
+ httpConn.disconnect();
444
+ } catch (Exception ignored) {}
445
+ }
362
446
  }
363
447
  }
364
448
 
@@ -412,26 +496,20 @@ public class DownloadService extends Worker {
412
496
  // Create a temporary file for the compressed data
413
497
  File compressedFile = new File(getApplicationContext().getCacheDir(), "temp_" + targetFile.getName() + ".tmp");
414
498
 
415
- try (Response response = client.newCall(request).execute()) {
499
+ try (Response response = sharedClient.newCall(request).execute()) {
416
500
  if (!response.isSuccessful()) {
417
501
  throw new IOException("Unexpected response code: " + response.code());
418
502
  }
419
503
 
420
- // Download compressed file
421
- try (ResponseBody responseBody = response.body(); FileOutputStream compressedFos = new FileOutputStream(compressedFile)) {
422
- if (responseBody == null) {
423
- throw new IOException("Response body is null");
424
- }
425
-
426
- byte[] buffer = new byte[8192];
427
- int bytesRead;
428
- try (InputStream inputStream = responseBody.byteStream()) {
429
- while ((bytesRead = inputStream.read(buffer)) != -1) {
430
- compressedFos.write(buffer, 0, bytesRead);
431
- }
432
- }
504
+ // Download compressed file atomically
505
+ ResponseBody responseBody = response.body();
506
+ if (responseBody == null) {
507
+ throw new IOException("Response body is null");
433
508
  }
434
509
 
510
+ // Use OkIO for atomic write
511
+ writeFileAtomic(compressedFile, responseBody.byteStream(), null);
512
+
435
513
  if (!publicKey.isEmpty() && sessionKey != null && !sessionKey.isEmpty()) {
436
514
  logger.debug("Decrypting file " + targetFile.getName());
437
515
  CryptoCipherV2.decryptFile(compressedFile, publicKey, sessionKey);
@@ -439,13 +517,22 @@ public class DownloadService extends Worker {
439
517
 
440
518
  // Only decompress if file has .br extension
441
519
  if (isBrotli) {
442
- // Use new decompression method
443
- byte[] compressedData = Files.readAllBytes(compressedFile.toPath());
444
- byte[] decompressedData = decompressBrotli(compressedData, targetFile.getName());
445
- Files.write(finalTargetFile.toPath(), decompressedData);
520
+ // Use new decompression method with atomic write
521
+ try (FileInputStream fis = new FileInputStream(compressedFile)) {
522
+ byte[] compressedData = new byte[(int) compressedFile.length()];
523
+ fis.read(compressedData);
524
+ byte[] decompressedData = decompressBrotli(compressedData, targetFile.getName());
525
+
526
+ // Write decompressed data atomically
527
+ try (java.io.ByteArrayInputStream bais = new java.io.ByteArrayInputStream(decompressedData)) {
528
+ writeFileAtomic(finalTargetFile, bais, null);
529
+ }
530
+ }
446
531
  } else {
447
- // Just copy the file without decompression
448
- Files.copy(compressedFile.toPath(), finalTargetFile.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING);
532
+ // Just copy the file without decompression using atomic operation
533
+ try (FileInputStream fis = new FileInputStream(compressedFile)) {
534
+ writeFileAtomic(finalTargetFile, fis, null);
535
+ }
449
536
  }
450
537
 
451
538
  // Delete the compressed file
@@ -454,8 +541,10 @@ public class DownloadService extends Worker {
454
541
 
455
542
  // Verify checksum
456
543
  if (calculatedHash.equals(expectedHash)) {
457
- // Only cache if checksum is correct
458
- copyFile(finalTargetFile, cacheFile);
544
+ // Only cache if checksum is correct - use atomic copy
545
+ try (FileInputStream fis = new FileInputStream(finalTargetFile)) {
546
+ writeFileAtomic(cacheFile, fis, expectedHash);
547
+ }
459
548
  } else {
460
549
  finalTargetFile.delete();
461
550
  throw new IOException(
@@ -561,4 +650,75 @@ public class DownloadService extends Worker {
561
650
  throw e;
562
651
  }
563
652
  }
653
+
654
+ /**
655
+ * Atomically write data to a file using OkIO
656
+ */
657
+ private void writeFileAtomic(File targetFile, InputStream inputStream, String expectedChecksum) throws IOException {
658
+ File tempFile = new File(targetFile.getParent(), targetFile.getName() + ".tmp");
659
+
660
+ try {
661
+ // Write to temp file first using OkIO
662
+ try (BufferedSink sink = Okio.buffer(Okio.sink(tempFile)); BufferedSource source = Okio.buffer(Okio.source(inputStream))) {
663
+ sink.writeAll(source);
664
+ }
665
+
666
+ // Verify checksum if provided
667
+ if (expectedChecksum != null && !expectedChecksum.isEmpty()) {
668
+ String actualChecksum = calculateFileChecksum(tempFile);
669
+ if (!expectedChecksum.equalsIgnoreCase(actualChecksum)) {
670
+ tempFile.delete();
671
+ throw new IOException("Checksum verification failed");
672
+ }
673
+ }
674
+
675
+ // Atomic rename (on same filesystem)
676
+ Files.move(tempFile.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
677
+ } catch (Exception e) {
678
+ // Clean up temp file on error
679
+ if (tempFile.exists()) {
680
+ tempFile.delete();
681
+ }
682
+ throw new IOException("Failed to write file atomically: " + e.getMessage(), e);
683
+ }
684
+ }
685
+
686
+ /**
687
+ * Calculate MD5 checksum of a file
688
+ */
689
+ private String calculateFileChecksum(File file) throws IOException {
690
+ try (FileInputStream fis = new FileInputStream(file)) {
691
+ MessageDigest md = MessageDigest.getInstance("MD5");
692
+ byte[] buffer = new byte[8192];
693
+ int bytesRead;
694
+ while ((bytesRead = fis.read(buffer)) != -1) {
695
+ md.update(buffer, 0, bytesRead);
696
+ }
697
+ byte[] digest = md.digest();
698
+ StringBuilder sb = new StringBuilder();
699
+ for (byte b : digest) {
700
+ sb.append(String.format("%02x", b));
701
+ }
702
+ return sb.toString();
703
+ } catch (Exception e) {
704
+ throw new IOException("Failed to calculate checksum: " + e.getMessage(), e);
705
+ }
706
+ }
707
+
708
+ /**
709
+ * Clean up old temporary files
710
+ */
711
+ private void cleanupOldTempFiles(File directory) {
712
+ if (directory == null || !directory.exists()) return;
713
+
714
+ File[] tempFiles = directory.listFiles((dir, name) -> name.endsWith(".tmp"));
715
+ if (tempFiles != null) {
716
+ long oneHourAgo = System.currentTimeMillis() - 3600000;
717
+ for (File tempFile : tempFiles) {
718
+ if (tempFile.lastModified() < oneHourAgo) {
719
+ tempFile.delete();
720
+ }
721
+ }
722
+ }
723
+ }
564
724
  }
@@ -5,12 +5,11 @@ import androidx.work.BackoffPolicy;
5
5
  import androidx.work.Configuration;
6
6
  import androidx.work.Constraints;
7
7
  import androidx.work.Data;
8
+ import androidx.work.ExistingWorkPolicy;
8
9
  import androidx.work.NetworkType;
9
10
  import androidx.work.OneTimeWorkRequest;
10
11
  import androidx.work.WorkManager;
11
12
  import androidx.work.WorkRequest;
12
- import java.util.HashSet;
13
- import java.util.Set;
14
13
  import java.util.concurrent.TimeUnit;
15
14
 
16
15
  public class DownloadWorkerManager {
@@ -22,8 +21,6 @@ public class DownloadWorkerManager {
22
21
  }
23
22
 
24
23
  private static volatile boolean isInitialized = false;
25
- private static final Set<String> activeVersions = new HashSet<>();
26
- private static final Object activeVersionsLock = new Object();
27
24
 
28
25
  private static synchronized void initializeIfNeeded(Context context) {
29
26
  if (!isInitialized) {
@@ -37,9 +34,17 @@ public class DownloadWorkerManager {
37
34
  }
38
35
  }
39
36
 
40
- public static boolean isVersionDownloading(String version) {
41
- synchronized (activeVersionsLock) {
42
- return activeVersions.contains(version);
37
+ public static boolean isVersionDownloading(Context context, String version) {
38
+ initializeIfNeeded(context.getApplicationContext());
39
+ try {
40
+ return WorkManager.getInstance(context)
41
+ .getWorkInfosByTag(version)
42
+ .get()
43
+ .stream()
44
+ .anyMatch(workInfo -> !workInfo.getState().isFinished());
45
+ } catch (Exception e) {
46
+ logger.error("Error checking download status: " + e.getMessage());
47
+ return false;
43
48
  }
44
49
  }
45
50
 
@@ -60,14 +65,8 @@ public class DownloadWorkerManager {
60
65
  ) {
61
66
  initializeIfNeeded(context.getApplicationContext());
62
67
 
63
- // If version is already downloading, don't start another one
64
- synchronized (activeVersionsLock) {
65
- if (activeVersions.contains(version)) {
66
- logger.info("Version " + version + " is already downloading");
67
- return;
68
- }
69
- activeVersions.add(version);
70
- }
68
+ // Use unique work name for this bundle to prevent duplicates
69
+ String uniqueWorkName = "bundle_" + id + "_" + version;
71
70
 
72
71
  // Create input data
73
72
  Data inputData = new Data.Builder()
@@ -100,7 +99,7 @@ public class DownloadWorkerManager {
100
99
  .setConstraints(constraints)
101
100
  .setInputData(inputData)
102
101
  .addTag(id)
103
- .addTag(version) // Add version tag for tracking
102
+ .addTag(version)
104
103
  .addTag("capacitor_updater_download");
105
104
 
106
105
  // More aggressive retry policy for emulators
@@ -112,23 +111,29 @@ public class DownloadWorkerManager {
112
111
 
113
112
  OneTimeWorkRequest workRequest = workRequestBuilder.build();
114
113
 
115
- // Enqueue work
116
- WorkManager.getInstance(context).enqueue(workRequest);
114
+ // Use beginUniqueWork to prevent duplicate downloads
115
+ WorkManager.getInstance(context)
116
+ .beginUniqueWork(
117
+ uniqueWorkName,
118
+ ExistingWorkPolicy.KEEP, // Don't start if already running
119
+ workRequest
120
+ )
121
+ .enqueue();
117
122
  }
118
123
 
119
124
  public static void cancelVersionDownload(Context context, String version) {
120
125
  initializeIfNeeded(context.getApplicationContext());
121
126
  WorkManager.getInstance(context).cancelAllWorkByTag(version);
122
- synchronized (activeVersionsLock) {
123
- activeVersions.remove(version);
124
- }
127
+ }
128
+
129
+ public static void cancelBundleDownload(Context context, String id, String version) {
130
+ String uniqueWorkName = "bundle_" + id + "_" + version;
131
+ initializeIfNeeded(context.getApplicationContext());
132
+ WorkManager.getInstance(context).cancelUniqueWork(uniqueWorkName);
125
133
  }
126
134
 
127
135
  public static void cancelAllDownloads(Context context) {
128
136
  initializeIfNeeded(context.getApplicationContext());
129
137
  WorkManager.getInstance(context).cancelAllWorkByTag("capacitor_updater_download");
130
- synchronized (activeVersionsLock) {
131
- activeVersions.clear();
132
- }
133
138
  }
134
139
  }
@@ -86,7 +86,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
86
86
  CAPPluginMethod(name: "isShakeMenuEnabled", returnType: CAPPluginReturnPromise)
87
87
  ]
88
88
  public var implementation = CapgoUpdater()
89
- private let PLUGIN_VERSION: String = "7.9.1"
89
+ private let PLUGIN_VERSION: String = "7.9.2"
90
90
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
91
91
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
92
92
  static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
@@ -117,8 +117,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
117
117
  override public func load() {
118
118
  let disableJSLogging = getConfig().getBoolean("disableJSLogging", false)
119
119
  // Set webView for logging to JavaScript console
120
- if ((self.bridge?.webView) != nil) && !disableJSLogging {
121
- logger.setWebView(webView: (self.bridge?.webView)!)
120
+ if let webView = self.bridge?.webView, !disableJSLogging {
121
+ logger.setWebView(webView: webView)
122
122
  logger.info("WebView set successfully for logging")
123
123
  } else {
124
124
  logger.error("Failed to get webView for logging")
@@ -135,7 +135,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
135
135
  logger.info("init for device \(self.implementation.deviceID)")
136
136
  guard let versionName = getConfig().getString("version", Bundle.main.versionName) else {
137
137
  logger.error("Cannot get version name")
138
- // crash the app
138
+ // crash the app on purpose
139
139
  fatalError("Cannot get version name")
140
140
  }
141
141
  do {
@@ -195,6 +195,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
195
195
  implementation.appId = config?["appId"] as? String ?? implementation.appId
196
196
  implementation.appId = getConfig().getString("appId", implementation.appId)!
197
197
  if implementation.appId == "" {
198
+ // crash the app on purpose it should not happen
198
199
  fatalError("appId is missing in capacitor.config.json or plugin config, and cannot be retrieved from the native app, please add it globally or in the plugin config")
199
200
  }
200
201
  logger.info("appId \(implementation.appId)")
@@ -252,8 +253,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
252
253
  }
253
254
 
254
255
  private func semaphoreWait(waitTime: Int) {
255
- // print("\(CapgoUpdater.TAG) semaphoreWait \(waitTime)")
256
- _ = semaphoreReady.wait(timeout: .now() + .milliseconds(waitTime))
256
+ // print("\\(CapgoUpdater.TAG) semaphoreWait \\(waitTime)")
257
+ let result = semaphoreReady.wait(timeout: .now() + .milliseconds(waitTime))
258
+ if result == .timedOut {
259
+ logger.error("Semaphore wait timed out after \(waitTime)ms")
260
+ }
257
261
  }
258
262
 
259
263
  private func semaphoreUp() {
@@ -1087,6 +1091,15 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1087
1091
  @objc func appKilled() {
1088
1092
  logger.info("onActivityDestroyed: all activity destroyed")
1089
1093
  self.delayUpdateUtils.checkCancelDelay(source: .killed)
1094
+
1095
+ // Clean up resources
1096
+ periodicUpdateTimer?.invalidate()
1097
+ periodicUpdateTimer = nil
1098
+ backgroundWork?.cancel()
1099
+ backgroundWork = nil
1100
+
1101
+ // Signal any waiting semaphores to prevent deadlocks
1102
+ semaphoreReady.signal()
1090
1103
  }
1091
1104
 
1092
1105
  private func installNext() {
@@ -1164,8 +1177,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1164
1177
  // Clean up any existing timer
1165
1178
  periodicUpdateTimer?.invalidate()
1166
1179
 
1167
- periodicUpdateTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(periodCheckDelay), repeats: true) { [weak self] _ in
1168
- guard let self = self else { return }
1180
+ periodicUpdateTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(periodCheckDelay), repeats: true) { [weak self] timer in
1181
+ guard let self = self else {
1182
+ timer.invalidate()
1183
+ return
1184
+ }
1169
1185
  DispatchQueue.global(qos: .background).async {
1170
1186
  let res = self.implementation.getLatest(url: url, channel: nil)
1171
1187
  let current = self.implementation.getCurrentBundle()
@@ -43,7 +43,9 @@ import UIKit
43
43
  public var publicKey: String = ""
44
44
 
45
45
  private var userAgent: String {
46
- return "CapacitorUpdater/\(PLUGIN_VERSION) (\(appId))"
46
+ let safePluginVersion = PLUGIN_VERSION.isEmpty ? "unknown" : PLUGIN_VERSION
47
+ let safeAppId = appId.isEmpty ? "unknown" : appId
48
+ return "CapacitorUpdater/\(safePluginVersion) (\(safeAppId))"
47
49
  }
48
50
 
49
51
  private lazy var alamofireSession: Session = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "7.9.1",
3
+ "version": "7.9.2",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",