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