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,384 @@
1
+ package com.otasdk
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+ import android.net.Uri
6
+ import android.util.Log
7
+ import java.io.File
8
+ import java.io.FileOutputStream
9
+ import java.net.HttpURLConnection
10
+ import java.net.URL
11
+ import java.security.MessageDigest
12
+ import java.util.UUID
13
+ import org.json.JSONObject
14
+
15
+ internal class OtaUpdateManager(context: Context, private val config: OtaSdkConfig) {
16
+ private val appContext = context.applicationContext
17
+ private val preferences: SharedPreferences =
18
+ appContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
19
+ private val stateLock = Any()
20
+ private val otaRoot = File(appContext.filesDir, "ota")
21
+ private val updatesDirectory = File(otaRoot, "updates")
22
+ private val stagingDirectory = File(otaRoot, "staging")
23
+ private val publicKeyPem: ByteArray = config.publicKeyPem.copyOf()
24
+
25
+ @Volatile private var startupBundlePath: String? = null
26
+
27
+ /** Called from Application.onCreate before ReactHost is initialized. */
28
+ fun beginProcess() {
29
+ if (config.isDebugBuild) {
30
+ startupBundlePath = null
31
+ return
32
+ }
33
+
34
+ synchronized(stateLock) {
35
+ val savedRuntime = preferences.getString(KEY_RUNTIME_VERSION, null)
36
+ if (savedRuntime != null && savedRuntime != config.runtimeVersion) {
37
+ // OTA bundles are native-runtime specific. Never boot a bundle from another APK runtime.
38
+ preferences.edit().clear().putString(KEY_RUNTIME_VERSION, config.runtimeVersion).commit()
39
+ updatesDirectory.deleteRecursively()
40
+ stagingDirectory.deleteRecursively()
41
+ } else if (savedRuntime == null) {
42
+ preferences.edit().putString(KEY_RUNTIME_VERSION, config.runtimeVersion).commit()
43
+ }
44
+
45
+ val before = readActivationState()
46
+ val transition = OtaActivationPolicy.beginStartup(before)
47
+ persistActivationState(transition.state)
48
+ transition.failedReleaseId?.let { File(updatesDirectory, it).deleteRecursively() }
49
+
50
+ val selected = transition.selectedReleaseId?.let(::bundleFile)
51
+ if (selected != null && selected.isFile) {
52
+ startupBundlePath = selected.absolutePath
53
+ } else if (transition.selectedReleaseId != null) {
54
+ // A missing active bundle must never prevent the APK's embedded bundle from starting.
55
+ val currentState = readActivationState()
56
+ val fallback =
57
+ if (currentState.pendingReleaseId != null) {
58
+ OtaActivationPolicy.beginStartup(
59
+ currentState.copy(pendingLaunchAttempted = true)
60
+ )
61
+ .state
62
+ } else {
63
+ currentState.copy(activeReleaseId = null, activeVersion = config.baseVersion)
64
+ }
65
+ persistActivationState(
66
+ fallback.copy(
67
+ pendingReleaseId = null,
68
+ pendingVersion = null,
69
+ previousReleaseId = null,
70
+ previousVersion = null,
71
+ pendingLaunchAttempted = false,
72
+ )
73
+ )
74
+ startupBundlePath = null
75
+ } else {
76
+ startupBundlePath = null
77
+ }
78
+ }
79
+ }
80
+
81
+ fun bundlePathForReactHost(): String? = startupBundlePath
82
+
83
+ fun status(): Map<String, Any?> = synchronized(stateLock) {
84
+ val state = readActivationState()
85
+ val pending = state.pendingReleaseId != null
86
+ mapOf(
87
+ "enabled" to !config.isDebugBuild,
88
+ "status" to if (config.isDebugBuild) "debug_build" else if (pending) "restart_required" else "ready",
89
+ "message" to if (config.isDebugBuild) {
90
+ "OTA activation is tested with a release APK; debug builds load Metro."
91
+ } else if (pending) {
92
+ "A verified update is ready. Close and reopen the app to apply it."
93
+ } else {
94
+ "This app is using its active bundle."
95
+ },
96
+ "runtimeVersion" to config.runtimeVersion,
97
+ "channel" to config.channel,
98
+ "currentVersion" to state.activeVersion,
99
+ "pendingVersion" to state.pendingVersion,
100
+ "pendingRestart" to pending,
101
+ "updateAvailable" to pending,
102
+ "action" to null,
103
+ )
104
+ }
105
+
106
+ fun markHealthy(): Map<String, Any?> = synchronized(stateLock) {
107
+ if (config.isDebugBuild) return status()
108
+ val before = readActivationState()
109
+ val after = OtaActivationPolicy.markHealthy(before)
110
+ if (after != before) {
111
+ persistActivationState(after)
112
+ Log.i(TAG, "OTA release ${after.activeReleaseId} confirmed healthy")
113
+ }
114
+ status()
115
+ }
116
+
117
+ fun checkForUpdate(): Map<String, Any?> {
118
+ if (config.isDebugBuild) return status()
119
+ if (!supportsConfiguredTransport()) {
120
+ return result(
121
+ "error",
122
+ "HTTPS is required unless the host app explicitly allows local development HTTP.",
123
+ )
124
+ }
125
+
126
+ val currentState = synchronized(stateLock) { readActivationState() }
127
+ if (currentState.pendingReleaseId != null) return status()
128
+
129
+ return try {
130
+ val response = requestUpdateCheck(currentState.activeVersion)
131
+ if (!response.has("update_available")) {
132
+ throw OtaVerificationException("Update server response is missing update_available")
133
+ }
134
+ if (!response.getBoolean("update_available")) {
135
+ return result("up_to_date", "No compatible update is available.")
136
+ }
137
+
138
+ val releaseJson = response.getJSONObject("release")
139
+ val verified =
140
+ OtaReleaseVerifier.verify(
141
+ releaseJson = releaseJson,
142
+ publicKeyPem = publicKeyPem,
143
+ expectedAppId = config.appId,
144
+ expectedRuntimeVersion = config.runtimeVersion,
145
+ expectedChannel = config.channel,
146
+ currentVersion = currentState.activeVersion,
147
+ )
148
+ validateDownloadUrl(verified.downloadUrl)
149
+
150
+ val existingDirectory = OtaBundleInstaller.releaseDirectory(updatesDirectory, verified.manifest.releaseId)
151
+ if (!OtaBundleInstaller.verifyExisting(existingDirectory, verified)) {
152
+ val archive = downloadArchive(verified)
153
+ try {
154
+ OtaBundleInstaller.install(archive, updatesDirectory, verified)
155
+ } finally {
156
+ archive.delete()
157
+ }
158
+ }
159
+
160
+ synchronized(stateLock) {
161
+ val latestState = readActivationState()
162
+ if (latestState.pendingReleaseId != null) return status()
163
+ val staged = OtaActivationPolicy.stage(
164
+ latestState,
165
+ verified.manifest.releaseId,
166
+ verified.manifest.version,
167
+ )
168
+ persistActivationState(staged)
169
+ }
170
+ result(
171
+ "restart_required",
172
+ "Verified release ${verified.manifest.version} is ready. Close and reopen the app to apply it.",
173
+ pendingVersion = verified.manifest.version,
174
+ action = verified.action,
175
+ )
176
+ } catch (exception: Exception) {
177
+ Log.w(TAG, "OTA update check failed", exception)
178
+ result("error", exception.message ?: "The update could not be verified or downloaded.")
179
+ }
180
+ }
181
+
182
+ fun closeAppForUpdate(currentActivity: android.app.Activity) {
183
+ android.os.Handler(android.os.Looper.getMainLooper()).post {
184
+ currentActivity.finishAndRemoveTask()
185
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(
186
+ { android.os.Process.killProcess(android.os.Process.myPid()) },
187
+ 150,
188
+ )
189
+ }
190
+ }
191
+
192
+ private fun requestUpdateCheck(currentVersion: Int): JSONObject {
193
+ val installationId =
194
+ preferences.getString(KEY_INSTALLATION_ID, null) ?: UUID.randomUUID().toString().also {
195
+ check(preferences.edit().putString(KEY_INSTALLATION_ID, it).commit()) {
196
+ "Could not persist installation ID"
197
+ }
198
+ }
199
+ val requestBody =
200
+ JSONObject()
201
+ .put("platform", "android")
202
+ .put("app_id", config.appId)
203
+ .put("runtime_version", config.runtimeVersion)
204
+ .put("channel", config.channel)
205
+ .put("current_version", currentVersion)
206
+ .put("installation_id", installationId)
207
+ .put("failed_release_id", readActivationState().failedReleaseId ?: JSONObject.NULL)
208
+
209
+ val url = URL("${config.serverUrl.trimEnd('/')}/api/v1/updates/check")
210
+ val connection = openConnection(url).apply {
211
+ requestMethod = "POST"
212
+ doOutput = true
213
+ setRequestProperty("Content-Type", "application/json; charset=utf-8")
214
+ setRequestProperty("Accept", "application/json")
215
+ }
216
+ try {
217
+ connection.outputStream.use { it.write(requestBody.toString().toByteArray(Charsets.UTF_8)) }
218
+ val code = connection.responseCode
219
+ val stream = if (code in 200..299) connection.inputStream else connection.errorStream
220
+ val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
221
+ if (code !in 200..299) throw OtaVerificationException("Update server returned HTTP $code")
222
+ return JSONObject(body)
223
+ } finally {
224
+ connection.disconnect()
225
+ }
226
+ }
227
+
228
+ private fun downloadArchive(release: VerifiedOtaRelease): File {
229
+ validateDownloadUrl(release.downloadUrl)
230
+ stagingDirectory.mkdirs()
231
+ val archive = File(stagingDirectory, "${release.manifest.releaseId}.zip.part")
232
+ archive.delete()
233
+ val connection = openConnection(URL(release.downloadUrl)).apply {
234
+ requestMethod = "GET"
235
+ setRequestProperty("Accept", "application/zip")
236
+ }
237
+ try {
238
+ val code = connection.responseCode
239
+ if (code != HttpURLConnection.HTTP_OK) {
240
+ throw OtaVerificationException("Bundle server returned HTTP $code")
241
+ }
242
+ val contentLength = connection.contentLengthLong
243
+ if (contentLength > MAX_ARTIFACT_BYTES ||
244
+ (contentLength >= 0L && contentLength != release.manifest.artifactSizeBytes)) {
245
+ throw OtaVerificationException("Bundle download size does not match the signed manifest")
246
+ }
247
+
248
+ val digest = MessageDigest.getInstance("SHA-256")
249
+ var total = 0L
250
+ connection.inputStream.use { input ->
251
+ FileOutputStream(archive).use { output ->
252
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
253
+ while (true) {
254
+ val count = input.read(buffer)
255
+ if (count < 0) break
256
+ total += count
257
+ if (total > MAX_ARTIFACT_BYTES || total > release.manifest.artifactSizeBytes) {
258
+ throw OtaVerificationException("Bundle exceeds the permitted download size")
259
+ }
260
+ digest.update(buffer, 0, count)
261
+ output.write(buffer, 0, count)
262
+ }
263
+ output.fd.sync()
264
+ }
265
+ }
266
+ if (total != release.manifest.artifactSizeBytes ||
267
+ digest.digest().toHex() != release.manifest.artifactSha256) {
268
+ throw OtaVerificationException("Downloaded bundle hash or size does not match the signed manifest")
269
+ }
270
+ return archive
271
+ } catch (exception: Exception) {
272
+ archive.delete()
273
+ throw exception
274
+ } finally {
275
+ connection.disconnect()
276
+ }
277
+ }
278
+
279
+ private fun openConnection(url: URL): HttpURLConnection {
280
+ return (url.openConnection() as HttpURLConnection).apply {
281
+ connectTimeout = CONNECT_TIMEOUT_MILLIS
282
+ readTimeout = READ_TIMEOUT_MILLIS
283
+ instanceFollowRedirects = false
284
+ useCaches = false
285
+ }
286
+ }
287
+
288
+ private fun validateDownloadUrl(downloadUrl: String) {
289
+ val base = Uri.parse(config.serverUrl)
290
+ val download = Uri.parse(downloadUrl)
291
+ val basePort = effectivePort(base)
292
+ if (download.userInfo != null || download.host != base.host || effectivePort(download) != basePort) {
293
+ throw OtaVerificationException("Bundle URL must use the configured OTA server origin")
294
+ }
295
+ if (download.scheme != base.scheme) {
296
+ throw OtaVerificationException("Bundle URL scheme does not match the configured OTA server")
297
+ }
298
+ if (download.scheme != "https" && !isLocalDevelopmentHttpAllowed(download)) {
299
+ throw OtaVerificationException("Release APKs require HTTPS for OTA downloads")
300
+ }
301
+ }
302
+
303
+ private fun supportsConfiguredTransport(): Boolean =
304
+ Uri.parse(config.serverUrl).scheme == "https" ||
305
+ isLocalDevelopmentHttpAllowed(Uri.parse(config.serverUrl))
306
+
307
+ private fun isLocalDevelopmentHttpAllowed(uri: Uri): Boolean =
308
+ config.allowInsecureHttp &&
309
+ uri.scheme == "http" &&
310
+ uri.host?.lowercase() in setOf("localhost", "127.0.0.1", "::1", "10.0.2.2")
311
+
312
+ private fun effectivePort(uri: Uri): Int =
313
+ if (uri.port != -1) uri.port else if (uri.scheme == "https") 443 else 80
314
+
315
+ private fun readActivationState(): OtaActivationState =
316
+ OtaActivationState(
317
+ activeReleaseId = preferences.getString(KEY_ACTIVE_RELEASE_ID, null),
318
+ activeVersion = preferences.getInt(KEY_ACTIVE_VERSION, config.baseVersion),
319
+ pendingReleaseId = preferences.getString(KEY_PENDING_RELEASE_ID, null),
320
+ pendingVersion = preferences.getInt(KEY_PENDING_VERSION, -1).takeIf { it >= 0 },
321
+ previousReleaseId = preferences.getString(KEY_PREVIOUS_RELEASE_ID, null),
322
+ previousVersion = preferences.getInt(KEY_PREVIOUS_VERSION, -1).takeIf { it >= 0 },
323
+ pendingLaunchAttempted = preferences.getBoolean(KEY_PENDING_LAUNCH_ATTEMPTED, false),
324
+ failedReleaseId = preferences.getString(KEY_FAILED_RELEASE_ID, null),
325
+ )
326
+
327
+ private fun persistActivationState(state: OtaActivationState) {
328
+ val editor =
329
+ preferences.edit()
330
+ .putString(KEY_RUNTIME_VERSION, config.runtimeVersion)
331
+ .putInt(KEY_ACTIVE_VERSION, state.activeVersion)
332
+ .putBoolean(KEY_PENDING_LAUNCH_ATTEMPTED, state.pendingLaunchAttempted)
333
+ editor.putNullableString(KEY_ACTIVE_RELEASE_ID, state.activeReleaseId)
334
+ editor.putNullableString(KEY_PENDING_RELEASE_ID, state.pendingReleaseId)
335
+ editor.putNullableInt(KEY_PENDING_VERSION, state.pendingVersion)
336
+ editor.putNullableString(KEY_PREVIOUS_RELEASE_ID, state.previousReleaseId)
337
+ editor.putNullableInt(KEY_PREVIOUS_VERSION, state.previousVersion)
338
+ editor.putNullableString(KEY_FAILED_RELEASE_ID, state.failedReleaseId)
339
+ check(editor.commit()) { "Could not save OTA activation state" }
340
+ }
341
+
342
+ private fun bundleFile(releaseId: String): File =
343
+ File(OtaBundleInstaller.releaseDirectory(updatesDirectory, releaseId), "index.android.bundle")
344
+
345
+ private fun result(
346
+ status: String,
347
+ message: String,
348
+ pendingVersion: Int? = null,
349
+ action: String? = null,
350
+ ): Map<String, Any?> =
351
+ status().toMutableMap().apply {
352
+ put("status", status)
353
+ put("message", message)
354
+ put("updateAvailable", status == "restart_required")
355
+ put("action", action)
356
+ if (pendingVersion != null) put("pendingVersion", pendingVersion)
357
+ }
358
+
359
+ private fun SharedPreferences.Editor.putNullableString(key: String, value: String?) =
360
+ if (value == null) remove(key) else putString(key, value)
361
+
362
+ private fun SharedPreferences.Editor.putNullableInt(key: String, value: Int?) =
363
+ if (value == null) remove(key) else putInt(key, value)
364
+
365
+ private fun ByteArray.toHex(): String = joinToString("") { byte -> "%02x".format(byte) }
366
+
367
+ companion object {
368
+ private const val TAG = "OTAUpdates"
369
+ private const val PREFERENCES_NAME = "ota_update_state"
370
+ private const val KEY_RUNTIME_VERSION = "runtime_version"
371
+ private const val KEY_INSTALLATION_ID = "installation_id"
372
+ private const val KEY_ACTIVE_RELEASE_ID = "active_release_id"
373
+ private const val KEY_ACTIVE_VERSION = "active_version"
374
+ private const val KEY_PENDING_RELEASE_ID = "pending_release_id"
375
+ private const val KEY_PENDING_VERSION = "pending_version"
376
+ private const val KEY_PREVIOUS_RELEASE_ID = "previous_release_id"
377
+ private const val KEY_PREVIOUS_VERSION = "previous_version"
378
+ private const val KEY_PENDING_LAUNCH_ATTEMPTED = "pending_launch_attempted"
379
+ private const val KEY_FAILED_RELEASE_ID = "failed_release_id"
380
+ private const val CONNECT_TIMEOUT_MILLIS = 10_000
381
+ private const val READ_TIMEOUT_MILLIS = 30_000
382
+ private const val MAX_ARTIFACT_BYTES = 100L * 1024L * 1024L
383
+ }
384
+ }
@@ -0,0 +1,70 @@
1
+ package com.otasdk
2
+
3
+ import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.Promise
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.bridge.WritableMap
7
+ import com.facebook.react.module.annotations.ReactModule
8
+ import java.util.concurrent.Executors
9
+
10
+ @ReactModule(name = OtaUpdatesModule.NAME)
11
+ class OtaUpdatesModule(context: ReactApplicationContext) : NativeOTAUpdatesSpec(context) {
12
+ private val executor = Executors.newSingleThreadExecutor()
13
+
14
+ override fun getStatus(promise: Promise) {
15
+ promise.resolve(toWritableMap(OtaSdkUpdates.status()))
16
+ }
17
+
18
+ override fun checkForUpdate(promise: Promise) {
19
+ executor.execute {
20
+ try {
21
+ promise.resolve(toWritableMap(OtaSdkUpdates.checkForUpdate()))
22
+ } catch (exception: Exception) {
23
+ promise.reject("OTA_CHECK_FAILED", exception.message, exception)
24
+ }
25
+ }
26
+ }
27
+
28
+ override fun markUpdateSuccessful(promise: Promise) {
29
+ try {
30
+ promise.resolve(toWritableMap(OtaSdkUpdates.markHealthy()))
31
+ } catch (exception: Exception) {
32
+ promise.reject("OTA_CONFIRM_FAILED", exception.message, exception)
33
+ }
34
+ }
35
+
36
+ override fun closeAppToApplyUpdate(promise: Promise) {
37
+ val activity = reactApplicationContext.currentActivity
38
+ if (activity == null) {
39
+ promise.reject("OTA_NO_ACTIVITY", "There is no foreground Activity to close")
40
+ return
41
+ }
42
+ if (OtaSdkUpdates.status()["pendingRestart"] != true) {
43
+ promise.reject("OTA_NO_PENDING_UPDATE", "There is no verified update waiting for restart")
44
+ return
45
+ }
46
+
47
+ promise.resolve(true)
48
+ OtaSdkUpdates.closeAppForUpdate(activity)
49
+ }
50
+
51
+ private fun toWritableMap(values: Map<String, Any?>): WritableMap {
52
+ val result = Arguments.createMap()
53
+ values.forEach { (key, value) ->
54
+ when (value) {
55
+ null -> result.putNull(key)
56
+ is String -> result.putString(key, value)
57
+ is Boolean -> result.putBoolean(key, value)
58
+ is Int -> result.putInt(key, value)
59
+ is Long -> result.putDouble(key, value.toDouble())
60
+ is Double -> result.putDouble(key, value)
61
+ else -> result.putString(key, value.toString())
62
+ }
63
+ }
64
+ return result
65
+ }
66
+
67
+ companion object {
68
+ const val NAME = "OTAUpdates"
69
+ }
70
+ }
@@ -0,0 +1,47 @@
1
+ package com.otasdk
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNull
5
+ import org.junit.Test
6
+
7
+ class OtaActivationPolicyTest {
8
+ @Test
9
+ fun firstLaunchSelectsPendingBundleAndMarksAttempt() {
10
+ val pending = OtaActivationPolicy.stage(OtaActivationState(activeVersion = 0), "release-1", 1)
11
+
12
+ val transition = OtaActivationPolicy.beginStartup(pending)
13
+
14
+ assertEquals("release-1", transition.selectedReleaseId)
15
+ assertEquals(true, transition.state.pendingLaunchAttempted)
16
+ assertEquals(0, transition.state.activeVersion)
17
+ }
18
+
19
+ @Test
20
+ fun healthyStartupCommitsPendingRelease() {
21
+ val pending =
22
+ OtaActivationPolicy.beginStartup(
23
+ OtaActivationPolicy.stage(OtaActivationState(activeVersion = 0), "release-1", 1)
24
+ )
25
+
26
+ val healthy = OtaActivationPolicy.markHealthy(pending.state)
27
+
28
+ assertEquals("release-1", healthy.activeReleaseId)
29
+ assertEquals(1, healthy.activeVersion)
30
+ assertNull(healthy.pendingReleaseId)
31
+ }
32
+
33
+ @Test
34
+ fun unconfirmedStartupRollsBackToPreviouslyActiveRelease() {
35
+ val previous = OtaActivationState(activeReleaseId = "release-1", activeVersion = 1)
36
+ val pending = OtaActivationPolicy.stage(previous, "release-2", 2)
37
+ val firstStart = OtaActivationPolicy.beginStartup(pending)
38
+
39
+ val nextStart = OtaActivationPolicy.beginStartup(firstStart.state)
40
+
41
+ assertEquals("release-1", nextStart.selectedReleaseId)
42
+ assertEquals("release-2", nextStart.failedReleaseId)
43
+ assertEquals("release-2", nextStart.state.failedReleaseId)
44
+ assertEquals(1, nextStart.state.activeVersion)
45
+ assertNull(nextStart.state.pendingReleaseId)
46
+ }
47
+ }
@@ -0,0 +1,48 @@
1
+ package com.otasdk
2
+
3
+ import java.nio.charset.StandardCharsets
4
+ import java.security.KeyPairGenerator
5
+ import java.security.Signature
6
+ import org.junit.Assert.assertArrayEquals
7
+ import org.junit.Assert.assertThrows
8
+ import org.junit.Test
9
+
10
+ class OtaReleaseManifestTest {
11
+ private val manifest =
12
+ OtaReleaseManifest(
13
+ releaseId = "519b9918-f1e8-4bcf-9319-19c54389e575",
14
+ platform = "android",
15
+ appId = "com.ota",
16
+ runtimeVersion = "rn-0.88.0-android-v1",
17
+ version = 3,
18
+ artifactSha256 = "a".repeat(64),
19
+ artifactSizeBytes = 1234,
20
+ createdAt = "2026-10-22T10:25:36.458384Z",
21
+ )
22
+
23
+ @Test
24
+ fun canonicalManifestUsesPublisherKeyOrderingAndCompactEncoding() {
25
+ val expected =
26
+ "{\"app_id\":\"com.ota\",\"artifact_sha256\":\"${"a".repeat(64)}\"," +
27
+ "\"artifact_size_bytes\":1234,\"created_at\":\"2026-10-22T10:25:36.458384Z\"," +
28
+ "\"manifest_version\":1,\"platform\":\"android\"," +
29
+ "\"release_id\":\"519b9918-f1e8-4bcf-9319-19c54389e575\"," +
30
+ "\"runtime_version\":\"rn-0.88.0-android-v1\",\"version\":3}"
31
+
32
+ assertArrayEquals(expected.toByteArray(StandardCharsets.UTF_8), manifest.canonicalBytes())
33
+ }
34
+
35
+ @Test
36
+ fun p256SignatureVerifiesAndDetectsManifestTampering() {
37
+ val keyPair = KeyPairGenerator.getInstance("EC").apply { initialize(256) }.generateKeyPair()
38
+ val signer = Signature.getInstance("SHA256withECDSA")
39
+ signer.initSign(keyPair.private)
40
+ signer.update(manifest.canonicalBytes())
41
+ val signature = signer.sign()
42
+
43
+ manifest.verifySignature(signature, keyPair.public)
44
+ assertThrows(OtaVerificationException::class.java) {
45
+ manifest.copy(version = 4).verifySignature(signature, keyPair.public)
46
+ }
47
+ }
48
+ }
package/ios/OtaSdk.h ADDED
@@ -0,0 +1,5 @@
1
+ #import <OtaSdkSpec/OtaSdkSpec.h>
2
+
3
+ @interface OtaSdk : NSObject <NativeOtaSdkSpec>
4
+
5
+ @end
package/ios/OtaSdk.mm ADDED
@@ -0,0 +1,21 @@
1
+ #import "OtaSdk.h"
2
+
3
+ @implementation OtaSdk
4
+ - (NSNumber *)multiply:(double)a b:(double)b {
5
+ NSNumber *result = @(a * b);
6
+
7
+ return result;
8
+ }
9
+
10
+ - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
11
+ (const facebook::react::ObjCTurboModule::InitParams &)params
12
+ {
13
+ return std::make_shared<facebook::react::NativeOtaSdkSpecJSI>(params);
14
+ }
15
+
16
+ + (NSString *)moduleName
17
+ {
18
+ return @"OtaSdk";
19
+ }
20
+
21
+ @end
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+
3
+ import { TurboModuleRegistry } from 'react-native';
4
+ export default TurboModuleRegistry.getEnforcing('OTAUpdates');
5
+ //# sourceMappingURL=NativeOTAUpdates.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["TurboModuleRegistry","getEnforcing"],"sourceRoot":"../../src","sources":["NativeOTAUpdates.ts"],"mappings":";;AACA,SAASA,mBAAmB,QAAQ,cAAc;AAsBlD,eAAeA,mBAAmB,CAACC,YAAY,CAAO,YAAY,CAAC","ignoreList":[]}
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+
3
+ import { TurboModuleRegistry } from 'react-native';
4
+ export default TurboModuleRegistry.getEnforcing('OtaSdk');
5
+ //# sourceMappingURL=NativeOtaSdk.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["TurboModuleRegistry","getEnforcing"],"sourceRoot":"../../src","sources":["NativeOtaSdk.ts"],"mappings":";;AAAA,SAASA,mBAAmB,QAA0B,cAAc;AAMpE,eAAeA,mBAAmB,CAACC,YAAY,CAAO,QAAQ,CAAC","ignoreList":[]}
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+
3
+ export { multiply } from './multiply';
4
+ export { default as OTAUpdates } from "./NativeOTAUpdates.js";
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["multiply","default","OTAUpdates"],"sourceRoot":"../../src","sources":["index.tsx"],"mappings":";;AAAA,SAASA,QAAQ,QAAQ,YAAY;AACrC,SAASC,OAAO,IAAIC,UAAU,QAAQ,uBAAoB","ignoreList":[]}
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+
3
+ export function multiply(a, b) {
4
+ return a * b;
5
+ }
6
+ //# sourceMappingURL=multiply.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["multiply","a","b"],"sourceRoot":"../../src","sources":["multiply.tsx"],"mappings":";;AAAA,OAAO,SAASA,QAAQA,CAACC,CAAS,EAAEC,CAAS,EAAU;EACrD,OAAOD,CAAC,GAAGC,CAAC;AACd","ignoreList":[]}
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+
3
+ import OtaSdk from "./NativeOtaSdk.js";
4
+ export function multiply(a, b) {
5
+ return OtaSdk.multiply(a, b);
6
+ }
7
+ //# sourceMappingURL=multiply.native.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["OtaSdk","multiply","a","b"],"sourceRoot":"../../src","sources":["multiply.native.tsx"],"mappings":";;AAAA,OAAOA,MAAM,MAAM,mBAAgB;AAEnC,OAAO,SAASC,QAAQA,CAACC,CAAS,EAAEC,CAAS,EAAU;EACrD,OAAOH,MAAM,CAACC,QAAQ,CAACC,CAAC,EAAEC,CAAC,CAAC;AAC9B","ignoreList":[]}
@@ -0,0 +1 @@
1
+ {"type":"module"}
@@ -0,0 +1 @@
1
+ {"type":"module"}
@@ -0,0 +1,22 @@
1
+ import type { TurboModule } from 'react-native';
2
+ export type OtaUpdateStatus = {
3
+ enabled: boolean;
4
+ status: string;
5
+ message: string;
6
+ runtimeVersion: string;
7
+ channel: string;
8
+ currentVersion: number;
9
+ pendingVersion: number | null;
10
+ pendingRestart: boolean;
11
+ updateAvailable: boolean;
12
+ action: string | null;
13
+ };
14
+ export interface Spec extends TurboModule {
15
+ getStatus(): Promise<OtaUpdateStatus>;
16
+ checkForUpdate(): Promise<OtaUpdateStatus>;
17
+ markUpdateSuccessful(): Promise<OtaUpdateStatus>;
18
+ closeAppToApplyUpdate(): Promise<boolean>;
19
+ }
20
+ declare const _default: Spec;
21
+ export default _default;
22
+ //# sourceMappingURL=NativeOTAUpdates.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"NativeOTAUpdates.d.ts","sourceRoot":"","sources":["../../../src/NativeOTAUpdates.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAGhD,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,cAAc,EAAE,OAAO,CAAC;IACxB,eAAe,EAAE,OAAO,CAAC;IACzB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,CAAC;AAEF,MAAM,WAAW,IAAK,SAAQ,WAAW;IACvC,SAAS,IAAI,OAAO,CAAC,eAAe,CAAC,CAAC;IACtC,cAAc,IAAI,OAAO,CAAC,eAAe,CAAC,CAAC;IAC3C,oBAAoB,IAAI,OAAO,CAAC,eAAe,CAAC,CAAC;IACjD,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;CAC3C;;AAED,wBAAoE"}
@@ -0,0 +1,7 @@
1
+ import { type TurboModule } from 'react-native';
2
+ export interface Spec extends TurboModule {
3
+ multiply(a: number, b: number): number;
4
+ }
5
+ declare const _default: Spec;
6
+ export default _default;
7
+ //# sourceMappingURL=NativeOtaSdk.d.ts.map