@otakit/capacitor-updater 2.3.2 → 3.0.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/README.md +46 -5
- package/android/build.gradle +16 -0
- package/android/src/main/java/com/otakit/updater/BundleCrypto.java +38 -17
- package/android/src/main/java/com/otakit/updater/BundleInfo.java +10 -0
- package/android/src/main/java/com/otakit/updater/BundleStore.java +134 -86
- package/android/src/main/java/com/otakit/updater/CheckFailure.java +53 -0
- package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +46 -41
- package/android/src/main/java/com/otakit/updater/DeviceEventClient.java +125 -26
- package/android/src/main/java/com/otakit/updater/DocumentReadyBridge.java +71 -0
- package/android/src/main/java/com/otakit/updater/DownloadRetry.java +144 -0
- package/android/src/main/java/com/otakit/updater/EventOutbox.java +174 -0
- package/android/src/main/java/com/otakit/updater/FileDownloader.java +80 -0
- package/android/src/main/java/com/otakit/updater/ForegroundDeadline.java +84 -0
- package/android/src/main/java/com/otakit/updater/HashUtils.java +26 -0
- package/android/src/main/java/com/otakit/updater/ManifestClient.java +12 -9
- package/android/src/main/java/com/otakit/updater/ManifestKeyConfig.java +47 -0
- package/android/src/main/java/com/otakit/updater/ManifestVerifier.java +22 -4
- package/android/src/main/java/com/otakit/updater/SDKVersion.java +9 -0
- package/android/src/main/java/com/otakit/updater/UpdateOwner.java +27 -0
- package/android/src/main/java/com/otakit/updater/UpdaterCoordinator.java +154 -59
- package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +284 -209
- package/android/src/main/java/com/otakit/updater/WebViewActivation.java +26 -0
- package/dist/esm/definitions.d.ts +3 -3
- package/dist/esm/definitions.d.ts.map +1 -1
- package/ios/Sources/UpdaterPlugin/BundleCrypto.swift +35 -3
- package/ios/Sources/UpdaterPlugin/BundleInfo.swift +13 -0
- package/ios/Sources/UpdaterPlugin/BundleStore.swift +103 -77
- package/ios/Sources/UpdaterPlugin/CheckFailure.swift +41 -0
- package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +29 -17
- package/ios/Sources/UpdaterPlugin/DeviceEventClient.swift +12 -10
- package/ios/Sources/UpdaterPlugin/DocumentReadyBridge.swift +43 -0
- package/ios/Sources/UpdaterPlugin/DownloadRetry.swift +51 -0
- package/ios/Sources/UpdaterPlugin/Downloader.swift +97 -39
- package/ios/Sources/UpdaterPlugin/EventOutbox.swift +183 -0
- package/ios/Sources/UpdaterPlugin/ForegroundDeadline.swift +94 -0
- package/ios/Sources/UpdaterPlugin/HashUtils.swift +16 -0
- package/ios/Sources/UpdaterPlugin/ManifestClient.swift +2 -4
- package/ios/Sources/UpdaterPlugin/ManifestKeyConfig.swift +20 -0
- package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +7 -1
- package/ios/Sources/UpdaterPlugin/SDKVersion.swift +4 -0
- package/ios/Sources/UpdaterPlugin/UpdateOwner.swift +12 -0
- package/ios/Sources/UpdaterPlugin/UpdaterCoordinator.swift +146 -110
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +206 -202
- package/ios/Tests/UpdaterPluginTests/BundleCryptoTests.swift +80 -0
- package/ios/Tests/UpdaterPluginTests/BundlePersistenceTests.swift +102 -0
- package/ios/Tests/UpdaterPluginTests/CheckFailureTests.swift +55 -0
- package/ios/Tests/UpdaterPluginTests/DeltaCacheIntegrityTests.swift +69 -0
- package/ios/Tests/UpdaterPluginTests/DocumentReadyBridgeTests.swift +102 -0
- package/ios/Tests/UpdaterPluginTests/DownloadIntegrityTests.swift +40 -0
- package/ios/Tests/UpdaterPluginTests/DownloadRetryTests.swift +189 -0
- package/ios/Tests/UpdaterPluginTests/EventDeliveryTests.swift +56 -0
- package/ios/Tests/UpdaterPluginTests/EventOutboxTests.swift +86 -0
- package/ios/Tests/UpdaterPluginTests/ForegroundDeadlineTests.swift +144 -0
- package/ios/Tests/UpdaterPluginTests/ManifestKeyConfigTests.swift +48 -0
- package/ios/Tests/UpdaterPluginTests/UpdateOwnerTests.swift +34 -0
- package/ios/Tests/UpdaterPluginTests/UpdaterCoordinatorTests.swift +394 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -19,6 +19,36 @@ The plugin is intentionally small:
|
|
|
19
19
|
|
|
20
20
|
There is no built-in splash or overlay manager in this model.
|
|
21
21
|
|
|
22
|
+
## Reliability update compatibility
|
|
23
|
+
|
|
24
|
+
These native reliability changes require rebuilding and distributing the iOS/Android
|
|
25
|
+
app. An OTA web update cannot replace the native plugin.
|
|
26
|
+
|
|
27
|
+
- **Android WebView:** applying an update requires support for
|
|
28
|
+
`WebViewFeature.DOCUMENT_START_SCRIPT`. Update Android System WebView on devices
|
|
29
|
+
without that capability. A confirmed installed app can still launch, and a new
|
|
30
|
+
update stays staged, but it cannot activate without the readiness guard. Include
|
|
31
|
+
older supported WebView versions in your native rollout tests.
|
|
32
|
+
- **Readiness:** keep calling `OtaKit.notifyAppReady()` with no arguments after
|
|
33
|
+
startup succeeds. Native code binds the call to its WebView document. The default
|
|
34
|
+
`appReadyTimeout` remains 10 seconds and now counts cumulative foreground time;
|
|
35
|
+
backgrounding pauses the remaining budget. Process exit before readiness still
|
|
36
|
+
triggers rollback on the next launch.
|
|
37
|
+
- **Encrypted bundles:** the downloaded encrypted object must fit within 128 MiB
|
|
38
|
+
and a smaller device-dependent memory budget. Oversized objects fail before
|
|
39
|
+
decryption with `insufficient_memory_for_encrypted_bundle`, preserving the
|
|
40
|
+
current installation. Previously attempted large encrypted bundles may now be
|
|
41
|
+
rejected; reduce their assets before rollout.
|
|
42
|
+
- **Signing configuration:** explicit `manifestKeys` values must be arrays.
|
|
43
|
+
A string, object, number, boolean, or null fails closed. Omitted keys or an empty
|
|
44
|
+
array retain the hosted defaults or existing self-hosted unsigned mode.
|
|
45
|
+
- **Self-hosted telemetry:** deploy nullable event-context columns, then the
|
|
46
|
+
updated ingest Worker and Tinybird endpoints, before releasing the native SDK.
|
|
47
|
+
Old ingest Workers discard context and reject the new `check_error` action.
|
|
48
|
+
|
|
49
|
+
Use a native canary rollout to check startup, rollback, background/resume behavior,
|
|
50
|
+
and encrypted downloads on physical low-memory devices before widening adoption.
|
|
51
|
+
|
|
22
52
|
## Hosted config
|
|
23
53
|
|
|
24
54
|
```ts
|
|
@@ -247,8 +277,8 @@ OtaKit.addListener('rollback', (failure) => {}); // applied bundle reverted (not
|
|
|
247
277
|
OtaKit.removeAllListeners();
|
|
248
278
|
```
|
|
249
279
|
|
|
250
|
-
|
|
251
|
-
replay. Reconcile on startup:
|
|
280
|
+
JavaScript listener events fire only while the app process is alive and have no
|
|
281
|
+
buffering or replay. Reconcile on startup:
|
|
252
282
|
|
|
253
283
|
- a bundle staged in a previous session: `getState().staged`
|
|
254
284
|
- a startup rollback (app restarted before `notifyAppReady()`): it happens
|
|
@@ -262,6 +292,11 @@ early in app startup, not in the restart click handler.
|
|
|
262
292
|
Download and stage are atomic in this plugin — there is a single
|
|
263
293
|
`updateStaged` event, not separate "downloaded" and "staged" events.
|
|
264
294
|
|
|
295
|
+
Server telemetry uses a separate durable retry queue with stable event IDs. It is
|
|
296
|
+
limited to 256 events, seven days, and 8 KiB per event; storage failures and process
|
|
297
|
+
termination between a state commit and event creation can still lose telemetry.
|
|
298
|
+
Suspended/terminated apps resume delivery when their process runs again.
|
|
299
|
+
|
|
265
300
|
The headline pattern — background download via `shadow` policies, prompt to
|
|
266
301
|
restart:
|
|
267
302
|
|
|
@@ -399,9 +434,15 @@ Operational rules:
|
|
|
399
434
|
receive updates until a store build ships a new key.
|
|
400
435
|
- Decryption failures are never fatal: they behave like download failures —
|
|
401
436
|
the running bundle is untouched and nothing unverified is ever applied.
|
|
402
|
-
- **Memory:** decryption
|
|
403
|
-
|
|
404
|
-
|
|
437
|
+
- **Memory:** authenticated decryption still uses whole-object buffers. Admission
|
|
438
|
+
is limited to the smaller of 128 MiB and one quarter of currently available
|
|
439
|
+
process memory after reserving 32 MiB. This is a conservative estimate, not a
|
|
440
|
+
reservation or an OOM guarantee. Test representative physical devices and keep
|
|
441
|
+
encrypted assets small.
|
|
442
|
+
- **iOS Simulator:** admission uses host physical RAM as an estimate because the
|
|
443
|
+
process-memory API can report zero in simulator apps. The same reserve, factor,
|
|
444
|
+
and 128 MiB ceiling apply. Simulator decryption does not validate physical-device
|
|
445
|
+
memory pressure or establish that the host has that much memory free.
|
|
405
446
|
|
|
406
447
|
## Source areas
|
|
407
448
|
|
package/android/build.gradle
CHANGED
|
@@ -33,6 +33,20 @@ android {
|
|
|
33
33
|
lintOptions {
|
|
34
34
|
abortOnError false
|
|
35
35
|
}
|
|
36
|
+
testOptions {
|
|
37
|
+
unitTests.includeAndroidResources = true
|
|
38
|
+
unitTests.all {
|
|
39
|
+
jvmArgs '--add-opens=java.base/java.lang=ALL-UNNAMED',
|
|
40
|
+
'--add-opens=java.base/java.util=ALL-UNNAMED',
|
|
41
|
+
'--add-opens=java.base/java.io=ALL-UNNAMED',
|
|
42
|
+
'--add-opens=java.base/java.net=ALL-UNNAMED',
|
|
43
|
+
'--add-opens=java.base/java.security=ALL-UNNAMED',
|
|
44
|
+
'--add-opens=java.base/java.text=ALL-UNNAMED',
|
|
45
|
+
'--add-opens=java.base/jdk.internal.access=ALL-UNNAMED',
|
|
46
|
+
'--add-opens=java.desktop/java.awt.font=ALL-UNNAMED',
|
|
47
|
+
'--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED'
|
|
48
|
+
}
|
|
49
|
+
}
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
repositories {
|
|
@@ -43,7 +57,9 @@ repositories {
|
|
|
43
57
|
dependencies {
|
|
44
58
|
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
|
45
59
|
implementation project(':capacitor-android')
|
|
60
|
+
implementation "androidx.webkit:webkit:${rootProject.ext.has('androidxWebkitVersion') ? rootProject.ext.androidxWebkitVersion : '1.12.1'}"
|
|
46
61
|
testImplementation "junit:junit:$junitVersion"
|
|
62
|
+
testImplementation 'org.robolectric:robolectric:4.17'
|
|
47
63
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
|
48
64
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
|
49
65
|
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
package com.otakit.updater;
|
|
2
2
|
|
|
3
|
-
import java.io.
|
|
3
|
+
import java.io.DataInputStream;
|
|
4
4
|
import java.io.File;
|
|
5
5
|
import java.io.FileInputStream;
|
|
6
6
|
import java.io.FileOutputStream;
|
|
7
|
-
import java.io.InputStream;
|
|
8
7
|
import javax.crypto.Cipher;
|
|
9
8
|
import javax.crypto.spec.GCMParameterSpec;
|
|
10
9
|
import javax.crypto.spec.SecretKeySpec;
|
|
@@ -21,6 +20,8 @@ final class BundleCrypto {
|
|
|
21
20
|
private static final int GCM_TAG_BITS = 128;
|
|
22
21
|
private static final int KEY_LENGTH = 32;
|
|
23
22
|
private static final int TAG_LENGTH = 16;
|
|
23
|
+
private static final long RESERVE_BYTES = 32L * 1024 * 1024;
|
|
24
|
+
private static final long MAX_ENCRYPTED_BYTES = 128L * 1024 * 1024;
|
|
24
25
|
|
|
25
26
|
private BundleCrypto() {}
|
|
26
27
|
|
|
@@ -45,6 +46,34 @@ final class BundleCrypto {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
static void decryptFile(byte[] dek, byte[] nonce, File input, File output) throws Exception {
|
|
49
|
+
Runtime runtime = Runtime.getRuntime();
|
|
50
|
+
long available = runtime.maxMemory() - (runtime.totalMemory() - runtime.freeMemory());
|
|
51
|
+
decryptFile(dek, nonce, input, output, available);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static void requireMemoryBudget(long encryptedBytes, long availableBytes) {
|
|
55
|
+
long budget =
|
|
56
|
+
availableBytes <= RESERVE_BYTES
|
|
57
|
+
? 0
|
|
58
|
+
: Math.min(MAX_ENCRYPTED_BYTES, (availableBytes - RESERVE_BYTES) / 4);
|
|
59
|
+
if (encryptedBytes > budget) throw new IllegalStateException(
|
|
60
|
+
"insufficient_memory_for_encrypted_bundle; encryptedBytes=" +
|
|
61
|
+
encryptedBytes +
|
|
62
|
+
"; budgetBytes=" +
|
|
63
|
+
budget
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
static void decryptFile(byte[] dek, byte[] nonce, File input, File output, long availableBytes)
|
|
68
|
+
throws Exception {
|
|
69
|
+
long size = input.length();
|
|
70
|
+
if (size <= TAG_LENGTH) throw new IllegalStateException(
|
|
71
|
+
"invalid bundle encryption parameter: ciphertext too short"
|
|
72
|
+
);
|
|
73
|
+
requireMemoryBudget(size, availableBytes);
|
|
74
|
+
if (dek == null || dek.length != KEY_LENGTH || nonce == null || nonce.length != 12) {
|
|
75
|
+
throw new IllegalStateException("invalid bundle encryption parameter: key or nonce length");
|
|
76
|
+
}
|
|
48
77
|
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
|
49
78
|
cipher.init(
|
|
50
79
|
Cipher.DECRYPT_MODE,
|
|
@@ -55,27 +84,19 @@ final class BundleCrypto {
|
|
|
55
84
|
// Explicit doFinal (not CipherOutputStream) so a GCM tag failure always
|
|
56
85
|
// throws instead of depending on close() behavior. GCM decryption
|
|
57
86
|
// buffers the full input internally anyway before releasing plaintext.
|
|
58
|
-
byte[] ciphertext =
|
|
59
|
-
if (ciphertext.length <= TAG_LENGTH) {
|
|
60
|
-
throw new IllegalStateException("invalid bundle encryption parameter: ciphertext too short");
|
|
61
|
-
}
|
|
87
|
+
byte[] ciphertext = readExactBytes(input, (int) size);
|
|
62
88
|
byte[] plaintext = cipher.doFinal(ciphertext);
|
|
63
89
|
try (FileOutputStream out = new FileOutputStream(output)) {
|
|
64
90
|
out.write(plaintext);
|
|
65
91
|
}
|
|
66
92
|
}
|
|
67
93
|
|
|
68
|
-
private static byte[]
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
byte[] buffer = new byte[8192];
|
|
74
|
-
int read;
|
|
75
|
-
while ((read = in.read(buffer)) > 0) {
|
|
76
|
-
out.write(buffer, 0, read);
|
|
77
|
-
}
|
|
78
|
-
return out.toByteArray();
|
|
94
|
+
private static byte[] readExactBytes(File file, int size) throws Exception {
|
|
95
|
+
byte[] bytes = new byte[size];
|
|
96
|
+
try (DataInputStream input = new DataInputStream(new FileInputStream(file))) {
|
|
97
|
+
input.readFully(bytes);
|
|
98
|
+
if (input.read() != -1) throw new java.io.IOException("Encrypted bundle changed during read");
|
|
79
99
|
}
|
|
100
|
+
return bytes;
|
|
80
101
|
}
|
|
81
102
|
}
|
|
@@ -42,6 +42,16 @@ class BundleInfo {
|
|
|
42
42
|
return "builtin".equals(id);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
String attemptId() {
|
|
46
|
+
if (
|
|
47
|
+
id == null ||
|
|
48
|
+
!id.matches(
|
|
49
|
+
"bundle-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
|
|
50
|
+
)
|
|
51
|
+
) return null;
|
|
52
|
+
return id;
|
|
53
|
+
}
|
|
54
|
+
|
|
45
55
|
JSObject toJSObject() {
|
|
46
56
|
JSObject object = new JSObject();
|
|
47
57
|
object.put("id", id);
|
|
@@ -2,9 +2,9 @@ package com.otakit.updater;
|
|
|
2
2
|
|
|
3
3
|
import android.content.Context;
|
|
4
4
|
import android.content.SharedPreferences;
|
|
5
|
+
import android.util.AtomicFile;
|
|
5
6
|
import com.getcapacitor.JSArray;
|
|
6
7
|
import java.io.File;
|
|
7
|
-
import java.io.FileInputStream;
|
|
8
8
|
import java.io.FileOutputStream;
|
|
9
9
|
import java.nio.charset.StandardCharsets;
|
|
10
10
|
import java.util.ArrayList;
|
|
@@ -16,6 +16,11 @@ import org.json.JSONObject;
|
|
|
16
16
|
|
|
17
17
|
final class BundleStore {
|
|
18
18
|
|
|
19
|
+
/** Release identity is metadata; an installation must never reuse a live directory. */
|
|
20
|
+
static String newInstallationId() {
|
|
21
|
+
return "bundle-" + java.util.UUID.randomUUID();
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
private static final String PREFS_NAME = "otakit_updater_state";
|
|
20
25
|
private static final String KEY_CURRENT = "current_bundle_id";
|
|
21
26
|
private static final String KEY_FALLBACK = "fallback_bundle_id";
|
|
@@ -105,9 +110,7 @@ final class BundleStore {
|
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
byte[] bytes = bundle.toJSONObject().toString().getBytes(StandardCharsets.UTF_8);
|
|
108
|
-
|
|
109
|
-
output.write(bytes);
|
|
110
|
-
}
|
|
113
|
+
writeAtomically(metadataFile(bundle.id), bytes);
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
synchronized BundleInfo getBundle(String id) {
|
|
@@ -116,12 +119,8 @@ final class BundleStore {
|
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
File metadata = metadataFile(id);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
try (FileInputStream input = new FileInputStream(metadata)) {
|
|
124
|
-
byte[] bytes = readAllBytes(input);
|
|
122
|
+
try {
|
|
123
|
+
byte[] bytes = new AtomicFile(metadata).readFully();
|
|
125
124
|
JSONObject json = new JSONObject(new String(bytes, StandardCharsets.UTF_8));
|
|
126
125
|
return BundleInfo.fromJSONObject(json);
|
|
127
126
|
} catch (Exception ignored) {
|
|
@@ -129,104 +128,156 @@ final class BundleStore {
|
|
|
129
128
|
}
|
|
130
129
|
}
|
|
131
130
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
131
|
+
synchronized boolean bundleExists(String id) {
|
|
132
|
+
return getBundle(id) != null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private File stateFile() {
|
|
136
|
+
return new File(context.getFilesDir(), "otakit-state.json");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private static void writeAtomically(File file, byte[] bytes) throws Exception {
|
|
140
|
+
AtomicFile atomic = new AtomicFile(file);
|
|
141
|
+
FileOutputStream output = null;
|
|
142
|
+
try {
|
|
143
|
+
output = atomic.startWrite();
|
|
144
|
+
output.write(bytes);
|
|
145
|
+
output.getFD().sync();
|
|
146
|
+
atomic.finishWrite(output);
|
|
147
|
+
output = null;
|
|
148
|
+
if (!Arrays.equals(atomic.readFully(), bytes)) {
|
|
149
|
+
throw new java.io.IOException("Atomic updater state write did not complete");
|
|
150
|
+
}
|
|
151
|
+
} catch (Exception error) {
|
|
152
|
+
if (output != null) atomic.failWrite(output);
|
|
153
|
+
throw error;
|
|
138
154
|
}
|
|
139
|
-
return buffer.toByteArray();
|
|
140
155
|
}
|
|
141
156
|
|
|
142
|
-
|
|
143
|
-
|
|
157
|
+
private JSONObject readCoreState() throws Exception {
|
|
158
|
+
File file = stateFile();
|
|
159
|
+
if (file.exists() || new File(file.getPath() + ".bak").exists()) {
|
|
160
|
+
JSONObject state = new JSONObject(
|
|
161
|
+
new String(new AtomicFile(file).readFully(), StandardCharsets.UTF_8)
|
|
162
|
+
);
|
|
163
|
+
if (state.getInt("version") != 1) throw new IllegalStateException(
|
|
164
|
+
"Unsupported updater state"
|
|
165
|
+
);
|
|
166
|
+
return state;
|
|
167
|
+
}
|
|
168
|
+
JSONObject state = new JSONObject();
|
|
169
|
+
state.put("version", 1);
|
|
170
|
+
state.put("currentId", prefs.getString(KEY_CURRENT, null));
|
|
171
|
+
state.put("fallbackId", prefs.getString(KEY_FALLBACK, null));
|
|
172
|
+
state.put("stagedId", prefs.getString(KEY_STAGED, null));
|
|
173
|
+
String failed = prefs.getString(KEY_LAST_FAILED_BUNDLE_INFO, null);
|
|
174
|
+
if (failed != null) {
|
|
175
|
+
try {
|
|
176
|
+
state.put("lastFailed", new JSONObject(failed));
|
|
177
|
+
} catch (JSONException ignored) {}
|
|
178
|
+
}
|
|
179
|
+
return state;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private JSONObject readCoreStateOrEmpty() {
|
|
183
|
+
try {
|
|
184
|
+
return readCoreState();
|
|
185
|
+
} catch (Exception error) {
|
|
186
|
+
return new JSONObject();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private void writeCoreState(JSONObject state) throws Exception {
|
|
191
|
+
writeAtomically(stateFile(), state.toString().getBytes(StandardCharsets.UTF_8));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private void setCoreValue(String key, Object value) {
|
|
195
|
+
try {
|
|
196
|
+
JSONObject state = readCoreState();
|
|
197
|
+
state.put(key, value);
|
|
198
|
+
writeCoreState(state);
|
|
199
|
+
} catch (Exception error) {
|
|
200
|
+
throw new IllegalStateException("Could not persist updater state", error);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
synchronized void setCoreState(
|
|
205
|
+
String currentId,
|
|
206
|
+
String fallbackId,
|
|
207
|
+
String stagedId,
|
|
208
|
+
BundleInfo lastFailed
|
|
209
|
+
) {
|
|
210
|
+
try {
|
|
211
|
+
readCoreState();
|
|
212
|
+
JSONObject state = new JSONObject();
|
|
213
|
+
state.put("version", 1);
|
|
214
|
+
state.put("currentId", currentId);
|
|
215
|
+
state.put("fallbackId", fallbackId);
|
|
216
|
+
state.put("stagedId", stagedId);
|
|
217
|
+
if (lastFailed != null) state.put("lastFailed", lastFailed.toJSONObject());
|
|
218
|
+
writeCoreState(state);
|
|
219
|
+
} catch (Exception error) {
|
|
220
|
+
throw new IllegalStateException("Could not persist updater state", error);
|
|
221
|
+
}
|
|
144
222
|
}
|
|
145
223
|
|
|
146
224
|
synchronized BundleInfo getCurrentBundle() {
|
|
147
|
-
String
|
|
148
|
-
|
|
149
|
-
|
|
225
|
+
String id = getCurrentBundleId();
|
|
226
|
+
BundleInfo bundle = id == null ? null : getBundle(id);
|
|
227
|
+
return bundle != null ? bundle : builtinBundle();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
synchronized java.util.Set<String> protectedBundleIds() throws Exception {
|
|
231
|
+
JSONObject state = readCoreState();
|
|
232
|
+
java.util.Set<String> ids = new java.util.HashSet<>();
|
|
233
|
+
for (String key : new String[] { "currentId", "fallbackId", "stagedId" }) {
|
|
234
|
+
String id = state.optString(key, null);
|
|
235
|
+
if (id != null) ids.add(id);
|
|
150
236
|
}
|
|
151
|
-
|
|
152
|
-
return current != null ? current : builtinBundle();
|
|
237
|
+
return ids;
|
|
153
238
|
}
|
|
154
239
|
|
|
155
240
|
synchronized String getCurrentBundleId() {
|
|
156
|
-
return
|
|
241
|
+
return readCoreStateOrEmpty().optString("currentId", null);
|
|
157
242
|
}
|
|
158
243
|
|
|
159
244
|
synchronized void setCurrentBundleId(String id) {
|
|
160
|
-
|
|
161
|
-
if (id == null) {
|
|
162
|
-
editor.remove(KEY_CURRENT);
|
|
163
|
-
} else {
|
|
164
|
-
editor.putString(KEY_CURRENT, id);
|
|
165
|
-
}
|
|
166
|
-
editor.commit();
|
|
245
|
+
setCoreValue("currentId", id);
|
|
167
246
|
}
|
|
168
247
|
|
|
169
248
|
synchronized BundleInfo getFallbackBundle() {
|
|
170
|
-
String
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
174
|
-
BundleInfo fallback = getBundle(fallbackId);
|
|
175
|
-
return fallback != null ? fallback : builtinBundle();
|
|
249
|
+
String id = getFallbackBundleId();
|
|
250
|
+
BundleInfo bundle = id == null ? null : getBundle(id);
|
|
251
|
+
return bundle != null ? bundle : builtinBundle();
|
|
176
252
|
}
|
|
177
253
|
|
|
178
254
|
synchronized String getFallbackBundleId() {
|
|
179
|
-
return
|
|
255
|
+
return readCoreStateOrEmpty().optString("fallbackId", null);
|
|
180
256
|
}
|
|
181
257
|
|
|
182
258
|
synchronized void setFallbackBundleId(String id) {
|
|
183
|
-
|
|
184
|
-
if (id == null) {
|
|
185
|
-
editor.remove(KEY_FALLBACK);
|
|
186
|
-
} else {
|
|
187
|
-
editor.putString(KEY_FALLBACK, id);
|
|
188
|
-
}
|
|
189
|
-
editor.commit();
|
|
259
|
+
setCoreValue("fallbackId", id);
|
|
190
260
|
}
|
|
191
261
|
|
|
192
262
|
synchronized String getStagedBundleId() {
|
|
193
|
-
return
|
|
263
|
+
return readCoreStateOrEmpty().optString("stagedId", null);
|
|
194
264
|
}
|
|
195
265
|
|
|
196
266
|
synchronized void setStagedBundleId(String id) {
|
|
197
|
-
|
|
198
|
-
if (id == null) {
|
|
199
|
-
editor.remove(KEY_STAGED);
|
|
200
|
-
} else {
|
|
201
|
-
editor.putString(KEY_STAGED, id);
|
|
202
|
-
}
|
|
203
|
-
editor.commit();
|
|
267
|
+
setCoreValue("stagedId", id);
|
|
204
268
|
}
|
|
205
269
|
|
|
206
270
|
synchronized void setLastFailedBundle(BundleInfo bundle) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
try {
|
|
212
|
-
editor.putString(KEY_LAST_FAILED_BUNDLE_INFO, bundle.toJSONObject().toString());
|
|
213
|
-
} catch (JSONException ignored) {
|
|
214
|
-
editor.remove(KEY_LAST_FAILED_BUNDLE_INFO);
|
|
215
|
-
}
|
|
271
|
+
try {
|
|
272
|
+
setCoreValue("lastFailed", bundle == null ? null : bundle.toJSONObject());
|
|
273
|
+
} catch (JSONException error) {
|
|
274
|
+
throw new IllegalStateException(error);
|
|
216
275
|
}
|
|
217
|
-
editor.apply();
|
|
218
276
|
}
|
|
219
277
|
|
|
220
278
|
synchronized BundleInfo getLastFailedBundle() {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
return null;
|
|
224
|
-
}
|
|
225
|
-
try {
|
|
226
|
-
return BundleInfo.fromJSONObject(new JSONObject(json));
|
|
227
|
-
} catch (Exception e) {
|
|
228
|
-
return null;
|
|
229
|
-
}
|
|
279
|
+
JSONObject failed = readCoreStateOrEmpty().optJSONObject("lastFailed");
|
|
280
|
+
return failed == null ? null : BundleInfo.fromJSONObject(failed);
|
|
230
281
|
}
|
|
231
282
|
|
|
232
283
|
synchronized String getOverrideChannel() {
|
|
@@ -265,7 +316,9 @@ final class BundleStore {
|
|
|
265
316
|
}
|
|
266
317
|
try {
|
|
267
318
|
saveBundle(existing.withStatus(status));
|
|
268
|
-
} catch (Exception
|
|
319
|
+
} catch (Exception error) {
|
|
320
|
+
throw new IllegalStateException("Could not persist bundle status", error);
|
|
321
|
+
}
|
|
269
322
|
}
|
|
270
323
|
|
|
271
324
|
synchronized void deleteBundle(String id) throws Exception {
|
|
@@ -273,18 +326,13 @@ final class BundleStore {
|
|
|
273
326
|
return;
|
|
274
327
|
}
|
|
275
328
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
setCurrentBundleId(null);
|
|
284
|
-
}
|
|
285
|
-
if (id.equals(prefs.getString(KEY_FALLBACK, null))) {
|
|
286
|
-
setFallbackBundleId(null);
|
|
287
|
-
}
|
|
329
|
+
setCoreState(
|
|
330
|
+
id.equals(getCurrentBundleId()) ? null : getCurrentBundleId(),
|
|
331
|
+
id.equals(getFallbackBundleId()) ? null : getFallbackBundleId(),
|
|
332
|
+
id.equals(getStagedBundleId()) ? null : getStagedBundleId(),
|
|
333
|
+
getLastFailedBundle()
|
|
334
|
+
);
|
|
335
|
+
deleteRecursively(bundleDirectory(id));
|
|
288
336
|
}
|
|
289
337
|
|
|
290
338
|
synchronized List<BundleInfo> listDownloadedBundleInfos() {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
package com.otakit.updater;
|
|
2
|
+
|
|
3
|
+
import java.util.concurrent.Callable;
|
|
4
|
+
import java.util.concurrent.CancellationException;
|
|
5
|
+
|
|
6
|
+
/** Bounded diagnostics: never include manifest bodies, URLs, signatures, or key IDs. */
|
|
7
|
+
final class CheckFailure {
|
|
8
|
+
|
|
9
|
+
final String phase;
|
|
10
|
+
final String detail;
|
|
11
|
+
|
|
12
|
+
private CheckFailure(String phase, String detail) {
|
|
13
|
+
this.phase = phase;
|
|
14
|
+
this.detail = detail;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static CheckFailure from(Exception error) {
|
|
18
|
+
if (
|
|
19
|
+
error instanceof CancellationException || Thread.currentThread().isInterrupted()
|
|
20
|
+
) return null;
|
|
21
|
+
if (error instanceof ManifestVerifier.VerificationException) {
|
|
22
|
+
return new CheckFailure("signature", ((ManifestVerifier.VerificationException) error).reason);
|
|
23
|
+
}
|
|
24
|
+
if (error instanceof ManifestClient.HttpFailure) {
|
|
25
|
+
return new CheckFailure(
|
|
26
|
+
"check",
|
|
27
|
+
"manifest_http_" + ((ManifestClient.HttpFailure) error).status
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (error instanceof org.json.JSONException) return new CheckFailure(
|
|
31
|
+
"check",
|
|
32
|
+
"manifest_invalid"
|
|
33
|
+
);
|
|
34
|
+
if (error instanceof java.io.IOException) {
|
|
35
|
+
return new CheckFailure("check", "manifest_network_" + error.getClass().getSimpleName());
|
|
36
|
+
}
|
|
37
|
+
return new CheckFailure("check", "manifest_check_failed");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Reporter {
|
|
41
|
+
void report(CheckFailure failure) throws Exception;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static <T> T observe(Callable<T> operation, Reporter reporter) throws Exception {
|
|
45
|
+
try {
|
|
46
|
+
return operation.call();
|
|
47
|
+
} catch (Exception error) {
|
|
48
|
+
CheckFailure failure = from(error);
|
|
49
|
+
if (failure != null) reporter.report(failure);
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|