@capgo/capacitor-updater 8.51.14 → 8.51.16

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.
@@ -83,7 +83,11 @@ public class DownloadService extends Worker {
83
83
  private static final String UPDATE_FILE = "update.dat";
84
84
 
85
85
  // Shared OkHttpClient to prevent resource leaks
86
- protected static OkHttpClient sharedClient;
86
+ protected static volatile OkHttpClient sharedClient;
87
+ private static final Object HTTP_CLIENT_LOCK = new Object();
88
+ // Match CapgoUpdater.timeout / responseTimeout default (20s). OkHttp's 10s
89
+ // defaults were unused by the plugin config and aborted slow manifest GETs.
90
+ private static volatile int httpTimeoutMs = 20_000;
87
91
  private static String currentAppId = "unknown";
88
92
  private static String currentPluginVersion = "unknown";
89
93
  private static String currentVersionOs = "unknown";
@@ -96,6 +100,9 @@ public class DownloadService extends Worker {
96
100
  sharedClient = new OkHttpClient.Builder()
97
101
  .dispatcher(dispatcher)
98
102
  .protocols(Arrays.asList(Protocol.HTTP_2, Protocol.HTTP_1_1))
103
+ .connectTimeout(httpTimeoutMs, TimeUnit.MILLISECONDS)
104
+ .readTimeout(httpTimeoutMs, TimeUnit.MILLISECONDS)
105
+ .writeTimeout(httpTimeoutMs, TimeUnit.MILLISECONDS)
99
106
  .addInterceptor((chain) -> {
100
107
  Request originalRequest = chain.request();
101
108
  String userAgent = buildUserAgent(currentAppId, currentPluginVersion, currentVersionOs);
@@ -105,6 +112,32 @@ public class DownloadService extends Worker {
105
112
  .build();
106
113
  }
107
114
 
115
+ static int httpTimeoutMs() {
116
+ return httpTimeoutMs;
117
+ }
118
+
119
+ static void applyHttpTimeouts(int timeoutMs) {
120
+ // OkHttp treats 0 as infinite; keep plugin responseTimeout floor (20s default).
121
+ int ms = timeoutMs > 0 ? timeoutMs : 20_000;
122
+ synchronized (HTTP_CLIENT_LOCK) {
123
+ if (
124
+ sharedClient.connectTimeoutMillis() == ms &&
125
+ sharedClient.readTimeoutMillis() == ms &&
126
+ sharedClient.writeTimeoutMillis() == ms
127
+ ) {
128
+ httpTimeoutMs = ms;
129
+ return;
130
+ }
131
+ sharedClient = sharedClient
132
+ .newBuilder()
133
+ .connectTimeout(ms, TimeUnit.MILLISECONDS)
134
+ .readTimeout(ms, TimeUnit.MILLISECONDS)
135
+ .writeTimeout(ms, TimeUnit.MILLISECONDS)
136
+ .build();
137
+ httpTimeoutMs = ms;
138
+ }
139
+ }
140
+
108
141
  static int manifestMaxConcurrentFiles() {
109
142
  return manifestMaxConcurrentFiles(Runtime.getRuntime().availableProcessors());
110
143
  }
@@ -342,10 +375,12 @@ public class DownloadService extends Worker {
342
375
  logger.debug("doWork isManifest: " + isManifest);
343
376
 
344
377
  if (isManifest) {
345
- JSONArray manifest = DataManager.getInstance().getAndClearManifest();
378
+ JSONArray manifest = DataManager.getInstance().getAndClearManifest(id);
346
379
  if (manifest != null) {
347
380
  handleManifestDownload(id, documentsDir, dest, version, sessionKey, publicKey, manifest);
348
381
  return createSuccessResult(dest, version, sessionKey, checksum, true);
382
+ } else if (isStopped()) {
383
+ return createFailureResult("download_cancelled");
349
384
  } else {
350
385
  logger.error("Manifest is null");
351
386
  return createFailureResult("Manifest is null");
@@ -526,8 +561,7 @@ public class DownloadService extends Worker {
526
561
  try {
527
562
  if (tryCopyBuiltinAsset(assets, fileName, targetFile, finalFileHash)) {
528
563
  logger.debug("using builtin asset " + fileName);
529
- } else if (builtinFile.exists() && verifyChecksum(builtinFile, finalFileHash)) {
530
- copyFile(builtinFile, targetFile);
564
+ } else if (tryCopyBuiltinFile(builtinFile, targetFile, finalFileHash)) {
531
565
  logger.debug("using builtin file " + fileName);
532
566
  } else if (
533
567
  tryCopyFromCache(cacheFile, targetFile, finalFileHash) ||
@@ -626,9 +660,11 @@ public class DownloadService extends Worker {
626
660
  URL u = new URL(url);
627
661
  httpConn = (HttpURLConnection) u.openConnection();
628
662
 
629
- // Set reasonable timeouts
630
- httpConn.setConnectTimeout(30000); // 30 seconds
631
- httpConn.setReadTimeout(60000); // 60 seconds
663
+ // Zip can stall longer than a JSON API call; keep a floor so
664
+ // responseTimeout cannot shrink large-bundle downloads.
665
+ int zipTimeoutMs = Math.max(httpTimeoutMs, 60_000);
666
+ httpConn.setConnectTimeout(zipTimeoutMs);
667
+ httpConn.setReadTimeout(zipTimeoutMs);
632
668
 
633
669
  // Reading progress file (if exist)
634
670
  long downloadedBytes = 0;
@@ -678,7 +714,7 @@ public class DownloadService extends Worker {
678
714
  writer = null;
679
715
  }
680
716
 
681
- byte[] buffer = new byte[8192]; // Larger buffer for better performance
717
+ byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
682
718
  int lastNotifiedPercent = 0;
683
719
  int bytesRead;
684
720
 
@@ -801,6 +837,10 @@ public class DownloadService extends Worker {
801
837
  }
802
838
 
803
839
  private void copyFile(File source, File dest) throws IOException {
840
+ copyFileChannel(source, dest);
841
+ }
842
+
843
+ static void copyFileChannel(final File source, final File dest) throws IOException {
804
844
  final File parent = dest.getParentFile();
805
845
  if (parent != null && !parent.exists() && !parent.mkdirs()) {
806
846
  throw new IOException("Failed to create parent directory: " + parent.getAbsolutePath());
@@ -971,33 +1011,19 @@ public class DownloadService extends Worker {
971
1011
  }
972
1012
  }
973
1013
 
974
- private boolean verifyChecksum(File file, String expectedHash) {
975
- try {
976
- String actualHash = calculateFileHash(file);
977
- return actualHash.equalsIgnoreCase(expectedHash);
978
- } catch (Exception e) {
979
- e.printStackTrace();
1014
+ static boolean tryCopyBuiltinFile(final File builtinFile, final File dest, final String expectedHash) {
1015
+ if (builtinFile == null || dest == null || expectedHash == null || expectedHash.isEmpty() || !builtinFile.isFile()) {
980
1016
  return false;
981
1017
  }
982
- }
983
-
984
- private String calculateFileHash(File file) throws Exception {
985
- MessageDigest digest = MessageDigest.getInstance("SHA-256");
986
- byte[] byteArray = new byte[1024];
987
- int bytesCount = 0;
988
-
989
- try (FileInputStream fis = new FileInputStream(file)) {
990
- while ((bytesCount = fis.read(byteArray)) != -1) {
991
- digest.update(byteArray, 0, bytesCount);
1018
+ try {
1019
+ if (!expectedHash.equalsIgnoreCase(CryptoCipher.calcChecksum(builtinFile))) {
1020
+ return false;
992
1021
  }
1022
+ copyFileChannel(builtinFile, dest);
1023
+ return true;
1024
+ } catch (IOException e) {
1025
+ return false;
993
1026
  }
994
-
995
- byte[] bytes = digest.digest();
996
- StringBuilder sb = new StringBuilder();
997
- for (byte aByte : bytes) {
998
- sb.append(Integer.toString((aByte & 0xff) + 0x100, 16).substring(1));
999
- }
1000
- return sb.toString();
1001
1027
  }
1002
1028
 
1003
1029
  static void decompressBrotli(File input, File output, String fileName) throws IOException {
@@ -1,6 +1,7 @@
1
1
  package ee.forgr.capacitor_updater;
2
2
 
3
3
  import android.content.Context;
4
+ import android.os.Build;
4
5
  import androidx.work.BackoffPolicy;
5
6
  import androidx.work.Configuration;
6
7
  import androidx.work.Constraints;
@@ -8,9 +9,18 @@ import androidx.work.Data;
8
9
  import androidx.work.ExistingWorkPolicy;
9
10
  import androidx.work.NetworkType;
10
11
  import androidx.work.OneTimeWorkRequest;
12
+ import androidx.work.OutOfQuotaPolicy;
13
+ import androidx.work.WorkInfo;
11
14
  import androidx.work.WorkManager;
12
15
  import androidx.work.WorkRequest;
16
+ import java.util.HashSet;
17
+ import java.util.List;
18
+ import java.util.Set;
19
+ import java.util.concurrent.ExecutorService;
20
+ import java.util.concurrent.Executors;
21
+ import java.util.concurrent.Future;
13
22
  import java.util.concurrent.TimeUnit;
23
+ import java.util.concurrent.TimeoutException;
14
24
 
15
25
  public class DownloadWorkerManager {
16
26
 
@@ -21,6 +31,7 @@ public class DownloadWorkerManager {
21
31
  }
22
32
 
23
33
  private static volatile boolean isInitialized = false;
34
+ private static final ExecutorService cancelExecutor = Executors.newSingleThreadExecutor();
24
35
 
25
36
  private static synchronized void initializeIfNeeded(Context context) {
26
37
  if (!isInitialized) {
@@ -120,6 +131,11 @@ public class DownloadWorkerManager {
120
131
  .addTag(id)
121
132
  .addTag(version)
122
133
  .addTag("capacitor_updater_download");
134
+ // Android 12+ expedited jobs skip the WorkManager delay without a
135
+ // foreground service. Older APIs require getForegroundInfo().
136
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
137
+ workRequestBuilder.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST);
138
+ }
123
139
 
124
140
  // More aggressive retry policy for emulators
125
141
  if (isEmulator) {
@@ -142,17 +158,101 @@ public class DownloadWorkerManager {
142
158
 
143
159
  public static void cancelVersionDownload(Context context, String version) {
144
160
  initializeIfNeeded(context.getApplicationContext());
145
- WorkManager.getInstance(context).cancelAllWorkByTag(version);
161
+ cancelExecutor.execute(() -> cancelVersionDownloadInternal(context, version, false));
162
+ }
163
+
164
+ public static boolean cancelVersionDownloadAndAwait(Context context, String version) {
165
+ initializeIfNeeded(context.getApplicationContext());
166
+ Future<?> future = cancelExecutor.submit(() -> cancelVersionDownloadInternal(context, version, true));
167
+ try {
168
+ future.get(10, TimeUnit.SECONDS);
169
+ return true;
170
+ } catch (TimeoutException e) {
171
+ future.cancel(true);
172
+ logger.error("Timed out awaiting version download cancel");
173
+ return false;
174
+ } catch (Exception e) {
175
+ logger.error("Error awaiting version download cancel: " + e.getMessage());
176
+ return false;
177
+ }
178
+ }
179
+
180
+ private static Set<String> collectManifestIdsForVersion(WorkManager workManager, String version) {
181
+ Set<String> downloadIds = new HashSet<>();
182
+ try {
183
+ List<WorkInfo> workInfos = workManager.getWorkInfosByTag(version).get();
184
+ for (WorkInfo workInfo : workInfos) {
185
+ for (String tag : workInfo.getTags()) {
186
+ if (!"capacitor_updater_download".equals(tag) && !version.equals(tag)) {
187
+ downloadIds.add(tag);
188
+ }
189
+ }
190
+ }
191
+ } catch (InterruptedException e) {
192
+ Thread.currentThread().interrupt();
193
+ throw new IllegalStateException("Interrupted while collecting manifest ids before version cancel", e);
194
+ } catch (Exception e) {
195
+ logger.error("Error collecting manifest ids before version cancel: " + e.getMessage());
196
+ }
197
+ return downloadIds;
198
+ }
199
+
200
+ private static void clearManifestIds(Set<String> downloadIds) {
201
+ for (String downloadId : downloadIds) {
202
+ DataManager.getInstance().clearManifest(downloadId);
203
+ }
204
+ }
205
+
206
+ private static void cancelVersionDownloadInternal(Context context, String version, boolean awaitFinished) {
207
+ if (Thread.currentThread().isInterrupted()) {
208
+ return;
209
+ }
210
+ WorkManager workManager = WorkManager.getInstance(context);
211
+ Set<String> downloadIds = collectManifestIdsForVersion(workManager, version);
212
+ workManager.cancelAllWorkByTag(version);
213
+ clearManifestIds(downloadIds);
214
+ if (awaitFinished) {
215
+ awaitVersionWorkFinished(workManager, version);
216
+ }
217
+ }
218
+
219
+ private static void awaitVersionWorkFinished(WorkManager workManager, String version) {
220
+ for (int i = 0; i < 100; i++) {
221
+ if (Thread.currentThread().isInterrupted()) {
222
+ throw new IllegalStateException("Interrupted while waiting for version download cancel");
223
+ }
224
+ try {
225
+ boolean anyActive = workManager
226
+ .getWorkInfosByTag(version)
227
+ .get()
228
+ .stream()
229
+ .anyMatch((workInfo) -> !workInfo.getState().isFinished());
230
+ if (!anyActive) {
231
+ return;
232
+ }
233
+ Thread.sleep(100);
234
+ } catch (InterruptedException e) {
235
+ Thread.currentThread().interrupt();
236
+ throw new IllegalStateException("Interrupted while waiting for download cancel", e);
237
+ } catch (Exception e) {
238
+ throw new IllegalStateException("Error waiting for download cancel: " + e.getMessage(), e);
239
+ }
240
+ }
241
+ throw new IllegalStateException("Timed out waiting for version download cancel: " + version);
146
242
  }
147
243
 
148
244
  public static void cancelBundleDownload(Context context, String id, String version) {
149
245
  String uniqueWorkName = "bundle_" + id + "_" + version;
150
246
  initializeIfNeeded(context.getApplicationContext());
151
- WorkManager.getInstance(context).cancelUniqueWork(uniqueWorkName);
247
+ WorkManager workManager = WorkManager.getInstance(context);
248
+ workManager.cancelUniqueWork(uniqueWorkName);
249
+ DataManager.getInstance().clearManifest(id);
152
250
  }
153
251
 
154
252
  public static void cancelAllDownloads(Context context) {
155
253
  initializeIfNeeded(context.getApplicationContext());
156
- WorkManager.getInstance(context).cancelAllWorkByTag("capacitor_updater_download");
254
+ WorkManager workManager = WorkManager.getInstance(context);
255
+ workManager.cancelAllWorkByTag("capacitor_updater_download");
256
+ DataManager.getInstance().clearAllManifests();
157
257
  }
158
258
  }
package/dist/docs.json CHANGED
@@ -4583,7 +4583,7 @@
4583
4583
  "name": "example"
4584
4584
  }
4585
4585
  ],
4586
- "docs": "Configure the number of seconds the native plugin should wait before considering API timeout.\n\nOnly available for Android and iOS.",
4586
+ "docs": "Configure the number of seconds the native plugin should wait before considering an HTTP timeout.\nApplies to update checks and file downloads. On Android these are idle connect/read/write\ntimeouts and do not cap total download time; on iOS the request timeout also bounds the\ntotal download duration.\n\nOnly available for Android and iOS.",
4587
4587
  "complexTypes": [],
4588
4588
  "type": "number | undefined"
4589
4589
  },
@@ -15,7 +15,10 @@ declare module '@capacitor/cli' {
15
15
  */
16
16
  appReadyTimeout?: number;
17
17
  /**
18
- * Configure the number of seconds the native plugin should wait before considering API timeout.
18
+ * Configure the number of seconds the native plugin should wait before considering an HTTP timeout.
19
+ * Applies to update checks and file downloads. On Android these are idle connect/read/write
20
+ * timeouts and do not cap total download time; on iOS the request timeout also bounds the
21
+ * total download duration.
19
22
  *
20
23
  * Only available for Android and iOS.
21
24
  *