@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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -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 +173 -0
  8. package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +188 -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 +543 -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 +94 -0
  16. package/android/src/main/java/com/rovq/engine/UpdateService.kt +139 -0
  17. package/android/src/main/java/com/rovq/engine/ZipStructureCheck.kt +194 -0
  18. package/android/src/react/java/com/rovq/engine/react/Rovq.kt +101 -0
  19. package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +164 -0
  20. package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +26 -0
  21. package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +74 -0
  22. package/ios/Engine/EngineState.swift +119 -0
  23. package/ios/Engine/ManifestVerifier.swift +197 -0
  24. package/ios/Engine/PackageDownloader.swift +231 -0
  25. package/ios/Engine/PackageRules.swift +38 -0
  26. package/ios/Engine/ProgressThrottle.swift +20 -0
  27. package/ios/Engine/RovqEngine.swift +633 -0
  28. package/ios/Engine/RovqError.swift +35 -0
  29. package/ios/Engine/SafePackageExtractor.swift +107 -0
  30. package/ios/Engine/UpdateInstaller.swift +126 -0
  31. package/ios/Engine/UpdateService.swift +158 -0
  32. package/ios/Engine/ZipReader.swift +378 -0
  33. package/ios/React/RovqHost.swift +253 -0
  34. package/ios/React/RovqModule.mm +89 -0
  35. package/package.json +74 -3
  36. package/react-native.config.js +11 -0
  37. package/src/NativeRovq.ts +72 -0
  38. package/src/errors.ts +44 -0
  39. package/src/index.ts +228 -0
@@ -0,0 +1,37 @@
1
+ package com.rovq.engine
2
+
3
+ /**
4
+ * Decodes standard Base64 and accepts only one spelling of each value, as the server
5
+ * does. It is written here because java.util.Base64 needs API 26, android.util.Base64
6
+ * does not exist in unit tests, and neither of them rejects non-canonical input.
7
+ */
8
+ internal object StrictBase64 {
9
+ private const val ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
10
+
11
+ /** Returns null unless the value is valid, canonical, and decodes to this many bytes. */
12
+ fun decode(value: String, expectedSize: Int): ByteArray? {
13
+ if (value.isEmpty() || value.length % 4 != 0) return null
14
+ val padding = value.length - value.trimEnd('=').length
15
+ if (padding > 2) return null
16
+
17
+ val output = ByteArray(value.length / 4 * 3 - padding)
18
+ if (output.size != expectedSize) return null
19
+
20
+ var buffer = 0
21
+ var bits = 0
22
+ var written = 0
23
+ for (index in 0 until value.length - padding) {
24
+ val digit = ALPHABET.indexOf(value[index])
25
+ if (digit < 0) return null
26
+ buffer = (buffer shl 6) or digit
27
+ bits += 6
28
+ if (bits >= 8) {
29
+ bits -= 8
30
+ output[written++] = (buffer shr bits).toByte()
31
+ buffer = buffer and ((1 shl bits) - 1)
32
+ }
33
+ }
34
+ // Leftover bits must be zero, or two different strings would decode to the same bytes.
35
+ return if (buffer == 0) output else null
36
+ }
37
+ }
@@ -0,0 +1,94 @@
1
+ package com.rovq.engine
2
+
3
+ import java.io.File
4
+
5
+ /** What the server offered. None of this is trusted until the manifest has been verified. */
6
+ data class OfferedUpdate(
7
+ val manifest: String,
8
+ val signature: String,
9
+ val keyId: String,
10
+ val downloadUrl: String,
11
+ )
12
+
13
+ /** A verified release, fully extracted in its own folder. */
14
+ data class InstalledRelease(
15
+ val manifest: VerifiedManifest,
16
+ val folder: File,
17
+ val bundleFile: File,
18
+ )
19
+
20
+ /**
21
+ * Takes an offered update to a verified folder on disk, without activating it.
22
+ *
23
+ * Each release gets its own folder because the image cache keys on the path. Reusing a
24
+ * path would show old images after a reload. See docs/android-bundle-loading.md.
25
+ *
26
+ * @param rootDir a folder only the engine uses, normally inside the app's private storage.
27
+ */
28
+ class UpdateInstaller(
29
+ rootDir: File,
30
+ private val verifier: ManifestVerifier,
31
+ private val downloader: PackageDownloader,
32
+ ) {
33
+ private val updatesDir = File(rootDir, "updates")
34
+ private val workDir = File(rootDir, "work")
35
+
36
+ // Outside the work folder, because a partial download must survive a failure or a restart.
37
+ private val partialDir = File(rootDir, "partial")
38
+ private val extractor = SafePackageExtractor()
39
+
40
+ /**
41
+ * @param highestSequence the highest sequence this device has accepted in this stream.
42
+ * @throws RovqException if any step fails. Nothing is left behind in that case.
43
+ */
44
+ @Synchronized
45
+ fun install(
46
+ offer: OfferedUpdate,
47
+ highestSequence: Long,
48
+ onProgress: (received: Long, total: Long) -> Unit = { _, _ -> },
49
+ ): InstalledRelease {
50
+ // Verified first, so an unsigned update causes no network traffic or disk use.
51
+ val manifest = verifier.verify(offer.manifest, offer.signature, offer.keyId, highestSequence)
52
+
53
+ val folder = File(updatesDir, manifest.releaseId)
54
+ // Folders appear only through the atomic rename below, so one that exists is complete.
55
+ if (!folder.isDirectory) {
56
+ install(offer, manifest, folder, onProgress)
57
+ }
58
+ return InstalledRelease(manifest, folder, File(folder, PackageRules.BUNDLE_FILE_NAME))
59
+ }
60
+
61
+ private fun install(
62
+ offer: OfferedUpdate,
63
+ manifest: VerifiedManifest,
64
+ folder: File,
65
+ onProgress: (Long, Long) -> Unit,
66
+ ) {
67
+ // Removes anything an earlier run left when the app was killed or the phone lost power.
68
+ workDir.deleteRecursively()
69
+ val extracted = File(workDir, "extracted")
70
+ if (!extracted.mkdirs() ||
71
+ !(updatesDir.isDirectory || updatesDir.mkdirs()) ||
72
+ !(partialDir.isDirectory || partialDir.mkdirs())
73
+ ) {
74
+ throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not create the engine folders")
75
+ }
76
+ val packageFile = File(partialDir, "${manifest.packageHash}.zip")
77
+ // Only the current offer is worth finishing.
78
+ partialDir.listFiles()?.filter { it != packageFile }?.forEach { it.delete() }
79
+ try {
80
+ downloader.download(offer.downloadUrl, manifest, packageFile, onProgress)
81
+ extractor.extract(packageFile, extracted)
82
+ if (!extracted.renameTo(folder)) {
83
+ throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not move the release into place")
84
+ }
85
+ packageFile.delete()
86
+ } catch (error: RovqException) {
87
+ // A file that failed anything but the network is not worth continuing from.
88
+ if (error.code != RovqErrorCode.DOWNLOAD_FAILED) packageFile.delete()
89
+ throw error
90
+ } finally {
91
+ workDir.deleteRecursively()
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,139 @@
1
+ package com.rovq.engine
2
+
3
+ import java.io.IOException
4
+ import java.net.HttpURLConnection
5
+ import java.net.URL
6
+ import org.json.JSONArray
7
+ import org.json.JSONException
8
+ import org.json.JSONObject
9
+
10
+ private const val CONNECT_TIMEOUT_MS = 15_000
11
+ private const val READ_TIMEOUT_MS = 30_000
12
+
13
+ /** What the device tells the server when asking for updates. */
14
+ data class CheckRequest(
15
+ val deploymentKey: String,
16
+ val deviceId: String,
17
+ val runtimeVersion: String,
18
+ val binaryVersion: String,
19
+ val currentReleaseId: String?,
20
+ val highestSequence: Long,
21
+ )
22
+
23
+ /** The server's answer. It is advice only, because [OfferedUpdate] is verified before use. */
24
+ sealed interface CheckResponse {
25
+ data object None : CheckResponse
26
+ data object Rollback : CheckResponse
27
+ data class Update(val offer: OfferedUpdate) : CheckResponse
28
+ }
29
+
30
+ /** A key's signed statement that it must no longer be trusted. The engine verifies it. */
31
+ data class KeyRevocation(val keyId: String, val statement: String, val signature: String)
32
+
33
+ data class CheckAnswer(
34
+ val response: CheckResponse,
35
+ val revocations: List<KeyRevocation> = emptyList(),
36
+ )
37
+
38
+ /** The server API the engine needs, kept separate so tests can replace it. */
39
+ interface UpdateService {
40
+ /** @throws IOException if the server cannot be reached or answers with an error. */
41
+ fun check(request: CheckRequest): CheckAnswer
42
+
43
+ /** @throws IOException if the events could not be delivered. */
44
+ fun report(deploymentKey: String, deviceId: String, events: List<QueuedEvent>)
45
+ }
46
+
47
+ class HttpUpdateService(
48
+ private val apiUrl: String,
49
+ private val allowInsecureUrls: Boolean = false,
50
+ ) : UpdateService {
51
+
52
+ override fun check(request: CheckRequest): CheckAnswer {
53
+ val body = JSONObject()
54
+ .put("deploymentKey", request.deploymentKey)
55
+ .put("deviceId", request.deviceId)
56
+ .put("platform", "ANDROID")
57
+ .put("runtimeVersion", request.runtimeVersion)
58
+ .put("binaryVersion", request.binaryVersion)
59
+ .put("highestSequence", request.highestSequence)
60
+ request.currentReleaseId?.let { body.put("currentReleaseId", it) }
61
+
62
+ return try {
63
+ val response = post("/device/updates/check", body)
64
+ val action = when (val action = response.getString("action")) {
65
+ "NONE" -> CheckResponse.None
66
+ "ROLLBACK" -> CheckResponse.Rollback
67
+ "UPDATE" -> {
68
+ val update = response.getJSONObject("update")
69
+ CheckResponse.Update(
70
+ OfferedUpdate(
71
+ manifest = update.getString("manifest"),
72
+ signature = update.getString("signature"),
73
+ keyId = update.getString("keyId"),
74
+ downloadUrl = update.getString("downloadUrl"),
75
+ ),
76
+ )
77
+ }
78
+ else -> throw IOException("Unknown action $action")
79
+ }
80
+ CheckAnswer(action, readRevocations(response))
81
+ } catch (error: JSONException) {
82
+ throw IOException("The server sent an unreadable response", error)
83
+ }
84
+ }
85
+
86
+ private fun readRevocations(response: JSONObject): List<KeyRevocation> {
87
+ val list = response.optJSONArray("revokedKeys") ?: return emptyList()
88
+ return (0 until list.length()).map { index ->
89
+ val item = list.getJSONObject(index)
90
+ KeyRevocation(item.getString("keyId"), item.getString("statement"), item.getString("signature"))
91
+ }
92
+ }
93
+
94
+ override fun report(deploymentKey: String, deviceId: String, events: List<QueuedEvent>) {
95
+ val list = JSONArray()
96
+ events.forEach { event ->
97
+ val json = JSONObject()
98
+ .put("clientEventId", event.clientEventId)
99
+ .put("type", event.type)
100
+ .put("occurredAt", event.occurredAt)
101
+ event.releaseId?.let { json.put("releaseId", it) }
102
+ event.errorCode?.let { json.put("errorCode", it) }
103
+ list.put(json)
104
+ }
105
+ post(
106
+ "/device/events",
107
+ JSONObject()
108
+ .put("deploymentKey", deploymentKey)
109
+ .put("deviceId", deviceId)
110
+ .put("events", list),
111
+ )
112
+ }
113
+
114
+ private fun post(path: String, body: JSONObject): JSONObject {
115
+ val url = URL(apiUrl.trimEnd('/') + path)
116
+ if (url.protocol != "https" && !allowInsecureUrls) {
117
+ throw IOException("The API URL must use HTTPS")
118
+ }
119
+ val connection = url.openConnection() as HttpURLConnection
120
+ try {
121
+ connection.connectTimeout = CONNECT_TIMEOUT_MS
122
+ connection.readTimeout = READ_TIMEOUT_MS
123
+ connection.requestMethod = "POST"
124
+ connection.doOutput = true
125
+ connection.setRequestProperty("content-type", "application/json")
126
+ connection.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) }
127
+ if (connection.responseCode != HttpURLConnection.HTTP_OK) {
128
+ throw IOException("The server answered ${connection.responseCode}")
129
+ }
130
+ return try {
131
+ JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
132
+ } catch (error: JSONException) {
133
+ throw IOException("The server sent an unreadable response", error)
134
+ }
135
+ } finally {
136
+ connection.disconnect()
137
+ }
138
+ }
139
+ }
@@ -0,0 +1,194 @@
1
+ package com.rovq.engine
2
+
3
+ import java.io.File
4
+ import java.io.IOException
5
+ import java.io.RandomAccessFile
6
+ import java.nio.ByteBuffer
7
+ import java.nio.ByteOrder
8
+
9
+ private const val END_OF_DIRECTORY_SIGNATURE = 0x06054b50L
10
+ private const val CENTRAL_HEADER_SIGNATURE = 0x02014b50L
11
+ private const val LOCAL_HEADER_SIGNATURE = 0x04034b50L
12
+ private const val END_OF_DIRECTORY_SIZE = 22
13
+ private const val CENTRAL_HEADER_SIZE = 46
14
+ private const val LOCAL_HEADER_SIZE = 30
15
+ private const val MAX_COMMENT_SIZE = 65_535
16
+ private const val MAX_DIRECTORY_SIZE = 64L * 1024 * 1024
17
+ private const val FLAG_ENCRYPTED = 0x1
18
+ private const val FLAG_UTF8_NAME = 0x800
19
+ private const val UNICODE_PATH_EXTRA_FIELD = 0x7075
20
+ private const val FILE_TYPE_MASK = 0xF000
21
+ private const val REGULAR_FILE = 0x8000
22
+ private const val FOLDER = 0x4000
23
+ private const val MAX_16_BITS = 0xFFFF
24
+ private const val MAX_32_BITS = 0xFFFFFFFFL
25
+
26
+ /**
27
+ * Checks the structure of a ZIP file, which Java's ZipFile reads but does not question. These are
28
+ * the same rules as the iOS reader, so both platforms refuse the same files.
29
+ */
30
+ internal object ZipStructureCheck {
31
+ private class Header(val name: String, val offset: Long, val compressedSize: Long)
32
+
33
+ /** @throws RovqException with INVALID_PACKAGE if anything is wrong. */
34
+ fun check(file: File) {
35
+ try {
36
+ RandomAccessFile(file, "r").use { zip ->
37
+ val headers = readDirectory(zip)
38
+ checkNoOverlap(zip, headers)
39
+ }
40
+ } catch (error: IOException) {
41
+ throw invalid("Could not read the ZIP file", error)
42
+ }
43
+ }
44
+
45
+ private fun readDirectory(zip: RandomAccessFile): List<Header> {
46
+ val end = findEndOfDirectory(zip)
47
+ if (end.u16(4) != 0 || end.u16(6) != 0 || end.u16(8) != end.u16(10)) {
48
+ throw invalid("Split ZIP files are not supported")
49
+ }
50
+ val count = end.u16(10)
51
+ val directorySize = end.u32(12)
52
+ val directoryOffset = end.u32(16)
53
+ if (count == MAX_16_BITS || directorySize == MAX_32_BITS || directoryOffset == MAX_32_BITS) {
54
+ throw invalid("ZIP64 files are not supported")
55
+ }
56
+ if (directoryOffset + directorySize > zip.length()) {
57
+ throw invalid("The directory is outside the file")
58
+ }
59
+ if (directorySize > MAX_DIRECTORY_SIZE) throw invalid("The directory is too large")
60
+
61
+ val directory = read(zip, directoryOffset, directorySize.toInt())
62
+ val headers = ArrayList<Header>(minOf(count, PackageRules.MAX_ENTRIES))
63
+ var position = 0
64
+ repeat(count) {
65
+ if (position + CENTRAL_HEADER_SIZE > directory.capacity() ||
66
+ directory.u32(position) != CENTRAL_HEADER_SIGNATURE
67
+ ) {
68
+ throw invalid("The directory is malformed")
69
+ }
70
+ val flags = directory.u16(position + 8)
71
+ val nameLength = directory.u16(position + 28)
72
+ val extraLength = directory.u16(position + 30)
73
+ val commentLength = directory.u16(position + 32)
74
+ val attributes = directory.u32(position + 38)
75
+ val total = CENTRAL_HEADER_SIZE + nameLength + extraLength + commentLength
76
+ if (position + total > directory.capacity()) throw invalid("The directory is malformed")
77
+ if (flags and FLAG_ENCRYPTED != 0) throw invalid("A file is encrypted")
78
+
79
+ val nameBytes = directory.bytes(position + CENTRAL_HEADER_SIZE, nameLength)
80
+ val name = decodeName(nameBytes, flags)
81
+ checkExtraFields(directory.bytes(position + CENTRAL_HEADER_SIZE + nameLength, extraLength))
82
+
83
+ // Symbolic links could point outside the package folder after extraction.
84
+ val fileType = ((attributes shr 16) and FILE_TYPE_MASK.toLong()).toInt()
85
+ if (fileType != 0 && fileType != REGULAR_FILE && fileType != FOLDER) {
86
+ throw invalid("The file $name is not a regular file")
87
+ }
88
+ headers += Header(name, directory.u32(position + 42), directory.u32(position + 20))
89
+ position += total
90
+ }
91
+ if (position != directory.capacity()) throw invalid("The directory has extra data")
92
+ return headers
93
+ }
94
+
95
+ /**
96
+ * Rejects names that different ZIP readers would decode differently. Otherwise the server
97
+ * could approve one path while this device writes to another.
98
+ */
99
+ private fun decodeName(bytes: ByteBuffer, flags: Int): String {
100
+ val raw = ByteArray(bytes.remaining()).also { bytes.duplicate().get(it) }
101
+ if (flags and FLAG_UTF8_NAME == 0 && raw.any { it < 0x20 || it >= 0x7F }) {
102
+ throw invalid("A file name is not ASCII and is not marked as UTF-8")
103
+ }
104
+ val name = String(raw, Charsets.UTF_8)
105
+ if (!name.toByteArray(Charsets.UTF_8).contentEquals(raw)) {
106
+ throw invalid("A file name is not valid UTF-8")
107
+ }
108
+ return name
109
+ }
110
+
111
+ private fun checkExtraFields(extra: ByteBuffer) {
112
+ var position = 0
113
+ while (position + 4 <= extra.capacity()) {
114
+ if (extra.u16(position) == UNICODE_PATH_EXTRA_FIELD) {
115
+ throw invalid("A file has an alternative name, which is not allowed")
116
+ }
117
+ position += 4 + extra.u16(position + 2)
118
+ }
119
+ }
120
+
121
+ private fun findEndOfDirectory(zip: RandomAccessFile): ByteBuffer {
122
+ val size = zip.length()
123
+ if (size < END_OF_DIRECTORY_SIZE) throw invalid("Not a ZIP file")
124
+ val tailSize = minOf(size, (END_OF_DIRECTORY_SIZE + MAX_COMMENT_SIZE).toLong()).toInt()
125
+ val tail = read(zip, size - tailSize, tailSize)
126
+ var position = tailSize - END_OF_DIRECTORY_SIZE
127
+ while (position >= 0) {
128
+ // The comment length must reach exactly to the end, so comment bytes that merely look
129
+ // like this record are not mistaken for it.
130
+ if (tail.u32(position) == END_OF_DIRECTORY_SIGNATURE &&
131
+ position + END_OF_DIRECTORY_SIZE + tail.u16(position + 20) == tailSize
132
+ ) {
133
+ return tail.bytes(position, END_OF_DIRECTORY_SIZE)
134
+ }
135
+ position -= 1
136
+ }
137
+ throw invalid("Not a ZIP file")
138
+ }
139
+
140
+ /** Entries that share bytes let a tiny file expand enormously, so each must have its own. */
141
+ private fun checkNoOverlap(zip: RandomAccessFile, headers: List<Header>) {
142
+ val ranges = headers.map { it.offset to dataEnd(zip, it) }.sortedBy { it.first }
143
+ ranges.zipWithNext { previous, next ->
144
+ if (next.first < previous.second) throw invalid("Files in the ZIP overlap")
145
+ }
146
+ }
147
+
148
+ /**
149
+ * The local header must name the same file as the directory, because other readers trust the
150
+ * local header instead.
151
+ */
152
+ private fun dataEnd(zip: RandomAccessFile, entry: Header): Long {
153
+ if (entry.offset + LOCAL_HEADER_SIZE > zip.length()) {
154
+ throw invalid("A file is outside the ZIP file")
155
+ }
156
+ val header = read(zip, entry.offset, LOCAL_HEADER_SIZE)
157
+ if (header.u32(0) != LOCAL_HEADER_SIGNATURE) throw invalid("A file header is malformed")
158
+ val nameLength = header.u16(26)
159
+ val extraLength = header.u16(28)
160
+ if (entry.offset + LOCAL_HEADER_SIZE + nameLength > zip.length()) {
161
+ throw invalid("A file is outside the ZIP file")
162
+ }
163
+ val name = read(zip, entry.offset + LOCAL_HEADER_SIZE, nameLength)
164
+ val nameBytes = ByteArray(nameLength).also { name.get(it) }
165
+ if (!nameBytes.contentEquals(entry.name.toByteArray(Charsets.UTF_8))) {
166
+ throw invalid("The file ${entry.name} has a different name in its local header")
167
+ }
168
+ val end = entry.offset + LOCAL_HEADER_SIZE + nameLength + extraLength + entry.compressedSize
169
+ if (end > zip.length()) throw invalid("The file ${entry.name} is outside the ZIP file")
170
+ return end
171
+ }
172
+
173
+ private fun read(zip: RandomAccessFile, offset: Long, count: Int): ByteBuffer {
174
+ val bytes = ByteArray(count)
175
+ zip.seek(offset)
176
+ zip.readFully(bytes)
177
+ return ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
178
+ }
179
+
180
+ private fun ByteBuffer.u16(position: Int): Int = getShort(position).toInt() and 0xFFFF
181
+
182
+ private fun ByteBuffer.u32(position: Int): Long = getInt(position).toLong() and MAX_32_BITS
183
+
184
+ private fun ByteBuffer.bytes(position: Int, count: Int): ByteBuffer {
185
+ val copy = ByteArray(count)
186
+ val view = duplicate()
187
+ view.position(position)
188
+ view.get(copy)
189
+ return ByteBuffer.wrap(copy).order(ByteOrder.LITTLE_ENDIAN)
190
+ }
191
+
192
+ private fun invalid(message: String, cause: Throwable? = null) =
193
+ RovqException(RovqErrorCode.INVALID_PACKAGE, message, cause)
194
+ }
@@ -0,0 +1,101 @@
1
+ package com.rovq.engine.react
2
+
3
+ import android.app.Activity
4
+ import android.app.Application
5
+ import android.content.pm.PackageInfo
6
+ import android.os.Build
7
+ import android.os.Bundle
8
+ import com.rovq.engine.EngineConfig
9
+ import com.rovq.engine.RovqEngine
10
+ import java.io.File
11
+
12
+ /** What an app supplies. Everything here ships inside the app, so nothing here is secret. */
13
+ data class RovqConfig(
14
+ val apiUrl: String,
15
+ val deploymentKey: String,
16
+ /** Change this whenever native code changes. */
17
+ val runtimeVersion: String,
18
+ /** Key IDs and their Base64 Ed25519 public keys. */
19
+ val trustedKeys: Map<String, String>,
20
+ /** Allows http:// for local development only. */
21
+ val allowInsecureUrls: Boolean = false,
22
+ )
23
+
24
+ /** Holds the one engine of this process. Call [initialize] first, in Application.onCreate. */
25
+ object Rovq {
26
+ @Volatile
27
+ private var instance: RovqEngine? = null
28
+
29
+ val engine: RovqEngine
30
+ get() = instance ?: error("Call Rovq.initialize in Application.onCreate first")
31
+
32
+ /** For JavaScript, where a missing engine should be an error it can handle, not a crash. */
33
+ val engineOrNull: RovqEngine?
34
+ get() = instance
35
+
36
+ @Synchronized
37
+ fun initialize(application: Application, config: RovqConfig) {
38
+ if (instance != null) return
39
+ val info = application.packageManager.getPackageInfo(application.packageName, 0)
40
+ val engine = RovqEngine(
41
+ File(application.filesDir, "rovq"),
42
+ EngineConfig(
43
+ apiUrl = config.apiUrl,
44
+ deploymentKey = config.deploymentKey,
45
+ runtimeVersion = config.runtimeVersion,
46
+ binaryVersion = info.versionName ?: "0",
47
+ binaryId = binaryIdOf(info),
48
+ trustedKeys = config.trustedKeys,
49
+ allowInsecureUrls = config.allowInsecureUrls,
50
+ ),
51
+ )
52
+ instance = engine
53
+ application.registerActivityLifecycleCallbacks(BackgroundWatcher(engine))
54
+ watchForCrashes(engine)
55
+ }
56
+
57
+ private fun watchForCrashes(engine: RovqEngine) {
58
+ val previous = Thread.getDefaultUncaughtExceptionHandler()
59
+ Thread.setDefaultUncaughtExceptionHandler { thread, error ->
60
+ try {
61
+ engine.noteCrashed()
62
+ } finally {
63
+ // The app's own crash handling must always run, whatever happens above.
64
+ previous?.uncaughtException(thread, error)
65
+ }
66
+ }
67
+ }
68
+
69
+ /** Tells the engine when no activity is visible, which is what closing an app looks like. */
70
+ private class BackgroundWatcher(private val engine: RovqEngine) :
71
+ Application.ActivityLifecycleCallbacks {
72
+ private var visible = 0
73
+
74
+ override fun onActivityStarted(activity: Activity) {
75
+ visible += 1
76
+ }
77
+
78
+ override fun onActivityStopped(activity: Activity) {
79
+ visible -= 1
80
+ // Rotating the screen also stops the activity, but nobody left the app.
81
+ if (visible == 0 && !activity.isChangingConfigurations) engine.noteBackgrounded()
82
+ }
83
+
84
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
85
+ override fun onActivityResumed(activity: Activity) = Unit
86
+ override fun onActivityPaused(activity: Activity) = Unit
87
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
88
+ override fun onActivityDestroyed(activity: Activity) = Unit
89
+ }
90
+
91
+ /** The install time changes with every update of the app itself, even at the same version. */
92
+ private fun binaryIdOf(info: PackageInfo): String {
93
+ val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
94
+ info.longVersionCode
95
+ } else {
96
+ @Suppress("DEPRECATION")
97
+ info.versionCode.toLong()
98
+ }
99
+ return "$versionCode:${info.lastUpdateTime}"
100
+ }
101
+ }