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,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
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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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"}
|