@rovq/react-native 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -2
  3. package/Rovq.podspec +19 -0
  4. package/android/build.gradle +86 -0
  5. package/android/src/main/AndroidManifest.xml +4 -0
  6. package/android/src/main/java/com/rovq/engine/EngineState.kt +176 -0
  7. package/android/src/main/java/com/rovq/engine/ManifestVerifier.kt +230 -0
  8. package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +199 -0
  9. package/android/src/main/java/com/rovq/engine/PackageRules.kt +39 -0
  10. package/android/src/main/java/com/rovq/engine/ProgressThrottle.kt +17 -0
  11. package/android/src/main/java/com/rovq/engine/RovqEngine.kt +550 -0
  12. package/android/src/main/java/com/rovq/engine/RovqException.kt +31 -0
  13. package/android/src/main/java/com/rovq/engine/SafePackageExtractor.kt +121 -0
  14. package/android/src/main/java/com/rovq/engine/StrictBase64.kt +37 -0
  15. package/android/src/main/java/com/rovq/engine/UpdateInstaller.kt +347 -0
  16. package/android/src/main/java/com/rovq/engine/UpdateService.kt +175 -0
  17. package/android/src/main/java/com/rovq/engine/ZipStructureCheck.kt +194 -0
  18. package/android/src/react/java/com/rovq/engine/react/ResourceFiles.kt +52 -0
  19. package/android/src/react/java/com/rovq/engine/react/Rovq.kt +102 -0
  20. package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +164 -0
  21. package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +26 -0
  22. package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +74 -0
  23. package/ios/Engine/EngineState.swift +119 -0
  24. package/ios/Engine/ManifestVerifier.swift +279 -0
  25. package/ios/Engine/PackageDownloader.swift +249 -0
  26. package/ios/Engine/PackageRules.swift +38 -0
  27. package/ios/Engine/ProgressThrottle.swift +20 -0
  28. package/ios/Engine/RovqEngine.swift +646 -0
  29. package/ios/Engine/RovqError.swift +35 -0
  30. package/ios/Engine/SafePackageExtractor.swift +107 -0
  31. package/ios/Engine/UpdateInstaller.swift +348 -0
  32. package/ios/Engine/UpdateService.swift +185 -0
  33. package/ios/Engine/ZipReader.swift +378 -0
  34. package/ios/React/RovqHost.swift +255 -0
  35. package/ios/React/RovqModule.mm +91 -0
  36. package/package.json +78 -3
  37. package/react-native.config.js +11 -0
  38. package/src/NativeRovq.ts +72 -0
  39. package/src/api.ts +228 -0
  40. package/src/errors.ts +44 -0
  41. package/src/index.ts +10 -0
  42. package/src/react.tsx +158 -0
  43. package/src/update-flow.ts +91 -0
@@ -0,0 +1,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,52 @@
1
+ package com.rovq.engine.react
2
+
3
+ import android.content.Context
4
+ import android.util.TypedValue
5
+ import com.rovq.engine.BuiltInFiles
6
+ import java.io.InputStream
7
+
8
+ // The densities react-native bundle writes drawables for, in dots per inch.
9
+ private val DENSITIES = mapOf(
10
+ "ldpi" to 120,
11
+ "mdpi" to 160,
12
+ "hdpi" to 240,
13
+ "xhdpi" to 320,
14
+ "xxhdpi" to 480,
15
+ "xxxhdpi" to 640,
16
+ )
17
+
18
+ /**
19
+ * Reads the app's own copy of a package file, so a release made of files can reuse it instead
20
+ * of downloading it again. In an APK, react-native bundle's files are resources: a path such as
21
+ * raw/src_assets_audio_1.mp3 is the raw resource src_assets_audio_1, and drawable-xhdpi/x.png is
22
+ * the drawable x at 320 dpi.
23
+ *
24
+ * Whatever this returns is hashed and compared with the signed hash, so a resource that the
25
+ * build changed, such as a PNG that was compressed again, is simply downloaded instead.
26
+ */
27
+ internal class ResourceFiles(private val context: Context) : BuiltInFiles {
28
+ override fun open(path: String): InputStream? {
29
+ val segments = path.split('/')
30
+ if (segments.size != 2) return null
31
+ val (folder, fileName) = segments
32
+ val type = folder.substringBefore('-')
33
+ val name = fileName.substringBeforeLast('.')
34
+ val resources = context.resources
35
+ // The only way to find a resource by the name a package uses.
36
+ @Suppress("DiscouragedApi")
37
+ val id = resources.getIdentifier(name, type, context.packageName)
38
+ if (id == 0) return null
39
+ return when (type) {
40
+ "raw" -> resources.openRawResource(id)
41
+ "drawable" -> {
42
+ val density = DENSITIES[folder.substringAfter('-', "")] ?: return null
43
+ val value = TypedValue()
44
+ resources.getValueForDensity(id, density, value, true)
45
+ val file = value.string?.toString() ?: return null
46
+ // Works for files stored uncompressed in the APK, which images and audio are.
47
+ resources.assets.openNonAssetFd(value.assetCookie, file).createInputStream()
48
+ }
49
+ else -> null
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,102 @@
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
+ builtInFiles = ResourceFiles(application),
52
+ )
53
+ instance = engine
54
+ application.registerActivityLifecycleCallbacks(BackgroundWatcher(engine))
55
+ watchForCrashes(engine)
56
+ }
57
+
58
+ private fun watchForCrashes(engine: RovqEngine) {
59
+ val previous = Thread.getDefaultUncaughtExceptionHandler()
60
+ Thread.setDefaultUncaughtExceptionHandler { thread, error ->
61
+ try {
62
+ engine.noteCrashed()
63
+ } finally {
64
+ // The app's own crash handling must always run, whatever happens above.
65
+ previous?.uncaughtException(thread, error)
66
+ }
67
+ }
68
+ }
69
+
70
+ /** Tells the engine when no activity is visible, which is what closing an app looks like. */
71
+ private class BackgroundWatcher(private val engine: RovqEngine) :
72
+ Application.ActivityLifecycleCallbacks {
73
+ private var visible = 0
74
+
75
+ override fun onActivityStarted(activity: Activity) {
76
+ visible += 1
77
+ }
78
+
79
+ override fun onActivityStopped(activity: Activity) {
80
+ visible -= 1
81
+ // Rotating the screen also stops the activity, but nobody left the app.
82
+ if (visible == 0 && !activity.isChangingConfigurations) engine.noteBackgrounded()
83
+ }
84
+
85
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
86
+ override fun onActivityResumed(activity: Activity) = Unit
87
+ override fun onActivityPaused(activity: Activity) = Unit
88
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
89
+ override fun onActivityDestroyed(activity: Activity) = Unit
90
+ }
91
+
92
+ /** The install time changes with every update of the app itself, even at the same version. */
93
+ private fun binaryIdOf(info: PackageInfo): String {
94
+ val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
95
+ info.longVersionCode
96
+ } else {
97
+ @Suppress("DEPRECATION")
98
+ info.versionCode.toLong()
99
+ }
100
+ return "$versionCode:${info.lastUpdateTime}"
101
+ }
102
+ }
@@ -0,0 +1,164 @@
1
+ package com.rovq.engine.react
2
+
3
+ import android.util.Log
4
+ import com.facebook.react.ReactApplication
5
+ import com.facebook.react.bridge.Arguments
6
+ import com.facebook.react.bridge.Promise
7
+ import com.facebook.react.bridge.ReactApplicationContext
8
+ import com.facebook.react.bridge.UiThreadUtil
9
+ import com.facebook.react.bridge.WritableMap
10
+ import com.facebook.react.module.annotations.ReactModule
11
+ import com.rovq.engine.CheckResult
12
+ import com.rovq.engine.DownloadResult
13
+ import com.rovq.engine.InstallResult
14
+ import com.rovq.engine.RovqEngine
15
+ import com.rovq.engine.RovqErrorCode
16
+ import com.rovq.engine.ProgressThrottle
17
+ import com.rovq.engine.SyncResult
18
+ import com.rovq.engine.UpdateInfo
19
+ import java.util.concurrent.Executors
20
+
21
+ @ReactModule(name = RovqModule.NAME)
22
+ class RovqModule(context: ReactApplicationContext) : NativeRovqSpec(context) {
23
+ // Network and disk work must stay off the JavaScript thread.
24
+ private val worker = Executors.newSingleThreadExecutor()
25
+
26
+ // A download keeps running through a reload, when this module's JavaScript has gone.
27
+ @Volatile
28
+ private var invalidated = false
29
+
30
+ override fun getName(): String = NAME
31
+
32
+ override fun checkForUpdate(promise: Promise) = run(promise) { engine ->
33
+ when (val result = engine.checkForUpdate()) {
34
+ CheckResult.UpToDate -> outcome("UP_TO_DATE")
35
+ CheckResult.RolledBack -> outcome("ROLLED_BACK")
36
+ is CheckResult.UpdateAvailable -> outcome("UPDATE_AVAILABLE").apply {
37
+ putMap("update", toMap(result.update))
38
+ putBoolean("alreadyDownloaded", result.alreadyDownloaded)
39
+ }
40
+ is CheckResult.Failed -> failure(result.code, result.message)
41
+ }
42
+ }
43
+
44
+ override fun downloadUpdate(promise: Promise) = run(promise) { engine ->
45
+ when (val result = engine.downloadUpdate(progressReporter())) {
46
+ is DownloadResult.Downloaded -> outcome("DOWNLOADED").apply { putMap("update", toMap(result.update)) }
47
+ is DownloadResult.Failed -> failure(result.code, result.message)
48
+ }
49
+ }
50
+
51
+ override fun installUpdate(promise: Promise) = run(promise) { engine ->
52
+ when (val result = engine.installUpdate()) {
53
+ is InstallResult.Installed -> outcome("INSTALLED").apply { putString("releaseId", result.releaseId) }
54
+ is InstallResult.Failed -> failure(result.code, result.message)
55
+ }
56
+ }
57
+
58
+ override fun sync(promise: Promise) = run(promise) { engine ->
59
+ when (val result = engine.sync(progressReporter())) {
60
+ SyncResult.UpToDate -> outcome("UP_TO_DATE")
61
+ SyncResult.RolledBack -> outcome("ROLLED_BACK")
62
+ is SyncResult.UpdateReady -> outcome("UPDATE_READY").apply {
63
+ putString("releaseId", result.releaseId)
64
+ putBoolean("mandatory", result.mandatory)
65
+ }
66
+ is SyncResult.Failed -> failure(result.code, result.message)
67
+ }
68
+ }
69
+
70
+ override fun notifyApplicationReady() {
71
+ Rovq.engineOrNull?.notifyReady()
72
+ }
73
+
74
+ override fun restartApp() {
75
+ UiThreadUtil.runOnUiThread {
76
+ val application = reactApplicationContext.applicationContext as ReactApplication
77
+ application.reactHost?.reload("ROVQ restart")
78
+ }
79
+ }
80
+
81
+ override fun rollback(promise: Promise) {
82
+ worker.execute {
83
+ try {
84
+ promise.resolve(Rovq.engineOrNull?.rollback() ?: false)
85
+ } catch (error: Exception) {
86
+ promise.reject("UNKNOWN", error)
87
+ }
88
+ }
89
+ }
90
+
91
+ override fun getStatus(promise: Promise) {
92
+ val status = Rovq.engineOrNull?.status()
93
+ promise.resolve(
94
+ Arguments.createMap().apply {
95
+ if (status != null) {
96
+ putString("deviceId", status.deviceId)
97
+ putString("currentReleaseId", status.currentReleaseId)
98
+ putString("downloadedReleaseId", status.downloadedReleaseId)
99
+ putString("pendingReleaseId", status.pendingReleaseId)
100
+ putString("tryingReleaseId", status.tryingReleaseId)
101
+ putDouble("highestSequence", status.highestSequence.toDouble())
102
+ }
103
+ },
104
+ )
105
+ }
106
+
107
+ private fun run(promise: Promise, work: (RovqEngine) -> WritableMap) {
108
+ worker.execute {
109
+ try {
110
+ val engine = Rovq.engineOrNull
111
+ promise.resolve(
112
+ if (engine != null) {
113
+ work(engine)
114
+ } else {
115
+ failure(RovqErrorCode.ENGINE_UNAVAILABLE, "Rovq.initialize was not called")
116
+ },
117
+ )
118
+ } catch (error: Exception) {
119
+ promise.reject("UNKNOWN", error)
120
+ }
121
+ }
122
+ }
123
+
124
+ private fun progressReporter(): (Long, Long) -> Unit {
125
+ val throttle = ProgressThrottle { received, total ->
126
+ if (!invalidated) {
127
+ emitOnDownloadProgress(
128
+ Arguments.createMap().apply {
129
+ putDouble("receivedBytes", received.toDouble())
130
+ putDouble("totalBytes", total.toDouble())
131
+ },
132
+ )
133
+ }
134
+ }
135
+ return throttle::report
136
+ }
137
+
138
+ private fun outcome(name: String): WritableMap = Arguments.createMap().apply { putString("result", name) }
139
+
140
+ private fun failure(code: RovqErrorCode, message: String): WritableMap {
141
+ Log.w(NAME, "Failed with $code: $message")
142
+ return outcome("FAILED").apply {
143
+ putString("code", code.name)
144
+ putString("message", message)
145
+ }
146
+ }
147
+
148
+ private fun toMap(update: UpdateInfo): WritableMap = Arguments.createMap().apply {
149
+ putString("releaseId", update.releaseId)
150
+ putDouble("sequence", update.sequence.toDouble())
151
+ putBoolean("mandatory", update.mandatory)
152
+ putDouble("packageSize", update.packageSize.toDouble())
153
+ }
154
+
155
+ override fun invalidate() {
156
+ invalidated = true
157
+ worker.shutdown()
158
+ super.invalidate()
159
+ }
160
+
161
+ companion object {
162
+ const val NAME = "Rovq"
163
+ }
164
+ }
@@ -0,0 +1,26 @@
1
+ package com.rovq.engine.react
2
+
3
+ import com.facebook.react.BaseReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.module.model.ReactModuleInfo
7
+ import com.facebook.react.module.model.ReactModuleInfoProvider
8
+
9
+ /** Found by React Native autolinking. */
10
+ class RovqPackage : BaseReactPackage() {
11
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
12
+ if (name == RovqModule.NAME) RovqModule(reactContext) else null
13
+
14
+ override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider {
15
+ mapOf(
16
+ RovqModule.NAME to ReactModuleInfo(
17
+ RovqModule.NAME,
18
+ RovqModule::class.java.name,
19
+ false,
20
+ false,
21
+ false,
22
+ true,
23
+ ),
24
+ )
25
+ }
26
+ }
@@ -0,0 +1,74 @@
1
+ package com.rovq.engine.react
2
+
3
+ import android.app.Application
4
+ import android.util.Log
5
+ import com.facebook.react.ReactHost
6
+ import com.facebook.react.ReactPackage
7
+ import com.facebook.react.bridge.JSBundleLoader
8
+ import com.facebook.react.bridge.JSBundleLoaderDelegate
9
+ import com.facebook.react.common.annotations.UnstableReactNativeAPI
10
+ import com.facebook.react.common.build.ReactBuildConfig
11
+ import com.facebook.react.defaults.DefaultComponentsRegistry
12
+ import com.facebook.react.defaults.DefaultReactHostDelegate
13
+ import com.facebook.react.defaults.DefaultTurboModuleManagerDelegate
14
+ import com.facebook.react.fabric.ComponentFactory
15
+ import com.facebook.react.runtime.ReactHostImpl
16
+ import com.facebook.react.runtime.hermes.HermesInstance
17
+
18
+ private const val TAG = "Rovq"
19
+ private const val EMBEDDED_BUNDLE = "assets://index.android.bundle"
20
+
21
+ /**
22
+ * Chooses the bundle each time JavaScript starts, not once when the host is created.
23
+ * This is what lets reload apply an update without killing the process.
24
+ */
25
+ private class RovqBundleLoader(private val application: Application) : JSBundleLoader() {
26
+ override fun loadScript(delegate: JSBundleLoaderDelegate): String {
27
+ val file = try {
28
+ Rovq.engine.resolveBundle()
29
+ } catch (error: Throwable) {
30
+ // Whatever goes wrong in the engine, the app must still start.
31
+ Log.e(TAG, "Falling back to the bundle inside the app", error)
32
+ null
33
+ }
34
+ if (file == null) {
35
+ Log.i(TAG, "Loading the bundle inside the app")
36
+ delegate.loadScriptFromAssets(application.assets, EMBEDDED_BUNDLE, true)
37
+ return EMBEDDED_BUNDLE
38
+ }
39
+ Log.i(TAG, "Loading ${file.absolutePath}")
40
+ delegate.loadScriptFromFile(file.absolutePath, file.absolutePath, false)
41
+ return file.absolutePath
42
+ }
43
+ }
44
+
45
+ /**
46
+ * The only file that touches React Native's unstable host API. It does what
47
+ * getDefaultReactHost does, with a different loader, so check it against that function
48
+ * when upgrading React Native. See docs/android-bundle-loading.md.
49
+ */
50
+ @OptIn(UnstableReactNativeAPI::class)
51
+ object RovqReactHost {
52
+ /** Use in place of getDefaultReactHost in MainApplication. */
53
+ fun create(application: Application, packages: List<ReactPackage>): ReactHost {
54
+ val componentFactory = ComponentFactory()
55
+ DefaultComponentsRegistry.register(componentFactory)
56
+ val delegate = DefaultReactHostDelegate(
57
+ jsMainModulePath = "index",
58
+ jsBundleLoader = RovqBundleLoader(application),
59
+ reactPackages = packages,
60
+ jsRuntimeFactory = HermesInstance(),
61
+ turboModuleManagerDelegateBuilder = DefaultTurboModuleManagerDelegate.Builder(),
62
+ // Letting a fatal JavaScript error kill the process is what makes rollback work:
63
+ // the release stays unconfirmed, so the next start abandons it.
64
+ exceptionHandler = { error -> throw error },
65
+ )
66
+ return ReactHostImpl(
67
+ application.applicationContext,
68
+ delegate,
69
+ componentFactory,
70
+ true,
71
+ ReactBuildConfig.DEBUG,
72
+ )
73
+ }
74
+ }