@otakit/capacitor-updater 2.3.3 → 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.
Files changed (56) hide show
  1. package/README.md +46 -5
  2. package/android/build.gradle +16 -0
  3. package/android/src/main/java/com/otakit/updater/BundleCrypto.java +38 -17
  4. package/android/src/main/java/com/otakit/updater/BundleInfo.java +10 -0
  5. package/android/src/main/java/com/otakit/updater/BundleStore.java +134 -86
  6. package/android/src/main/java/com/otakit/updater/CheckFailure.java +53 -0
  7. package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +46 -41
  8. package/android/src/main/java/com/otakit/updater/DeviceEventClient.java +125 -26
  9. package/android/src/main/java/com/otakit/updater/DocumentReadyBridge.java +71 -0
  10. package/android/src/main/java/com/otakit/updater/DownloadRetry.java +144 -0
  11. package/android/src/main/java/com/otakit/updater/EventOutbox.java +174 -0
  12. package/android/src/main/java/com/otakit/updater/FileDownloader.java +80 -0
  13. package/android/src/main/java/com/otakit/updater/ForegroundDeadline.java +84 -0
  14. package/android/src/main/java/com/otakit/updater/HashUtils.java +26 -0
  15. package/android/src/main/java/com/otakit/updater/ManifestClient.java +12 -9
  16. package/android/src/main/java/com/otakit/updater/ManifestKeyConfig.java +47 -0
  17. package/android/src/main/java/com/otakit/updater/ManifestVerifier.java +22 -4
  18. package/android/src/main/java/com/otakit/updater/SDKVersion.java +9 -0
  19. package/android/src/main/java/com/otakit/updater/UpdateOwner.java +27 -0
  20. package/android/src/main/java/com/otakit/updater/UpdaterCoordinator.java +154 -59
  21. package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +262 -173
  22. package/dist/esm/definitions.d.ts +3 -3
  23. package/dist/esm/definitions.d.ts.map +1 -1
  24. package/ios/Sources/UpdaterPlugin/BundleCrypto.swift +35 -3
  25. package/ios/Sources/UpdaterPlugin/BundleInfo.swift +13 -0
  26. package/ios/Sources/UpdaterPlugin/BundleStore.swift +103 -77
  27. package/ios/Sources/UpdaterPlugin/CheckFailure.swift +41 -0
  28. package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +29 -17
  29. package/ios/Sources/UpdaterPlugin/DeviceEventClient.swift +12 -10
  30. package/ios/Sources/UpdaterPlugin/DocumentReadyBridge.swift +43 -0
  31. package/ios/Sources/UpdaterPlugin/DownloadRetry.swift +51 -0
  32. package/ios/Sources/UpdaterPlugin/Downloader.swift +97 -39
  33. package/ios/Sources/UpdaterPlugin/EventOutbox.swift +183 -0
  34. package/ios/Sources/UpdaterPlugin/ForegroundDeadline.swift +94 -0
  35. package/ios/Sources/UpdaterPlugin/HashUtils.swift +16 -0
  36. package/ios/Sources/UpdaterPlugin/ManifestClient.swift +2 -4
  37. package/ios/Sources/UpdaterPlugin/ManifestKeyConfig.swift +20 -0
  38. package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +7 -1
  39. package/ios/Sources/UpdaterPlugin/SDKVersion.swift +4 -0
  40. package/ios/Sources/UpdaterPlugin/UpdateOwner.swift +12 -0
  41. package/ios/Sources/UpdaterPlugin/UpdaterCoordinator.swift +146 -110
  42. package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +206 -202
  43. package/ios/Tests/UpdaterPluginTests/BundleCryptoTests.swift +80 -0
  44. package/ios/Tests/UpdaterPluginTests/BundlePersistenceTests.swift +102 -0
  45. package/ios/Tests/UpdaterPluginTests/CheckFailureTests.swift +55 -0
  46. package/ios/Tests/UpdaterPluginTests/DeltaCacheIntegrityTests.swift +69 -0
  47. package/ios/Tests/UpdaterPluginTests/DocumentReadyBridgeTests.swift +102 -0
  48. package/ios/Tests/UpdaterPluginTests/DownloadIntegrityTests.swift +40 -0
  49. package/ios/Tests/UpdaterPluginTests/DownloadRetryTests.swift +189 -0
  50. package/ios/Tests/UpdaterPluginTests/EventDeliveryTests.swift +56 -0
  51. package/ios/Tests/UpdaterPluginTests/EventOutboxTests.swift +86 -0
  52. package/ios/Tests/UpdaterPluginTests/ForegroundDeadlineTests.swift +144 -0
  53. package/ios/Tests/UpdaterPluginTests/ManifestKeyConfigTests.swift +48 -0
  54. package/ios/Tests/UpdaterPluginTests/UpdateOwnerTests.swift +34 -0
  55. package/ios/Tests/UpdaterPluginTests/UpdaterCoordinatorTests.swift +394 -0
  56. 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
- Events fire only while the app process is alive there is no buffering or
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 buffers the bundle in memory (roughly 2–3× the
403
- bundle size at peak). Keep encrypted bundles comfortably under ~100 MB,
404
- especially for low-end Android devices.
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
 
@@ -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.ByteArrayOutputStream;
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 = readAllBytes(input);
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[] readAllBytes(File file) throws Exception {
69
- try (
70
- InputStream in = new FileInputStream(file);
71
- ByteArrayOutputStream out = new ByteArrayOutputStream()
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
- try (FileOutputStream output = new FileOutputStream(metadataFile(bundle.id), false)) {
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
- if (!metadata.exists()) {
120
- return null;
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
- private static byte[] readAllBytes(FileInputStream input) throws Exception {
133
- java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
134
- byte[] chunk = new byte[8192];
135
- int read;
136
- while ((read = input.read(chunk)) != -1) {
137
- buffer.write(chunk, 0, read);
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
- synchronized boolean bundleExists(String id) {
143
- return getBundle(id) != null;
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 currentId = prefs.getString(KEY_CURRENT, null);
148
- if (currentId == null) {
149
- return builtinBundle();
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
- BundleInfo current = getBundle(currentId);
152
- return current != null ? current : builtinBundle();
237
+ return ids;
153
238
  }
154
239
 
155
240
  synchronized String getCurrentBundleId() {
156
- return prefs.getString(KEY_CURRENT, null);
241
+ return readCoreStateOrEmpty().optString("currentId", null);
157
242
  }
158
243
 
159
244
  synchronized void setCurrentBundleId(String id) {
160
- SharedPreferences.Editor editor = prefs.edit();
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 fallbackId = prefs.getString(KEY_FALLBACK, null);
171
- if (fallbackId == null) {
172
- return builtinBundle();
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 prefs.getString(KEY_FALLBACK, null);
255
+ return readCoreStateOrEmpty().optString("fallbackId", null);
180
256
  }
181
257
 
182
258
  synchronized void setFallbackBundleId(String id) {
183
- SharedPreferences.Editor editor = prefs.edit();
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 prefs.getString(KEY_STAGED, null);
263
+ return readCoreStateOrEmpty().optString("stagedId", null);
194
264
  }
195
265
 
196
266
  synchronized void setStagedBundleId(String id) {
197
- SharedPreferences.Editor editor = prefs.edit();
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
- SharedPreferences.Editor editor = prefs.edit();
208
- if (bundle == null) {
209
- editor.remove(KEY_LAST_FAILED_BUNDLE_INFO);
210
- } else {
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
- String json = prefs.getString(KEY_LAST_FAILED_BUNDLE_INFO, null);
222
- if (json == null) {
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 ignored) {}
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
- File directory = bundleDirectory(id);
277
- deleteRecursively(directory);
278
-
279
- if (id.equals(getStagedBundleId())) {
280
- setStagedBundleId(null);
281
- }
282
- if (id.equals(prefs.getString(KEY_CURRENT, null))) {
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
+ }