@otakit/capacitor-updater 1.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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +183 -0
  3. package/UpdatekitUpdater.podspec +18 -0
  4. package/android/build.gradle +49 -0
  5. package/android/src/main/AndroidManifest.xml +3 -0
  6. package/android/src/main/java/com/updatekit/updater/BundleInfo.java +100 -0
  7. package/android/src/main/java/com/updatekit/updater/BundleStatus.java +28 -0
  8. package/android/src/main/java/com/updatekit/updater/BundleStore.java +264 -0
  9. package/android/src/main/java/com/updatekit/updater/DateUtils.java +17 -0
  10. package/android/src/main/java/com/updatekit/updater/HashUtils.java +32 -0
  11. package/android/src/main/java/com/updatekit/updater/HostedManifestKeys.java +37 -0
  12. package/android/src/main/java/com/updatekit/updater/ManifestClient.java +209 -0
  13. package/android/src/main/java/com/updatekit/updater/ManifestVerifier.java +146 -0
  14. package/android/src/main/java/com/updatekit/updater/StatsClient.java +80 -0
  15. package/android/src/main/java/com/updatekit/updater/UpdaterPlugin.java +963 -0
  16. package/android/src/main/java/com/updatekit/updater/ZipUtils.java +72 -0
  17. package/dist/esm/definitions.d.ts +229 -0
  18. package/dist/esm/definitions.d.ts.map +1 -0
  19. package/dist/esm/definitions.js +17 -0
  20. package/dist/esm/definitions.js.map +1 -0
  21. package/dist/esm/index.d.ts +5 -0
  22. package/dist/esm/index.d.ts.map +1 -0
  23. package/dist/esm/index.js +85 -0
  24. package/dist/esm/index.js.map +1 -0
  25. package/dist/esm/web.d.ts +25 -0
  26. package/dist/esm/web.d.ts.map +1 -0
  27. package/dist/esm/web.js +56 -0
  28. package/dist/esm/web.js.map +1 -0
  29. package/dist/plugin.cjs.js +165 -0
  30. package/dist/plugin.cjs.js.map +1 -0
  31. package/dist/plugin.js +168 -0
  32. package/dist/plugin.js.map +1 -0
  33. package/ios/Sources/UpdaterPlugin/BundleInfo.swift +39 -0
  34. package/ios/Sources/UpdaterPlugin/BundleStatus.swift +9 -0
  35. package/ios/Sources/UpdaterPlugin/BundleStore.swift +233 -0
  36. package/ios/Sources/UpdaterPlugin/Downloader.swift +120 -0
  37. package/ios/Sources/UpdaterPlugin/HashUtils.swift +33 -0
  38. package/ios/Sources/UpdaterPlugin/HostedManifestKeys.swift +23 -0
  39. package/ios/Sources/UpdaterPlugin/ManifestClient.swift +161 -0
  40. package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +115 -0
  41. package/ios/Sources/UpdaterPlugin/StatsClient.swift +66 -0
  42. package/ios/Sources/UpdaterPlugin/UpdaterPlugin.m +15 -0
  43. package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +913 -0
  44. package/ios/Sources/UpdaterPlugin/ZipUtils.swift +90 -0
  45. package/package.json +85 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OtaKit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # @otakit/capacitor-updater
2
+
3
+ Capacitor OTA updater plugin for OtaKit.
4
+
5
+ ## What it does
6
+
7
+ - checks the manifest endpoint for a newer bundle
8
+ - downloads and verifies OTA bundles
9
+ - stages updates safely
10
+ - activates them on the next launch or immediately during startup
11
+ - also supports fully manual update prompts when the app wants control
12
+ - requires `notifyAppReady()` as the success handshake
13
+ - rolls back automatically if the new bundle does not prove healthy
14
+
15
+ For normal app code, the main public methods are:
16
+
17
+ ```ts
18
+ const state = await OtaKit.getState();
19
+ const latest = await OtaKit.check();
20
+
21
+ // Low-level manual flow:
22
+ const bundle = await OtaKit.download();
23
+ if (bundle) {
24
+ await OtaKit.apply();
25
+ }
26
+
27
+ // Or the one-shot manual helper:
28
+ await OtaKit.update();
29
+ await OtaKit.notifyAppReady();
30
+ ```
31
+
32
+ For manual mode, `getState()` tells you if something is already staged,
33
+ `check()` tells you whether a newer update exists, `download()` stages it, and
34
+ `update()` is the one-shot helper that downloads and applies the newest update.
35
+
36
+ Manual inspection and support methods like state inspection and reset live under
37
+ `OtaKit.debug`.
38
+
39
+ ## Hosted config
40
+
41
+ ```ts
42
+ plugins: {
43
+ OtaKit: {
44
+ appId: "YOUR_OTAKIT_APP_ID",
45
+ appReadyTimeout: 10000,
46
+ // Optional:
47
+ // channel: "staging",
48
+ // updateMode: "manual",
49
+ // updateMode: "immediate",
50
+ }
51
+ }
52
+ ```
53
+
54
+ Advanced overrides for self-hosting or custom trust only:
55
+
56
+ - `serverUrl`
57
+ - `manifestKeys`
58
+ - `allowInsecureUrls`
59
+
60
+ Hosted OtaKit already points at `https://otakit.app/api/v1` and already trusts
61
+ the managed manifest signing keys.
62
+
63
+ ## Trust model
64
+
65
+ The plugin does not just download from a URL and trust the result.
66
+
67
+ 1. it fetches a manifest from the server
68
+ 2. it verifies the manifest signature when manifest keys are configured
69
+ 3. it downloads the bundle zip
70
+ 4. it verifies the zip against the manifest `sha256`
71
+ 5. it stages and activates the bundle
72
+
73
+ In the hosted path, managed signing keys are already built in.
74
+
75
+ ## Update modes
76
+
77
+ - `manual`
78
+ no automatic startup check and no automatic staged activation
79
+ - `next-launch`
80
+ automatic startup check, download in the background, activate on the next cold launch
81
+ - `immediate`
82
+ automatic startup check, then download and activate during startup
83
+
84
+ The default is `next-launch`.
85
+
86
+ ## Runtime model
87
+
88
+ The plugin keeps three important runtime pointers:
89
+
90
+ - `current`
91
+ the bundle the WebView is currently serving
92
+ - `fallback`
93
+ the last known-good bundle used for rollback
94
+ - `staged`
95
+ a downloaded bundle waiting to be activated
96
+
97
+ Bundle lifecycle is intentionally small:
98
+
99
+ ```text
100
+ download -> pending -> trial -> success
101
+ |
102
+ +-> error -> rollback -> delete failed files
103
+ ```
104
+
105
+ In practice:
106
+
107
+ - downloads start as `pending`
108
+ - an activated bundle becomes `trial`
109
+ - `notifyAppReady()` promotes `trial` to `success`
110
+ - timeout or restart during `trial` causes rollback
111
+
112
+ That gives the plugin a small but important safety model:
113
+
114
+ - `current` is what is serving now
115
+ - `fallback` is the last known-good bundle
116
+ - `staged` is what can be activated next
117
+ - rollback always goes back to `fallback`, not to an arbitrary older bundle
118
+
119
+ ## Automatic and manual flows
120
+
121
+ ### Automatic flow
122
+
123
+ For the normal hosted path, the app usually just needs to call:
124
+
125
+ ```ts
126
+ await OtaKit.notifyAppReady();
127
+ ```
128
+
129
+ The plugin handles checking, downloading, activation, and rollback based on
130
+ `updateMode`.
131
+
132
+ For most apps, this is the entire runtime integration.
133
+
134
+ ### Manual flow
135
+
136
+ For app-driven prompts:
137
+
138
+ ```ts
139
+ const state = await OtaKit.getState();
140
+ const latest = await OtaKit.check();
141
+
142
+ if (latest) {
143
+ await OtaKit.update();
144
+ }
145
+ ```
146
+
147
+ Or the split version:
148
+
149
+ ```ts
150
+ await OtaKit.download();
151
+ await OtaKit.apply();
152
+ ```
153
+
154
+ Use the split flow when the app wants to download in the background and switch
155
+ only after explicit user confirmation.
156
+
157
+ ## Retention and deletion
158
+
159
+ - the builtin, current, fallback, and staged bundles are protected
160
+ - superseded staged bundles are deleted automatically
161
+ - failed trial bundles are deleted during rollback
162
+ - `debug.deleteBundle()` only works for downloaded bundles outside runtime state
163
+
164
+ ## Source areas
165
+
166
+ - `src/definitions.ts`: public types and methods
167
+ - `src/index.ts`: Capacitor registration and JS wrapper
168
+ - `src/web.ts`: web fallback implementation
169
+ - `ios/Sources/UpdaterPlugin/*`: iOS implementation
170
+ - `android/src/main/java/com/updatekit/updater/*`: Android implementation
171
+
172
+ ## Build locally
173
+
174
+ ```bash
175
+ pnpm --filter @otakit/capacitor-updater build
176
+ pnpm --filter @otakit/capacitor-updater typecheck
177
+ ```
178
+
179
+ Repo-level verification:
180
+
181
+ ```bash
182
+ npm run build
183
+ ```
@@ -0,0 +1,18 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'UpdatekitUpdater'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.license = package['license']
10
+ s.homepage = package['repository']['url']
11
+ s.author = package['author']
12
+ s.source = { :git => package['repository']['url'], :tag => s.version.to_s }
13
+ s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
14
+ s.ios.deployment_target = '15.0'
15
+ s.dependency 'Capacitor'
16
+ s.dependency 'ZIPFoundation', '~> 0.9'
17
+ s.swift_version = '5.9'
18
+ end
@@ -0,0 +1,49 @@
1
+ ext {
2
+ junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
3
+ androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.1.5'
4
+ androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.5.1'
5
+ }
6
+
7
+ buildscript {
8
+ repositories {
9
+ google()
10
+ mavenCentral()
11
+ }
12
+ dependencies {
13
+ classpath 'com.android.tools.build:gradle:8.2.2'
14
+ }
15
+ }
16
+
17
+ apply plugin: 'com.android.library'
18
+
19
+ android {
20
+ namespace "com.updatekit.updater"
21
+ compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
22
+ defaultConfig {
23
+ minSdk project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
24
+ targetSdk project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
25
+ versionCode 1
26
+ versionName "0.0.1"
27
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
28
+ }
29
+ compileOptions {
30
+ sourceCompatibility JavaVersion.VERSION_17
31
+ targetCompatibility JavaVersion.VERSION_17
32
+ }
33
+ lintOptions {
34
+ abortOnError false
35
+ }
36
+ }
37
+
38
+ repositories {
39
+ google()
40
+ mavenCentral()
41
+ }
42
+
43
+ dependencies {
44
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
45
+ implementation project(':capacitor-android')
46
+ testImplementation "junit:junit:$junitVersion"
47
+ androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
48
+ androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
49
+ }
@@ -0,0 +1,3 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3
+ </manifest>
@@ -0,0 +1,100 @@
1
+ package com.updatekit.updater;
2
+
3
+ import com.getcapacitor.JSObject;
4
+ import org.json.JSONException;
5
+ import org.json.JSONObject;
6
+
7
+ class BundleInfo {
8
+
9
+ final String id;
10
+ final String version;
11
+ final BundleStatus status;
12
+ final Long downloadedAt;
13
+ final String sha256;
14
+ final String path;
15
+ final String channel;
16
+ final String releaseId;
17
+
18
+ BundleInfo(
19
+ String id,
20
+ String version,
21
+ BundleStatus status,
22
+ Long downloadedAt,
23
+ String sha256,
24
+ String path,
25
+ String channel,
26
+ String releaseId
27
+ ) {
28
+ this.id = id;
29
+ this.version = version;
30
+ this.status = status;
31
+ this.downloadedAt = downloadedAt;
32
+ this.sha256 = sha256;
33
+ this.path = path;
34
+ this.channel = channel;
35
+ this.releaseId = releaseId;
36
+ }
37
+
38
+ boolean isBuiltin() {
39
+ return "builtin".equals(id);
40
+ }
41
+
42
+ JSObject toJSObject() {
43
+ JSObject object = new JSObject();
44
+ object.put("id", id);
45
+ object.put("version", version);
46
+ object.put("status", status.value());
47
+ if (downloadedAt != null) {
48
+ object.put("downloadedAt", DateUtils.toIsoString(downloadedAt));
49
+ }
50
+ if (sha256 != null) {
51
+ object.put("sha256", sha256);
52
+ }
53
+ if (channel != null) {
54
+ object.put("channel", channel);
55
+ }
56
+ if (releaseId != null) {
57
+ object.put("releaseId", releaseId);
58
+ }
59
+ return object;
60
+ }
61
+
62
+ JSONObject toJSONObject() throws JSONException {
63
+ JSONObject object = new JSONObject();
64
+ object.put("id", id);
65
+ object.put("version", version);
66
+ object.put("status", status.value());
67
+ if (downloadedAt != null) {
68
+ object.put("downloadedAt", downloadedAt);
69
+ }
70
+ if (sha256 != null) {
71
+ object.put("sha256", sha256);
72
+ }
73
+ if (channel != null) {
74
+ object.put("channel", channel);
75
+ }
76
+ if (releaseId != null) {
77
+ object.put("releaseId", releaseId);
78
+ }
79
+ if (path != null) {
80
+ object.put("path", path);
81
+ }
82
+ return object;
83
+ }
84
+
85
+ static BundleInfo fromJSONObject(JSONObject object) {
86
+ String id = object.optString("id", "builtin");
87
+ String version = object.optString("version", "0.0.0");
88
+ BundleStatus status = BundleStatus.from(object.optString("status", "pending"));
89
+ Long downloadedAt = object.has("downloadedAt") ? object.optLong("downloadedAt") : null;
90
+ String sha256 = object.has("sha256") ? object.optString("sha256", null) : null;
91
+ String channel = object.has("channel") ? object.optString("channel", null) : null;
92
+ String releaseId = object.has("releaseId") ? object.optString("releaseId", null) : null;
93
+ String path = object.has("path") ? object.optString("path", null) : null;
94
+ return new BundleInfo(id, version, status, downloadedAt, sha256, path, channel, releaseId);
95
+ }
96
+
97
+ BundleInfo withStatus(BundleStatus nextStatus) {
98
+ return new BundleInfo(id, version, nextStatus, downloadedAt, sha256, path, channel, releaseId);
99
+ }
100
+ }
@@ -0,0 +1,28 @@
1
+ package com.updatekit.updater;
2
+
3
+ enum BundleStatus {
4
+ BUILTIN("builtin"),
5
+ PENDING("pending"),
6
+ TRIAL("trial"),
7
+ SUCCESS("success"),
8
+ ERROR("error");
9
+
10
+ private final String value;
11
+
12
+ BundleStatus(String value) {
13
+ this.value = value;
14
+ }
15
+
16
+ public String value() {
17
+ return value;
18
+ }
19
+
20
+ public static BundleStatus from(String raw) {
21
+ for (BundleStatus status : values()) {
22
+ if (status.value.equals(raw)) {
23
+ return status;
24
+ }
25
+ }
26
+ return PENDING;
27
+ }
28
+ }
@@ -0,0 +1,264 @@
1
+ package com.updatekit.updater;
2
+
3
+ import android.content.Context;
4
+ import android.content.SharedPreferences;
5
+ import com.getcapacitor.JSArray;
6
+ import java.io.File;
7
+ import java.io.FileInputStream;
8
+ import java.io.FileOutputStream;
9
+ import java.nio.charset.StandardCharsets;
10
+ import java.util.ArrayList;
11
+ import java.util.Arrays;
12
+ import java.util.Comparator;
13
+ import java.util.List;
14
+ import org.json.JSONException;
15
+ import org.json.JSONObject;
16
+
17
+ final class BundleStore {
18
+
19
+ private static final String PREFS_NAME = "otakit_updater_state";
20
+ private static final String KEY_CURRENT = "current_bundle_id";
21
+ private static final String KEY_FALLBACK = "fallback_bundle_id";
22
+ private static final String KEY_STAGED = "staged_bundle_id";
23
+ private static final String KEY_FAILED_INFO = "failed_bundle_info";
24
+
25
+ private final Context context;
26
+ private final SharedPreferences prefs;
27
+ private final File bundlesDirectory;
28
+ private final String builtinVersion;
29
+ private final String nativeBuild;
30
+
31
+ BundleStore(Context context, String builtinVersion, String nativeBuild) {
32
+ this.context = context.getApplicationContext();
33
+ this.prefs = this.context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
34
+ this.builtinVersion = builtinVersion;
35
+ this.nativeBuild = nativeBuild;
36
+ this.bundlesDirectory = new File(this.context.getFilesDir(), "otakit_bundles");
37
+ if (!bundlesDirectory.exists()) {
38
+ //noinspection ResultOfMethodCallIgnored
39
+ bundlesDirectory.mkdirs();
40
+ }
41
+ }
42
+
43
+ String getNativeBuild() {
44
+ return nativeBuild;
45
+ }
46
+
47
+ String getBuiltinVersion() {
48
+ return builtinVersion;
49
+ }
50
+
51
+ BundleInfo builtinBundle() {
52
+ return new BundleInfo(
53
+ "builtin",
54
+ builtinVersion,
55
+ BundleStatus.BUILTIN,
56
+ null,
57
+ null,
58
+ null,
59
+ null,
60
+ null
61
+ );
62
+ }
63
+
64
+ File bundleDirectory(String id) {
65
+ return new File(bundlesDirectory, id);
66
+ }
67
+
68
+ private File metadataFile(String id) {
69
+ return new File(bundleDirectory(id), "bundle.json");
70
+ }
71
+
72
+ synchronized void saveBundle(BundleInfo bundle) throws Exception {
73
+ if (bundle.isBuiltin()) {
74
+ return;
75
+ }
76
+
77
+ File directory = bundleDirectory(bundle.id);
78
+ if (!directory.exists() && !directory.mkdirs()) {
79
+ throw new IllegalStateException("Failed to create bundle directory");
80
+ }
81
+
82
+ byte[] bytes = bundle.toJSONObject().toString().getBytes(StandardCharsets.UTF_8);
83
+ try (FileOutputStream output = new FileOutputStream(metadataFile(bundle.id), false)) {
84
+ output.write(bytes);
85
+ }
86
+ }
87
+
88
+ synchronized BundleInfo getBundle(String id) {
89
+ if ("builtin".equals(id)) {
90
+ return builtinBundle();
91
+ }
92
+
93
+ File metadata = metadataFile(id);
94
+ if (!metadata.exists()) {
95
+ return null;
96
+ }
97
+
98
+ try (FileInputStream input = new FileInputStream(metadata)) {
99
+ byte[] bytes = readAllBytes(input);
100
+ JSONObject json = new JSONObject(new String(bytes, StandardCharsets.UTF_8));
101
+ return BundleInfo.fromJSONObject(json);
102
+ } catch (Exception ignored) {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ private static byte[] readAllBytes(FileInputStream input) throws Exception {
108
+ java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
109
+ byte[] chunk = new byte[8192];
110
+ int read;
111
+ while ((read = input.read(chunk)) != -1) {
112
+ buffer.write(chunk, 0, read);
113
+ }
114
+ return buffer.toByteArray();
115
+ }
116
+
117
+ synchronized boolean bundleExists(String id) {
118
+ return getBundle(id) != null;
119
+ }
120
+
121
+ synchronized BundleInfo getCurrentBundle() {
122
+ String currentId = prefs.getString(KEY_CURRENT, null);
123
+ if (currentId == null) {
124
+ return builtinBundle();
125
+ }
126
+ BundleInfo current = getBundle(currentId);
127
+ return current != null ? current : builtinBundle();
128
+ }
129
+
130
+ synchronized void setCurrentBundleId(String id) {
131
+ SharedPreferences.Editor editor = prefs.edit();
132
+ if (id == null) {
133
+ editor.remove(KEY_CURRENT);
134
+ } else {
135
+ editor.putString(KEY_CURRENT, id);
136
+ }
137
+ editor.commit();
138
+ }
139
+
140
+ synchronized BundleInfo getFallbackBundle() {
141
+ String fallbackId = prefs.getString(KEY_FALLBACK, null);
142
+ if (fallbackId == null) {
143
+ return builtinBundle();
144
+ }
145
+ BundleInfo fallback = getBundle(fallbackId);
146
+ return fallback != null ? fallback : builtinBundle();
147
+ }
148
+
149
+ synchronized void setFallbackBundleId(String id) {
150
+ SharedPreferences.Editor editor = prefs.edit();
151
+ if (id == null) {
152
+ editor.remove(KEY_FALLBACK);
153
+ } else {
154
+ editor.putString(KEY_FALLBACK, id);
155
+ }
156
+ editor.commit();
157
+ }
158
+
159
+ synchronized String getStagedBundleId() {
160
+ return prefs.getString(KEY_STAGED, null);
161
+ }
162
+
163
+ synchronized void setStagedBundleId(String id) {
164
+ SharedPreferences.Editor editor = prefs.edit();
165
+ if (id == null) {
166
+ editor.remove(KEY_STAGED);
167
+ } else {
168
+ editor.putString(KEY_STAGED, id);
169
+ }
170
+ editor.commit();
171
+ }
172
+
173
+ synchronized void setFailedBundle(BundleInfo bundle) {
174
+ SharedPreferences.Editor editor = prefs.edit();
175
+ if (bundle == null) {
176
+ editor.remove(KEY_FAILED_INFO);
177
+ } else {
178
+ try {
179
+ editor.putString(KEY_FAILED_INFO, bundle.toJSONObject().toString());
180
+ } catch (JSONException ignored) {
181
+ editor.remove(KEY_FAILED_INFO);
182
+ }
183
+ }
184
+ editor.apply();
185
+ }
186
+
187
+ synchronized BundleInfo getFailedBundle() {
188
+ String json = prefs.getString(KEY_FAILED_INFO, null);
189
+ if (json == null) {
190
+ return null;
191
+ }
192
+ try {
193
+ return BundleInfo.fromJSONObject(new JSONObject(json));
194
+ } catch (Exception e) {
195
+ return null;
196
+ }
197
+ }
198
+
199
+ synchronized void markStatus(String bundleId, BundleStatus status) {
200
+ BundleInfo existing = getBundle(bundleId);
201
+ if (existing == null) {
202
+ return;
203
+ }
204
+ try {
205
+ saveBundle(existing.withStatus(status));
206
+ } catch (Exception ignored) {}
207
+ }
208
+
209
+ synchronized void deleteBundle(String id) throws Exception {
210
+ if ("builtin".equals(id)) {
211
+ return;
212
+ }
213
+
214
+ File directory = bundleDirectory(id);
215
+ deleteRecursively(directory);
216
+
217
+ if (id.equals(getStagedBundleId())) {
218
+ setStagedBundleId(null);
219
+ }
220
+ if (id.equals(prefs.getString(KEY_CURRENT, null))) {
221
+ setCurrentBundleId(null);
222
+ }
223
+ if (id.equals(prefs.getString(KEY_FALLBACK, null))) {
224
+ setFallbackBundleId(null);
225
+ }
226
+ }
227
+
228
+ synchronized JSArray listDownloadedBundles() {
229
+ List<BundleInfo> result = new ArrayList<>();
230
+ File[] entries = bundlesDirectory.listFiles();
231
+ if (entries != null) {
232
+ Arrays.sort(entries, Comparator.comparing(File::getName));
233
+ for (File entry : entries) {
234
+ BundleInfo bundle = getBundle(entry.getName());
235
+ if (bundle != null) {
236
+ result.add(bundle);
237
+ }
238
+ }
239
+ }
240
+
241
+ JSArray array = new JSArray();
242
+ for (BundleInfo bundle : result) {
243
+ array.put(bundle.toJSObject());
244
+ }
245
+ return array;
246
+ }
247
+
248
+ private void deleteRecursively(File target) throws Exception {
249
+ if (!target.exists()) {
250
+ return;
251
+ }
252
+ if (target.isDirectory()) {
253
+ File[] children = target.listFiles();
254
+ if (children != null) {
255
+ for (File child : children) {
256
+ deleteRecursively(child);
257
+ }
258
+ }
259
+ }
260
+ if (!target.delete()) {
261
+ throw new IllegalStateException("Failed to delete " + target.getAbsolutePath());
262
+ }
263
+ }
264
+ }
@@ -0,0 +1,17 @@
1
+ package com.updatekit.updater;
2
+
3
+ import java.text.SimpleDateFormat;
4
+ import java.util.Date;
5
+ import java.util.Locale;
6
+ import java.util.TimeZone;
7
+
8
+ final class DateUtils {
9
+
10
+ private DateUtils() {}
11
+
12
+ static String toIsoString(long timestampMs) {
13
+ SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
14
+ format.setTimeZone(TimeZone.getTimeZone("UTC"));
15
+ return format.format(new Date(timestampMs));
16
+ }
17
+ }