@rovq/react-native 0.1.0 → 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/README.md CHANGED
@@ -68,10 +68,54 @@ useEffect(() => {
68
68
  }, []);
69
69
  ```
70
70
 
71
- To ask the user before downloading, use `checkForUpdate`, `downloadUpdate({ onProgress })`, `installUpdate`, and `restartApp`. Failures throw `RovqError` with a stable `code`.
71
+ For full control, use `checkForUpdate`, `downloadUpdate({ onProgress })`, `installUpdate`, and `restartApp`. They throw `RovqError` with a stable `code`.
72
+
73
+ ## Updating in the foreground or the background
74
+
75
+ Wrap your app in `RovqUpdateGate`, and choose how updates arrive:
76
+
77
+ ```tsx
78
+ import { RovqUpdateGate } from '@rovq/react-native';
79
+
80
+ export default function Root() {
81
+ return (
82
+ <RovqUpdateGate mode="auto" variant="bar">
83
+ <App />
84
+ </RovqUpdateGate>
85
+ );
86
+ }
87
+ ```
88
+
89
+ | `mode` | What the user sees |
90
+ | ---------------- | --------------------------------------------------------------------------------- |
91
+ | `background` | Nothing. The update downloads while they use the app, and loads on the next start |
92
+ | `foreground` | A progress screen, then the app restarts on the new version at once |
93
+ | `auto` (default) | Foreground for a release published with `--mandatory`, background otherwise |
94
+
95
+ `variant` is `bar` or `percent`. The gate shows your app while it checks, so a slow server never holds it up, and it confirms the running release with `notifyApplicationReady` for you. Pass `title`, `color`, and `backgroundColor`, or draw your own screen with `renderProgress={(state) => ...}`.
96
+
97
+ To build your own interface, use the hook and the progress component:
98
+
99
+ ```tsx
100
+ import { RovqUpdateProgress, useRovqUpdate } from '@rovq/react-native';
101
+
102
+ const { phase, progress, update, error, check } = useRovqUpdate({ mode: 'background' });
103
+ // phase: idle, checking, upToDate, downloading, installing, ready, restarting, or error
104
+ {
105
+ progress !== null && <RovqUpdateProgress progress={progress} variant="percent" />;
106
+ }
107
+ ```
108
+
109
+ A failure never throws. It ends in the `error` phase, and the app keeps running what it has.
110
+
111
+ "Background" means while the app is open. When the user leaves the app, the system can pause the download, and it continues from where it stopped the next time the app runs.
72
112
 
73
113
  Publish updates with [`@rovq/cli`](https://www.npmjs.com/package/@rovq/cli).
74
114
 
115
+ ## Downloading only what changed
116
+
117
+ A release published by `@rovq/cli` 0.2 lists its files, and the SDK downloads only those it does not already have. Files from the release before, and assets inside the app itself, are checked against the signed hash and reused. A change to one screen downloads only the bundle, however many images and sounds the app has.
118
+
75
119
  ## License
76
120
 
77
121
  MIT
@@ -2,14 +2,14 @@ package com.rovq.engine
2
2
 
3
3
  import com.google.crypto.tink.subtle.Ed25519Verify
4
4
  import java.security.GeneralSecurityException
5
+ import org.json.JSONArray
5
6
  import org.json.JSONException
6
7
  import org.json.JSONObject
7
8
 
8
9
  private const val PUBLIC_KEY_SIZE = 32
9
10
  private const val SIGNATURE_SIZE = 64
10
- private const val MANIFEST_VERSION = 1
11
11
  private const val PLATFORM = "ANDROID"
12
- private val MANIFEST_FIELDS = setOf(
12
+ private val STREAM_FIELDS = setOf(
13
13
  "version",
14
14
  "releaseId",
15
15
  "deploymentKey",
@@ -18,19 +18,30 @@ private val MANIFEST_FIELDS = setOf(
18
18
  "binaryVersion",
19
19
  "sequence",
20
20
  "mandatory",
21
- "package",
22
21
  )
22
+
23
+ // Version 1 names one ZIP package. Version 2 lists files. See docs/file-packages.md.
24
+ private val ZIP_MANIFEST_FIELDS = STREAM_FIELDS + "package"
25
+ private val FILES_MANIFEST_FIELDS = STREAM_FIELDS + "files"
23
26
  private val PACKAGE_FIELDS = setOf("hash", "size")
27
+ private val FILE_FIELDS = setOf("path", "hash", "size")
24
28
  private val UUID_PATTERN = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
25
29
  private val HASH_PATTERN = Regex("^[0-9a-f]{64}$")
26
30
 
31
+ /** One file of a release made of files, from a verified manifest. */
32
+ data class ManifestFile(val path: String, val hash: String, val size: Long)
33
+
27
34
  /** Facts from a manifest whose signature has been verified. Nothing else should be trusted. */
28
35
  data class VerifiedManifest(
29
36
  val releaseId: String,
30
37
  val sequence: Long,
31
38
  val mandatory: Boolean,
39
+ /** The ZIP package's hash. Empty for a release made of files. */
32
40
  val packageHash: String,
41
+ /** The ZIP package's size, or all files together. */
33
42
  val packageSize: Long,
43
+ /** Set for a release made of files, sorted by path. Null for a ZIP package. */
44
+ val files: List<ManifestFile>? = null,
34
45
  )
35
46
 
36
47
  /**
@@ -112,8 +123,9 @@ class ManifestVerifier(
112
123
 
113
124
  private fun parseAndCheck(manifest: String): VerifiedManifest {
114
125
  val root = parseObject(manifest)
115
- requireOnlyFields(root, MANIFEST_FIELDS)
116
- if (longField(root, "version") != MANIFEST_VERSION.toLong()) invalid("Unknown manifest version")
126
+ val version = longField(root, "version")
127
+ if (version != 1L && version != 2L) invalid("Unknown manifest version")
128
+ requireOnlyFields(root, if (version == 1L) ZIP_MANIFEST_FIELDS else FILES_MANIFEST_FIELDS)
117
129
 
118
130
  val releaseId = stringField(root, "releaseId")
119
131
  // Later used as a folder name, so it must be exactly a UUID.
@@ -125,6 +137,16 @@ class ManifestVerifier(
125
137
  val mandatory = root.opt("mandatory") as? Boolean ?: invalid("mandatory must be a boolean")
126
138
  stringField(root, "binaryVersion")
127
139
 
140
+ // A valid signature for another deployment, platform or runtime must not install here.
141
+ requireMatch("deploymentKey", stringField(root, "deploymentKey"), deploymentKey)
142
+ requireMatch("platform", stringField(root, "platform"), PLATFORM)
143
+ requireMatch("runtimeVersion", stringField(root, "runtimeVersion"), runtimeVersion)
144
+
145
+ if (version == 2L) {
146
+ val files = fileList(root.opt("files"))
147
+ return VerifiedManifest(releaseId, sequence, mandatory, "", files.sumOf { it.size }, files)
148
+ }
149
+
128
150
  val packageInfo = root.opt("package") as? JSONObject ?: invalid("package must be an object")
129
151
  requireOnlyFields(packageInfo, PACKAGE_FIELDS)
130
152
  val hash = stringField(packageInfo, "hash")
@@ -132,14 +154,49 @@ class ManifestVerifier(
132
154
  val size = longField(packageInfo, "size")
133
155
  if (size <= 0) invalid("package size must be positive")
134
156
 
135
- // A valid signature for another deployment, platform or runtime must not install here.
136
- requireMatch("deploymentKey", stringField(root, "deploymentKey"), deploymentKey)
137
- requireMatch("platform", stringField(root, "platform"), PLATFORM)
138
- requireMatch("runtimeVersion", stringField(root, "runtimeVersion"), runtimeVersion)
139
-
140
157
  return VerifiedManifest(releaseId, sequence, mandatory, hash, size)
141
158
  }
142
159
 
160
+ /**
161
+ * The same rules as a ZIP package, because each path is written to disk the same way.
162
+ * Sorted by UTF-16 code units, as JavaScript and Kotlin both sort, so one set of files has
163
+ * one signed form.
164
+ */
165
+ private fun fileList(value: Any?): List<ManifestFile> {
166
+ val list = value as? JSONArray ?: invalid("files must be an array")
167
+ if (list.length() == 0 || list.length() > PackageRules.MAX_ENTRIES) {
168
+ invalid("A package must have from 1 to ${PackageRules.MAX_ENTRIES} files")
169
+ }
170
+ val files = ArrayList<ManifestFile>(list.length())
171
+ val seen = HashSet<String>()
172
+ var total = 0L
173
+ for (index in 0 until list.length()) {
174
+ val entry = list.opt(index) as? JSONObject ?: invalid("Each file must be an object")
175
+ requireOnlyFields(entry, FILE_FIELDS)
176
+ val path = stringField(entry, "path")
177
+ PackageRules.findPathProblem(path)?.let { invalid("The file $path $it") }
178
+ val previous = files.lastOrNull()
179
+ if (previous != null && previous.path >= path) {
180
+ invalid("The files must be sorted by path, each path once")
181
+ }
182
+ if (!seen.add(PackageRules.pathIdentity(path))) {
183
+ invalid("The file $path differs from another only by letter case")
184
+ }
185
+ val hash = stringField(entry, "hash")
186
+ if (!HASH_PATTERN.matches(hash)) invalid("The file $path has a malformed hash")
187
+ val size = longField(entry, "size")
188
+ // Bounded before adding, so the total cannot overflow.
189
+ if (size < 0 || size > PackageRules.MAX_TOTAL_SIZE) invalid("The file $path has an invalid size")
190
+ total += size
191
+ if (total > PackageRules.MAX_TOTAL_SIZE) invalid("The files are larger than the allowed total")
192
+ files += ManifestFile(path, hash, size)
193
+ }
194
+ val bundle = files.firstOrNull { it.path == PackageRules.BUNDLE_FILE_NAME }
195
+ ?: invalid("The bundle file is missing")
196
+ if (bundle.size == 0L) invalid("The bundle file is empty")
197
+ return files
198
+ }
199
+
143
200
  private fun parseObject(text: String): JSONObject = try {
144
201
  JSONObject(text)
145
202
  } catch (error: JSONException) {
@@ -29,16 +29,21 @@ class PackageDownloader(
29
29
  * deletes it. It counts as good only once the whole file matches the signed hash, so a partial
30
30
  * or wrong file can never be mistaken for a good one.
31
31
  *
32
+ * @param hash the signed SHA-256 of the whole file.
33
+ * @param size the signed size of the whole file.
34
+ * @param extraSpace room needed beyond the file itself, such as for extracting it.
32
35
  * @param onProgress receives bytes received so far and the total.
33
36
  */
34
37
  fun download(
35
38
  url: String,
36
- manifest: VerifiedManifest,
39
+ hash: String,
40
+ size: Long,
37
41
  target: File,
42
+ extraSpace: Long = 0,
38
43
  onProgress: (received: Long, total: Long) -> Unit = { _, _ -> },
39
44
  ) {
40
45
  try {
41
- downloadToFile(url, manifest, target, onProgress)
46
+ downloadToFile(url, Expected(hash, size), target, extraSpace, onProgress)
42
47
  } catch (error: RovqException) {
43
48
  if (error.code != RovqErrorCode.DOWNLOAD_FAILED) target.delete()
44
49
  throw error
@@ -48,10 +53,17 @@ class PackageDownloader(
48
53
  }
49
54
  }
50
55
 
56
+ /** The signed facts a download must end up matching. */
57
+ private data class Expected(val hash: String, val size: Long)
58
+
59
+ /** Room left in a folder. Replaced in tests. */
60
+ internal fun freeSpaceIn(folder: File): Long = freeSpace(folder)
61
+
51
62
  private fun downloadToFile(
52
63
  url: String,
53
- manifest: VerifiedManifest,
64
+ expected: Expected,
54
65
  target: File,
66
+ extraSpace: Long,
55
67
  onProgress: (Long, Long) -> Unit,
56
68
  ) {
57
69
  val parsed = try {
@@ -64,11 +76,15 @@ class PackageDownloader(
64
76
  }
65
77
 
66
78
  val digest = MessageDigest.getInstance("SHA-256")
67
- val existing = readExisting(target, manifest, digest)
68
- checkFreeSpace(target, manifest, existing)
79
+ val existing = readExisting(target, expected, digest)
80
+ checkFreeSpace(target, expected.size - existing + extraSpace)
69
81
  var received = existing
70
- if (existing == manifest.packageSize) {
71
- verifyHash(digest, manifest)
82
+ if (existing == expected.size) {
83
+ // An empty file has nothing to download, but must still exist.
84
+ if (!target.isFile && !target.createNewFile()) {
85
+ throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not create the file")
86
+ }
87
+ verifyHash(digest, expected)
72
88
  return
73
89
  }
74
90
 
@@ -119,7 +135,7 @@ class PackageDownloader(
119
135
  if (count < 0) break
120
136
  received += count
121
137
  // Stops at once, so a wrong server cannot fill the disk.
122
- if (received > manifest.packageSize) {
138
+ if (received > expected.size) {
123
139
  throw RovqException(
124
140
  RovqErrorCode.SIZE_MISMATCH,
125
141
  "Download is larger than the signed size",
@@ -131,7 +147,7 @@ class PackageDownloader(
131
147
  } catch (error: IOException) {
132
148
  throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not write the file", error)
133
149
  }
134
- onProgress(received, manifest.packageSize)
150
+ onProgress(received, expected.size)
135
151
  }
136
152
  }
137
153
  // Forces the bytes to disk, so power loss cannot leave a file that looks complete.
@@ -143,16 +159,16 @@ class PackageDownloader(
143
159
 
144
160
  // A lost connection can look like a normal end of file, so a short download counts
145
161
  // as a network failure, which is worth retrying.
146
- if (received < manifest.packageSize) {
162
+ if (received < expected.size) {
147
163
  throw RovqException(RovqErrorCode.DOWNLOAD_FAILED, "The download ended early")
148
164
  }
149
- verifyHash(digest, manifest)
165
+ verifyHash(digest, expected)
150
166
  }
151
167
 
152
168
  /** @return how many bytes are already there, after adding them to [digest]. */
153
- private fun readExisting(target: File, manifest: VerifiedManifest, digest: MessageDigest): Long {
169
+ private fun readExisting(target: File, expected: Expected, digest: MessageDigest): Long {
154
170
  if (!target.isFile) return 0
155
- if (target.length() > manifest.packageSize) {
171
+ if (target.length() > expected.size) {
156
172
  target.delete()
157
173
  return 0
158
174
  }
@@ -167,21 +183,16 @@ class PackageDownloader(
167
183
  return target.length()
168
184
  }
169
185
 
170
- /**
171
- * Extracting needs room too, and its size is unknown until then, so this asks for the package
172
- * twice. A package can still expand further, and extraction reports that if it happens.
173
- */
174
- private fun checkFreeSpace(target: File, manifest: VerifiedManifest, existing: Long) {
175
- val needed = manifest.packageSize - existing + manifest.packageSize
186
+ private fun checkFreeSpace(target: File, needed: Long) {
176
187
  val folder = target.parentFile ?: target
177
188
  if (freeSpace(folder) < needed) {
178
189
  throw RovqException(RovqErrorCode.STORAGE_FAILED, "Not enough free space for this update")
179
190
  }
180
191
  }
181
192
 
182
- private fun verifyHash(digest: MessageDigest, manifest: VerifiedManifest) {
193
+ private fun verifyHash(digest: MessageDigest, expected: Expected) {
183
194
  val hash = digest.digest().joinToString("") { "%02x".format(it) }
184
- if (hash != manifest.packageHash) {
195
+ if (hash != expected.hash) {
185
196
  throw RovqException(RovqErrorCode.HASH_MISMATCH, "Download does not match the signed hash")
186
197
  }
187
198
  }
@@ -104,6 +104,8 @@ class RovqEngine(
104
104
  private val rootDir: File,
105
105
  private val config: EngineConfig,
106
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,
107
109
  private val clock: () -> Long = System::currentTimeMillis,
108
110
  ) {
109
111
  private val store: EngineStateStore
@@ -126,6 +128,7 @@ class RovqEngine(
126
128
  rootDir,
127
129
  verifier,
128
130
  PackageDownloader(config.allowInsecureUrls),
131
+ builtInFiles,
129
132
  )
130
133
  state = loadState()
131
134
  verifier.revokedPublicKeys = state.revokedPublicKeys
@@ -396,7 +399,11 @@ class RovqEngine(
396
399
  state.highestSequence
397
400
  }
398
401
  return try {
399
- val manifest = installer.install(offered, highestSequence, onProgress).manifest
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
400
407
  synchronized(this) {
401
408
  save(
402
409
  state.copy(