@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.
- package/LICENSE +21 -21
- package/README.md +199 -199
- package/android/com/sigx/updates/UpdateDownloader.kt +154 -154
- package/android/com/sigx/updates/UpdateStore.kt +367 -367
- package/android/com/sigx/updates/UpdatesActivityHook.kt +25 -25
- package/android/com/sigx/updates/UpdatesBundleResolver.kt +18 -18
- package/android/com/sigx/updates/UpdatesEventBus.kt +54 -54
- package/android/com/sigx/updates/UpdatesLifecyclePublisher.kt +42 -42
- package/android/com/sigx/updates/UpdatesModule.kt +235 -235
- package/ios/UpdateDownloader.swift +152 -152
- package/ios/UpdateStore.swift +286 -286
- package/ios/UpdatesBundleResolver.swift +15 -15
- package/ios/UpdatesEventBus.swift +59 -59
- package/ios/UpdatesLifecyclePublisher.swift +48 -48
- package/ios/UpdatesModule.swift +178 -178
- package/package.json +3 -3
- package/signalx-module.json +35 -35
|
@@ -1,367 +1,367 @@
|
|
|
1
|
-
package com.sigx.updates
|
|
2
|
-
|
|
3
|
-
import android.content.Context
|
|
4
|
-
import android.content.pm.PackageManager
|
|
5
|
-
import android.os.Build
|
|
6
|
-
import android.util.Log
|
|
7
|
-
import com.lynx.tasm.LynxView
|
|
8
|
-
import org.json.JSONObject
|
|
9
|
-
import java.io.File
|
|
10
|
-
import java.io.FileOutputStream
|
|
11
|
-
import java.lang.ref.WeakReference
|
|
12
|
-
import java.security.MessageDigest
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* On-disk update store + state machine shared by [UpdatesBundleResolver]
|
|
16
|
-
* (startup, before any LynxView exists) and [UpdatesModule] (JS bridge).
|
|
17
|
-
*
|
|
18
|
-
* Layout under `filesDir/sigx-updates/`:
|
|
19
|
-
* state.json — single source of truth (atomic writes)
|
|
20
|
-
* updates/<updateId>/main.lynx.bundle + update.json
|
|
21
|
-
* tmp/ — in-flight downloads; wiped at startup
|
|
22
|
-
*
|
|
23
|
-
* Update lifecycle: downloaded (dir exists) → pending (state.pendingUpdateId)
|
|
24
|
-
* → committed (state.currentUpdateId). A pending update that exhausts its
|
|
25
|
-
* launch attempts before JS calls markReady() is rolled back and deleted.
|
|
26
|
-
*/
|
|
27
|
-
object UpdateStore {
|
|
28
|
-
|
|
29
|
-
private const val TAG = "SigxUpdates"
|
|
30
|
-
private const val DIR = "sigx-updates"
|
|
31
|
-
private const val SCHEMA_VERSION = 1
|
|
32
|
-
private const val DEFAULT_MAX_LAUNCH_ATTEMPTS = 2
|
|
33
|
-
private const val RUNTIME_VERSION_META_KEY = "com.sigx.updates.RUNTIME_VERSION"
|
|
34
|
-
|
|
35
|
-
/** What this process actually loaded (set by the resolver). */
|
|
36
|
-
@Volatile var launchedUpdateId: String? = null
|
|
37
|
-
private set
|
|
38
|
-
/** True when this launch is a pending update's trial run. */
|
|
39
|
-
@Volatile var isFirstLaunchAfterUpdate: Boolean = false
|
|
40
|
-
private set
|
|
41
|
-
/** True when the resolver rolled back at this startup. */
|
|
42
|
-
@Volatile var didRollBack: Boolean = false
|
|
43
|
-
private set
|
|
44
|
-
/** Update id that was rolled back at this startup (for events/info). */
|
|
45
|
-
@Volatile var rolledBackUpdateId: String? = null
|
|
46
|
-
private set
|
|
47
|
-
|
|
48
|
-
/** Last-attached LynxView — apply-now reload target. */
|
|
49
|
-
@Volatile private var lynxViewRef: WeakReference<LynxView>? = null
|
|
50
|
-
|
|
51
|
-
fun attachView(view: LynxView) {
|
|
52
|
-
lynxViewRef = WeakReference(view)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
fun currentView(): LynxView? = lynxViewRef?.get()
|
|
56
|
-
|
|
57
|
-
// ── Paths ────────────────────────────────────────────────────────────
|
|
58
|
-
|
|
59
|
-
fun rootDir(context: Context): File = File(context.filesDir, DIR)
|
|
60
|
-
fun updatesDir(context: Context): File = File(rootDir(context), "updates")
|
|
61
|
-
fun tmpDir(context: Context): File = File(rootDir(context), "tmp")
|
|
62
|
-
fun updateDir(context: Context, updateId: String): File = File(updatesDir(context), updateId)
|
|
63
|
-
fun bundleFile(context: Context, updateId: String): File =
|
|
64
|
-
File(updateDir(context, updateId), "main.lynx.bundle")
|
|
65
|
-
fun updateJsonFile(context: Context, updateId: String): File =
|
|
66
|
-
File(updateDir(context, updateId), "update.json")
|
|
67
|
-
private fun stateFile(context: Context): File = File(rootDir(context), "state.json")
|
|
68
|
-
|
|
69
|
-
// ── Binary identity ──────────────────────────────────────────────────
|
|
70
|
-
|
|
71
|
-
/** The binary's runtime fingerprint, injected by `sigx prebuild` as manifest meta-data. */
|
|
72
|
-
fun installedRuntimeVersion(context: Context): String {
|
|
73
|
-
return try {
|
|
74
|
-
val ai = context.packageManager.getApplicationInfo(
|
|
75
|
-
context.packageName, PackageManager.GET_META_DATA)
|
|
76
|
-
ai.metaData?.getString(RUNTIME_VERSION_META_KEY) ?: "unknown"
|
|
77
|
-
} catch (e: Exception) {
|
|
78
|
-
Log.w(TAG, "Could not read runtime version meta-data: ${e.message}")
|
|
79
|
-
"unknown"
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
fun installedBinaryVersion(context: Context): String {
|
|
84
|
-
return try {
|
|
85
|
-
val info = context.packageManager.getPackageInfo(context.packageName, 0)
|
|
86
|
-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
87
|
-
info.longVersionCode.toString()
|
|
88
|
-
} else {
|
|
89
|
-
@Suppress("DEPRECATION")
|
|
90
|
-
info.versionCode.toString()
|
|
91
|
-
}
|
|
92
|
-
} catch (e: Exception) {
|
|
93
|
-
"unknown"
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// ── State ────────────────────────────────────────────────────────────
|
|
98
|
-
|
|
99
|
-
data class State(
|
|
100
|
-
var installedRuntimeVersion: String = "",
|
|
101
|
-
var installedBinaryVersion: String = "",
|
|
102
|
-
var currentUpdateId: String? = null,
|
|
103
|
-
var previousUpdateId: String? = null,
|
|
104
|
-
var pendingUpdateId: String? = null,
|
|
105
|
-
var pendingLaunchAttempts: Int = 0,
|
|
106
|
-
var maxLaunchAttempts: Int = DEFAULT_MAX_LAUNCH_ATTEMPTS,
|
|
107
|
-
var lastRollbackUpdateId: String? = null,
|
|
108
|
-
var lastRollbackReason: String? = null,
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
@Synchronized
|
|
112
|
-
fun readState(context: Context): State? {
|
|
113
|
-
val file = stateFile(context)
|
|
114
|
-
if (!file.exists()) return null
|
|
115
|
-
return try {
|
|
116
|
-
val json = JSONObject(file.readText())
|
|
117
|
-
State(
|
|
118
|
-
installedRuntimeVersion = json.optString("installedRuntimeVersion", ""),
|
|
119
|
-
installedBinaryVersion = json.optString("installedBinaryVersion", ""),
|
|
120
|
-
currentUpdateId = json.optString("currentUpdateId").ifEmpty { null },
|
|
121
|
-
previousUpdateId = json.optString("previousUpdateId").ifEmpty { null },
|
|
122
|
-
pendingUpdateId = json.optString("pendingUpdateId").ifEmpty { null },
|
|
123
|
-
pendingLaunchAttempts = json.optInt("pendingLaunchAttempts", 0),
|
|
124
|
-
maxLaunchAttempts = json.optInt("maxLaunchAttempts", DEFAULT_MAX_LAUNCH_ATTEMPTS),
|
|
125
|
-
lastRollbackUpdateId = json.optString("lastRollbackUpdateId").ifEmpty { null },
|
|
126
|
-
lastRollbackReason = json.optString("lastRollbackReason").ifEmpty { null },
|
|
127
|
-
)
|
|
128
|
-
} catch (e: Exception) {
|
|
129
|
-
Log.w(TAG, "state.json unreadable: ${e.message}")
|
|
130
|
-
null
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Atomic + fsynced write — the launch-attempt counter must survive a crash. */
|
|
135
|
-
@Synchronized
|
|
136
|
-
fun writeState(context: Context, state: State) {
|
|
137
|
-
val json = JSONObject()
|
|
138
|
-
.put("schemaVersion", SCHEMA_VERSION)
|
|
139
|
-
.put("installedRuntimeVersion", state.installedRuntimeVersion)
|
|
140
|
-
.put("installedBinaryVersion", state.installedBinaryVersion)
|
|
141
|
-
.put("currentUpdateId", state.currentUpdateId ?: "")
|
|
142
|
-
.put("previousUpdateId", state.previousUpdateId ?: "")
|
|
143
|
-
.put("pendingUpdateId", state.pendingUpdateId ?: "")
|
|
144
|
-
.put("pendingLaunchAttempts", state.pendingLaunchAttempts)
|
|
145
|
-
.put("maxLaunchAttempts", state.maxLaunchAttempts)
|
|
146
|
-
.put("lastRollbackUpdateId", state.lastRollbackUpdateId ?: "")
|
|
147
|
-
.put("lastRollbackReason", state.lastRollbackReason ?: "")
|
|
148
|
-
val file = stateFile(context)
|
|
149
|
-
file.parentFile?.mkdirs()
|
|
150
|
-
val tmp = File(file.parentFile, "state.json.tmp")
|
|
151
|
-
FileOutputStream(tmp).use { out ->
|
|
152
|
-
out.write(json.toString().toByteArray(Charsets.UTF_8))
|
|
153
|
-
out.fd.sync()
|
|
154
|
-
}
|
|
155
|
-
if (!tmp.renameTo(file)) {
|
|
156
|
-
// Windows-style rename-over-existing failure can't happen on
|
|
157
|
-
// Android (POSIX rename), but belt-and-braces:
|
|
158
|
-
file.delete()
|
|
159
|
-
tmp.renameTo(file)
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** Fresh state seeded with the binary's identity. */
|
|
164
|
-
fun freshState(context: Context): State = State(
|
|
165
|
-
installedRuntimeVersion = installedRuntimeVersion(context),
|
|
166
|
-
installedBinaryVersion = installedBinaryVersion(context),
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
// ── Startup resolution (called by UpdatesBundleResolver) ─────────────
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Decide which bundle this launch loads. Returns an absolute path to an
|
|
173
|
-
* OTA bundle, or null to use the baked asset. Mutates rollback state —
|
|
174
|
-
* call exactly once per process launch.
|
|
175
|
-
*/
|
|
176
|
-
@Synchronized
|
|
177
|
-
fun resolveStartupBundlePath(context: Context): String? {
|
|
178
|
-
try {
|
|
179
|
-
tmpDir(context).deleteRecursively()
|
|
180
|
-
|
|
181
|
-
var state = readState(context)
|
|
182
|
-
if (state == null) {
|
|
183
|
-
if (rootDir(context).exists() && stateFile(context).exists()) {
|
|
184
|
-
// Unparseable state — wipe everything, run baked.
|
|
185
|
-
Log.w(TAG, "Corrupt state.json — clearing all updates")
|
|
186
|
-
rootDir(context).deleteRecursively()
|
|
187
|
-
}
|
|
188
|
-
return null
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Binary-update tripwire: a store update (or any reinstall that
|
|
192
|
-
// changed the fingerprint/versionCode) invalidates every
|
|
193
|
-
// downloaded update — they were published for the old runtime.
|
|
194
|
-
val runtimeNow = installedRuntimeVersion(context)
|
|
195
|
-
val binaryNow = installedBinaryVersion(context)
|
|
196
|
-
if (state.installedRuntimeVersion != runtimeNow ||
|
|
197
|
-
state.installedBinaryVersion != binaryNow
|
|
198
|
-
) {
|
|
199
|
-
Log.i(TAG, "Binary changed (runtime ${state.installedRuntimeVersion} -> $runtimeNow, " +
|
|
200
|
-
"build ${state.installedBinaryVersion} -> $binaryNow) — clearing updates")
|
|
201
|
-
updatesDir(context).deleteRecursively()
|
|
202
|
-
writeState(context, freshState(context))
|
|
203
|
-
return null
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Pending update: crash-guarded trial launch.
|
|
207
|
-
val pending = state.pendingUpdateId
|
|
208
|
-
if (pending != null) {
|
|
209
|
-
if (state.pendingLaunchAttempts >= state.maxLaunchAttempts) {
|
|
210
|
-
Log.w(TAG, "Update $pending failed ${state.pendingLaunchAttempts} launches — rolling back")
|
|
211
|
-
rollbackPending(context, state, "crash")
|
|
212
|
-
state = readState(context) ?: return null
|
|
213
|
-
} else {
|
|
214
|
-
val bundle = bundleFile(context, pending)
|
|
215
|
-
val firstAttempt = state.pendingLaunchAttempts == 0
|
|
216
|
-
val ok = bundle.exists() && (!firstAttempt || verifySha256(context, pending))
|
|
217
|
-
if (!ok) {
|
|
218
|
-
Log.w(TAG, "Update $pending missing or corrupt — rolling back")
|
|
219
|
-
rollbackPending(context, state, "corrupt")
|
|
220
|
-
state = readState(context) ?: return null
|
|
221
|
-
} else {
|
|
222
|
-
state.pendingLaunchAttempts += 1
|
|
223
|
-
writeState(context, state)
|
|
224
|
-
launchedUpdateId = pending
|
|
225
|
-
isFirstLaunchAfterUpdate = firstAttempt
|
|
226
|
-
sweepOrphans(context, state)
|
|
227
|
-
return bundle.absolutePath
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Committed update: trusted (existence check only).
|
|
233
|
-
val current = state.currentUpdateId
|
|
234
|
-
if (current != null) {
|
|
235
|
-
val bundle = bundleFile(context, current)
|
|
236
|
-
if (bundle.exists()) {
|
|
237
|
-
launchedUpdateId = current
|
|
238
|
-
sweepOrphans(context, state)
|
|
239
|
-
return bundle.absolutePath
|
|
240
|
-
}
|
|
241
|
-
Log.w(TAG, "Committed update $current missing on disk — reverting to baked bundle")
|
|
242
|
-
state.currentUpdateId = null
|
|
243
|
-
writeState(context, state)
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
sweepOrphans(context, state)
|
|
247
|
-
return null
|
|
248
|
-
} catch (e: Exception) {
|
|
249
|
-
// Any unexpected failure must never take the app down with it —
|
|
250
|
-
// the baked bundle is always the safe answer.
|
|
251
|
-
Log.e(TAG, "resolveStartupBundlePath failed: ${e.message}")
|
|
252
|
-
return null
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
private fun rollbackPending(context: Context, state: State, reason: String) {
|
|
257
|
-
val pending = state.pendingUpdateId ?: return
|
|
258
|
-
didRollBack = true
|
|
259
|
-
rolledBackUpdateId = pending
|
|
260
|
-
updateDir(context, pending).deleteRecursively()
|
|
261
|
-
state.lastRollbackUpdateId = pending
|
|
262
|
-
state.lastRollbackReason = reason
|
|
263
|
-
state.pendingUpdateId = null
|
|
264
|
-
state.pendingLaunchAttempts = 0
|
|
265
|
-
writeState(context, state)
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/** Delete update dirs not referenced by state (≤3 may remain by invariant). */
|
|
269
|
-
private fun sweepOrphans(context: Context, state: State) {
|
|
270
|
-
val keep = setOfNotNull(state.currentUpdateId, state.previousUpdateId, state.pendingUpdateId)
|
|
271
|
-
updatesDir(context).listFiles()?.forEach { dir ->
|
|
272
|
-
if (dir.isDirectory && dir.name !in keep) {
|
|
273
|
-
dir.deleteRecursively()
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// ── Transitions (called by UpdatesModule) ─────────────────────────────
|
|
279
|
-
|
|
280
|
-
/** Stage a downloaded update to load on the next launch. */
|
|
281
|
-
@Synchronized
|
|
282
|
-
fun stagePending(context: Context, updateId: String): String? {
|
|
283
|
-
if (!bundleFile(context, updateId).exists()) {
|
|
284
|
-
return "Update $updateId is not on disk"
|
|
285
|
-
}
|
|
286
|
-
val state = readState(context) ?: freshState(context)
|
|
287
|
-
if (state.currentUpdateId == updateId) return null // already active
|
|
288
|
-
state.pendingUpdateId = updateId
|
|
289
|
-
state.pendingLaunchAttempts = 0
|
|
290
|
-
writeState(context, state)
|
|
291
|
-
return null
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Record a launch attempt for an in-place reload (applyNow). The reload
|
|
296
|
-
* doesn't go through the resolver, so the crash guard is armed here.
|
|
297
|
-
*/
|
|
298
|
-
@Synchronized
|
|
299
|
-
fun recordReloadAttempt(context: Context, updateId: String): String? {
|
|
300
|
-
val state = readState(context) ?: freshState(context)
|
|
301
|
-
if (state.pendingUpdateId != updateId) {
|
|
302
|
-
state.pendingUpdateId = updateId
|
|
303
|
-
}
|
|
304
|
-
state.pendingLaunchAttempts += 1
|
|
305
|
-
writeState(context, state)
|
|
306
|
-
launchedUpdateId = updateId
|
|
307
|
-
isFirstLaunchAfterUpdate = true
|
|
308
|
-
return null
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/** Commit the running pending update as healthy. Idempotent. */
|
|
312
|
-
@Synchronized
|
|
313
|
-
fun markReady(context: Context) {
|
|
314
|
-
val state = readState(context) ?: return
|
|
315
|
-
val pending = state.pendingUpdateId ?: return
|
|
316
|
-
if (launchedUpdateId != pending) return // pending staged but not yet launched
|
|
317
|
-
// previous → deleted, current → previous, pending → current
|
|
318
|
-
state.previousUpdateId?.let { updateDir(context, it).deleteRecursively() }
|
|
319
|
-
state.previousUpdateId = state.currentUpdateId
|
|
320
|
-
state.currentUpdateId = pending
|
|
321
|
-
state.pendingUpdateId = null
|
|
322
|
-
state.pendingLaunchAttempts = 0
|
|
323
|
-
writeState(context, state)
|
|
324
|
-
Log.i(TAG, "Update $pending committed")
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
@Synchronized
|
|
328
|
-
fun setMaxLaunchAttempts(context: Context, max: Int) {
|
|
329
|
-
val state = readState(context) ?: freshState(context)
|
|
330
|
-
state.maxLaunchAttempts = max.coerceIn(1, 10)
|
|
331
|
-
writeState(context, state)
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
@Synchronized
|
|
335
|
-
fun clearAll(context: Context) {
|
|
336
|
-
updatesDir(context).deleteRecursively()
|
|
337
|
-
tmpDir(context).deleteRecursively()
|
|
338
|
-
writeState(context, freshState(context))
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// ── Helpers ──────────────────────────────────────────────────────────
|
|
342
|
-
|
|
343
|
-
/** Re-hash the stored bundle against the sha256 recorded in update.json. */
|
|
344
|
-
fun verifySha256(context: Context, updateId: String): Boolean {
|
|
345
|
-
return try {
|
|
346
|
-
val meta = JSONObject(updateJsonFile(context, updateId).readText())
|
|
347
|
-
val expected = meta.optString("sha256").lowercase()
|
|
348
|
-
if (expected.isEmpty()) return false
|
|
349
|
-
sha256Of(bundleFile(context, updateId)) == expected
|
|
350
|
-
} catch (e: Exception) {
|
|
351
|
-
false
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
fun sha256Of(file: File): String {
|
|
356
|
-
val digest = MessageDigest.getInstance("SHA-256")
|
|
357
|
-
file.inputStream().use { input ->
|
|
358
|
-
val buffer = ByteArray(64 * 1024)
|
|
359
|
-
while (true) {
|
|
360
|
-
val read = input.read(buffer)
|
|
361
|
-
if (read < 0) break
|
|
362
|
-
digest.update(buffer, 0, read)
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
return digest.digest().joinToString("") { "%02x".format(it) }
|
|
366
|
-
}
|
|
367
|
-
}
|
|
1
|
+
package com.sigx.updates
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.pm.PackageManager
|
|
5
|
+
import android.os.Build
|
|
6
|
+
import android.util.Log
|
|
7
|
+
import com.lynx.tasm.LynxView
|
|
8
|
+
import org.json.JSONObject
|
|
9
|
+
import java.io.File
|
|
10
|
+
import java.io.FileOutputStream
|
|
11
|
+
import java.lang.ref.WeakReference
|
|
12
|
+
import java.security.MessageDigest
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* On-disk update store + state machine shared by [UpdatesBundleResolver]
|
|
16
|
+
* (startup, before any LynxView exists) and [UpdatesModule] (JS bridge).
|
|
17
|
+
*
|
|
18
|
+
* Layout under `filesDir/sigx-updates/`:
|
|
19
|
+
* state.json — single source of truth (atomic writes)
|
|
20
|
+
* updates/<updateId>/main.lynx.bundle + update.json
|
|
21
|
+
* tmp/ — in-flight downloads; wiped at startup
|
|
22
|
+
*
|
|
23
|
+
* Update lifecycle: downloaded (dir exists) → pending (state.pendingUpdateId)
|
|
24
|
+
* → committed (state.currentUpdateId). A pending update that exhausts its
|
|
25
|
+
* launch attempts before JS calls markReady() is rolled back and deleted.
|
|
26
|
+
*/
|
|
27
|
+
object UpdateStore {
|
|
28
|
+
|
|
29
|
+
private const val TAG = "SigxUpdates"
|
|
30
|
+
private const val DIR = "sigx-updates"
|
|
31
|
+
private const val SCHEMA_VERSION = 1
|
|
32
|
+
private const val DEFAULT_MAX_LAUNCH_ATTEMPTS = 2
|
|
33
|
+
private const val RUNTIME_VERSION_META_KEY = "com.sigx.updates.RUNTIME_VERSION"
|
|
34
|
+
|
|
35
|
+
/** What this process actually loaded (set by the resolver). */
|
|
36
|
+
@Volatile var launchedUpdateId: String? = null
|
|
37
|
+
private set
|
|
38
|
+
/** True when this launch is a pending update's trial run. */
|
|
39
|
+
@Volatile var isFirstLaunchAfterUpdate: Boolean = false
|
|
40
|
+
private set
|
|
41
|
+
/** True when the resolver rolled back at this startup. */
|
|
42
|
+
@Volatile var didRollBack: Boolean = false
|
|
43
|
+
private set
|
|
44
|
+
/** Update id that was rolled back at this startup (for events/info). */
|
|
45
|
+
@Volatile var rolledBackUpdateId: String? = null
|
|
46
|
+
private set
|
|
47
|
+
|
|
48
|
+
/** Last-attached LynxView — apply-now reload target. */
|
|
49
|
+
@Volatile private var lynxViewRef: WeakReference<LynxView>? = null
|
|
50
|
+
|
|
51
|
+
fun attachView(view: LynxView) {
|
|
52
|
+
lynxViewRef = WeakReference(view)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fun currentView(): LynxView? = lynxViewRef?.get()
|
|
56
|
+
|
|
57
|
+
// ── Paths ────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
fun rootDir(context: Context): File = File(context.filesDir, DIR)
|
|
60
|
+
fun updatesDir(context: Context): File = File(rootDir(context), "updates")
|
|
61
|
+
fun tmpDir(context: Context): File = File(rootDir(context), "tmp")
|
|
62
|
+
fun updateDir(context: Context, updateId: String): File = File(updatesDir(context), updateId)
|
|
63
|
+
fun bundleFile(context: Context, updateId: String): File =
|
|
64
|
+
File(updateDir(context, updateId), "main.lynx.bundle")
|
|
65
|
+
fun updateJsonFile(context: Context, updateId: String): File =
|
|
66
|
+
File(updateDir(context, updateId), "update.json")
|
|
67
|
+
private fun stateFile(context: Context): File = File(rootDir(context), "state.json")
|
|
68
|
+
|
|
69
|
+
// ── Binary identity ──────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/** The binary's runtime fingerprint, injected by `sigx prebuild` as manifest meta-data. */
|
|
72
|
+
fun installedRuntimeVersion(context: Context): String {
|
|
73
|
+
return try {
|
|
74
|
+
val ai = context.packageManager.getApplicationInfo(
|
|
75
|
+
context.packageName, PackageManager.GET_META_DATA)
|
|
76
|
+
ai.metaData?.getString(RUNTIME_VERSION_META_KEY) ?: "unknown"
|
|
77
|
+
} catch (e: Exception) {
|
|
78
|
+
Log.w(TAG, "Could not read runtime version meta-data: ${e.message}")
|
|
79
|
+
"unknown"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
fun installedBinaryVersion(context: Context): String {
|
|
84
|
+
return try {
|
|
85
|
+
val info = context.packageManager.getPackageInfo(context.packageName, 0)
|
|
86
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
87
|
+
info.longVersionCode.toString()
|
|
88
|
+
} else {
|
|
89
|
+
@Suppress("DEPRECATION")
|
|
90
|
+
info.versionCode.toString()
|
|
91
|
+
}
|
|
92
|
+
} catch (e: Exception) {
|
|
93
|
+
"unknown"
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── State ────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
data class State(
|
|
100
|
+
var installedRuntimeVersion: String = "",
|
|
101
|
+
var installedBinaryVersion: String = "",
|
|
102
|
+
var currentUpdateId: String? = null,
|
|
103
|
+
var previousUpdateId: String? = null,
|
|
104
|
+
var pendingUpdateId: String? = null,
|
|
105
|
+
var pendingLaunchAttempts: Int = 0,
|
|
106
|
+
var maxLaunchAttempts: Int = DEFAULT_MAX_LAUNCH_ATTEMPTS,
|
|
107
|
+
var lastRollbackUpdateId: String? = null,
|
|
108
|
+
var lastRollbackReason: String? = null,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
@Synchronized
|
|
112
|
+
fun readState(context: Context): State? {
|
|
113
|
+
val file = stateFile(context)
|
|
114
|
+
if (!file.exists()) return null
|
|
115
|
+
return try {
|
|
116
|
+
val json = JSONObject(file.readText())
|
|
117
|
+
State(
|
|
118
|
+
installedRuntimeVersion = json.optString("installedRuntimeVersion", ""),
|
|
119
|
+
installedBinaryVersion = json.optString("installedBinaryVersion", ""),
|
|
120
|
+
currentUpdateId = json.optString("currentUpdateId").ifEmpty { null },
|
|
121
|
+
previousUpdateId = json.optString("previousUpdateId").ifEmpty { null },
|
|
122
|
+
pendingUpdateId = json.optString("pendingUpdateId").ifEmpty { null },
|
|
123
|
+
pendingLaunchAttempts = json.optInt("pendingLaunchAttempts", 0),
|
|
124
|
+
maxLaunchAttempts = json.optInt("maxLaunchAttempts", DEFAULT_MAX_LAUNCH_ATTEMPTS),
|
|
125
|
+
lastRollbackUpdateId = json.optString("lastRollbackUpdateId").ifEmpty { null },
|
|
126
|
+
lastRollbackReason = json.optString("lastRollbackReason").ifEmpty { null },
|
|
127
|
+
)
|
|
128
|
+
} catch (e: Exception) {
|
|
129
|
+
Log.w(TAG, "state.json unreadable: ${e.message}")
|
|
130
|
+
null
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Atomic + fsynced write — the launch-attempt counter must survive a crash. */
|
|
135
|
+
@Synchronized
|
|
136
|
+
fun writeState(context: Context, state: State) {
|
|
137
|
+
val json = JSONObject()
|
|
138
|
+
.put("schemaVersion", SCHEMA_VERSION)
|
|
139
|
+
.put("installedRuntimeVersion", state.installedRuntimeVersion)
|
|
140
|
+
.put("installedBinaryVersion", state.installedBinaryVersion)
|
|
141
|
+
.put("currentUpdateId", state.currentUpdateId ?: "")
|
|
142
|
+
.put("previousUpdateId", state.previousUpdateId ?: "")
|
|
143
|
+
.put("pendingUpdateId", state.pendingUpdateId ?: "")
|
|
144
|
+
.put("pendingLaunchAttempts", state.pendingLaunchAttempts)
|
|
145
|
+
.put("maxLaunchAttempts", state.maxLaunchAttempts)
|
|
146
|
+
.put("lastRollbackUpdateId", state.lastRollbackUpdateId ?: "")
|
|
147
|
+
.put("lastRollbackReason", state.lastRollbackReason ?: "")
|
|
148
|
+
val file = stateFile(context)
|
|
149
|
+
file.parentFile?.mkdirs()
|
|
150
|
+
val tmp = File(file.parentFile, "state.json.tmp")
|
|
151
|
+
FileOutputStream(tmp).use { out ->
|
|
152
|
+
out.write(json.toString().toByteArray(Charsets.UTF_8))
|
|
153
|
+
out.fd.sync()
|
|
154
|
+
}
|
|
155
|
+
if (!tmp.renameTo(file)) {
|
|
156
|
+
// Windows-style rename-over-existing failure can't happen on
|
|
157
|
+
// Android (POSIX rename), but belt-and-braces:
|
|
158
|
+
file.delete()
|
|
159
|
+
tmp.renameTo(file)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Fresh state seeded with the binary's identity. */
|
|
164
|
+
fun freshState(context: Context): State = State(
|
|
165
|
+
installedRuntimeVersion = installedRuntimeVersion(context),
|
|
166
|
+
installedBinaryVersion = installedBinaryVersion(context),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
// ── Startup resolution (called by UpdatesBundleResolver) ─────────────
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Decide which bundle this launch loads. Returns an absolute path to an
|
|
173
|
+
* OTA bundle, or null to use the baked asset. Mutates rollback state —
|
|
174
|
+
* call exactly once per process launch.
|
|
175
|
+
*/
|
|
176
|
+
@Synchronized
|
|
177
|
+
fun resolveStartupBundlePath(context: Context): String? {
|
|
178
|
+
try {
|
|
179
|
+
tmpDir(context).deleteRecursively()
|
|
180
|
+
|
|
181
|
+
var state = readState(context)
|
|
182
|
+
if (state == null) {
|
|
183
|
+
if (rootDir(context).exists() && stateFile(context).exists()) {
|
|
184
|
+
// Unparseable state — wipe everything, run baked.
|
|
185
|
+
Log.w(TAG, "Corrupt state.json — clearing all updates")
|
|
186
|
+
rootDir(context).deleteRecursively()
|
|
187
|
+
}
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Binary-update tripwire: a store update (or any reinstall that
|
|
192
|
+
// changed the fingerprint/versionCode) invalidates every
|
|
193
|
+
// downloaded update — they were published for the old runtime.
|
|
194
|
+
val runtimeNow = installedRuntimeVersion(context)
|
|
195
|
+
val binaryNow = installedBinaryVersion(context)
|
|
196
|
+
if (state.installedRuntimeVersion != runtimeNow ||
|
|
197
|
+
state.installedBinaryVersion != binaryNow
|
|
198
|
+
) {
|
|
199
|
+
Log.i(TAG, "Binary changed (runtime ${state.installedRuntimeVersion} -> $runtimeNow, " +
|
|
200
|
+
"build ${state.installedBinaryVersion} -> $binaryNow) — clearing updates")
|
|
201
|
+
updatesDir(context).deleteRecursively()
|
|
202
|
+
writeState(context, freshState(context))
|
|
203
|
+
return null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Pending update: crash-guarded trial launch.
|
|
207
|
+
val pending = state.pendingUpdateId
|
|
208
|
+
if (pending != null) {
|
|
209
|
+
if (state.pendingLaunchAttempts >= state.maxLaunchAttempts) {
|
|
210
|
+
Log.w(TAG, "Update $pending failed ${state.pendingLaunchAttempts} launches — rolling back")
|
|
211
|
+
rollbackPending(context, state, "crash")
|
|
212
|
+
state = readState(context) ?: return null
|
|
213
|
+
} else {
|
|
214
|
+
val bundle = bundleFile(context, pending)
|
|
215
|
+
val firstAttempt = state.pendingLaunchAttempts == 0
|
|
216
|
+
val ok = bundle.exists() && (!firstAttempt || verifySha256(context, pending))
|
|
217
|
+
if (!ok) {
|
|
218
|
+
Log.w(TAG, "Update $pending missing or corrupt — rolling back")
|
|
219
|
+
rollbackPending(context, state, "corrupt")
|
|
220
|
+
state = readState(context) ?: return null
|
|
221
|
+
} else {
|
|
222
|
+
state.pendingLaunchAttempts += 1
|
|
223
|
+
writeState(context, state)
|
|
224
|
+
launchedUpdateId = pending
|
|
225
|
+
isFirstLaunchAfterUpdate = firstAttempt
|
|
226
|
+
sweepOrphans(context, state)
|
|
227
|
+
return bundle.absolutePath
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Committed update: trusted (existence check only).
|
|
233
|
+
val current = state.currentUpdateId
|
|
234
|
+
if (current != null) {
|
|
235
|
+
val bundle = bundleFile(context, current)
|
|
236
|
+
if (bundle.exists()) {
|
|
237
|
+
launchedUpdateId = current
|
|
238
|
+
sweepOrphans(context, state)
|
|
239
|
+
return bundle.absolutePath
|
|
240
|
+
}
|
|
241
|
+
Log.w(TAG, "Committed update $current missing on disk — reverting to baked bundle")
|
|
242
|
+
state.currentUpdateId = null
|
|
243
|
+
writeState(context, state)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
sweepOrphans(context, state)
|
|
247
|
+
return null
|
|
248
|
+
} catch (e: Exception) {
|
|
249
|
+
// Any unexpected failure must never take the app down with it —
|
|
250
|
+
// the baked bundle is always the safe answer.
|
|
251
|
+
Log.e(TAG, "resolveStartupBundlePath failed: ${e.message}")
|
|
252
|
+
return null
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private fun rollbackPending(context: Context, state: State, reason: String) {
|
|
257
|
+
val pending = state.pendingUpdateId ?: return
|
|
258
|
+
didRollBack = true
|
|
259
|
+
rolledBackUpdateId = pending
|
|
260
|
+
updateDir(context, pending).deleteRecursively()
|
|
261
|
+
state.lastRollbackUpdateId = pending
|
|
262
|
+
state.lastRollbackReason = reason
|
|
263
|
+
state.pendingUpdateId = null
|
|
264
|
+
state.pendingLaunchAttempts = 0
|
|
265
|
+
writeState(context, state)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Delete update dirs not referenced by state (≤3 may remain by invariant). */
|
|
269
|
+
private fun sweepOrphans(context: Context, state: State) {
|
|
270
|
+
val keep = setOfNotNull(state.currentUpdateId, state.previousUpdateId, state.pendingUpdateId)
|
|
271
|
+
updatesDir(context).listFiles()?.forEach { dir ->
|
|
272
|
+
if (dir.isDirectory && dir.name !in keep) {
|
|
273
|
+
dir.deleteRecursively()
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── Transitions (called by UpdatesModule) ─────────────────────────────
|
|
279
|
+
|
|
280
|
+
/** Stage a downloaded update to load on the next launch. */
|
|
281
|
+
@Synchronized
|
|
282
|
+
fun stagePending(context: Context, updateId: String): String? {
|
|
283
|
+
if (!bundleFile(context, updateId).exists()) {
|
|
284
|
+
return "Update $updateId is not on disk"
|
|
285
|
+
}
|
|
286
|
+
val state = readState(context) ?: freshState(context)
|
|
287
|
+
if (state.currentUpdateId == updateId) return null // already active
|
|
288
|
+
state.pendingUpdateId = updateId
|
|
289
|
+
state.pendingLaunchAttempts = 0
|
|
290
|
+
writeState(context, state)
|
|
291
|
+
return null
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Record a launch attempt for an in-place reload (applyNow). The reload
|
|
296
|
+
* doesn't go through the resolver, so the crash guard is armed here.
|
|
297
|
+
*/
|
|
298
|
+
@Synchronized
|
|
299
|
+
fun recordReloadAttempt(context: Context, updateId: String): String? {
|
|
300
|
+
val state = readState(context) ?: freshState(context)
|
|
301
|
+
if (state.pendingUpdateId != updateId) {
|
|
302
|
+
state.pendingUpdateId = updateId
|
|
303
|
+
}
|
|
304
|
+
state.pendingLaunchAttempts += 1
|
|
305
|
+
writeState(context, state)
|
|
306
|
+
launchedUpdateId = updateId
|
|
307
|
+
isFirstLaunchAfterUpdate = true
|
|
308
|
+
return null
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Commit the running pending update as healthy. Idempotent. */
|
|
312
|
+
@Synchronized
|
|
313
|
+
fun markReady(context: Context) {
|
|
314
|
+
val state = readState(context) ?: return
|
|
315
|
+
val pending = state.pendingUpdateId ?: return
|
|
316
|
+
if (launchedUpdateId != pending) return // pending staged but not yet launched
|
|
317
|
+
// previous → deleted, current → previous, pending → current
|
|
318
|
+
state.previousUpdateId?.let { updateDir(context, it).deleteRecursively() }
|
|
319
|
+
state.previousUpdateId = state.currentUpdateId
|
|
320
|
+
state.currentUpdateId = pending
|
|
321
|
+
state.pendingUpdateId = null
|
|
322
|
+
state.pendingLaunchAttempts = 0
|
|
323
|
+
writeState(context, state)
|
|
324
|
+
Log.i(TAG, "Update $pending committed")
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
@Synchronized
|
|
328
|
+
fun setMaxLaunchAttempts(context: Context, max: Int) {
|
|
329
|
+
val state = readState(context) ?: freshState(context)
|
|
330
|
+
state.maxLaunchAttempts = max.coerceIn(1, 10)
|
|
331
|
+
writeState(context, state)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
@Synchronized
|
|
335
|
+
fun clearAll(context: Context) {
|
|
336
|
+
updatesDir(context).deleteRecursively()
|
|
337
|
+
tmpDir(context).deleteRecursively()
|
|
338
|
+
writeState(context, freshState(context))
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
/** Re-hash the stored bundle against the sha256 recorded in update.json. */
|
|
344
|
+
fun verifySha256(context: Context, updateId: String): Boolean {
|
|
345
|
+
return try {
|
|
346
|
+
val meta = JSONObject(updateJsonFile(context, updateId).readText())
|
|
347
|
+
val expected = meta.optString("sha256").lowercase()
|
|
348
|
+
if (expected.isEmpty()) return false
|
|
349
|
+
sha256Of(bundleFile(context, updateId)) == expected
|
|
350
|
+
} catch (e: Exception) {
|
|
351
|
+
false
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
fun sha256Of(file: File): String {
|
|
356
|
+
val digest = MessageDigest.getInstance("SHA-256")
|
|
357
|
+
file.inputStream().use { input ->
|
|
358
|
+
val buffer = ByteArray(64 * 1024)
|
|
359
|
+
while (true) {
|
|
360
|
+
val read = input.read(buffer)
|
|
361
|
+
if (read < 0) break
|
|
362
|
+
digest.update(buffer, 0, read)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return digest.digest().joinToString("") { "%02x".format(it) }
|
|
366
|
+
}
|
|
367
|
+
}
|