@rovq/react-native 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +76 -2
- package/Rovq.podspec +19 -0
- package/android/build.gradle +86 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/com/rovq/engine/EngineState.kt +176 -0
- package/android/src/main/java/com/rovq/engine/ManifestVerifier.kt +173 -0
- package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +188 -0
- package/android/src/main/java/com/rovq/engine/PackageRules.kt +39 -0
- package/android/src/main/java/com/rovq/engine/ProgressThrottle.kt +17 -0
- package/android/src/main/java/com/rovq/engine/RovqEngine.kt +543 -0
- package/android/src/main/java/com/rovq/engine/RovqException.kt +31 -0
- package/android/src/main/java/com/rovq/engine/SafePackageExtractor.kt +121 -0
- package/android/src/main/java/com/rovq/engine/StrictBase64.kt +37 -0
- package/android/src/main/java/com/rovq/engine/UpdateInstaller.kt +94 -0
- package/android/src/main/java/com/rovq/engine/UpdateService.kt +139 -0
- package/android/src/main/java/com/rovq/engine/ZipStructureCheck.kt +194 -0
- package/android/src/react/java/com/rovq/engine/react/Rovq.kt +101 -0
- package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +164 -0
- package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +26 -0
- package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +74 -0
- package/ios/Engine/EngineState.swift +119 -0
- package/ios/Engine/ManifestVerifier.swift +197 -0
- package/ios/Engine/PackageDownloader.swift +231 -0
- package/ios/Engine/PackageRules.swift +38 -0
- package/ios/Engine/ProgressThrottle.swift +20 -0
- package/ios/Engine/RovqEngine.swift +633 -0
- package/ios/Engine/RovqError.swift +35 -0
- package/ios/Engine/SafePackageExtractor.swift +107 -0
- package/ios/Engine/UpdateInstaller.swift +126 -0
- package/ios/Engine/UpdateService.swift +158 -0
- package/ios/Engine/ZipReader.swift +378 -0
- package/ios/React/RovqHost.swift +253 -0
- package/ios/React/RovqModule.mm +89 -0
- package/package.json +74 -3
- package/react-native.config.js +11 -0
- package/src/NativeRovq.ts +72 -0
- package/src/errors.ts +44 -0
- package/src/index.ts +228 -0
|
@@ -0,0 +1,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
|
+
}
|