@otakit/capacitor-updater 2.2.0 → 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.
- package/android/src/main/java/com/otakit/updater/BundleStore.java +11 -0
- package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +416 -0
- package/android/src/main/java/com/otakit/updater/HashUtils.java +16 -0
- package/android/src/main/java/com/otakit/updater/ManifestClient.java +69 -4
- package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +180 -1
- package/dist/esm/definitions.d.ts +6 -4
- package/dist/esm/definitions.d.ts.map +1 -1
- package/ios/Sources/UpdaterPlugin/BundleStore.swift +17 -0
- package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +316 -0
- package/ios/Sources/UpdaterPlugin/ManifestClient.swift +54 -6
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +166 -2
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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;
|