@rovq/react-native 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +120 -2
- package/Rovq.podspec +19 -0
- package/android/build.gradle +86 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/com/rovq/engine/EngineState.kt +176 -0
- package/android/src/main/java/com/rovq/engine/ManifestVerifier.kt +230 -0
- package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +199 -0
- package/android/src/main/java/com/rovq/engine/PackageRules.kt +39 -0
- package/android/src/main/java/com/rovq/engine/ProgressThrottle.kt +17 -0
- package/android/src/main/java/com/rovq/engine/RovqEngine.kt +550 -0
- package/android/src/main/java/com/rovq/engine/RovqException.kt +31 -0
- package/android/src/main/java/com/rovq/engine/SafePackageExtractor.kt +121 -0
- package/android/src/main/java/com/rovq/engine/StrictBase64.kt +37 -0
- package/android/src/main/java/com/rovq/engine/UpdateInstaller.kt +347 -0
- package/android/src/main/java/com/rovq/engine/UpdateService.kt +175 -0
- package/android/src/main/java/com/rovq/engine/ZipStructureCheck.kt +194 -0
- package/android/src/react/java/com/rovq/engine/react/ResourceFiles.kt +52 -0
- package/android/src/react/java/com/rovq/engine/react/Rovq.kt +102 -0
- package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +164 -0
- package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +26 -0
- package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +74 -0
- package/ios/Engine/EngineState.swift +119 -0
- package/ios/Engine/ManifestVerifier.swift +279 -0
- package/ios/Engine/PackageDownloader.swift +249 -0
- package/ios/Engine/PackageRules.swift +38 -0
- package/ios/Engine/ProgressThrottle.swift +20 -0
- package/ios/Engine/RovqEngine.swift +646 -0
- package/ios/Engine/RovqError.swift +35 -0
- package/ios/Engine/SafePackageExtractor.swift +107 -0
- package/ios/Engine/UpdateInstaller.swift +348 -0
- package/ios/Engine/UpdateService.swift +185 -0
- package/ios/Engine/ZipReader.swift +378 -0
- package/ios/React/RovqHost.swift +255 -0
- package/ios/React/RovqModule.mm +91 -0
- package/package.json +78 -3
- package/react-native.config.js +11 -0
- package/src/NativeRovq.ts +72 -0
- package/src/api.ts +228 -0
- package/src/errors.ts +44 -0
- package/src/index.ts +10 -0
- package/src/react.tsx +158 -0
- package/src/update-flow.ts +91 -0
|
@@ -0,0 +1,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
|
+
}
|