@capgo/capacitor-updater 8.51.13 → 8.51.15

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.
@@ -76,12 +76,18 @@ public class DownloadService extends Worker {
76
76
  public static final String DEFAULT_CHANNEL = "default_channel";
77
77
  public static final String IS_PROD = "is_prod";
78
78
  public static final String IS_EMULATOR = "is_emulator";
79
- // HTTP + decode share one pool. Cap by CPU: 8 on 4 cores, 16 on 8 cores, 64 max.
79
+ // HTTP + decode share one pool. Cap per host by CPU: 8 on 4 cores, 16 on 8 cores.
80
+ // Keep the global cap at 64 so API calls on another host are not starved by manifest downloads.
80
81
  private static final int MANIFEST_MAX_CONCURRENT_FILES = manifestMaxConcurrentFiles();
82
+ private static final int SHARED_MAX_REQUESTS = 64;
81
83
  private static final String UPDATE_FILE = "update.dat";
82
84
 
83
85
  // Shared OkHttpClient to prevent resource leaks
84
- 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;
85
91
  private static String currentAppId = "unknown";
86
92
  private static String currentPluginVersion = "unknown";
87
93
  private static String currentVersionOs = "unknown";
@@ -89,11 +95,14 @@ public class DownloadService extends Worker {
89
95
  // Initialize shared client with User-Agent interceptor
90
96
  static {
91
97
  Dispatcher dispatcher = new Dispatcher();
92
- dispatcher.setMaxRequests(MANIFEST_MAX_CONCURRENT_FILES);
98
+ dispatcher.setMaxRequests(SHARED_MAX_REQUESTS);
93
99
  dispatcher.setMaxRequestsPerHost(MANIFEST_MAX_CONCURRENT_FILES);
94
100
  sharedClient = new OkHttpClient.Builder()
95
101
  .dispatcher(dispatcher)
96
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)
97
106
  .addInterceptor((chain) -> {
98
107
  Request originalRequest = chain.request();
99
108
  String userAgent = buildUserAgent(currentAppId, currentPluginVersion, currentVersionOs);
@@ -103,6 +112,32 @@ public class DownloadService extends Worker {
103
112
  .build();
104
113
  }
105
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
+
106
141
  static int manifestMaxConcurrentFiles() {
107
142
  return manifestMaxConcurrentFiles(Runtime.getRuntime().availableProcessors());
108
143
  }
@@ -340,10 +375,12 @@ public class DownloadService extends Worker {
340
375
  logger.debug("doWork isManifest: " + isManifest);
341
376
 
342
377
  if (isManifest) {
343
- JSONArray manifest = DataManager.getInstance().getAndClearManifest();
378
+ JSONArray manifest = DataManager.getInstance().getAndClearManifest(id);
344
379
  if (manifest != null) {
345
380
  handleManifestDownload(id, documentsDir, dest, version, sessionKey, publicKey, manifest);
346
381
  return createSuccessResult(dest, version, sessionKey, checksum, true);
382
+ } else if (isStopped()) {
383
+ return createFailureResult("download_cancelled");
347
384
  } else {
348
385
  logger.error("Manifest is null");
349
386
  return createFailureResult("Manifest is null");
@@ -524,8 +561,7 @@ public class DownloadService extends Worker {
524
561
  try {
525
562
  if (tryCopyBuiltinAsset(assets, fileName, targetFile, finalFileHash)) {
526
563
  logger.debug("using builtin asset " + fileName);
527
- } else if (builtinFile.exists() && verifyChecksum(builtinFile, finalFileHash)) {
528
- copyFile(builtinFile, targetFile);
564
+ } else if (tryCopyBuiltinFile(builtinFile, targetFile, finalFileHash)) {
529
565
  logger.debug("using builtin file " + fileName);
530
566
  } else if (
531
567
  tryCopyFromCache(cacheFile, targetFile, finalFileHash) ||
@@ -624,9 +660,11 @@ public class DownloadService extends Worker {
624
660
  URL u = new URL(url);
625
661
  httpConn = (HttpURLConnection) u.openConnection();
626
662
 
627
- // Set reasonable timeouts
628
- httpConn.setConnectTimeout(30000); // 30 seconds
629
- 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);
630
668
 
631
669
  // Reading progress file (if exist)
632
670
  long downloadedBytes = 0;
@@ -676,7 +714,7 @@ public class DownloadService extends Worker {
676
714
  writer = null;
677
715
  }
678
716
 
679
- byte[] buffer = new byte[8192]; // Larger buffer for better performance
717
+ byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
680
718
  int lastNotifiedPercent = 0;
681
719
  int bytesRead;
682
720
 
@@ -799,6 +837,10 @@ public class DownloadService extends Worker {
799
837
  }
800
838
 
801
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 {
802
844
  final File parent = dest.getParentFile();
803
845
  if (parent != null && !parent.exists() && !parent.mkdirs()) {
804
846
  throw new IOException("Failed to create parent directory: " + parent.getAbsolutePath());
@@ -905,12 +947,9 @@ public class DownloadService extends Worker {
905
947
  } catch (IOException e) {
906
948
  String msg = e.getMessage();
907
949
  if (msg != null && msg.contains("Checksum verification failed")) {
908
- if (finalTargetFile.exists() && !finalTargetFile.delete()) {
909
- logger.debug("Failed to delete dest after checksum mismatch");
910
- }
911
950
  sendStatsAsync("download_manifest_checksum_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
912
951
  keepPartial = false;
913
- } else if (isBrotli) {
952
+ } else if (isBrotli && msg != null && msg.toLowerCase(java.util.Locale.US).contains("brotli")) {
914
953
  sendStatsAsync("download_manifest_brotli_fail", getInputData().getString(VERSION) + ":" + finalTargetFile.getName());
915
954
  keepPartial = false;
916
955
  }
@@ -947,7 +986,8 @@ public class DownloadService extends Worker {
947
986
  if (CapgoUpdater.isSafeCacheHash(hash) && hash.length() == 64) {
948
987
  return new File(cacheDir, "partial_" + hash + "_" + token + ".tmp");
949
988
  }
950
- return new File(cacheDir, "temp_" + UUID.randomUUID() + "_" + token + ".tmp");
989
+ String digest = CryptoCipher.shortPathKey((hash == null ? "" : hash) + "\0" + (fileName == null ? "" : fileName));
990
+ return new File(cacheDir, "partial_" + digest + "_" + token + ".tmp");
951
991
  }
952
992
 
953
993
  static boolean shouldAppendHttpBody(int statusCode, long existingBytes) {
@@ -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 {
@@ -1062,7 +1088,7 @@ public class DownloadService extends Worker {
1062
1088
  }
1063
1089
  }
1064
1090
  logger.error("Error: Raw data (" + fileName + "): " + hexDump);
1065
- throw e;
1091
+ throw new IOException("Brotli process failed for " + fileName + ": " + e.getMessage(), e);
1066
1092
  }
1067
1093
  }
1068
1094
 
@@ -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
  }
@@ -238,6 +238,9 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
238
238
 
239
239
  private void setPreviewMenuButtonsEnabled(List<Button> buttons, boolean enabled) {
240
240
  for (Button button : buttons) {
241
+ if (!enabled && "Close menu".equals(button.getText().toString())) {
242
+ continue;
243
+ }
241
244
  button.setEnabled(enabled);
242
245
  }
243
246
  }
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
  *