@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,121 @@
|
|
|
1
|
+
package com.rovq.engine
|
|
2
|
+
|
|
3
|
+
import java.io.File
|
|
4
|
+
import java.io.FileOutputStream
|
|
5
|
+
import java.io.IOException
|
|
6
|
+
import java.util.zip.ZipEntry
|
|
7
|
+
import java.util.zip.ZipException
|
|
8
|
+
import java.util.zip.ZipFile
|
|
9
|
+
|
|
10
|
+
private const val BUFFER_SIZE = 64 * 1024
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extracts a package without trusting it, even though its signature was already
|
|
14
|
+
* verified. If a signing key ever leaks, this keeps an attacker inside the package
|
|
15
|
+
* folder, so the files of the app itself stay out of reach.
|
|
16
|
+
*/
|
|
17
|
+
internal class SafePackageExtractor {
|
|
18
|
+
|
|
19
|
+
/** @param destination an empty folder that only this extraction uses. */
|
|
20
|
+
fun extract(packageFile: File, destination: File) {
|
|
21
|
+
val root = destination.canonicalPath + File.separator
|
|
22
|
+
ZipStructureCheck.check(packageFile)
|
|
23
|
+
try {
|
|
24
|
+
// ZipFile reads names from the central directory, the same source the server
|
|
25
|
+
// checked. Local file headers could hold different names.
|
|
26
|
+
ZipFile(packageFile).use { zip ->
|
|
27
|
+
extractEntries(zip, destination, root)
|
|
28
|
+
}
|
|
29
|
+
} catch (error: ZipException) {
|
|
30
|
+
throw invalid("Not a readable ZIP file", error)
|
|
31
|
+
} catch (error: IllegalArgumentException) {
|
|
32
|
+
// Android rejects some malformed names with this exception.
|
|
33
|
+
throw invalid("The ZIP file has a malformed entry", error)
|
|
34
|
+
} catch (error: IllegalStateException) {
|
|
35
|
+
throw invalid("The ZIP file could not be read", error)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private fun extractEntries(zip: ZipFile, destination: File, root: String) {
|
|
40
|
+
val seen = HashSet<String>()
|
|
41
|
+
var hasBundle = false
|
|
42
|
+
var count = 0
|
|
43
|
+
var totalSize = 0L
|
|
44
|
+
|
|
45
|
+
val entries = zip.entries()
|
|
46
|
+
while (entries.hasMoreElements()) {
|
|
47
|
+
val entry = entries.nextElement()
|
|
48
|
+
count += 1
|
|
49
|
+
if (count > PackageRules.MAX_ENTRIES) throw invalid("Too many files")
|
|
50
|
+
if (entry.isDirectory) throw invalid("Folder entries are not allowed")
|
|
51
|
+
|
|
52
|
+
val name = entry.name
|
|
53
|
+
PackageRules.findPathProblem(name)?.let { throw invalid("The file $name $it") }
|
|
54
|
+
if (!seen.add(PackageRules.pathIdentity(name))) {
|
|
55
|
+
throw invalid("The file $name appears more than once")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
val target = File(destination, name)
|
|
59
|
+
// A second, independent check: whatever the rules allow, the real path must
|
|
60
|
+
// stay inside the folder.
|
|
61
|
+
if (!target.canonicalPath.startsWith(root)) throw invalid("The file $name leaves the folder")
|
|
62
|
+
|
|
63
|
+
val written = writeEntry(zip, entry, target, PackageRules.MAX_TOTAL_SIZE - totalSize)
|
|
64
|
+
totalSize += written
|
|
65
|
+
if (name == PackageRules.BUNDLE_FILE_NAME) {
|
|
66
|
+
if (written == 0L) throw invalid("The bundle file is empty")
|
|
67
|
+
hasBundle = true
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!hasBundle) throw invalid("The bundle file is missing")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Counts real bytes, because a ZIP file can lie about how large its contents are. */
|
|
74
|
+
private fun writeEntry(zip: ZipFile, entry: ZipEntry, target: File, allowance: Long): Long {
|
|
75
|
+
target.parentFile?.let { parent ->
|
|
76
|
+
if (!parent.isDirectory && !parent.mkdirs()) {
|
|
77
|
+
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not create ${parent.name}")
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
var written = 0L
|
|
81
|
+
val input = try {
|
|
82
|
+
zip.getInputStream(entry)
|
|
83
|
+
} catch (error: ZipException) {
|
|
84
|
+
throw invalid("Could not read ${entry.name}", error)
|
|
85
|
+
}
|
|
86
|
+
input.use { source ->
|
|
87
|
+
val output = try {
|
|
88
|
+
FileOutputStream(target)
|
|
89
|
+
} catch (error: IOException) {
|
|
90
|
+
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not create ${entry.name}", error)
|
|
91
|
+
}
|
|
92
|
+
output.use { file ->
|
|
93
|
+
val buffer = ByteArray(BUFFER_SIZE)
|
|
94
|
+
while (true) {
|
|
95
|
+
val count = try {
|
|
96
|
+
source.read(buffer)
|
|
97
|
+
} catch (error: IOException) {
|
|
98
|
+
throw invalid("Could not read ${entry.name}", error)
|
|
99
|
+
}
|
|
100
|
+
if (count < 0) break
|
|
101
|
+
written += count
|
|
102
|
+
if (written > allowance) throw invalid("The package is too large when decompressed")
|
|
103
|
+
try {
|
|
104
|
+
file.write(buffer, 0, count)
|
|
105
|
+
} catch (error: IOException) {
|
|
106
|
+
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not write ${entry.name}", error)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Forces the bytes to disk, because the release becomes active by renaming
|
|
110
|
+
// its folder, and that rename must not outrun the data.
|
|
111
|
+
file.fd.sync()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Not every ZIP reader enforces this, so a mismatch has to be checked here.
|
|
115
|
+
if (written != entry.size) throw invalid("The file ${entry.name} lies about its size")
|
|
116
|
+
return written
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private fun invalid(message: String, cause: Throwable? = null) =
|
|
120
|
+
RovqException(RovqErrorCode.INVALID_PACKAGE, message, cause)
|
|
121
|
+
}
|
|
@@ -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,347 @@
|
|
|
1
|
+
package com.rovq.engine
|
|
2
|
+
|
|
3
|
+
import java.io.File
|
|
4
|
+
import java.io.IOException
|
|
5
|
+
import java.io.InputStream
|
|
6
|
+
import java.security.MessageDigest
|
|
7
|
+
|
|
8
|
+
// Download links last an hour, so they are fetched a few files at a time, just before use.
|
|
9
|
+
private const val LINK_BATCH_SIZE = 20
|
|
10
|
+
private const val BUFFER_SIZE = 64 * 1024
|
|
11
|
+
private const val EMPTY_FILE_HASH = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
12
|
+
|
|
13
|
+
/** What the server offered. None of this is trusted until the manifest has been verified. */
|
|
14
|
+
data class OfferedUpdate(
|
|
15
|
+
val manifest: String,
|
|
16
|
+
val signature: String,
|
|
17
|
+
val keyId: String,
|
|
18
|
+
/** Only for a ZIP package. A release made of files is fetched file by file. */
|
|
19
|
+
val downloadUrl: String?,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
/** A verified release, fully extracted in its own folder. */
|
|
23
|
+
data class InstalledRelease(
|
|
24
|
+
val manifest: VerifiedManifest,
|
|
25
|
+
val folder: File,
|
|
26
|
+
val bundleFile: File,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
/** Asks the server for download links, by hash, for files of one release. */
|
|
30
|
+
fun interface FileLinks {
|
|
31
|
+
/** @throws IOException if the server cannot be reached or answers with an error. */
|
|
32
|
+
fun linksFor(releaseId: String, hashes: List<String>): Map<String, String>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The app's own copies of package files, which a release made of files can reuse. */
|
|
36
|
+
fun interface BuiltInFiles {
|
|
37
|
+
/** Opens the app's copy of a package path, or returns null when it has none. */
|
|
38
|
+
fun open(path: String): InputStream?
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Takes an offered update to a verified folder on disk, without activating it.
|
|
43
|
+
*
|
|
44
|
+
* Each release gets its own folder because the image cache keys on the path. Reusing a
|
|
45
|
+
* path would show old images after a reload. See docs/android-bundle-loading.md.
|
|
46
|
+
*
|
|
47
|
+
* @param rootDir a folder only the engine uses, normally inside the app's private storage.
|
|
48
|
+
* @param builtInFiles the app's own assets, which can be reused instead of downloaded.
|
|
49
|
+
*/
|
|
50
|
+
class UpdateInstaller(
|
|
51
|
+
rootDir: File,
|
|
52
|
+
private val verifier: ManifestVerifier,
|
|
53
|
+
private val downloader: PackageDownloader,
|
|
54
|
+
private val builtInFiles: BuiltInFiles? = null,
|
|
55
|
+
) {
|
|
56
|
+
private val updatesDir = File(rootDir, "updates")
|
|
57
|
+
private val workDir = File(rootDir, "work")
|
|
58
|
+
|
|
59
|
+
// Outside the work folder, because a partial download must survive a failure or a restart.
|
|
60
|
+
private val partialDir = File(rootDir, "partial")
|
|
61
|
+
private val extractor = SafePackageExtractor()
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param highestSequence the highest sequence this device has accepted in this stream.
|
|
65
|
+
* @param fileLinks download links for a release made of files.
|
|
66
|
+
* @throws RovqException if any step fails. Nothing is left behind in that case.
|
|
67
|
+
*/
|
|
68
|
+
@Synchronized
|
|
69
|
+
fun install(
|
|
70
|
+
offer: OfferedUpdate,
|
|
71
|
+
highestSequence: Long,
|
|
72
|
+
fileLinks: FileLinks = FileLinks { _, _ -> throw IOException("No way to fetch files") },
|
|
73
|
+
onProgress: (received: Long, total: Long) -> Unit = { _, _ -> },
|
|
74
|
+
): InstalledRelease {
|
|
75
|
+
// Verified first, so an unsigned update causes no network traffic or disk use.
|
|
76
|
+
val manifest = verifier.verify(offer.manifest, offer.signature, offer.keyId, highestSequence)
|
|
77
|
+
|
|
78
|
+
val folder = File(updatesDir, manifest.releaseId)
|
|
79
|
+
// Folders appear only through the atomic rename below, so one that exists is complete.
|
|
80
|
+
if (!folder.isDirectory) {
|
|
81
|
+
val files = manifest.files
|
|
82
|
+
if (files != null) {
|
|
83
|
+
installFileList(files, manifest.releaseId, folder, fileLinks, onProgress)
|
|
84
|
+
} else {
|
|
85
|
+
installZip(offer, manifest, folder, onProgress)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return InstalledRelease(manifest, folder, File(folder, PackageRules.BUNDLE_FILE_NAME))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Creates an empty work folder for assembling a release, and the folders around it. */
|
|
92
|
+
private fun startWork(): File {
|
|
93
|
+
// Removes anything an earlier run left when the app was killed or the phone lost power.
|
|
94
|
+
workDir.deleteRecursively()
|
|
95
|
+
val assembled = File(workDir, "extracted")
|
|
96
|
+
if (!assembled.mkdirs() ||
|
|
97
|
+
!(updatesDir.isDirectory || updatesDir.mkdirs()) ||
|
|
98
|
+
!(partialDir.isDirectory || partialDir.mkdirs())
|
|
99
|
+
) {
|
|
100
|
+
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not create the engine folders")
|
|
101
|
+
}
|
|
102
|
+
return assembled
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private fun moveIntoPlace(assembled: File, folder: File) {
|
|
106
|
+
if (!assembled.renameTo(folder)) {
|
|
107
|
+
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not move the release into place")
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private fun installZip(
|
|
112
|
+
offer: OfferedUpdate,
|
|
113
|
+
manifest: VerifiedManifest,
|
|
114
|
+
folder: File,
|
|
115
|
+
onProgress: (Long, Long) -> Unit,
|
|
116
|
+
) {
|
|
117
|
+
val downloadUrl = offer.downloadUrl
|
|
118
|
+
?: throw RovqException(RovqErrorCode.DOWNLOAD_FAILED, "The server sent no download URL")
|
|
119
|
+
val extracted = startWork()
|
|
120
|
+
val packageFile = File(partialDir, "${manifest.packageHash}.zip")
|
|
121
|
+
// Only the current offer is worth finishing.
|
|
122
|
+
partialDir.listFiles()?.filter { it != packageFile }?.forEach { it.delete() }
|
|
123
|
+
try {
|
|
124
|
+
// Extracting needs room too, and its size is unknown until then, so this asks for
|
|
125
|
+
// the package twice. A package can still expand further, and extraction reports that.
|
|
126
|
+
downloader.download(
|
|
127
|
+
downloadUrl,
|
|
128
|
+
manifest.packageHash,
|
|
129
|
+
manifest.packageSize,
|
|
130
|
+
packageFile,
|
|
131
|
+
extraSpace = manifest.packageSize,
|
|
132
|
+
onProgress = onProgress,
|
|
133
|
+
)
|
|
134
|
+
extractor.extract(packageFile, extracted)
|
|
135
|
+
moveIntoPlace(extracted, folder)
|
|
136
|
+
packageFile.delete()
|
|
137
|
+
} catch (error: RovqException) {
|
|
138
|
+
// A file that failed anything but the network is not worth continuing from.
|
|
139
|
+
if (error.code != RovqErrorCode.DOWNLOAD_FAILED) packageFile.delete()
|
|
140
|
+
throw error
|
|
141
|
+
} finally {
|
|
142
|
+
workDir.deleteRecursively()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Builds a release from its files: each comes from a kept release, from the app itself, or
|
|
148
|
+
* from the network, in that order, and only after its content matches the signed hash. See
|
|
149
|
+
* docs/file-packages.md.
|
|
150
|
+
*/
|
|
151
|
+
private fun installFileList(
|
|
152
|
+
list: List<ManifestFile>,
|
|
153
|
+
releaseId: String,
|
|
154
|
+
folder: File,
|
|
155
|
+
fileLinks: FileLinks,
|
|
156
|
+
onProgress: (Long, Long) -> Unit,
|
|
157
|
+
) {
|
|
158
|
+
val assembled = startWork()
|
|
159
|
+
try {
|
|
160
|
+
val wanted = list.map { it.hash }.toSet()
|
|
161
|
+
// Only the current offer is worth finishing.
|
|
162
|
+
partialDir.listFiles()?.filter { it.name !in wanted }?.forEach { it.delete() }
|
|
163
|
+
val root = assembled.canonicalPath + File.separator
|
|
164
|
+
|
|
165
|
+
// A hash already placed, so the same content at another path is linked, not fetched.
|
|
166
|
+
val placed = HashMap<String, File>()
|
|
167
|
+
// Files with no local copy at their own path. Another path may still provide them.
|
|
168
|
+
val unresolved = ArrayList<Pair<ManifestFile, File>>()
|
|
169
|
+
|
|
170
|
+
for (file in list) {
|
|
171
|
+
val target = File(assembled, file.path)
|
|
172
|
+
val parent = target.parentFile ?: assembled
|
|
173
|
+
if (!(parent.isDirectory || parent.mkdirs())) {
|
|
174
|
+
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not create a folder")
|
|
175
|
+
}
|
|
176
|
+
// A second, independent check: whatever the rules allow, the real path must stay
|
|
177
|
+
// inside the folder.
|
|
178
|
+
if (!(parent.canonicalPath + File.separator).startsWith(root)) {
|
|
179
|
+
throw RovqException(RovqErrorCode.INVALID_PACKAGE, "The file ${file.path} leaves the folder")
|
|
180
|
+
}
|
|
181
|
+
val source = placed[file.hash]
|
|
182
|
+
when {
|
|
183
|
+
source != null -> link(source, target)
|
|
184
|
+
file.size == 0L -> {
|
|
185
|
+
if (file.hash != EMPTY_FILE_HASH) {
|
|
186
|
+
throw RovqException(RovqErrorCode.HASH_MISMATCH, "An empty file does not match its signed hash")
|
|
187
|
+
}
|
|
188
|
+
if (!target.createNewFile()) {
|
|
189
|
+
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not create the file")
|
|
190
|
+
}
|
|
191
|
+
placed[file.hash] = target
|
|
192
|
+
}
|
|
193
|
+
placeLocalCopy(file, target, folder) -> placed[file.hash] = target
|
|
194
|
+
else -> unresolved += file to target
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Files to download, by hash, with every path that needs each one, in manifest order.
|
|
199
|
+
val targets = LinkedHashMap<String, MutableList<File>>()
|
|
200
|
+
val missing = ArrayList<ManifestFile>()
|
|
201
|
+
for ((file, target) in unresolved) {
|
|
202
|
+
val source = placed[file.hash]
|
|
203
|
+
when {
|
|
204
|
+
source != null -> link(source, target)
|
|
205
|
+
targets.containsKey(file.hash) -> targets.getValue(file.hash) += target
|
|
206
|
+
else -> {
|
|
207
|
+
targets[file.hash] = mutableListOf(target)
|
|
208
|
+
missing += file
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
download(missing, targets, releaseId, fileLinks, onProgress)
|
|
214
|
+
moveIntoPlace(assembled, folder)
|
|
215
|
+
// Linked into the release, so the content stays where it is needed.
|
|
216
|
+
missing.forEach { File(partialDir, it.hash).delete() }
|
|
217
|
+
} finally {
|
|
218
|
+
workDir.deleteRecursively()
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private fun download(
|
|
223
|
+
missing: List<ManifestFile>,
|
|
224
|
+
targets: Map<String, List<File>>,
|
|
225
|
+
releaseId: String,
|
|
226
|
+
fileLinks: FileLinks,
|
|
227
|
+
onProgress: (Long, Long) -> Unit,
|
|
228
|
+
) {
|
|
229
|
+
if (missing.isEmpty()) return
|
|
230
|
+
val total = missing.sumOf { it.size }
|
|
231
|
+
// Checked for the whole release first, so a full disk fails before any download.
|
|
232
|
+
val remaining = missing.sumOf { file ->
|
|
233
|
+
val partial = File(partialDir, file.hash)
|
|
234
|
+
file.size - (if (partial.isFile) minOf(partial.length(), file.size) else 0L)
|
|
235
|
+
}
|
|
236
|
+
if (downloader.freeSpaceIn(partialDir) < remaining) {
|
|
237
|
+
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Not enough free space for this update")
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
var finished = 0L
|
|
241
|
+
onProgress(0, total)
|
|
242
|
+
for (batch in missing.chunked(LINK_BATCH_SIZE)) {
|
|
243
|
+
val links = try {
|
|
244
|
+
fileLinks.linksFor(releaseId, batch.map { it.hash })
|
|
245
|
+
} catch (error: IOException) {
|
|
246
|
+
throw RovqException(RovqErrorCode.DOWNLOAD_FAILED, error.message ?: "Could not get download links", error)
|
|
247
|
+
}
|
|
248
|
+
for (file in batch) {
|
|
249
|
+
val url = links[file.hash]
|
|
250
|
+
?: throw RovqException(RovqErrorCode.DOWNLOAD_FAILED, "The server sent no link for a file")
|
|
251
|
+
val partial = File(partialDir, file.hash)
|
|
252
|
+
val before = finished
|
|
253
|
+
downloader.download(url, file.hash, file.size, partial) { received, _ ->
|
|
254
|
+
onProgress(before + received, total)
|
|
255
|
+
}
|
|
256
|
+
finished += file.size
|
|
257
|
+
targets[file.hash]?.forEach { link(partial, it) }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Places the same path from a kept release, or from the app itself, if its content is the
|
|
264
|
+
* signed one. Hashed every time, because reusing a damaged file would break the new release.
|
|
265
|
+
*/
|
|
266
|
+
private fun placeLocalCopy(file: ManifestFile, target: File, folder: File): Boolean {
|
|
267
|
+
val kept = updatesDir.listFiles()?.filter { it.isDirectory && it.name != folder.name }.orEmpty()
|
|
268
|
+
for (release in kept) {
|
|
269
|
+
val candidate = File(release, file.path)
|
|
270
|
+
if (candidate.isFile && candidate.length() == file.size && hashOf(candidate) == file.hash) {
|
|
271
|
+
link(candidate, target)
|
|
272
|
+
return true
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return copyBuiltIn(file, target)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Copies the app's own file while hashing it, and keeps the copy only if it matches. */
|
|
279
|
+
private fun copyBuiltIn(file: ManifestFile, target: File): Boolean {
|
|
280
|
+
val input = try {
|
|
281
|
+
builtInFiles?.open(file.path)
|
|
282
|
+
} catch (error: Exception) {
|
|
283
|
+
null
|
|
284
|
+
} ?: return false
|
|
285
|
+
val digest = MessageDigest.getInstance("SHA-256")
|
|
286
|
+
var copied = 0L
|
|
287
|
+
try {
|
|
288
|
+
input.use { source ->
|
|
289
|
+
target.outputStream().use { output ->
|
|
290
|
+
val buffer = ByteArray(BUFFER_SIZE)
|
|
291
|
+
while (true) {
|
|
292
|
+
val count = source.read(buffer)
|
|
293
|
+
if (count < 0) break
|
|
294
|
+
copied += count
|
|
295
|
+
if (copied > file.size) break
|
|
296
|
+
digest.update(buffer, 0, count)
|
|
297
|
+
output.write(buffer, 0, count)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
} catch (error: IOException) {
|
|
302
|
+
target.delete()
|
|
303
|
+
return false
|
|
304
|
+
}
|
|
305
|
+
val matches = copied == file.size && digest.digest().toHex() == file.hash
|
|
306
|
+
if (!matches) target.delete()
|
|
307
|
+
return matches
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private fun hashOf(file: File): String? = try {
|
|
311
|
+
val digest = MessageDigest.getInstance("SHA-256")
|
|
312
|
+
file.inputStream().use { input ->
|
|
313
|
+
val buffer = ByteArray(BUFFER_SIZE)
|
|
314
|
+
while (true) {
|
|
315
|
+
val count = input.read(buffer)
|
|
316
|
+
if (count < 0) break
|
|
317
|
+
digest.update(buffer, 0, count)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
digest.digest().toHex()
|
|
321
|
+
} catch (error: IOException) {
|
|
322
|
+
null
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* A hard link costs no space. Files in a release never change, so sharing them is safe.
|
|
329
|
+
* Android before 8.0 has no java.nio.file, so it copies instead.
|
|
330
|
+
*/
|
|
331
|
+
private fun link(source: File, target: File) {
|
|
332
|
+
val linked = try {
|
|
333
|
+
java.nio.file.Files.createLink(target.toPath(), source.toPath())
|
|
334
|
+
true
|
|
335
|
+
} catch (error: Exception) {
|
|
336
|
+
false
|
|
337
|
+
} catch (error: LinkageError) {
|
|
338
|
+
false
|
|
339
|
+
}
|
|
340
|
+
if (linked) return
|
|
341
|
+
try {
|
|
342
|
+
source.copyTo(target, overwrite = true)
|
|
343
|
+
} catch (error: IOException) {
|
|
344
|
+
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not place a file", error)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
* Download links for files of a release made of files, by hash. Each file is checked against
|
|
48
|
+
* the signed manifest, so the links are trusted for nothing.
|
|
49
|
+
*
|
|
50
|
+
* @throws IOException if the server cannot be reached or answers with an error.
|
|
51
|
+
*/
|
|
52
|
+
fun fileLinks(deploymentKey: String, deviceId: String, releaseId: String, hashes: List<String>): Map<String, String>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class HttpUpdateService(
|
|
56
|
+
private val apiUrl: String,
|
|
57
|
+
private val allowInsecureUrls: Boolean = false,
|
|
58
|
+
) : UpdateService {
|
|
59
|
+
|
|
60
|
+
override fun check(request: CheckRequest): CheckAnswer {
|
|
61
|
+
val body = JSONObject()
|
|
62
|
+
.put("deploymentKey", request.deploymentKey)
|
|
63
|
+
.put("deviceId", request.deviceId)
|
|
64
|
+
.put("platform", "ANDROID")
|
|
65
|
+
.put("runtimeVersion", request.runtimeVersion)
|
|
66
|
+
.put("binaryVersion", request.binaryVersion)
|
|
67
|
+
.put("highestSequence", request.highestSequence)
|
|
68
|
+
// Both a ZIP package and a list of files. See docs/file-packages.md.
|
|
69
|
+
.put("packageFormats", JSONArray().put(1).put(2))
|
|
70
|
+
request.currentReleaseId?.let { body.put("currentReleaseId", it) }
|
|
71
|
+
|
|
72
|
+
return try {
|
|
73
|
+
val response = post("/device/updates/check", body)
|
|
74
|
+
val action = when (val action = response.getString("action")) {
|
|
75
|
+
"NONE" -> CheckResponse.None
|
|
76
|
+
"ROLLBACK" -> CheckResponse.Rollback
|
|
77
|
+
"UPDATE" -> {
|
|
78
|
+
val update = response.getJSONObject("update")
|
|
79
|
+
CheckResponse.Update(
|
|
80
|
+
OfferedUpdate(
|
|
81
|
+
manifest = update.getString("manifest"),
|
|
82
|
+
signature = update.getString("signature"),
|
|
83
|
+
keyId = update.getString("keyId"),
|
|
84
|
+
// A release made of files has no single download URL.
|
|
85
|
+
downloadUrl = update.optString("downloadUrl").ifEmpty { null },
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
else -> throw IOException("Unknown action $action")
|
|
90
|
+
}
|
|
91
|
+
CheckAnswer(action, readRevocations(response))
|
|
92
|
+
} catch (error: JSONException) {
|
|
93
|
+
throw IOException("The server sent an unreadable response", error)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private fun readRevocations(response: JSONObject): List<KeyRevocation> {
|
|
98
|
+
val list = response.optJSONArray("revokedKeys") ?: return emptyList()
|
|
99
|
+
return (0 until list.length()).map { index ->
|
|
100
|
+
val item = list.getJSONObject(index)
|
|
101
|
+
KeyRevocation(item.getString("keyId"), item.getString("statement"), item.getString("signature"))
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
override fun report(deploymentKey: String, deviceId: String, events: List<QueuedEvent>) {
|
|
106
|
+
val list = JSONArray()
|
|
107
|
+
events.forEach { event ->
|
|
108
|
+
val json = JSONObject()
|
|
109
|
+
.put("clientEventId", event.clientEventId)
|
|
110
|
+
.put("type", event.type)
|
|
111
|
+
.put("occurredAt", event.occurredAt)
|
|
112
|
+
event.releaseId?.let { json.put("releaseId", it) }
|
|
113
|
+
event.errorCode?.let { json.put("errorCode", it) }
|
|
114
|
+
list.put(json)
|
|
115
|
+
}
|
|
116
|
+
post(
|
|
117
|
+
"/device/events",
|
|
118
|
+
JSONObject()
|
|
119
|
+
.put("deploymentKey", deploymentKey)
|
|
120
|
+
.put("deviceId", deviceId)
|
|
121
|
+
.put("events", list),
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
override fun fileLinks(
|
|
126
|
+
deploymentKey: String,
|
|
127
|
+
deviceId: String,
|
|
128
|
+
releaseId: String,
|
|
129
|
+
hashes: List<String>,
|
|
130
|
+
): Map<String, String> {
|
|
131
|
+
val response = post(
|
|
132
|
+
"/device/updates/files",
|
|
133
|
+
JSONObject()
|
|
134
|
+
.put("deploymentKey", deploymentKey)
|
|
135
|
+
.put("deviceId", deviceId)
|
|
136
|
+
.put("releaseId", releaseId)
|
|
137
|
+
.put("hashes", JSONArray(hashes)),
|
|
138
|
+
)
|
|
139
|
+
return try {
|
|
140
|
+
val files = response.getJSONArray("files")
|
|
141
|
+
(0 until files.length()).associate { index ->
|
|
142
|
+
val file = files.getJSONObject(index)
|
|
143
|
+
file.getString("hash") to file.getString("url")
|
|
144
|
+
}
|
|
145
|
+
} catch (error: JSONException) {
|
|
146
|
+
throw IOException("The server sent an unreadable response", error)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private fun post(path: String, body: JSONObject): JSONObject {
|
|
151
|
+
val url = URL(apiUrl.trimEnd('/') + path)
|
|
152
|
+
if (url.protocol != "https" && !allowInsecureUrls) {
|
|
153
|
+
throw IOException("The API URL must use HTTPS")
|
|
154
|
+
}
|
|
155
|
+
val connection = url.openConnection() as HttpURLConnection
|
|
156
|
+
try {
|
|
157
|
+
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
|
158
|
+
connection.readTimeout = READ_TIMEOUT_MS
|
|
159
|
+
connection.requestMethod = "POST"
|
|
160
|
+
connection.doOutput = true
|
|
161
|
+
connection.setRequestProperty("content-type", "application/json")
|
|
162
|
+
connection.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) }
|
|
163
|
+
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
|
|
164
|
+
throw IOException("The server answered ${connection.responseCode}")
|
|
165
|
+
}
|
|
166
|
+
return try {
|
|
167
|
+
JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
|
|
168
|
+
} catch (error: JSONException) {
|
|
169
|
+
throw IOException("The server sent an unreadable response", error)
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
connection.disconnect()
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|