@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.
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +1 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +10 -2
- package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +14 -3
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +164 -1
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1 -1
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
422
|
+
try {
|
|
412
423
|
byte[] buffer = new byte[BUFFER_SIZE];
|
|
413
424
|
int length;
|
|
414
|
-
while ((length =
|
|
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("
|
|
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 (
|
|
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.
|
|
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"
|