@otakit/capacitor-updater 2.2.0 → 2.3.1

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/README.md CHANGED
@@ -162,6 +162,11 @@ If a bundle is applied and never calls `notifyAppReady()`:
162
162
  The last failed applied bundle is persisted so the plugin does not immediately
163
163
  download and apply the same broken release again.
164
164
 
165
+ On top of this per-device rollback, a release published with the auto-revert
166
+ flag is reverted fleet-wide by the server when too many devices report
167
+ rollbacks within a 24-hour window, so remaining devices never download the
168
+ broken bundle.
169
+
165
170
  ## Automatic flow
166
171
 
167
172
  For the normal hosted path, most apps only need:
@@ -27,6 +27,7 @@ final class BundleStore {
27
27
  private final Context context;
28
28
  private final SharedPreferences prefs;
29
29
  private final File bundlesDirectory;
30
+ private final File filesCacheDirectory;
30
31
  private final String builtinVersion;
31
32
  private final String nativeBuild;
32
33
  private final String appRuntimeVersion;
@@ -47,6 +48,16 @@ final class BundleStore {
47
48
  //noinspection ResultOfMethodCallIgnored
48
49
  bundlesDirectory.mkdirs();
49
50
  }
51
+ this.filesCacheDirectory = new File(this.context.getFilesDir(), "otakit_files");
52
+ if (!filesCacheDirectory.exists()) {
53
+ //noinspection ResultOfMethodCallIgnored
54
+ filesCacheDirectory.mkdirs();
55
+ }
56
+ }
57
+
58
+ /** Content-addressed file cache for the deltas strategy ({@code otakit_files/<sha256>}). */
59
+ File getFilesCacheDirectory() {
60
+ return filesCacheDirectory;
50
61
  }
51
62
 
52
63
  String getNativeBuild() {
@@ -0,0 +1,416 @@
1
+ package com.otakit.updater;
2
+
3
+ import android.content.Context;
4
+ import android.content.res.AssetManager;
5
+ import java.io.File;
6
+ import java.io.FileInputStream;
7
+ import java.io.FileOutputStream;
8
+ import java.io.InputStream;
9
+ import java.net.HttpURLConnection;
10
+ import java.net.URL;
11
+ import java.nio.charset.StandardCharsets;
12
+ import java.security.MessageDigest;
13
+ import java.util.ArrayList;
14
+ import java.util.Arrays;
15
+ import java.util.HashSet;
16
+ import java.util.List;
17
+ import java.util.Set;
18
+ import org.json.JSONArray;
19
+ import org.json.JSONObject;
20
+
21
+ /**
22
+ * Assembles a delta-strategy bundle from per-file content-addressed objects.
23
+ *
24
+ * <p>The content cache ({@code otakit_files/<sha256>}) is the device-side
25
+ * state: previous bundles and the builtin seed populate it, and assembling a
26
+ * new bundle downloads only the cache misses. Mirrors DeltaAssembler.swift.
27
+ */
28
+ final class DeltaAssembler {
29
+
30
+ // Mirror ZipUtils' extraction limits.
31
+ private static final int MAX_FILES = 10_000;
32
+ private static final long MAX_TOTAL_SIZE = 500_000_000L; // 500 MB
33
+ private static final int MAX_PATH_LENGTH = 512;
34
+
35
+ private static final String BUILTIN_SEED_MARKER_NAME = "builtin_seed.json";
36
+
37
+ private final File cacheDirectory;
38
+ private final boolean allowInsecureUrls;
39
+
40
+ DeltaAssembler(File cacheDirectory, boolean allowInsecureUrls) {
41
+ this.cacheDirectory = cacheDirectory;
42
+ this.allowInsecureUrls = allowInsecureUrls;
43
+ }
44
+
45
+ // ── Canonical file list ─────────────────────────────────────────────
46
+
47
+ /**
48
+ * Canonical file list hash — must match the server's computeFilesHash
49
+ * (console/lib/delta-files.ts) and the iOS mirror byte-for-byte: entries
50
+ * sorted by UTF-8 bytes of path, lines {@code <path>:<sha256 lowercase>},
51
+ * joined with "\n", hashed with SHA-256 (hex).
52
+ */
53
+ static String computeFilesHash(List<ManifestClient.ManifestFileEntry> entries) throws Exception {
54
+ List<ManifestClient.ManifestFileEntry> sorted = new ArrayList<>(entries);
55
+ sorted.sort((lhs, rhs) -> {
56
+ byte[] lhsBytes = lhs.path.getBytes(StandardCharsets.UTF_8);
57
+ byte[] rhsBytes = rhs.path.getBytes(StandardCharsets.UTF_8);
58
+ int limit = Math.min(lhsBytes.length, rhsBytes.length);
59
+ for (int index = 0; index < limit; index++) {
60
+ int lhsByte = lhsBytes[index] & 0xff;
61
+ int rhsByte = rhsBytes[index] & 0xff;
62
+ if (lhsByte != rhsByte) {
63
+ return Integer.compare(lhsByte, rhsByte);
64
+ }
65
+ }
66
+ return Integer.compare(lhsBytes.length, rhsBytes.length);
67
+ });
68
+
69
+ StringBuilder canonical = new StringBuilder();
70
+ for (int index = 0; index < sorted.size(); index++) {
71
+ if (index > 0) {
72
+ canonical.append('\n');
73
+ }
74
+ ManifestClient.ManifestFileEntry entry = sorted.get(index);
75
+ canonical.append(entry.path).append(':').append(entry.sha256.toLowerCase());
76
+ }
77
+
78
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
79
+ byte[] hash = digest.digest(canonical.toString().getBytes(StandardCharsets.UTF_8));
80
+ StringBuilder builder = new StringBuilder();
81
+ for (byte b : hash) {
82
+ builder.append(String.format("%02x", b));
83
+ }
84
+ return builder.toString();
85
+ }
86
+
87
+ // ── Validation ──────────────────────────────────────────────────────
88
+
89
+ private static boolean isValidEntryPath(String path) {
90
+ if (path == null || path.isEmpty() || path.length() > MAX_PATH_LENGTH) {
91
+ return false;
92
+ }
93
+ if (path.startsWith("/") || path.contains("\\")) {
94
+ return false;
95
+ }
96
+ for (String segment : path.split("/", -1)) {
97
+ if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) {
98
+ return false;
99
+ }
100
+ }
101
+ for (int index = 0; index < path.length(); index++) {
102
+ char c = path.charAt(index);
103
+ if (c < 0x20 || c == 0x7f) {
104
+ return false;
105
+ }
106
+ }
107
+ // Metadata files the plugin writes into the bundle directory; an app
108
+ // file with the same root-level name would be overwritten.
109
+ if ("bundle.json".equals(path) || "otakit_files.json".equals(path)) {
110
+ return false;
111
+ }
112
+ return true;
113
+ }
114
+
115
+ void validate(List<ManifestClient.ManifestFileEntry> entries, String expectedFilesHash)
116
+ throws Exception {
117
+ if (entries == null || entries.isEmpty()) {
118
+ throw new IllegalStateException("Delta manifest has no files");
119
+ }
120
+ if (entries.size() > MAX_FILES) {
121
+ throw new IllegalStateException("Delta manifest exceeds file count limit: " + entries.size());
122
+ }
123
+
124
+ Set<String> seenPaths = new HashSet<>();
125
+ long totalSize = 0;
126
+ for (ManifestClient.ManifestFileEntry entry : entries) {
127
+ if (!isValidEntryPath(entry.path)) {
128
+ throw new IllegalStateException("Invalid file path in delta manifest: " + entry.path);
129
+ }
130
+ if (!seenPaths.add(entry.path)) {
131
+ throw new IllegalStateException("Duplicate file path in delta manifest: " + entry.path);
132
+ }
133
+ if (entry.size > 0) {
134
+ totalSize += entry.size;
135
+ if (totalSize > MAX_TOTAL_SIZE) {
136
+ throw new IllegalStateException("Delta manifest exceeds total size limit: " + totalSize);
137
+ }
138
+ }
139
+ }
140
+
141
+ if (!seenPaths.contains("index.html")) {
142
+ throw new IllegalStateException("Delta bundle does not contain index.html");
143
+ }
144
+
145
+ // The signed manifest sha256 is the filesHash; recomputing it here is what
146
+ // extends signature coverage to every (path, sha256) pair.
147
+ if (!computeFilesHash(entries).equals(expectedFilesHash.toLowerCase())) {
148
+ throw new IllegalStateException("Delta file list does not match the signed filesHash");
149
+ }
150
+ }
151
+
152
+ // ── Cache ───────────────────────────────────────────────────────────
153
+
154
+ private File cachePath(String sha256) {
155
+ return new File(cacheDirectory, sha256.toLowerCase());
156
+ }
157
+
158
+ private void ensureCached(ManifestClient.ManifestFileEntry entry, Context context)
159
+ throws Exception {
160
+ File cached = cachePath(entry.sha256);
161
+ if (cached.exists()) {
162
+ return;
163
+ }
164
+
165
+ URL url = new URL(entry.url);
166
+ ManifestClient.requireHTTPS(url, allowInsecureUrls);
167
+
168
+ File temporary = File.createTempFile("otakit-file-", ".tmp", context.getCacheDir());
169
+ try {
170
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
171
+ try {
172
+ connection.setRequestMethod("GET");
173
+ connection.setConnectTimeout(15_000);
174
+ connection.setReadTimeout(60_000);
175
+
176
+ int status = connection.getResponseCode();
177
+ if (status < 200 || status >= 300) {
178
+ throw new IllegalStateException("File download failed with HTTP " + status);
179
+ }
180
+
181
+ try (
182
+ InputStream input = connection.getInputStream();
183
+ FileOutputStream output = new FileOutputStream(temporary)
184
+ ) {
185
+ byte[] buffer = new byte[8192];
186
+ int read;
187
+ while ((read = input.read(buffer)) > 0) {
188
+ output.write(buffer, 0, read);
189
+ }
190
+ }
191
+ } finally {
192
+ connection.disconnect();
193
+ }
194
+
195
+ if (!HashUtils.verify(temporary, entry.sha256)) {
196
+ throw new IllegalStateException("Downloaded file hash mismatch: " + entry.path);
197
+ }
198
+
199
+ if (!cached.exists()) {
200
+ // Write via temp + rename so process death mid-copy can never leave
201
+ // a truncated file at a content-addressed path (exists() implies
202
+ // fully-written, hash-verified content).
203
+ atomicCopyIntoCache(temporary, cached);
204
+ }
205
+ } finally {
206
+ //noinspection ResultOfMethodCallIgnored
207
+ temporary.delete();
208
+ }
209
+ }
210
+
211
+ // ── Assembly ────────────────────────────────────────────────────────
212
+
213
+ /**
214
+ * Fill cache misses and lay out the bundle directory from the cache.
215
+ * Entries must be validated first.
216
+ */
217
+ void assemble(List<ManifestClient.ManifestFileEntry> entries, File destination, Context context)
218
+ throws Exception {
219
+ if (destination.exists()) {
220
+ deleteRecursively(destination);
221
+ }
222
+ if (!destination.mkdirs()) {
223
+ throw new IllegalStateException("Cannot create assembly directory");
224
+ }
225
+
226
+ String destinationPrefix = destination.getCanonicalPath() + File.separator;
227
+
228
+ for (ManifestClient.ManifestFileEntry entry : entries) {
229
+ ensureCached(entry, context);
230
+
231
+ File target = new File(destination, entry.path);
232
+ // Defense in depth alongside isValidEntryPath (mirrors ZipUtils).
233
+ if (!target.getCanonicalPath().startsWith(destinationPrefix)) {
234
+ throw new IllegalStateException("Invalid file path in delta manifest: " + entry.path);
235
+ }
236
+ File parent = target.getParentFile();
237
+ if (parent != null && !parent.exists() && !parent.mkdirs()) {
238
+ throw new IllegalStateException("Cannot create parent: " + parent.getAbsolutePath());
239
+ }
240
+ copyFile(cachePath(entry.sha256), target);
241
+ }
242
+ }
243
+
244
+ // ── Builtin seeding ─────────────────────────────────────────────────
245
+
246
+ private File builtinSeedFile() {
247
+ return new File(cacheDirectory, BUILTIN_SEED_MARKER_NAME);
248
+ }
249
+
250
+ private JSONObject readBuiltinSeed() {
251
+ try (FileInputStream input = new FileInputStream(builtinSeedFile())) {
252
+ java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
253
+ byte[] buffer = new byte[8192];
254
+ int read;
255
+ while ((read = input.read(buffer)) > 0) {
256
+ out.write(buffer, 0, read);
257
+ }
258
+ return new JSONObject(new String(out.toByteArray(), StandardCharsets.UTF_8));
259
+ } catch (Exception e) {
260
+ return null;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Hash the store-build web assets into the cache once per native build, so
266
+ * the first OTA only downloads what changed relative to the binary.
267
+ * Best-effort: failures only cost extra downloads.
268
+ */
269
+ void seedFromBuiltinIfNeeded(Context context, String assetPath, String nativeBuild) {
270
+ JSONObject seed = readBuiltinSeed();
271
+ if (seed != null && nativeBuild.equals(seed.optString("nativeBuild"))) {
272
+ return;
273
+ }
274
+
275
+ List<String> hashes = new ArrayList<>();
276
+ try {
277
+ seedAssetDirectory(context.getAssets(), assetPath, hashes, context);
278
+ } catch (Exception e) {
279
+ android.util.Log.w("OtaKit", "builtin delta seed failed", e);
280
+ return;
281
+ }
282
+
283
+ try {
284
+ JSONObject newSeed = new JSONObject();
285
+ newSeed.put("nativeBuild", nativeBuild);
286
+ newSeed.put("hashes", new JSONArray(hashes));
287
+ try (FileOutputStream output = new FileOutputStream(builtinSeedFile())) {
288
+ output.write(newSeed.toString().getBytes(StandardCharsets.UTF_8));
289
+ }
290
+ } catch (Exception e) {
291
+ android.util.Log.w("OtaKit", "builtin delta seed marker write failed", e);
292
+ }
293
+ }
294
+
295
+ private void seedAssetDirectory(
296
+ AssetManager assets,
297
+ String assetPath,
298
+ List<String> hashes,
299
+ Context context
300
+ ) throws Exception {
301
+ String[] children = assets.list(assetPath);
302
+ if (children == null || children.length == 0) {
303
+ // Leaf: treat as a file.
304
+ String sha256;
305
+ try (InputStream input = assets.open(assetPath)) {
306
+ sha256 = HashUtils.sha256(input);
307
+ }
308
+ File cached = cachePath(sha256);
309
+ if (!cached.exists()) {
310
+ File staging = new File(cacheDirectory, ".tmp-" + java.util.UUID.randomUUID());
311
+ try (
312
+ InputStream input = assets.open(assetPath);
313
+ FileOutputStream output = new FileOutputStream(staging)
314
+ ) {
315
+ byte[] buffer = new byte[8192];
316
+ int read;
317
+ while ((read = input.read(buffer)) > 0) {
318
+ output.write(buffer, 0, read);
319
+ }
320
+ }
321
+ renameIntoCache(staging, cached);
322
+ }
323
+ hashes.add(sha256);
324
+ return;
325
+ }
326
+ for (String child : children) {
327
+ seedAssetDirectory(assets, assetPath + "/" + child, hashes, context);
328
+ }
329
+ }
330
+
331
+ // ── Eviction ────────────────────────────────────────────────────────
332
+
333
+ /**
334
+ * Remove cache entries not referenced by any live bundle and not part of
335
+ * the builtin seed. Best-effort.
336
+ */
337
+ void pruneCache(Set<String> referencedHashes) {
338
+ Set<String> keep = new HashSet<>();
339
+ for (String hash : referencedHashes) {
340
+ keep.add(hash.toLowerCase());
341
+ }
342
+ JSONObject seed = readBuiltinSeed();
343
+ if (seed != null) {
344
+ JSONArray seedHashes = seed.optJSONArray("hashes");
345
+ if (seedHashes != null) {
346
+ for (int index = 0; index < seedHashes.length(); index++) {
347
+ String hash = seedHashes.optString(index, null);
348
+ if (hash != null) {
349
+ keep.add(hash.toLowerCase());
350
+ }
351
+ }
352
+ }
353
+ }
354
+
355
+ File[] items = cacheDirectory.listFiles();
356
+ if (items == null) {
357
+ return;
358
+ }
359
+ for (File item : items) {
360
+ String name = item.getName();
361
+ if (BUILTIN_SEED_MARKER_NAME.equals(name) || name.startsWith(".tmp-")) {
362
+ continue;
363
+ }
364
+ if (!keep.contains(name.toLowerCase())) {
365
+ //noinspection ResultOfMethodCallIgnored
366
+ item.delete();
367
+ }
368
+ }
369
+ }
370
+
371
+ // ── Helpers ─────────────────────────────────────────────────────────
372
+
373
+ private void atomicCopyIntoCache(File source, File destination) throws Exception {
374
+ File staging = new File(cacheDirectory, ".tmp-" + java.util.UUID.randomUUID());
375
+ copyFile(source, staging);
376
+ renameIntoCache(staging, destination);
377
+ }
378
+
379
+ private static void renameIntoCache(File staging, File destination) throws Exception {
380
+ if (!staging.renameTo(destination)) {
381
+ //noinspection ResultOfMethodCallIgnored
382
+ staging.delete();
383
+ // A concurrent writer may have won the rename; that's fine.
384
+ if (!destination.exists()) {
385
+ throw new IllegalStateException("Failed to move cached file into place: " + destination);
386
+ }
387
+ }
388
+ }
389
+
390
+ private static void copyFile(File source, File destination) throws Exception {
391
+ try (
392
+ FileInputStream input = new FileInputStream(source);
393
+ FileOutputStream output = new FileOutputStream(destination)
394
+ ) {
395
+ byte[] buffer = new byte[8192];
396
+ int read;
397
+ while ((read = input.read(buffer)) > 0) {
398
+ output.write(buffer, 0, read);
399
+ }
400
+ }
401
+ }
402
+
403
+ private static void deleteRecursively(File target) {
404
+ if (!target.exists()) {
405
+ return;
406
+ }
407
+ File[] children = target.listFiles();
408
+ if (children != null) {
409
+ for (File child : children) {
410
+ deleteRecursively(child);
411
+ }
412
+ }
413
+ //noinspection ResultOfMethodCallIgnored
414
+ target.delete();
415
+ }
416
+ }
@@ -2,12 +2,28 @@ package com.otakit.updater;
2
2
 
3
3
  import java.io.File;
4
4
  import java.io.FileInputStream;
5
+ import java.io.InputStream;
5
6
  import java.security.MessageDigest;
6
7
 
7
8
  final class HashUtils {
8
9
 
9
10
  private HashUtils() {}
10
11
 
12
+ static String sha256(InputStream input) throws Exception {
13
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
14
+ byte[] buffer = new byte[1024 * 1024];
15
+ int read;
16
+ while ((read = input.read(buffer)) > 0) {
17
+ digest.update(buffer, 0, read);
18
+ }
19
+ byte[] hash = digest.digest();
20
+ StringBuilder builder = new StringBuilder();
21
+ for (byte b : hash) {
22
+ builder.append(String.format("%02x", b));
23
+ }
24
+ return builder.toString();
25
+ }
26
+
11
27
  static String sha256(File file) throws Exception {
12
28
  MessageDigest digest = MessageDigest.getInstance("SHA-256");
13
29
  try (FileInputStream input = new FileInputStream(file)) {
@@ -45,10 +45,27 @@ final class ManifestClient {
45
45
  }
46
46
  }
47
47
 
48
+ static final class ManifestFileEntry {
49
+
50
+ final String path;
51
+ final String sha256;
52
+ final long size;
53
+ final String url;
54
+
55
+ ManifestFileEntry(String path, String sha256, long size, String url) {
56
+ this.path = path;
57
+ this.sha256 = sha256;
58
+ this.size = size;
59
+ this.url = url;
60
+ }
61
+ }
62
+
48
63
  static final class LatestManifest {
49
64
 
50
65
  final String version;
66
+ /** Bundle zip URL. Present for the zip strategy; null for deltas. */
51
67
  final String url;
68
+ /** Zip hash for the zip strategy; canonical filesHash for deltas. */
52
69
  final String sha256;
53
70
  final int size;
54
71
  final String runtimeVersion;
@@ -56,6 +73,8 @@ final class ManifestClient {
56
73
  final String strategy;
57
74
  final boolean forceImmediate;
58
75
  final ManifestEncryption encryption;
76
+ /** Per-file entries for the deltas strategy; null for zip. */
77
+ final java.util.List<ManifestFileEntry> files;
59
78
 
60
79
  LatestManifest(
61
80
  String version,
@@ -66,7 +85,8 @@ final class ManifestClient {
66
85
  String releaseId,
67
86
  String strategy,
68
87
  boolean forceImmediate,
69
- ManifestEncryption encryption
88
+ ManifestEncryption encryption,
89
+ java.util.List<ManifestFileEntry> files
70
90
  ) {
71
91
  this.version = version;
72
92
  this.url = url;
@@ -77,6 +97,7 @@ final class ManifestClient {
77
97
  this.strategy = strategy;
78
98
  this.forceImmediate = forceImmediate;
79
99
  this.encryption = encryption;
100
+ this.files = files;
80
101
  }
81
102
  }
82
103
 
@@ -146,7 +167,6 @@ final class ManifestClient {
146
167
  JSONObject json = new JSONObject(payload);
147
168
 
148
169
  String version = json.getString("version");
149
- String downloadUrl = json.getString("url");
150
170
  String sha256 = json.getString("sha256");
151
171
  int size = json.getInt("size");
152
172
 
@@ -183,7 +203,23 @@ final class ManifestClient {
183
203
  boolean forceImmediate = Boolean.TRUE.equals(rawForceImmediate);
184
204
  ManifestEncryption encryption = parseEncryption(json);
185
205
 
186
- requireHTTPS(new URL(downloadUrl), allowInsecureUrls);
206
+ String downloadUrl = null;
207
+ if (json.has("url") && !json.isNull("url")) {
208
+ String rawUrl = json.getString("url").trim();
209
+ if (!rawUrl.isEmpty()) {
210
+ downloadUrl = rawUrl;
211
+ }
212
+ }
213
+
214
+ java.util.List<ManifestFileEntry> files = null;
215
+ if ("deltas".equals(strategy)) {
216
+ files = parseFiles(json, allowInsecureUrls);
217
+ } else {
218
+ if (downloadUrl == null) {
219
+ throw new IllegalStateException("Manifest response missing required url");
220
+ }
221
+ requireHTTPS(new URL(downloadUrl), allowInsecureUrls);
222
+ }
187
223
 
188
224
  if (manifestKeys == null || manifestKeys.isEmpty()) {
189
225
  android.util.Log.w(
@@ -223,13 +259,42 @@ final class ManifestClient {
223
259
  releaseId,
224
260
  strategy,
225
261
  forceImmediate,
226
- encryption
262
+ encryption,
263
+ files
227
264
  );
228
265
  } finally {
229
266
  connection.disconnect();
230
267
  }
231
268
  }
232
269
 
270
+ private static java.util.List<ManifestFileEntry> parseFiles(
271
+ JSONObject json,
272
+ boolean allowInsecureUrls
273
+ ) throws Exception {
274
+ if (!json.has("files") || json.isNull("files")) {
275
+ throw new IllegalStateException("Delta manifest is missing its file list");
276
+ }
277
+ org.json.JSONArray rawFiles = json.getJSONArray("files");
278
+ if (rawFiles.length() == 0) {
279
+ throw new IllegalStateException("Delta manifest has an empty file list");
280
+ }
281
+
282
+ java.util.List<ManifestFileEntry> entries = new java.util.ArrayList<>(rawFiles.length());
283
+ for (int index = 0; index < rawFiles.length(); index++) {
284
+ JSONObject rawFile = rawFiles.getJSONObject(index);
285
+ if (!rawFile.has("path") || !rawFile.has("sha256") || !rawFile.has("url")) {
286
+ throw new IllegalStateException("Delta manifest file entry is missing required fields");
287
+ }
288
+ String path = rawFile.getString("path");
289
+ String fileSha256 = rawFile.getString("sha256");
290
+ String fileUrl = rawFile.getString("url");
291
+ long fileSize = rawFile.optLong("size", -1);
292
+ requireHTTPS(new URL(fileUrl), allowInsecureUrls);
293
+ entries.add(new ManifestFileEntry(path, fileSha256, fileSize, fileUrl));
294
+ }
295
+ return entries;
296
+ }
297
+
233
298
  private static ManifestEncryption parseEncryption(JSONObject json) throws Exception {
234
299
  if (!json.has("encryption") || json.isNull("encryption")) {
235
300
  return null;