react-native-ota-client 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.
Files changed (46) hide show
  1. package/LICENSE +20 -0
  2. package/OtaSdk.podspec +20 -0
  3. package/README.md +99 -2
  4. package/android/build.gradle +37 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/com/otasdk/OtaActivationPolicy.kt +74 -0
  7. package/android/src/main/java/com/otasdk/OtaBundleInstaller.kt +280 -0
  8. package/android/src/main/java/com/otasdk/OtaReleaseManifest.kt +187 -0
  9. package/android/src/main/java/com/otasdk/OtaSdkConfig.kt +22 -0
  10. package/android/src/main/java/com/otasdk/OtaSdkModule.kt +15 -0
  11. package/android/src/main/java/com/otasdk/OtaSdkPackage.kt +34 -0
  12. package/android/src/main/java/com/otasdk/OtaSdkUpdates.kt +51 -0
  13. package/android/src/main/java/com/otasdk/OtaUpdateManager.kt +384 -0
  14. package/android/src/main/java/com/otasdk/OtaUpdatesModule.kt +70 -0
  15. package/android/src/test/java/com/otasdk/OtaActivationPolicyTest.kt +47 -0
  16. package/android/src/test/java/com/otasdk/OtaReleaseManifestTest.kt +48 -0
  17. package/ios/OtaSdk.h +5 -0
  18. package/ios/OtaSdk.mm +21 -0
  19. package/lib/module/NativeOTAUpdates.js +5 -0
  20. package/lib/module/NativeOTAUpdates.js.map +1 -0
  21. package/lib/module/NativeOtaSdk.js +5 -0
  22. package/lib/module/NativeOtaSdk.js.map +1 -0
  23. package/lib/module/index.js +5 -0
  24. package/lib/module/index.js.map +1 -0
  25. package/lib/module/multiply.js +6 -0
  26. package/lib/module/multiply.js.map +1 -0
  27. package/lib/module/multiply.native.js +7 -0
  28. package/lib/module/multiply.native.js.map +1 -0
  29. package/lib/module/package.json +1 -0
  30. package/lib/typescript/package.json +1 -0
  31. package/lib/typescript/src/NativeOTAUpdates.d.ts +22 -0
  32. package/lib/typescript/src/NativeOTAUpdates.d.ts.map +1 -0
  33. package/lib/typescript/src/NativeOtaSdk.d.ts +7 -0
  34. package/lib/typescript/src/NativeOtaSdk.d.ts.map +1 -0
  35. package/lib/typescript/src/index.d.ts +4 -0
  36. package/lib/typescript/src/index.d.ts.map +1 -0
  37. package/lib/typescript/src/multiply.d.ts +2 -0
  38. package/lib/typescript/src/multiply.d.ts.map +1 -0
  39. package/lib/typescript/src/multiply.native.d.ts +2 -0
  40. package/lib/typescript/src/multiply.native.d.ts.map +1 -0
  41. package/package.json +181 -4
  42. package/src/NativeOTAUpdates.ts +24 -0
  43. package/src/NativeOtaSdk.ts +7 -0
  44. package/src/index.tsx +6 -0
  45. package/src/multiply.native.tsx +5 -0
  46. 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
+ }