@rovq/react-native 0.0.0-stage → 0.2.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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -2
  3. package/Rovq.podspec +19 -0
  4. package/android/build.gradle +86 -0
  5. package/android/src/main/AndroidManifest.xml +4 -0
  6. package/android/src/main/java/com/rovq/engine/EngineState.kt +176 -0
  7. package/android/src/main/java/com/rovq/engine/ManifestVerifier.kt +230 -0
  8. package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +199 -0
  9. package/android/src/main/java/com/rovq/engine/PackageRules.kt +39 -0
  10. package/android/src/main/java/com/rovq/engine/ProgressThrottle.kt +17 -0
  11. package/android/src/main/java/com/rovq/engine/RovqEngine.kt +550 -0
  12. package/android/src/main/java/com/rovq/engine/RovqException.kt +31 -0
  13. package/android/src/main/java/com/rovq/engine/SafePackageExtractor.kt +121 -0
  14. package/android/src/main/java/com/rovq/engine/StrictBase64.kt +37 -0
  15. package/android/src/main/java/com/rovq/engine/UpdateInstaller.kt +347 -0
  16. package/android/src/main/java/com/rovq/engine/UpdateService.kt +175 -0
  17. package/android/src/main/java/com/rovq/engine/ZipStructureCheck.kt +194 -0
  18. package/android/src/react/java/com/rovq/engine/react/ResourceFiles.kt +52 -0
  19. package/android/src/react/java/com/rovq/engine/react/Rovq.kt +102 -0
  20. package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +164 -0
  21. package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +26 -0
  22. package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +74 -0
  23. package/ios/Engine/EngineState.swift +119 -0
  24. package/ios/Engine/ManifestVerifier.swift +279 -0
  25. package/ios/Engine/PackageDownloader.swift +249 -0
  26. package/ios/Engine/PackageRules.swift +38 -0
  27. package/ios/Engine/ProgressThrottle.swift +20 -0
  28. package/ios/Engine/RovqEngine.swift +646 -0
  29. package/ios/Engine/RovqError.swift +35 -0
  30. package/ios/Engine/SafePackageExtractor.swift +107 -0
  31. package/ios/Engine/UpdateInstaller.swift +348 -0
  32. package/ios/Engine/UpdateService.swift +185 -0
  33. package/ios/Engine/ZipReader.swift +378 -0
  34. package/ios/React/RovqHost.swift +255 -0
  35. package/ios/React/RovqModule.mm +91 -0
  36. package/package.json +78 -3
  37. package/react-native.config.js +11 -0
  38. package/src/NativeRovq.ts +72 -0
  39. package/src/api.ts +228 -0
  40. package/src/errors.ts +44 -0
  41. package/src/index.ts +10 -0
  42. package/src/react.tsx +158 -0
  43. package/src/update-flow.ts +91 -0
@@ -0,0 +1,550 @@
1
+ package com.rovq.engine
2
+
3
+ import java.io.File
4
+ import java.io.IOException
5
+ import java.text.SimpleDateFormat
6
+ import java.util.Date
7
+ import java.util.Locale
8
+ import java.util.TimeZone
9
+ import java.util.UUID
10
+
11
+ private const val MAX_QUEUED_EVENTS = 200
12
+ private const val EVENT_BATCH_SIZE = 50
13
+
14
+ // A release closed before confirming gets this many more starts. A crash gets none.
15
+ private const val MAX_TRYING_RETRIES = 2
16
+
17
+ private const val FIRST_POSTPONE_MILLIS = 60 * 60 * 1000L
18
+ private const val MAX_POSTPONE_MILLIS = 24 * 60 * 60 * 1000L
19
+ private const val MAX_DOUBLINGS = 5
20
+
21
+ // The same file failed its checks, so downloading it again would likely waste data for nothing.
22
+ // A network failure is not here, because trying again can work.
23
+ private val POSTPONED_CODES = setOf(
24
+ RovqErrorCode.SIZE_MISMATCH,
25
+ RovqErrorCode.HASH_MISMATCH,
26
+ RovqErrorCode.INVALID_PACKAGE,
27
+ )
28
+
29
+ /**
30
+ * @param binaryId changes whenever the app itself is installed or updated from a store.
31
+ * @param trustedKeys key IDs and their Base64 public keys, built into the app.
32
+ */
33
+ data class EngineConfig(
34
+ val apiUrl: String,
35
+ val deploymentKey: String,
36
+ val runtimeVersion: String,
37
+ val binaryVersion: String,
38
+ val binaryId: String,
39
+ val trustedKeys: Map<String, String>,
40
+ val allowInsecureUrls: Boolean = false,
41
+ )
42
+
43
+ /** Facts about an update, all from a manifest whose signature has been verified. */
44
+ data class UpdateInfo(
45
+ val releaseId: String,
46
+ val sequence: Long,
47
+ val mandatory: Boolean,
48
+ val packageSize: Long,
49
+ )
50
+
51
+ sealed interface CheckResult {
52
+ data object UpToDate : CheckResult
53
+
54
+ /** @param alreadyDownloaded whether [RovqEngine.downloadUpdate] has nothing left to fetch. */
55
+ data class UpdateAvailable(val update: UpdateInfo, val alreadyDownloaded: Boolean) : CheckResult
56
+
57
+ /** The server asked to leave the current release. Restart to apply. */
58
+ data object RolledBack : CheckResult
59
+
60
+ data class Failed(val code: RovqErrorCode, val message: String) : CheckResult
61
+ }
62
+
63
+ sealed interface DownloadResult {
64
+ /** Verified and on disk. It loads only after [RovqEngine.installUpdate]. */
65
+ data class Downloaded(val update: UpdateInfo) : DownloadResult
66
+
67
+ data class Failed(val code: RovqErrorCode, val message: String) : DownloadResult
68
+ }
69
+
70
+ sealed interface InstallResult {
71
+ /** Loads on the next JavaScript start. */
72
+ data class Installed(val releaseId: String) : InstallResult
73
+
74
+ data class Failed(val code: RovqErrorCode, val message: String) : InstallResult
75
+ }
76
+
77
+ sealed interface SyncResult {
78
+ data object UpToDate : SyncResult
79
+
80
+ /** Installed, and loads on the next JavaScript start. */
81
+ data class UpdateReady(val releaseId: String, val mandatory: Boolean) : SyncResult
82
+
83
+ /** The server asked to leave the current release. Restart to apply. */
84
+ data object RolledBack : SyncResult
85
+
86
+ data class Failed(val code: RovqErrorCode, val message: String) : SyncResult
87
+ }
88
+
89
+ data class EngineStatus(
90
+ val deviceId: String,
91
+ val currentReleaseId: String?,
92
+ val downloadedReleaseId: String?,
93
+ val pendingReleaseId: String?,
94
+ val tryingReleaseId: String?,
95
+ val highestSequence: Long,
96
+ )
97
+
98
+ /**
99
+ * The state machine that decides which bundle runs. A wrong decision here can stop an
100
+ * app from starting, so every change is saved before it takes effect, and each method
101
+ * is written so that the process can die at any line.
102
+ */
103
+ class RovqEngine(
104
+ private val rootDir: File,
105
+ private val config: EngineConfig,
106
+ private val service: UpdateService = HttpUpdateService(config.apiUrl, config.allowInsecureUrls),
107
+ /** The app's own assets, which a release made of files reuses instead of downloading. */
108
+ builtInFiles: BuiltInFiles? = null,
109
+ private val clock: () -> Long = System::currentTimeMillis,
110
+ ) {
111
+ private val store: EngineStateStore
112
+ private val updatesDir = File(rootDir, "updates")
113
+ private val verifier = ManifestVerifier(config.trustedKeys, config.deploymentKey, config.runtimeVersion)
114
+ private val installer: UpdateInstaller
115
+
116
+ // Held for a whole check, download or install, because each depends on the one before.
117
+ private val syncLock = Any()
118
+
119
+ // Kept in memory only, because its download URL expires. Guarded by syncLock.
120
+ private var offer: OfferedUpdate? = null
121
+ private var offerManifest: VerifiedManifest? = null
122
+ private var state: EngineState
123
+
124
+ init {
125
+ rootDir.mkdirs()
126
+ store = EngineStateStore(rootDir)
127
+ installer = UpdateInstaller(
128
+ rootDir,
129
+ verifier,
130
+ PackageDownloader(config.allowInsecureUrls),
131
+ builtInFiles,
132
+ )
133
+ state = loadState()
134
+ verifier.revokedPublicKeys = state.revokedPublicKeys
135
+ }
136
+
137
+ /**
138
+ * Called each time JavaScript starts, before it loads. Returns the bundle to run, or
139
+ * null for the one inside the app. It must never throw, or the app could not start.
140
+ */
141
+ @Synchronized
142
+ fun resolveBundle(): File? {
143
+ // Still marked as trying means the last start never confirmed that it works.
144
+ state.trying?.let { failed ->
145
+ // A crash does not pass through the background, but closing the app does, so this
146
+ // start was probably cut short by a person, and the release deserves another one.
147
+ if (state.backgroundedWhileTrying && state.tryingRetries < MAX_TRYING_RETRIES && bundleOf(failed).isFile) {
148
+ val retry = state.copy(backgroundedWhileTrying = false, tryingRetries = state.tryingRetries + 1)
149
+ if (save(retry)) return bundleOf(failed)
150
+ }
151
+ save(
152
+ queue(
153
+ state.copy(trying = null, backgroundedWhileTrying = false, tryingRetries = 0),
154
+ "ROLLED_BACK",
155
+ failed.id,
156
+ "NOT_CONFIRMED",
157
+ ),
158
+ )
159
+ }
160
+ state.pending?.let { next ->
161
+ if (!bundleOf(next).isFile) {
162
+ save(state.copy(pending = null))
163
+ } else if (save(state.copy(pending = null, trying = next))) {
164
+ // Saved first, so that if this bundle kills the process the next start knows.
165
+ removeUnusedReleases()
166
+ return bundleOf(next)
167
+ } else {
168
+ // Trying it unrecorded could crash on every start forever, so run what is
169
+ // known to work. The update stays pending for when saving works again.
170
+ state = state.copy(pending = next, trying = null)
171
+ return state.current?.let(::bundleOf)?.takeIf { it.isFile }
172
+ }
173
+ }
174
+
175
+ // A confirmed release can only disappear through outside interference.
176
+ state.current?.let { current ->
177
+ if (!bundleOf(current).isFile) save(state.copy(current = state.previous, previous = null))
178
+ }
179
+ val running = state.current?.takeIf { bundleOf(it).isFile }
180
+ if (running == null && state.current != null) save(state.copy(current = null, previous = null))
181
+ removeUnusedReleases()
182
+ return running?.let(::bundleOf)
183
+ }
184
+
185
+ /** Called when the app goes to the background. See [resolveBundle]. */
186
+ @Synchronized
187
+ fun noteBackgrounded() {
188
+ if (state.trying != null && !state.backgroundedWhileTrying) {
189
+ save(state.copy(backgroundedWhileTrying = true))
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Called when the app is crashing. Android can stop the activity while its crash dialog shows,
195
+ * which looks like going to the background, so this uses up the release's other starts first.
196
+ */
197
+ @Synchronized
198
+ fun noteCrashed() {
199
+ if (state.trying != null) {
200
+ save(state.copy(backgroundedWhileTrying = false, tryingRetries = MAX_TRYING_RETRIES))
201
+ }
202
+ }
203
+
204
+ /** Called by the app once it has started properly. It keeps the running release. */
205
+ @Synchronized
206
+ fun notifyReady() {
207
+ val confirmed = state.trying ?: return
208
+ save(
209
+ queue(
210
+ state.copy(
211
+ trying = null,
212
+ current = confirmed,
213
+ previous = state.current,
214
+ backgroundedWhileTrying = false,
215
+ tryingRetries = 0,
216
+ ),
217
+ "CONFIRMED",
218
+ confirmed.id,
219
+ null,
220
+ ),
221
+ )
222
+ }
223
+
224
+ /**
225
+ * Goes back to the previous confirmed release, or to the one inside the app. It takes
226
+ * effect on the next JavaScript start. The sequence stays, so the release that was
227
+ * left is not offered again.
228
+ *
229
+ * @return whether there was anything to leave.
230
+ */
231
+ @Synchronized
232
+ fun rollback(reason: String = "REQUESTED"): Boolean {
233
+ val leaving = state.trying ?: state.current ?: return false
234
+ val next = if (state.trying != null) state.current else state.previous
235
+ val previous = if (state.trying != null) state.previous else null
236
+ save(
237
+ queue(
238
+ state.copy(
239
+ trying = null,
240
+ current = next,
241
+ previous = previous,
242
+ backgroundedWhileTrying = false,
243
+ tryingRetries = 0,
244
+ ),
245
+ "ROLLED_BACK",
246
+ leaving.id,
247
+ reason,
248
+ ),
249
+ )
250
+ return true
251
+ }
252
+
253
+ /**
254
+ * Asks the server for updates, and verifies any offer before reporting it, so an app never
255
+ * shows the user an update that it would refuse to download.
256
+ *
257
+ * A rollback from the server applies here, because it is how a bad release gets stopped,
258
+ * and that must not depend on what the app does with the answer.
259
+ */
260
+ fun checkForUpdate(): CheckResult = synchronized(syncLock) {
261
+ val result = checkOnce()
262
+ flushEvents()
263
+ result
264
+ }
265
+
266
+ /** Fetches the update found by the last [checkForUpdate], without activating it. */
267
+ fun downloadUpdate(onProgress: (received: Long, total: Long) -> Unit = { _, _ -> }): DownloadResult =
268
+ synchronized(syncLock) {
269
+ val result = downloadOnce(onProgress)
270
+ flushEvents()
271
+ result
272
+ }
273
+
274
+ /**
275
+ * Makes the downloaded update load on the next JavaScript start. It uses no network, so
276
+ * an app can restart straight after it. Its event goes with the next check.
277
+ */
278
+ fun installUpdate(): InstallResult = synchronized(syncLock) { installOnce() }
279
+
280
+ /** The three steps together, for an app that does not need to ask the user in between. */
281
+ fun sync(onProgress: (received: Long, total: Long) -> Unit = { _, _ -> }): SyncResult =
282
+ synchronized(syncLock) {
283
+ val result = syncOnce(onProgress)
284
+ flushEvents()
285
+ result
286
+ }
287
+
288
+ @Synchronized
289
+ fun status(): EngineStatus = EngineStatus(
290
+ deviceId = state.deviceId,
291
+ currentReleaseId = (state.trying ?: state.current)?.id,
292
+ downloadedReleaseId = state.downloaded?.id,
293
+ pendingReleaseId = state.pending?.id,
294
+ tryingReleaseId = state.trying?.id,
295
+ highestSequence = state.highestSequence,
296
+ )
297
+
298
+ private fun syncOnce(onProgress: (Long, Long) -> Unit): SyncResult {
299
+ val update = when (val checked = checkOnce()) {
300
+ CheckResult.UpToDate -> return SyncResult.UpToDate
301
+ CheckResult.RolledBack -> return SyncResult.RolledBack
302
+ is CheckResult.Failed -> return SyncResult.Failed(checked.code, checked.message)
303
+ is CheckResult.UpdateAvailable -> checked.update
304
+ }
305
+ val downloaded = downloadOnce(onProgress)
306
+ if (downloaded is DownloadResult.Failed) return SyncResult.Failed(downloaded.code, downloaded.message)
307
+ return when (val installed = installOnce()) {
308
+ is InstallResult.Installed -> SyncResult.UpdateReady(update.releaseId, update.mandatory)
309
+ is InstallResult.Failed -> SyncResult.Failed(installed.code, installed.message)
310
+ }
311
+ }
312
+
313
+ private fun checkOnce(): CheckResult {
314
+ offer = null
315
+ offerManifest = null
316
+ val request = synchronized(this) {
317
+ CheckRequest(
318
+ deploymentKey = config.deploymentKey,
319
+ deviceId = state.deviceId,
320
+ runtimeVersion = config.runtimeVersion,
321
+ binaryVersion = config.binaryVersion,
322
+ currentReleaseId = (state.trying ?: state.current)?.id,
323
+ highestSequence = state.highestSequence,
324
+ )
325
+ }
326
+ val answer = try {
327
+ service.check(request)
328
+ } catch (error: IOException) {
329
+ return CheckResult.Failed(RovqErrorCode.DOWNLOAD_FAILED, error.message ?: "Network failure")
330
+ }
331
+ // First, so that an offer signed by a key revoked in this same answer is refused.
332
+ applyRevocations(answer.revocations)
333
+ return when (val response = answer.response) {
334
+ CheckResponse.None -> {
335
+ keepDownloadedOnlyIf(null)
336
+ CheckResult.UpToDate
337
+ }
338
+ CheckResponse.Rollback -> {
339
+ keepDownloadedOnlyIf(null)
340
+ if (rollback("DISABLED_ON_SERVER")) CheckResult.RolledBack else CheckResult.UpToDate
341
+ }
342
+ is CheckResponse.Update -> verifyOffer(response.offer, request.highestSequence)
343
+ }
344
+ }
345
+
346
+ /** Permanent, so a server compromised later cannot make this device trust the key again. */
347
+ private fun applyRevocations(revocations: List<KeyRevocation>) {
348
+ val valid = revocations.mapNotNull { verifier.verifyRevocation(it.keyId, it.statement, it.signature) }
349
+ if (valid.isEmpty()) return
350
+ synchronized(this) {
351
+ val all = state.revokedPublicKeys + valid
352
+ if (all == state.revokedPublicKeys) return
353
+ // A downloaded release may have been signed with that key, and nothing checks it again.
354
+ save(state.copy(revokedPublicKeys = all, downloaded = null))
355
+ verifier.revokedPublicKeys = all
356
+ }
357
+ }
358
+
359
+ private fun verifyOffer(offered: OfferedUpdate, highestSequence: Long): CheckResult = try {
360
+ val manifest = verifier.verify(offered.manifest, offered.signature, offered.keyId, highestSequence)
361
+ offer = offered
362
+ offerManifest = manifest
363
+ keepDownloadedOnlyIf(manifest.releaseId)
364
+ val alreadyDownloaded = synchronized(this) { state.downloaded != null }
365
+ CheckResult.UpdateAvailable(manifest.toUpdateInfo(), alreadyDownloaded)
366
+ } catch (error: RovqException) {
367
+ recordFailure(error)
368
+ CheckResult.Failed(error.code, error.message ?: error.code.name)
369
+ }
370
+
371
+ /**
372
+ * The server offers a release until the device installs it, so an answer that does not
373
+ * include the downloaded release means it was disabled or replaced, and must not be
374
+ * installed. A failed check says nothing about it, so that does not get here.
375
+ */
376
+ private fun keepDownloadedOnlyIf(offeredReleaseId: String?) = synchronized(this) {
377
+ val downloaded = state.downloaded
378
+ if (downloaded != null && downloaded.id != offeredReleaseId) save(state.copy(downloaded = null))
379
+ }
380
+
381
+ private fun downloadOnce(onProgress: (Long, Long) -> Unit): DownloadResult {
382
+ val offered = offer ?: return DownloadResult.Failed(
383
+ RovqErrorCode.NO_UPDATE_AVAILABLE,
384
+ "No update to download. Check for updates first",
385
+ )
386
+ val releaseId = offerManifest?.releaseId
387
+ val highestSequence = synchronized(this) {
388
+ val postponed = state.postponed
389
+ // A clock moved back must not make the wait longer than the longest one allowed.
390
+ if (postponed?.releaseId == releaseId && postponed != null) {
391
+ val remaining = postponed.untilMillis - clock()
392
+ if (remaining in 1..MAX_POSTPONE_MILLIS) {
393
+ return DownloadResult.Failed(
394
+ RovqErrorCode.DOWNLOAD_POSTPONED,
395
+ "This update failed its checks, so it will not be downloaded again yet",
396
+ )
397
+ }
398
+ }
399
+ state.highestSequence
400
+ }
401
+ return try {
402
+ val deviceId = synchronized(this) { state.deviceId }
403
+ val links = FileLinks { releaseId, hashes ->
404
+ service.fileLinks(config.deploymentKey, deviceId, releaseId, hashes)
405
+ }
406
+ val manifest = installer.install(offered, highestSequence, links, onProgress).manifest
407
+ synchronized(this) {
408
+ save(
409
+ state.copy(
410
+ downloaded = ReleaseRef(manifest.releaseId, manifest.sequence),
411
+ postponed = null,
412
+ ),
413
+ )
414
+ }
415
+ DownloadResult.Downloaded(manifest.toUpdateInfo())
416
+ } catch (error: RovqException) {
417
+ recordFailure(error)
418
+ if (error.code in POSTPONED_CODES && releaseId != null) postpone(releaseId)
419
+ DownloadResult.Failed(error.code, error.message ?: error.code.name)
420
+ }
421
+ }
422
+
423
+ private fun postpone(releaseId: String) = synchronized(this) {
424
+ val attempts = if (state.postponed?.releaseId == releaseId) state.postponed!!.attempts + 1 else 1
425
+ val wait = (FIRST_POSTPONE_MILLIS shl (attempts - 1).coerceAtMost(MAX_DOUBLINGS))
426
+ .coerceAtMost(MAX_POSTPONE_MILLIS)
427
+ save(state.copy(postponed = PostponedDownload(releaseId, attempts, clock() + wait)))
428
+ }
429
+
430
+ private fun installOnce(): InstallResult = synchronized(this) {
431
+ val downloaded = state.downloaded?.takeIf { bundleOf(it).isFile }
432
+ if (downloaded == null) {
433
+ if (state.downloaded != null) save(state.copy(downloaded = null))
434
+ return InstallResult.Failed(RovqErrorCode.NO_UPDATE_DOWNLOADED, "No update has been downloaded")
435
+ }
436
+ offer = null
437
+ offerManifest = null
438
+ save(
439
+ queue(
440
+ state.copy(
441
+ downloaded = null,
442
+ pending = downloaded,
443
+ // Recorded now, so a release that later fails is never accepted again.
444
+ highestSequence = maxOf(state.highestSequence, downloaded.sequence),
445
+ ),
446
+ "INSTALLED",
447
+ downloaded.id,
448
+ null,
449
+ ),
450
+ )
451
+ InstallResult.Installed(downloaded.id)
452
+ }
453
+
454
+ private fun recordFailure(error: RovqException) {
455
+ val type = if (error.code == RovqErrorCode.DOWNLOAD_FAILED) "DOWNLOAD_FAILED" else "VERIFICATION_FAILED"
456
+ synchronized(this) { save(queue(state, type, null, error.code.name)) }
457
+ }
458
+
459
+ private fun VerifiedManifest.toUpdateInfo() = UpdateInfo(releaseId, sequence, mandatory, packageSize)
460
+
461
+ /** Best effort. Events stay queued and go with a later sync if this fails. */
462
+ private fun flushEvents() {
463
+ val (batch, deviceId) = synchronized(this) { state.events.take(EVENT_BATCH_SIZE) to state.deviceId }
464
+ if (batch.isEmpty()) return
465
+ try {
466
+ service.report(config.deploymentKey, deviceId, batch)
467
+ } catch (error: IOException) {
468
+ return
469
+ }
470
+ val sent = batch.map { it.clientEventId }.toSet()
471
+ synchronized(this) {
472
+ save(state.copy(events = state.events.filterNot { it.clientEventId in sent }))
473
+ }
474
+ }
475
+
476
+ private fun loadState(): EngineState {
477
+ val saved = store.read()
478
+ val fresh = EngineState(
479
+ deviceId = saved?.deviceId ?: UUID.randomUUID().toString(),
480
+ binaryId = config.binaryId,
481
+ runtimeVersion = config.runtimeVersion,
482
+ )
483
+ if (saved == null) {
484
+ // Without a readable state nothing on disk can be trusted, so start clean.
485
+ updatesDir.deleteRecursively()
486
+ trySave(fresh)
487
+ return fresh
488
+ }
489
+ if (saved.binaryId == config.binaryId && saved.runtimeVersion == config.runtimeVersion) {
490
+ return saved
491
+ }
492
+ // A new app from the store is newer than any update for the old one, and the old
493
+ // bundles may not work with its native code. Sequences belong to a runtime version,
494
+ // so the count only carries over if that did not change.
495
+ updatesDir.deleteRecursively()
496
+ val reset = fresh.copy(
497
+ highestSequence = if (saved.runtimeVersion == config.runtimeVersion) saved.highestSequence else 0,
498
+ events = saved.events,
499
+ // Revocation is permanent, whatever the version of the app.
500
+ revokedPublicKeys = saved.revokedPublicKeys,
501
+ )
502
+ trySave(reset)
503
+ return reset
504
+ }
505
+
506
+ private fun bundleOf(release: ReleaseRef) =
507
+ File(File(updatesDir, release.id), PackageRules.BUNDLE_FILE_NAME)
508
+
509
+ private fun removeUnusedReleases() {
510
+ val inUse = listOfNotNull(state.current, state.previous, state.downloaded, state.pending, state.trying)
511
+ .map { it.id }
512
+ .toSet()
513
+ updatesDir.listFiles()?.forEach { folder ->
514
+ if (folder.name !in inUse) folder.deleteRecursively()
515
+ }
516
+ }
517
+
518
+ private fun queue(
519
+ base: EngineState,
520
+ type: String,
521
+ releaseId: String?,
522
+ errorCode: String?,
523
+ ): EngineState {
524
+ val event = QueuedEvent(UUID.randomUUID().toString(), type, releaseId, errorCode, now())
525
+ return base.copy(events = (base.events + event).takeLast(MAX_QUEUED_EVENTS))
526
+ }
527
+
528
+ /** @return whether the change will survive a process death. */
529
+ private fun save(next: EngineState): Boolean {
530
+ state = next
531
+ return trySave(next)
532
+ }
533
+
534
+ /**
535
+ * A failed save must not stop the app, for example when the disk is full. In general
536
+ * the state from before the change applies on the next start. Callers that depend on
537
+ * the change surviving must check the result.
538
+ */
539
+ private fun trySave(next: EngineState): Boolean = try {
540
+ store.write(next)
541
+ true
542
+ } catch (error: IOException) {
543
+ false
544
+ }
545
+
546
+ private fun now(): String =
547
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ROOT)
548
+ .apply { timeZone = TimeZone.getTimeZone("UTC") }
549
+ .format(Date())
550
+ }
@@ -0,0 +1,31 @@
1
+ package com.rovq.engine
2
+
3
+ /**
4
+ * Why an update failed. The names are also sent to the server as device event error codes,
5
+ * except for the last four, which describe the app's use of the engine. The React Native
6
+ * SDK has the same list, and a test keeps them equal.
7
+ */
8
+ enum class RovqErrorCode {
9
+ UNKNOWN_SIGNING_KEY,
10
+ REVOKED_SIGNING_KEY,
11
+ INVALID_SIGNATURE,
12
+ INVALID_MANIFEST,
13
+ MANIFEST_MISMATCH,
14
+ STALE_SEQUENCE,
15
+ INSECURE_URL,
16
+ DOWNLOAD_FAILED,
17
+ SIZE_MISMATCH,
18
+ HASH_MISMATCH,
19
+ INVALID_PACKAGE,
20
+ STORAGE_FAILED,
21
+ NO_UPDATE_AVAILABLE,
22
+ NO_UPDATE_DOWNLOADED,
23
+ ENGINE_UNAVAILABLE,
24
+ DOWNLOAD_POSTPONED,
25
+ }
26
+
27
+ class RovqException(
28
+ val code: RovqErrorCode,
29
+ message: String,
30
+ cause: Throwable? = null,
31
+ ) : Exception(message, cause)