react-native-ota-client 0.0.0-stage → 0.1.1
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 +20 -0
- package/OtaSdk.podspec +20 -0
- package/README.md +136 -2
- package/android/build.gradle +37 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/otasdk/OtaActivationPolicy.kt +74 -0
- package/android/src/main/java/com/otasdk/OtaBundleInstaller.kt +280 -0
- package/android/src/main/java/com/otasdk/OtaReleaseManifest.kt +187 -0
- package/android/src/main/java/com/otasdk/OtaSdkConfig.kt +22 -0
- package/android/src/main/java/com/otasdk/OtaSdkModule.kt +15 -0
- package/android/src/main/java/com/otasdk/OtaSdkPackage.kt +34 -0
- package/android/src/main/java/com/otasdk/OtaSdkUpdates.kt +51 -0
- package/android/src/main/java/com/otasdk/OtaUpdateManager.kt +384 -0
- package/android/src/main/java/com/otasdk/OtaUpdatesModule.kt +70 -0
- package/android/src/test/java/com/otasdk/OtaActivationPolicyTest.kt +47 -0
- package/android/src/test/java/com/otasdk/OtaReleaseManifestTest.kt +48 -0
- package/ios/OtaSdk.h +5 -0
- package/ios/OtaSdk.mm +21 -0
- package/lib/module/NativeOTAUpdates.js +5 -0
- package/lib/module/NativeOTAUpdates.js.map +1 -0
- package/lib/module/NativeOtaSdk.js +5 -0
- package/lib/module/NativeOtaSdk.js.map +1 -0
- package/lib/module/index.js +5 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/multiply.js +6 -0
- package/lib/module/multiply.js.map +1 -0
- package/lib/module/multiply.native.js +7 -0
- package/lib/module/multiply.native.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativeOTAUpdates.d.ts +22 -0
- package/lib/typescript/src/NativeOTAUpdates.d.ts.map +1 -0
- package/lib/typescript/src/NativeOtaSdk.d.ts +7 -0
- package/lib/typescript/src/NativeOtaSdk.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +4 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/multiply.d.ts +2 -0
- package/lib/typescript/src/multiply.d.ts.map +1 -0
- package/lib/typescript/src/multiply.native.d.ts +2 -0
- package/lib/typescript/src/multiply.native.d.ts.map +1 -0
- package/package.json +181 -4
- package/src/NativeOTAUpdates.ts +24 -0
- package/src/NativeOtaSdk.ts +7 -0
- package/src/index.tsx +6 -0
- package/src/multiply.native.tsx +5 -0
- package/src/multiply.tsx +3 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
package com.otasdk
|
|
2
|
+
|
|
3
|
+
import android.util.Base64
|
|
4
|
+
import org.json.JSONObject
|
|
5
|
+
import java.nio.charset.StandardCharsets
|
|
6
|
+
import java.security.KeyFactory
|
|
7
|
+
import java.security.PublicKey
|
|
8
|
+
import java.security.Signature
|
|
9
|
+
import java.security.spec.X509EncodedKeySpec
|
|
10
|
+
import java.util.UUID
|
|
11
|
+
import java.util.regex.Pattern
|
|
12
|
+
|
|
13
|
+
internal data class OtaReleaseManifest(
|
|
14
|
+
val releaseId: String,
|
|
15
|
+
val platform: String,
|
|
16
|
+
val appId: String,
|
|
17
|
+
val runtimeVersion: String,
|
|
18
|
+
val version: Int,
|
|
19
|
+
val artifactSha256: String,
|
|
20
|
+
val artifactSizeBytes: Long,
|
|
21
|
+
val createdAt: String,
|
|
22
|
+
val manifestVersion: Int = 1,
|
|
23
|
+
) {
|
|
24
|
+
/** Must match the Python publisher's sorted-key, compact UTF-8 JSON encoding exactly. */
|
|
25
|
+
fun canonicalBytes(): ByteArray {
|
|
26
|
+
val json =
|
|
27
|
+
"{" +
|
|
28
|
+
"\"app_id\":${quote(appId)}," +
|
|
29
|
+
"\"artifact_sha256\":${quote(artifactSha256)}," +
|
|
30
|
+
"\"artifact_size_bytes\":$artifactSizeBytes," +
|
|
31
|
+
"\"created_at\":${quote(createdAt)}," +
|
|
32
|
+
"\"manifest_version\":$manifestVersion," +
|
|
33
|
+
"\"platform\":${quote(platform)}," +
|
|
34
|
+
"\"release_id\":${quote(releaseId)}," +
|
|
35
|
+
"\"runtime_version\":${quote(runtimeVersion)}," +
|
|
36
|
+
"\"version\":$version" +
|
|
37
|
+
"}"
|
|
38
|
+
return json.toByteArray(StandardCharsets.UTF_8)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
companion object {
|
|
42
|
+
private val appIdPattern = Pattern.compile("^[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)+$")
|
|
43
|
+
private val runtimePattern = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
44
|
+
private val sha256Pattern = Pattern.compile("^[a-f0-9]{64}$")
|
|
45
|
+
private const val MAX_ARTIFACT_BYTES = 100L * 1024L * 1024L
|
|
46
|
+
|
|
47
|
+
fun parse(json: JSONObject): OtaReleaseManifest {
|
|
48
|
+
val manifest =
|
|
49
|
+
OtaReleaseManifest(
|
|
50
|
+
manifestVersion = json.getInt("manifest_version"),
|
|
51
|
+
releaseId = json.getString("release_id"),
|
|
52
|
+
platform = json.getString("platform"),
|
|
53
|
+
appId = json.getString("app_id"),
|
|
54
|
+
runtimeVersion = json.getString("runtime_version"),
|
|
55
|
+
version = json.getInt("version"),
|
|
56
|
+
artifactSha256 = json.getString("artifact_sha256"),
|
|
57
|
+
artifactSizeBytes = json.getLong("artifact_size_bytes"),
|
|
58
|
+
createdAt = json.getString("created_at"),
|
|
59
|
+
)
|
|
60
|
+
manifest.validate()
|
|
61
|
+
return manifest
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private fun quote(value: String): String {
|
|
65
|
+
val output = StringBuilder(value.length + 2).append('"')
|
|
66
|
+
value.forEach { character ->
|
|
67
|
+
when (character) {
|
|
68
|
+
'\\' -> output.append("\\\\")
|
|
69
|
+
'"' -> output.append("\\\"")
|
|
70
|
+
'\b' -> output.append("\\b")
|
|
71
|
+
'\u000C' -> output.append("\\f")
|
|
72
|
+
'\n' -> output.append("\\n")
|
|
73
|
+
'\r' -> output.append("\\r")
|
|
74
|
+
'\t' -> output.append("\\t")
|
|
75
|
+
else -> {
|
|
76
|
+
if (character.code < 0x20) {
|
|
77
|
+
output.append("\\u%04x".format(character.code))
|
|
78
|
+
} else {
|
|
79
|
+
output.append(character)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return output.append('"').toString()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
fun verify(signatureBase64: String, publicKeyPem: ByteArray) {
|
|
89
|
+
val pem = String(publicKeyPem, StandardCharsets.US_ASCII)
|
|
90
|
+
val encodedKey =
|
|
91
|
+
pem.lineSequence()
|
|
92
|
+
.filterNot { it.startsWith("-----") }
|
|
93
|
+
.joinToString(separator = "")
|
|
94
|
+
val keyBytes = Base64.decode(encodedKey, Base64.DEFAULT)
|
|
95
|
+
val publicKey = KeyFactory.getInstance("EC").generatePublic(X509EncodedKeySpec(keyBytes))
|
|
96
|
+
verifySignature(Base64.decode(signatureBase64, Base64.DEFAULT), publicKey)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
fun verifySignature(signatureBytes: ByteArray, publicKey: PublicKey) {
|
|
100
|
+
val verifier = Signature.getInstance("SHA256withECDSA")
|
|
101
|
+
verifier.initVerify(publicKey)
|
|
102
|
+
verifier.update(canonicalBytes())
|
|
103
|
+
if (!verifier.verify(signatureBytes)) {
|
|
104
|
+
throw OtaVerificationException("Release manifest signature is invalid")
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private fun validate() {
|
|
109
|
+
if (manifestVersion != 1) throw OtaVerificationException("Unsupported release manifest version")
|
|
110
|
+
try {
|
|
111
|
+
UUID.fromString(releaseId)
|
|
112
|
+
} catch (exception: IllegalArgumentException) {
|
|
113
|
+
throw OtaVerificationException("Release ID is invalid")
|
|
114
|
+
}
|
|
115
|
+
if (platform != "android") throw OtaVerificationException("Release platform is not Android")
|
|
116
|
+
if (!appIdPattern.matcher(appId).matches()) throw OtaVerificationException("App ID is invalid")
|
|
117
|
+
if (!runtimePattern.matcher(runtimeVersion).matches()) {
|
|
118
|
+
throw OtaVerificationException("Runtime version is invalid")
|
|
119
|
+
}
|
|
120
|
+
if (version <= 0) throw OtaVerificationException("Release version must be positive")
|
|
121
|
+
if (!sha256Pattern.matcher(artifactSha256).matches()) {
|
|
122
|
+
throw OtaVerificationException("Artifact hash is invalid")
|
|
123
|
+
}
|
|
124
|
+
if (artifactSizeBytes !in 1..MAX_ARTIFACT_BYTES) {
|
|
125
|
+
throw OtaVerificationException("Artifact size is outside the allowed range")
|
|
126
|
+
}
|
|
127
|
+
if (createdAt.isBlank() || createdAt.length > 64) {
|
|
128
|
+
throw OtaVerificationException("Release creation time is invalid")
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
internal data class VerifiedOtaRelease(
|
|
134
|
+
val manifest: OtaReleaseManifest,
|
|
135
|
+
val signature: String,
|
|
136
|
+
val channel: String,
|
|
137
|
+
val rolloutPercentage: Int,
|
|
138
|
+
val action: String,
|
|
139
|
+
val downloadUrl: String,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
internal class OtaVerificationException(message: String, cause: Throwable? = null) :
|
|
143
|
+
Exception(message, cause)
|
|
144
|
+
|
|
145
|
+
internal object OtaReleaseVerifier {
|
|
146
|
+
fun verify(
|
|
147
|
+
releaseJson: JSONObject,
|
|
148
|
+
publicKeyPem: ByteArray,
|
|
149
|
+
expectedAppId: String,
|
|
150
|
+
expectedRuntimeVersion: String,
|
|
151
|
+
expectedChannel: String,
|
|
152
|
+
currentVersion: Int,
|
|
153
|
+
): VerifiedOtaRelease {
|
|
154
|
+
try {
|
|
155
|
+
val manifest = OtaReleaseManifest.parse(releaseJson.getJSONObject("manifest"))
|
|
156
|
+
val signature = releaseJson.getString("signature")
|
|
157
|
+
val channel = releaseJson.getString("channel")
|
|
158
|
+
val rollout = releaseJson.getInt("rollout_percentage")
|
|
159
|
+
val action = releaseJson.getString("action")
|
|
160
|
+
val downloadUrl = releaseJson.getString("download_url")
|
|
161
|
+
|
|
162
|
+
if (manifest.appId != expectedAppId) throw OtaVerificationException("App ID does not match this APK")
|
|
163
|
+
if (manifest.runtimeVersion != expectedRuntimeVersion) {
|
|
164
|
+
throw OtaVerificationException("Release targets a different native runtime")
|
|
165
|
+
}
|
|
166
|
+
if (channel != expectedChannel) throw OtaVerificationException("Release channel does not match")
|
|
167
|
+
if (rollout !in 0..100) throw OtaVerificationException("Rollout percentage is invalid")
|
|
168
|
+
when (action) {
|
|
169
|
+
"update" -> if (manifest.version <= currentVersion) {
|
|
170
|
+
throw OtaVerificationException("Update version is not newer than the installed version")
|
|
171
|
+
}
|
|
172
|
+
"rollback" -> if (manifest.version >= currentVersion) {
|
|
173
|
+
throw OtaVerificationException("Rollback version is not older than the installed version")
|
|
174
|
+
}
|
|
175
|
+
else -> throw OtaVerificationException("Update action is invalid")
|
|
176
|
+
}
|
|
177
|
+
if (downloadUrl.isBlank()) throw OtaVerificationException("Bundle URL is missing")
|
|
178
|
+
|
|
179
|
+
manifest.verify(signature, publicKeyPem)
|
|
180
|
+
return VerifiedOtaRelease(manifest, signature, channel, rollout, action, downloadUrl)
|
|
181
|
+
} catch (exception: OtaVerificationException) {
|
|
182
|
+
throw exception
|
|
183
|
+
} catch (exception: Exception) {
|
|
184
|
+
throw OtaVerificationException("Release response is malformed", exception)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
package com.otasdk
|
|
2
|
+
|
|
3
|
+
/** Values supplied by the host app for its selected flavor and build type. */
|
|
4
|
+
data class OtaSdkConfig(
|
|
5
|
+
val serverUrl: String,
|
|
6
|
+
val runtimeVersion: String,
|
|
7
|
+
val channel: String,
|
|
8
|
+
val baseVersion: Int,
|
|
9
|
+
val publicKeyPem: ByteArray,
|
|
10
|
+
val allowInsecureHttp: Boolean,
|
|
11
|
+
val isDebugBuild: Boolean,
|
|
12
|
+
val appId: String,
|
|
13
|
+
) {
|
|
14
|
+
init {
|
|
15
|
+
require(serverUrl.isNotBlank()) { "OTA server URL must not be blank" }
|
|
16
|
+
require(runtimeVersion.isNotBlank()) { "OTA runtime version must not be blank" }
|
|
17
|
+
require(channel.isNotBlank()) { "OTA channel must not be blank" }
|
|
18
|
+
require(baseVersion >= 0) { "OTA base version must not be negative" }
|
|
19
|
+
require(publicKeyPem.isNotEmpty()) { "OTA public verification key must not be empty" }
|
|
20
|
+
require(appId.isNotBlank()) { "OTA app ID must not be blank" }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
package com.otasdk
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
4
|
+
|
|
5
|
+
class OtaSdkModule(reactContext: ReactApplicationContext) :
|
|
6
|
+
NativeOtaSdkSpec(reactContext) {
|
|
7
|
+
|
|
8
|
+
override fun multiply(a: Double, b: Double): Double {
|
|
9
|
+
return a * b
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
companion object {
|
|
13
|
+
const val NAME = NativeOtaSdkSpec.NAME
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
package com.otasdk
|
|
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
|
+
class OtaSdkPackage : BaseReactPackage() {
|
|
10
|
+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
|
|
11
|
+
return when (name) {
|
|
12
|
+
OtaSdkModule.NAME -> OtaSdkModule(reactContext)
|
|
13
|
+
OtaUpdatesModule.NAME -> OtaUpdatesModule(reactContext)
|
|
14
|
+
else -> null
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
|
|
19
|
+
mapOf(
|
|
20
|
+
OtaSdkModule.NAME to moduleInfo(OtaSdkModule.NAME, OtaSdkModule::class.java.name),
|
|
21
|
+
OtaUpdatesModule.NAME to moduleInfo(OtaUpdatesModule.NAME, OtaUpdatesModule::class.java.name),
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private fun moduleInfo(name: String, className: String) =
|
|
26
|
+
ReactModuleInfo(
|
|
27
|
+
name = name,
|
|
28
|
+
className = className,
|
|
29
|
+
canOverrideExistingModule = false,
|
|
30
|
+
needsEagerInit = false,
|
|
31
|
+
isCxxModule = false,
|
|
32
|
+
isTurboModule = true,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
package com.otasdk
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.util.Log
|
|
6
|
+
|
|
7
|
+
/** Public host-app entry point for configuring and controlling OTA updates. */
|
|
8
|
+
object OtaSdkUpdates {
|
|
9
|
+
@Volatile private var manager: OtaUpdateManager? = null
|
|
10
|
+
|
|
11
|
+
/** Configure the SDK before starting React Native or calling any update methods. */
|
|
12
|
+
@JvmStatic
|
|
13
|
+
@Synchronized
|
|
14
|
+
fun initialize(context: Context, config: OtaSdkConfig) {
|
|
15
|
+
val appContext = context.applicationContext
|
|
16
|
+
if (manager == null) {
|
|
17
|
+
manager = OtaUpdateManager(appContext, config)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Log.i(
|
|
21
|
+
TAG,
|
|
22
|
+
"Initialized appId=${config.appId}, runtime=${config.runtimeVersion}, " +
|
|
23
|
+
"channel=${config.channel}, baseVersion=${config.baseVersion}, " +
|
|
24
|
+
"server=${config.serverUrl}, allowInsecureHttp=${config.allowInsecureHttp}, " +
|
|
25
|
+
"isDebugBuild=${config.isDebugBuild}",
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@JvmStatic
|
|
30
|
+
fun beginProcess() = configuredManager().beginProcess()
|
|
31
|
+
|
|
32
|
+
@JvmStatic
|
|
33
|
+
fun bundlePathForReactHost(): String? = configuredManager().bundlePathForReactHost()
|
|
34
|
+
|
|
35
|
+
@JvmStatic
|
|
36
|
+
fun status(): Map<String, Any?> = configuredManager().status()
|
|
37
|
+
|
|
38
|
+
@JvmStatic
|
|
39
|
+
fun checkForUpdate(): Map<String, Any?> = configuredManager().checkForUpdate()
|
|
40
|
+
|
|
41
|
+
@JvmStatic
|
|
42
|
+
fun markHealthy(): Map<String, Any?> = configuredManager().markHealthy()
|
|
43
|
+
|
|
44
|
+
@JvmStatic
|
|
45
|
+
fun closeAppForUpdate(activity: Activity) = configuredManager().closeAppForUpdate(activity)
|
|
46
|
+
|
|
47
|
+
private fun configuredManager(): OtaUpdateManager =
|
|
48
|
+
checkNotNull(manager) { "Call OtaSdkUpdates.initialize() before using OTA updates" }
|
|
49
|
+
|
|
50
|
+
private const val TAG = "OtaSdkUpdates"
|
|
51
|
+
}
|