@sigx/lynx-updates 0.8.0 → 0.9.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.
@@ -1,235 +1,235 @@
1
- package com.sigx.updates
2
-
3
- import android.content.Context
4
- import android.os.Handler
5
- import android.os.Looper
6
- import android.util.Log
7
- import com.lynx.jsbridge.LynxMethod
8
- import com.lynx.jsbridge.LynxModule
9
- import com.lynx.react.bridge.Callback
10
- import com.lynx.react.bridge.JavaOnlyMap
11
- import com.lynx.react.bridge.ReadableMap
12
- import com.lynx.tasm.TemplateData
13
- import org.json.JSONObject
14
- import java.io.File
15
- import java.util.concurrent.Executors
16
-
17
- /**
18
- * JS bridge for OTA updates.
19
- * JS usage: NativeModules.Updates.downloadUpdate({...}, callback)
20
- *
21
- * Downloads run on a single-thread executor (single-flight is also enforced
22
- * in [UpdateDownloader]); everything else is cheap file/state work.
23
- */
24
- class UpdatesModule(context: Context) : LynxModule(context) {
25
-
26
- companion object {
27
- private const val TAG = "SigxUpdates"
28
- private val executor = Executors.newSingleThreadExecutor { r ->
29
- Thread(r, "sigx-updates").apply { isDaemon = true }
30
- }
31
- private val mainHandler = Handler(Looper.getMainLooper())
32
- }
33
-
34
- private fun error(message: String, code: String? = null): JavaOnlyMap {
35
- val map = JavaOnlyMap()
36
- map.putString("error", message)
37
- if (code != null) map.putString("code", code)
38
- return map
39
- }
40
-
41
- private fun ok(): JavaOnlyMap {
42
- val map = JavaOnlyMap()
43
- map.putBoolean("ok", true)
44
- return map
45
- }
46
-
47
- @LynxMethod
48
- fun getInstalledRuntimeVersion(): String {
49
- return UpdateStore.installedRuntimeVersion(mContext)
50
- }
51
-
52
- @LynxMethod
53
- fun getPlatform(): String = "android"
54
-
55
- @LynxMethod
56
- fun getCurrentUpdate(callback: Callback?) {
57
- try {
58
- val map = JavaOnlyMap()
59
- val updateId = UpdateStore.launchedUpdateId
60
- if (updateId != null) {
61
- map.putString("updateId", updateId)
62
- map.putBoolean("isEmbedded", false)
63
- val metaFile = UpdateStore.updateJsonFile(mContext, updateId)
64
- if (metaFile.exists()) {
65
- try {
66
- val meta = JSONObject(metaFile.readText())
67
- map.putString("version", meta.optString("version"))
68
- } catch (_: Exception) { /* metadata is best-effort */ }
69
- }
70
- } else {
71
- map.putBoolean("isEmbedded", true)
72
- }
73
- map.putString("runtimeVersion", UpdateStore.installedRuntimeVersion(mContext))
74
- // Store-shipped app version — providers receive it as
75
- // UpdateCheckContext.embeddedVersion.
76
- val versionName = runCatching {
77
- mContext.packageManager.getPackageInfo(mContext.packageName, 0).versionName
78
- }.getOrNull()
79
- map.putString("embeddedVersion", versionName ?: "")
80
- map.putBoolean("isFirstLaunchAfterUpdate", UpdateStore.isFirstLaunchAfterUpdate)
81
- map.putBoolean("didRollBack", UpdateStore.didRollBack)
82
- UpdateStore.rolledBackUpdateId?.let { map.putString("rolledBackUpdateId", it) }
83
- callback?.invoke(map)
84
- } catch (e: Exception) {
85
- callback?.invoke(error(e.message ?: "getCurrentUpdate failed"))
86
- }
87
- }
88
-
89
- @LynxMethod
90
- fun getState(callback: Callback?) {
91
- try {
92
- val state = UpdateStore.readState(mContext)
93
- val map = JavaOnlyMap()
94
- if (state != null) {
95
- map.putString("currentUpdateId", state.currentUpdateId ?: "")
96
- map.putString("previousUpdateId", state.previousUpdateId ?: "")
97
- map.putString("pendingUpdateId", state.pendingUpdateId ?: "")
98
- map.putInt("pendingLaunchAttempts", state.pendingLaunchAttempts)
99
- map.putInt("maxLaunchAttempts", state.maxLaunchAttempts)
100
- map.putString("lastRollbackUpdateId", state.lastRollbackUpdateId ?: "")
101
- map.putString("lastRollbackReason", state.lastRollbackReason ?: "")
102
- }
103
- map.putString("runningUpdateId", UpdateStore.launchedUpdateId ?: "")
104
- callback?.invoke(map)
105
- } catch (e: Exception) {
106
- callback?.invoke(error(e.message ?: "getState failed"))
107
- }
108
- }
109
-
110
- @LynxMethod
111
- fun downloadUpdate(params: ReadableMap?, callback: Callback?) {
112
- val url = params?.getString("url")
113
- val sha256 = params?.getString("sha256")
114
- val updateId = params?.getString("updateId")
115
- val runtimeVersion = params?.getString("runtimeVersion")
116
- val manifestJson = try { params?.getString("manifestJson") } catch (_: Exception) { null }
117
- if (url.isNullOrEmpty() || sha256.isNullOrEmpty() || updateId.isNullOrEmpty()) {
118
- callback?.invoke(error("url, sha256 and updateId are required"))
119
- return
120
- }
121
-
122
- // Refuse incompatible bundles at the door — defense in depth on top
123
- // of the JS-side check.
124
- val installed = UpdateStore.installedRuntimeVersion(mContext)
125
- if (!runtimeVersion.isNullOrEmpty() && runtimeVersion != installed) {
126
- callback?.invoke(error(
127
- "Update requires runtime $runtimeVersion but binary is $installed",
128
- "E_RUNTIME_MISMATCH",
129
- ))
130
- return
131
- }
132
-
133
- val headers = mutableMapOf<String, String>()
134
- try {
135
- val headerMap = params.getMap("headers")
136
- if (headerMap != null) {
137
- val it = headerMap.keySetIterator()
138
- while (it.hasNextKey()) {
139
- val key = it.nextKey()
140
- headerMap.getString(key)?.let { v -> headers[key] = v }
141
- }
142
- }
143
- } catch (_: Exception) { /* headers optional */ }
144
-
145
- executor.execute {
146
- val result = UpdateDownloader.download(
147
- mContext, url, sha256, updateId, headers, manifestJson ?: "{}")
148
- if (result == null) {
149
- callback?.invoke(ok())
150
- } else {
151
- val code = when {
152
- result.startsWith("E_DOWNLOAD_IN_PROGRESS") -> "E_DOWNLOAD_IN_PROGRESS"
153
- result.startsWith("E_HASH_MISMATCH") -> "hash-mismatch"
154
- else -> null
155
- }
156
- callback?.invoke(error(result, code))
157
- }
158
- }
159
- }
160
-
161
- @LynxMethod
162
- fun applyOnNextLaunch(updateId: String?, callback: Callback?) {
163
- if (updateId.isNullOrEmpty()) {
164
- callback?.invoke(error("updateId is required"))
165
- return
166
- }
167
- val result = UpdateStore.stagePending(mContext, updateId)
168
- callback?.invoke(if (result == null) ok() else error(result))
169
- }
170
-
171
- @LynxMethod
172
- fun applyNow(updateId: String?, callback: Callback?) {
173
- if (updateId.isNullOrEmpty()) {
174
- callback?.invoke(error("updateId is required"))
175
- return
176
- }
177
- val bundle = UpdateStore.bundleFile(mContext, updateId)
178
- if (!bundle.exists()) {
179
- callback?.invoke(error("Update $updateId is not on disk"))
180
- return
181
- }
182
- val view = UpdateStore.currentView()
183
- if (view == null) {
184
- callback?.invoke(error("No LynxView attached — cannot reload in place", "E_NO_VIEW"))
185
- return
186
- }
187
- // Stage first so a crash mid-reload still gets crash-guarded rollback
188
- // (the reload bypasses the startup resolver).
189
- UpdateStore.stagePending(mContext, updateId)
190
- UpdateStore.recordReloadAttempt(mContext, updateId)
191
- mainHandler.post {
192
- try {
193
- val bytes = File(bundle.absolutePath).readBytes()
194
- view.reloadAndInit()
195
- view.renderTemplateWithBaseUrl(bytes, TemplateData.empty(), "file://${bundle.absolutePath}")
196
- // JS context is replaced — the callback never reaches the old
197
- // context on success, which is the documented contract.
198
- } catch (e: Exception) {
199
- Log.e(TAG, "applyNow reload failed: ${e.message}")
200
- callback?.invoke(error(e.message ?: "Reload failed", "apply-failed"))
201
- }
202
- }
203
- }
204
-
205
- @LynxMethod
206
- fun markReady(callback: Callback?) {
207
- try {
208
- UpdateStore.markReady(mContext)
209
- callback?.invoke(ok())
210
- } catch (e: Exception) {
211
- callback?.invoke(error(e.message ?: "markReady failed"))
212
- }
213
- }
214
-
215
- @LynxMethod
216
- fun setRollbackOptions(params: ReadableMap?, callback: Callback?) {
217
- try {
218
- val max = params?.getInt("maxFailedLaunches") ?: 2
219
- UpdateStore.setMaxLaunchAttempts(mContext, max)
220
- callback?.invoke(ok())
221
- } catch (e: Exception) {
222
- callback?.invoke(error(e.message ?: "setRollbackOptions failed"))
223
- }
224
- }
225
-
226
- @LynxMethod
227
- fun clearUpdates(callback: Callback?) {
228
- try {
229
- UpdateStore.clearAll(mContext)
230
- callback?.invoke(ok())
231
- } catch (e: Exception) {
232
- callback?.invoke(error(e.message ?: "clearUpdates failed"))
233
- }
234
- }
235
- }
1
+ package com.sigx.updates
2
+
3
+ import android.content.Context
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import android.util.Log
7
+ import com.lynx.jsbridge.LynxMethod
8
+ import com.lynx.jsbridge.LynxModule
9
+ import com.lynx.react.bridge.Callback
10
+ import com.lynx.react.bridge.JavaOnlyMap
11
+ import com.lynx.react.bridge.ReadableMap
12
+ import com.lynx.tasm.TemplateData
13
+ import org.json.JSONObject
14
+ import java.io.File
15
+ import java.util.concurrent.Executors
16
+
17
+ /**
18
+ * JS bridge for OTA updates.
19
+ * JS usage: NativeModules.Updates.downloadUpdate({...}, callback)
20
+ *
21
+ * Downloads run on a single-thread executor (single-flight is also enforced
22
+ * in [UpdateDownloader]); everything else is cheap file/state work.
23
+ */
24
+ class UpdatesModule(context: Context) : LynxModule(context) {
25
+
26
+ companion object {
27
+ private const val TAG = "SigxUpdates"
28
+ private val executor = Executors.newSingleThreadExecutor { r ->
29
+ Thread(r, "sigx-updates").apply { isDaemon = true }
30
+ }
31
+ private val mainHandler = Handler(Looper.getMainLooper())
32
+ }
33
+
34
+ private fun error(message: String, code: String? = null): JavaOnlyMap {
35
+ val map = JavaOnlyMap()
36
+ map.putString("error", message)
37
+ if (code != null) map.putString("code", code)
38
+ return map
39
+ }
40
+
41
+ private fun ok(): JavaOnlyMap {
42
+ val map = JavaOnlyMap()
43
+ map.putBoolean("ok", true)
44
+ return map
45
+ }
46
+
47
+ @LynxMethod
48
+ fun getInstalledRuntimeVersion(): String {
49
+ return UpdateStore.installedRuntimeVersion(mContext)
50
+ }
51
+
52
+ @LynxMethod
53
+ fun getPlatform(): String = "android"
54
+
55
+ @LynxMethod
56
+ fun getCurrentUpdate(callback: Callback?) {
57
+ try {
58
+ val map = JavaOnlyMap()
59
+ val updateId = UpdateStore.launchedUpdateId
60
+ if (updateId != null) {
61
+ map.putString("updateId", updateId)
62
+ map.putBoolean("isEmbedded", false)
63
+ val metaFile = UpdateStore.updateJsonFile(mContext, updateId)
64
+ if (metaFile.exists()) {
65
+ try {
66
+ val meta = JSONObject(metaFile.readText())
67
+ map.putString("version", meta.optString("version"))
68
+ } catch (_: Exception) { /* metadata is best-effort */ }
69
+ }
70
+ } else {
71
+ map.putBoolean("isEmbedded", true)
72
+ }
73
+ map.putString("runtimeVersion", UpdateStore.installedRuntimeVersion(mContext))
74
+ // Store-shipped app version — providers receive it as
75
+ // UpdateCheckContext.embeddedVersion.
76
+ val versionName = runCatching {
77
+ mContext.packageManager.getPackageInfo(mContext.packageName, 0).versionName
78
+ }.getOrNull()
79
+ map.putString("embeddedVersion", versionName ?: "")
80
+ map.putBoolean("isFirstLaunchAfterUpdate", UpdateStore.isFirstLaunchAfterUpdate)
81
+ map.putBoolean("didRollBack", UpdateStore.didRollBack)
82
+ UpdateStore.rolledBackUpdateId?.let { map.putString("rolledBackUpdateId", it) }
83
+ callback?.invoke(map)
84
+ } catch (e: Exception) {
85
+ callback?.invoke(error(e.message ?: "getCurrentUpdate failed"))
86
+ }
87
+ }
88
+
89
+ @LynxMethod
90
+ fun getState(callback: Callback?) {
91
+ try {
92
+ val state = UpdateStore.readState(mContext)
93
+ val map = JavaOnlyMap()
94
+ if (state != null) {
95
+ map.putString("currentUpdateId", state.currentUpdateId ?: "")
96
+ map.putString("previousUpdateId", state.previousUpdateId ?: "")
97
+ map.putString("pendingUpdateId", state.pendingUpdateId ?: "")
98
+ map.putInt("pendingLaunchAttempts", state.pendingLaunchAttempts)
99
+ map.putInt("maxLaunchAttempts", state.maxLaunchAttempts)
100
+ map.putString("lastRollbackUpdateId", state.lastRollbackUpdateId ?: "")
101
+ map.putString("lastRollbackReason", state.lastRollbackReason ?: "")
102
+ }
103
+ map.putString("runningUpdateId", UpdateStore.launchedUpdateId ?: "")
104
+ callback?.invoke(map)
105
+ } catch (e: Exception) {
106
+ callback?.invoke(error(e.message ?: "getState failed"))
107
+ }
108
+ }
109
+
110
+ @LynxMethod
111
+ fun downloadUpdate(params: ReadableMap?, callback: Callback?) {
112
+ val url = params?.getString("url")
113
+ val sha256 = params?.getString("sha256")
114
+ val updateId = params?.getString("updateId")
115
+ val runtimeVersion = params?.getString("runtimeVersion")
116
+ val manifestJson = try { params?.getString("manifestJson") } catch (_: Exception) { null }
117
+ if (url.isNullOrEmpty() || sha256.isNullOrEmpty() || updateId.isNullOrEmpty()) {
118
+ callback?.invoke(error("url, sha256 and updateId are required"))
119
+ return
120
+ }
121
+
122
+ // Refuse incompatible bundles at the door — defense in depth on top
123
+ // of the JS-side check.
124
+ val installed = UpdateStore.installedRuntimeVersion(mContext)
125
+ if (!runtimeVersion.isNullOrEmpty() && runtimeVersion != installed) {
126
+ callback?.invoke(error(
127
+ "Update requires runtime $runtimeVersion but binary is $installed",
128
+ "E_RUNTIME_MISMATCH",
129
+ ))
130
+ return
131
+ }
132
+
133
+ val headers = mutableMapOf<String, String>()
134
+ try {
135
+ val headerMap = params.getMap("headers")
136
+ if (headerMap != null) {
137
+ val it = headerMap.keySetIterator()
138
+ while (it.hasNextKey()) {
139
+ val key = it.nextKey()
140
+ headerMap.getString(key)?.let { v -> headers[key] = v }
141
+ }
142
+ }
143
+ } catch (_: Exception) { /* headers optional */ }
144
+
145
+ executor.execute {
146
+ val result = UpdateDownloader.download(
147
+ mContext, url, sha256, updateId, headers, manifestJson ?: "{}")
148
+ if (result == null) {
149
+ callback?.invoke(ok())
150
+ } else {
151
+ val code = when {
152
+ result.startsWith("E_DOWNLOAD_IN_PROGRESS") -> "E_DOWNLOAD_IN_PROGRESS"
153
+ result.startsWith("E_HASH_MISMATCH") -> "hash-mismatch"
154
+ else -> null
155
+ }
156
+ callback?.invoke(error(result, code))
157
+ }
158
+ }
159
+ }
160
+
161
+ @LynxMethod
162
+ fun applyOnNextLaunch(updateId: String?, callback: Callback?) {
163
+ if (updateId.isNullOrEmpty()) {
164
+ callback?.invoke(error("updateId is required"))
165
+ return
166
+ }
167
+ val result = UpdateStore.stagePending(mContext, updateId)
168
+ callback?.invoke(if (result == null) ok() else error(result))
169
+ }
170
+
171
+ @LynxMethod
172
+ fun applyNow(updateId: String?, callback: Callback?) {
173
+ if (updateId.isNullOrEmpty()) {
174
+ callback?.invoke(error("updateId is required"))
175
+ return
176
+ }
177
+ val bundle = UpdateStore.bundleFile(mContext, updateId)
178
+ if (!bundle.exists()) {
179
+ callback?.invoke(error("Update $updateId is not on disk"))
180
+ return
181
+ }
182
+ val view = UpdateStore.currentView()
183
+ if (view == null) {
184
+ callback?.invoke(error("No LynxView attached — cannot reload in place", "E_NO_VIEW"))
185
+ return
186
+ }
187
+ // Stage first so a crash mid-reload still gets crash-guarded rollback
188
+ // (the reload bypasses the startup resolver).
189
+ UpdateStore.stagePending(mContext, updateId)
190
+ UpdateStore.recordReloadAttempt(mContext, updateId)
191
+ mainHandler.post {
192
+ try {
193
+ val bytes = File(bundle.absolutePath).readBytes()
194
+ view.reloadAndInit()
195
+ view.renderTemplateWithBaseUrl(bytes, TemplateData.empty(), "file://${bundle.absolutePath}")
196
+ // JS context is replaced — the callback never reaches the old
197
+ // context on success, which is the documented contract.
198
+ } catch (e: Exception) {
199
+ Log.e(TAG, "applyNow reload failed: ${e.message}")
200
+ callback?.invoke(error(e.message ?: "Reload failed", "apply-failed"))
201
+ }
202
+ }
203
+ }
204
+
205
+ @LynxMethod
206
+ fun markReady(callback: Callback?) {
207
+ try {
208
+ UpdateStore.markReady(mContext)
209
+ callback?.invoke(ok())
210
+ } catch (e: Exception) {
211
+ callback?.invoke(error(e.message ?: "markReady failed"))
212
+ }
213
+ }
214
+
215
+ @LynxMethod
216
+ fun setRollbackOptions(params: ReadableMap?, callback: Callback?) {
217
+ try {
218
+ val max = params?.getInt("maxFailedLaunches") ?: 2
219
+ UpdateStore.setMaxLaunchAttempts(mContext, max)
220
+ callback?.invoke(ok())
221
+ } catch (e: Exception) {
222
+ callback?.invoke(error(e.message ?: "setRollbackOptions failed"))
223
+ }
224
+ }
225
+
226
+ @LynxMethod
227
+ fun clearUpdates(callback: Callback?) {
228
+ try {
229
+ UpdateStore.clearAll(mContext)
230
+ callback?.invoke(ok())
231
+ } catch (e: Exception) {
232
+ callback?.invoke(error(e.message ?: "clearUpdates failed"))
233
+ }
234
+ }
235
+ }