@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.
- package/LICENSE +21 -0
- package/README.md +120 -2
- package/Rovq.podspec +19 -0
- package/android/build.gradle +86 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/com/rovq/engine/EngineState.kt +176 -0
- package/android/src/main/java/com/rovq/engine/ManifestVerifier.kt +230 -0
- package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +199 -0
- package/android/src/main/java/com/rovq/engine/PackageRules.kt +39 -0
- package/android/src/main/java/com/rovq/engine/ProgressThrottle.kt +17 -0
- package/android/src/main/java/com/rovq/engine/RovqEngine.kt +550 -0
- package/android/src/main/java/com/rovq/engine/RovqException.kt +31 -0
- package/android/src/main/java/com/rovq/engine/SafePackageExtractor.kt +121 -0
- package/android/src/main/java/com/rovq/engine/StrictBase64.kt +37 -0
- package/android/src/main/java/com/rovq/engine/UpdateInstaller.kt +347 -0
- package/android/src/main/java/com/rovq/engine/UpdateService.kt +175 -0
- package/android/src/main/java/com/rovq/engine/ZipStructureCheck.kt +194 -0
- package/android/src/react/java/com/rovq/engine/react/ResourceFiles.kt +52 -0
- package/android/src/react/java/com/rovq/engine/react/Rovq.kt +102 -0
- package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +164 -0
- package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +26 -0
- package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +74 -0
- package/ios/Engine/EngineState.swift +119 -0
- package/ios/Engine/ManifestVerifier.swift +279 -0
- package/ios/Engine/PackageDownloader.swift +249 -0
- package/ios/Engine/PackageRules.swift +38 -0
- package/ios/Engine/ProgressThrottle.swift +20 -0
- package/ios/Engine/RovqEngine.swift +646 -0
- package/ios/Engine/RovqError.swift +35 -0
- package/ios/Engine/SafePackageExtractor.swift +107 -0
- package/ios/Engine/UpdateInstaller.swift +348 -0
- package/ios/Engine/UpdateService.swift +185 -0
- package/ios/Engine/ZipReader.swift +378 -0
- package/ios/React/RovqHost.swift +255 -0
- package/ios/React/RovqModule.mm +91 -0
- package/package.json +78 -3
- package/react-native.config.js +11 -0
- package/src/NativeRovq.ts +72 -0
- package/src/api.ts +228 -0
- package/src/errors.ts +44 -0
- package/src/index.ts +10 -0
- package/src/react.tsx +158 -0
- 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)
|