@otakit/capacitor-updater 2.1.2 → 2.3.0

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.
@@ -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)) {
@@ -28,14 +28,53 @@ final class ManifestClient {
28
28
  }
29
29
  }
30
30
 
31
+ static final class ManifestEncryption {
32
+
33
+ final String alg;
34
+ final String kid;
35
+ final String wrapNonce;
36
+ final String wrappedDek;
37
+ final String nonce;
38
+
39
+ ManifestEncryption(String alg, String kid, String wrapNonce, String wrappedDek, String nonce) {
40
+ this.alg = alg;
41
+ this.kid = kid;
42
+ this.wrapNonce = wrapNonce;
43
+ this.wrappedDek = wrappedDek;
44
+ this.nonce = nonce;
45
+ }
46
+ }
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
+
31
63
  static final class LatestManifest {
32
64
 
33
65
  final String version;
66
+ /** Bundle zip URL. Present for the zip strategy; null for deltas. */
34
67
  final String url;
68
+ /** Zip hash for the zip strategy; canonical filesHash for deltas. */
35
69
  final String sha256;
36
70
  final int size;
37
71
  final String runtimeVersion;
38
72
  final String releaseId;
73
+ final String strategy;
74
+ final boolean forceImmediate;
75
+ final ManifestEncryption encryption;
76
+ /** Per-file entries for the deltas strategy; null for zip. */
77
+ final java.util.List<ManifestFileEntry> files;
39
78
 
40
79
  LatestManifest(
41
80
  String version,
@@ -43,7 +82,11 @@ final class ManifestClient {
43
82
  String sha256,
44
83
  int size,
45
84
  String runtimeVersion,
46
- String releaseId
85
+ String releaseId,
86
+ String strategy,
87
+ boolean forceImmediate,
88
+ ManifestEncryption encryption,
89
+ java.util.List<ManifestFileEntry> files
47
90
  ) {
48
91
  this.version = version;
49
92
  this.url = url;
@@ -51,6 +94,10 @@ final class ManifestClient {
51
94
  this.size = size;
52
95
  this.runtimeVersion = runtimeVersion;
53
96
  this.releaseId = releaseId;
97
+ this.strategy = strategy;
98
+ this.forceImmediate = forceImmediate;
99
+ this.encryption = encryption;
100
+ this.files = files;
54
101
  }
55
102
  }
56
103
 
@@ -120,7 +167,6 @@ final class ManifestClient {
120
167
  JSONObject json = new JSONObject(payload);
121
168
 
122
169
  String version = json.getString("version");
123
- String downloadUrl = json.getString("url");
124
170
  String sha256 = json.getString("sha256");
125
171
  int size = json.getInt("size");
126
172
 
@@ -145,7 +191,35 @@ final class ManifestClient {
145
191
  throw new IllegalStateException("Manifest response missing required releaseId");
146
192
  }
147
193
 
148
- requireHTTPS(new URL(downloadUrl), allowInsecureUrls);
194
+ String strategy = "zip";
195
+ if (json.has("strategy") && !json.isNull("strategy")) {
196
+ String rawStrategy = json.getString("strategy").trim();
197
+ if (!rawStrategy.isEmpty()) {
198
+ strategy = rawStrategy;
199
+ }
200
+ }
201
+ // Strict boolean (no string coercion) to match the iOS parser.
202
+ Object rawForceImmediate = json.opt("forceImmediate");
203
+ boolean forceImmediate = Boolean.TRUE.equals(rawForceImmediate);
204
+ ManifestEncryption encryption = parseEncryption(json);
205
+
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
+ }
149
223
 
150
224
  if (manifestKeys == null || manifestKeys.isEmpty()) {
151
225
  android.util.Log.w(
@@ -168,6 +242,9 @@ final class ManifestClient {
168
242
  sha256,
169
243
  size,
170
244
  responseRuntimeVersion,
245
+ strategy,
246
+ forceImmediate,
247
+ encryption,
171
248
  signature,
172
249
  manifestKeys
173
250
  );
@@ -179,13 +256,68 @@ final class ManifestClient {
179
256
  sha256,
180
257
  size,
181
258
  responseRuntimeVersion,
182
- releaseId
259
+ releaseId,
260
+ strategy,
261
+ forceImmediate,
262
+ encryption,
263
+ files
183
264
  );
184
265
  } finally {
185
266
  connection.disconnect();
186
267
  }
187
268
  }
188
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
+
298
+ private static ManifestEncryption parseEncryption(JSONObject json) throws Exception {
299
+ if (!json.has("encryption") || json.isNull("encryption")) {
300
+ return null;
301
+ }
302
+ JSONObject encObj = json.getJSONObject("encryption");
303
+ if (
304
+ !encObj.has("alg") ||
305
+ !encObj.has("kid") ||
306
+ !encObj.has("wrapNonce") ||
307
+ !encObj.has("wrappedDek") ||
308
+ !encObj.has("nonce")
309
+ ) {
310
+ throw new IllegalStateException("Manifest encryption block is missing required fields");
311
+ }
312
+ return new ManifestEncryption(
313
+ encObj.getString("alg"),
314
+ encObj.getString("kid"),
315
+ encObj.getString("wrapNonce"),
316
+ encObj.getString("wrappedDek"),
317
+ encObj.getString("nonce")
318
+ );
319
+ }
320
+
189
321
  private static String readStream(InputStream input) throws Exception {
190
322
  if (input == null) {
191
323
  return "";