@capgo/capacitor-updater 8.51.9 → 8.51.10

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.
@@ -146,7 +146,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
146
146
  static final int APPLICATION_EXIT_REASON_USER_REQUESTED = 10;
147
147
  static final int APPLICATION_EXIT_REASON_DEPENDENCY_DIED = 12;
148
148
 
149
- private final String pluginVersion = "8.51.9";
149
+ private final String pluginVersion = "8.51.10";
150
150
  private static final String DELAY_CONDITION_PREFERENCES = "";
151
151
 
152
152
  private SharedPreferences.Editor editor;
@@ -515,11 +515,19 @@ public class CapgoUpdater {
515
515
  return false;
516
516
  }
517
517
 
518
- final File builtinFile = new File(this.activity.getFilesDir(), "public/" + fileName);
519
- if (verifyChecksum(builtinFile, fileHash)) {
518
+ if (DownloadService.builtinAssetMatches(this.activity.getAssets(), fileName, fileHash)) {
520
519
  return true;
521
520
  }
522
521
 
522
+ try {
523
+ final File builtinFile = DownloadService.resolveManifestBuiltinFile(new File(this.activity.getFilesDir(), "public"), fileName);
524
+ if (verifyChecksum(builtinFile, fileHash)) {
525
+ return true;
526
+ }
527
+ } catch (IOException ignored) {
528
+ // Invalid path; fall through to cache lookup.
529
+ }
530
+
523
531
  final boolean isBrotli = fileName.endsWith(".br");
524
532
  final String fileNameWithoutPath = new File(fileName).getName();
525
533
  final String cacheBaseName = isBrotli ? fileNameWithoutPath.substring(0, fileNameWithoutPath.length() - 3) : fileNameWithoutPath;
@@ -18,6 +18,7 @@ import java.io.FileInputStream;
18
18
  import java.io.FileOutputStream;
19
19
  import java.io.FileReader;
20
20
  import java.io.IOException;
21
+ import java.io.InputStream;
21
22
  import java.security.GeneralSecurityException;
22
23
  import java.security.InvalidAlgorithmParameterException;
23
24
  import java.security.InvalidKeyException;
@@ -399,6 +400,16 @@ public class CryptoCipher {
399
400
  }
400
401
 
401
402
  public static String calcChecksum(File file) {
403
+ try (FileInputStream fis = new FileInputStream(file)) {
404
+ return calcChecksum(fis);
405
+ } catch (IOException e) {
406
+ logger.error("Cannot calculate checksum");
407
+ logger.debug("Path: " + file.getPath() + ", Error: " + e.getMessage());
408
+ return "";
409
+ }
410
+ }
411
+
412
+ public static String calcChecksum(InputStream inputStream) {
402
413
  final int BUFFER_SIZE = checksumBufferBytes();
403
414
  MessageDigest digest;
404
415
  try {
@@ -408,10 +419,10 @@ public class CryptoCipher {
408
419
  return "";
409
420
  }
410
421
 
411
- try (FileInputStream fis = new FileInputStream(file)) {
422
+ try {
412
423
  byte[] buffer = new byte[BUFFER_SIZE];
413
424
  int length;
414
- while ((length = fis.read(buffer)) != -1) {
425
+ while ((length = inputStream.read(buffer)) != -1) {
415
426
  digest.update(buffer, 0, length);
416
427
  }
417
428
  byte[] hash = digest.digest();
@@ -424,7 +435,7 @@ public class CryptoCipher {
424
435
  return hexString.toString();
425
436
  } catch (IOException e) {
426
437
  logger.error("Cannot calculate checksum");
427
- logger.debug("Path: " + file.getPath() + ", Error: " + e.getMessage());
438
+ logger.debug("Error: " + e.getMessage());
428
439
  return "";
429
440
  }
430
441
  }
@@ -6,6 +6,7 @@
6
6
  package ee.forgr.capacitor_updater;
7
7
 
8
8
  import android.content.Context;
9
+ import android.content.res.AssetManager;
9
10
  import androidx.annotation.NonNull;
10
11
  import androidx.work.Data;
11
12
  import androidx.work.Worker;
@@ -18,8 +19,11 @@ import java.nio.channels.FileChannel;
18
19
  import java.security.MessageDigest;
19
20
  import java.util.ArrayList;
20
21
  import java.util.Arrays;
22
+ import java.util.HashSet;
21
23
  import java.util.List;
22
24
  import java.util.Objects;
25
+ import java.util.Set;
26
+ import java.util.UUID;
23
27
  import java.util.concurrent.ExecutorService;
24
28
  import java.util.concurrent.Executors;
25
29
  import java.util.concurrent.Future;
@@ -179,6 +183,131 @@ public class DownloadService extends Worker {
179
183
  return CapgoUpdater.resolvePathInsideDirectory(builtinFolder, resolvedName);
180
184
  }
181
185
 
186
+ /** APK web assets live in assets/public/; strip .br so store files match. */
187
+ static String resolveBuiltinAssetPath(final String fileName) throws IOException {
188
+ final File base = new File("/capgo-builtin-assets");
189
+ final File resolved = resolveManifestBuiltinFile(base, fileName);
190
+ final String basePath = base.getCanonicalPath();
191
+ final String resolvedPath = resolved.getCanonicalPath();
192
+ final String normalizedBasePath = basePath.endsWith(File.separator) ? basePath : basePath + File.separator;
193
+ if (!resolvedPath.startsWith(normalizedBasePath)) {
194
+ throw new IOException("Invalid manifest file path: " + fileName);
195
+ }
196
+ return "public/" + resolvedPath.substring(normalizedBasePath.length()).replace(File.separatorChar, '/');
197
+ }
198
+
199
+ static boolean copyStreamIfChecksumMatches(final InputStream input, final File dest, final String expectedHash) throws IOException {
200
+ if (expectedHash == null || expectedHash.isEmpty()) {
201
+ return false;
202
+ }
203
+ final File parent = dest.getParentFile();
204
+ if (parent == null) {
205
+ throw new IOException("Destination has no parent: " + dest.getAbsolutePath());
206
+ }
207
+ if (!parent.exists() && !parent.mkdirs()) {
208
+ throw new IOException("Failed to create parent directory: " + parent.getAbsolutePath());
209
+ }
210
+
211
+ final MessageDigest digest;
212
+ try {
213
+ digest = MessageDigest.getInstance("SHA-256");
214
+ } catch (java.security.NoSuchAlgorithmException e) {
215
+ throw new IOException("SHA-256 algorithm not available", e);
216
+ }
217
+
218
+ final File tempFile = File.createTempFile("capgo_asset_", ".tmp", parent);
219
+ try {
220
+ try (FileOutputStream outStream = new FileOutputStream(tempFile)) {
221
+ final byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
222
+ int length;
223
+ while ((length = input.read(buffer)) != -1) {
224
+ digest.update(buffer, 0, length);
225
+ outStream.write(buffer, 0, length);
226
+ }
227
+ }
228
+ if (!expectedHash.equalsIgnoreCase(sha256Hex(digest))) {
229
+ return false;
230
+ }
231
+ return replaceFile(tempFile, dest);
232
+ } finally {
233
+ deleteQuietly(tempFile);
234
+ }
235
+ }
236
+
237
+ private static String sha256Hex(final MessageDigest digest) {
238
+ final byte[] hash = digest.digest();
239
+ final StringBuilder hexString = new StringBuilder(hash.length * 2);
240
+ for (final byte b : hash) {
241
+ final String hex = Integer.toHexString(0xff & b);
242
+ if (hex.length() == 1) {
243
+ hexString.append('0');
244
+ }
245
+ hexString.append(hex);
246
+ }
247
+ return hexString.toString();
248
+ }
249
+
250
+ private static void deleteQuietly(final File file) {
251
+ if (file.exists() && !file.delete()) {
252
+ file.deleteOnExit();
253
+ }
254
+ }
255
+
256
+ static boolean replaceFile(final File tempFile, final File dest) {
257
+ if (tempFile.renameTo(dest)) {
258
+ return true;
259
+ }
260
+ final File parent = dest.getParentFile();
261
+ if (parent == null) {
262
+ return false;
263
+ }
264
+ final File backup = new File(parent, ".capgo_bak_" + UUID.randomUUID());
265
+ deleteQuietly(backup);
266
+ if (dest.exists() && !dest.renameTo(backup)) {
267
+ return false;
268
+ }
269
+ if (!tempFile.renameTo(dest)) {
270
+ if (backup.exists()) {
271
+ backup.renameTo(dest);
272
+ }
273
+ return false;
274
+ }
275
+ deleteQuietly(backup);
276
+ return true;
277
+ }
278
+
279
+ static boolean rememberManifestTarget(final Set<String> seenTargets, final File targetFile) throws IOException {
280
+ return seenTargets.add(targetFile.getCanonicalPath());
281
+ }
282
+
283
+ static boolean tryCopyBuiltinAsset(final AssetManager assets, final String fileName, final File dest, final String expectedHash) {
284
+ if (assets == null || fileName == null || dest == null) {
285
+ return false;
286
+ }
287
+ try {
288
+ final String assetPath = resolveBuiltinAssetPath(fileName);
289
+ try (InputStream in = assets.open(assetPath)) {
290
+ return copyStreamIfChecksumMatches(in, dest, expectedHash);
291
+ }
292
+ } catch (IOException e) {
293
+ return false;
294
+ }
295
+ }
296
+
297
+ static boolean builtinAssetMatches(final AssetManager assets, final String fileName, final String expectedHash) {
298
+ if (assets == null || fileName == null || expectedHash == null || expectedHash.isEmpty()) {
299
+ return false;
300
+ }
301
+ try {
302
+ final String assetPath = resolveBuiltinAssetPath(fileName);
303
+ try (InputStream in = assets.open(assetPath)) {
304
+ return expectedHash.equalsIgnoreCase(CryptoCipher.calcChecksum(in));
305
+ }
306
+ } catch (IOException e) {
307
+ return false;
308
+ }
309
+ }
310
+
182
311
  private String getInputString(String key, String fallback) {
183
312
  String value = getInputData().getString(key);
184
313
  return value != null ? value : fallback;
@@ -302,11 +431,13 @@ public class DownloadService extends Worker {
302
431
  File destFolder = new File(documentsDir, dest);
303
432
  File cacheFolder = new File(getApplicationContext().getCacheDir(), "capgo_downloads");
304
433
  File builtinFolder = new File(getApplicationContext().getFilesDir(), "public");
434
+ AssetManager assets = getApplicationContext().getAssets();
305
435
 
306
436
  // Ensure directories are created
307
437
  if (!destFolder.exists() && !destFolder.mkdirs()) {
308
438
  throw new IOException("Failed to create destination directory: " + destFolder.getAbsolutePath());
309
439
  }
440
+ cleanupOrphanedAssetTemps(destFolder);
310
441
  if (!cacheFolder.exists() && !cacheFolder.mkdirs()) {
311
442
  throw new IOException("Failed to create cache directory: " + cacheFolder.getAbsolutePath());
312
443
  }
@@ -317,6 +448,7 @@ public class DownloadService extends Worker {
317
448
 
318
449
  ExecutorService executor = Executors.newFixedThreadPool(Math.min(MANIFEST_MAX_CONCURRENT_FILES, Math.max(1, totalFiles)));
319
450
  List<Future<?>> futures = new ArrayList<>();
451
+ final Set<String> seenTargets = new HashSet<>();
320
452
 
321
453
  for (int i = 0; i < totalFiles; i++) {
322
454
  JSONObject entry = manifest.getJSONObject(i);
@@ -351,6 +483,12 @@ public class DownloadService extends Worker {
351
483
  try {
352
484
  targetFile = resolveManifestTargetFile(destFolder, fileName);
353
485
  builtinFile = resolveManifestBuiltinFile(builtinFolder, fileName);
486
+ if (!rememberManifestTarget(seenTargets, targetFile)) {
487
+ logger.error("Duplicate manifest target path: " + fileName);
488
+ sendStatsAsync("manifest_path_fail", version + ":" + fileName);
489
+ hasError.set(true);
490
+ continue;
491
+ }
354
492
  } catch (IOException e) {
355
493
  logger.error("Invalid manifest file path: " + fileName);
356
494
  sendStatsAsync("manifest_path_fail", version + ":" + fileName);
@@ -374,7 +512,9 @@ public class DownloadService extends Worker {
374
512
  final boolean finalIsBrotli = isBrotli;
375
513
  Future<?> future = executor.submit(() -> {
376
514
  try {
377
- if (builtinFile.exists() && verifyChecksum(builtinFile, finalFileHash)) {
515
+ if (tryCopyBuiltinAsset(assets, fileName, targetFile, finalFileHash)) {
516
+ logger.debug("using builtin asset " + fileName);
517
+ } else if (builtinFile.exists() && verifyChecksum(builtinFile, finalFileHash)) {
378
518
  copyFile(builtinFile, targetFile);
379
519
  logger.debug("using builtin file " + fileName);
380
520
  } else if (
@@ -952,4 +1092,27 @@ public class DownloadService extends Worker {
952
1092
  }
953
1093
  }
954
1094
  }
1095
+
1096
+ private void cleanupOrphanedAssetTemps(final File directory) {
1097
+ if (directory == null || !directory.isDirectory()) {
1098
+ return;
1099
+ }
1100
+ final File[] children = directory.listFiles();
1101
+ if (children == null) {
1102
+ return;
1103
+ }
1104
+ final long oneHourAgo = System.currentTimeMillis() - 3600000;
1105
+ for (final File child : children) {
1106
+ if (child.isDirectory()) {
1107
+ cleanupOrphanedAssetTemps(child);
1108
+ continue;
1109
+ }
1110
+ final String name = child.getName();
1111
+ final boolean orphanedAssetTemp = name.startsWith("capgo_asset_") && name.endsWith(".tmp");
1112
+ final boolean orphanedBackup = name.startsWith(".capgo_bak_");
1113
+ if ((orphanedAssetTemp || orphanedBackup) && child.lastModified() < oneHourAgo) {
1114
+ child.delete();
1115
+ }
1116
+ }
1117
+ }
955
1118
  }
@@ -96,7 +96,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
96
96
  deinit {
97
97
  implementation.shutdown()
98
98
  }
99
- private let pluginVersion: String = "8.51.9"
99
+ private let pluginVersion: String = "8.51.10"
100
100
  private let launchStartedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
101
101
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
102
102
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "8.51.9",
3
+ "version": "8.51.10",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",