@viettelpost/react-native-ota 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 (56) hide show
  1. package/README.md +38 -0
  2. package/android/build.gradle +48 -0
  3. package/android/src/main/java/com/viettelpost/otakit/OTAHashUtils.kt +21 -0
  4. package/android/src/main/java/com/viettelpost/otakit/OTATestReceiver.kt +51 -0
  5. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateBundleResolver.kt +405 -0
  6. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateCleanup.kt +186 -0
  7. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateDownloader.kt +649 -0
  8. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateMetadata.kt +72 -0
  9. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateModule.kt +140 -0
  10. package/android/src/main/java/com/viettelpost/otakit/OTAUpdatePackage.kt +30 -0
  11. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateSignatureVerifier.kt +63 -0
  12. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateStorage.kt +62 -0
  13. package/android/src/main/java/com/viettelpost/otakit/OTAZipUtils.kt +100 -0
  14. package/android/src/main/res/raw/ota_public_key.pem +9 -0
  15. package/bin/cli/assets-zip.js +77 -0
  16. package/bin/cli/bundle.js +72 -0
  17. package/bin/cli/deploy.js +224 -0
  18. package/bin/cli/sign.js +97 -0
  19. package/bin/cli/upload.js +109 -0
  20. package/bin/ota.js +200 -0
  21. package/docs/BACKEND_CONTRACT.md +93 -0
  22. package/docs/DEPLOY_CLI.md +39 -0
  23. package/docs/INTEGRATION_ANDROID.md +20 -0
  24. package/docs/INTEGRATION_IOS.md +21 -0
  25. package/docs/RELEASE_WORKFLOW.md +14 -0
  26. package/ios/OTAHashUtils.swift +22 -0
  27. package/ios/OTAUpdateBundleResolver.swift +359 -0
  28. package/ios/OTAUpdateCleanup.swift +269 -0
  29. package/ios/OTAUpdateDownloader.swift +709 -0
  30. package/ios/OTAUpdateMetadata.swift +47 -0
  31. package/ios/OTAUpdateModule.mm +190 -0
  32. package/ios/OTAUpdateSignatureVerifier.swift +81 -0
  33. package/ios/OTAUpdateStorage.swift +83 -0
  34. package/ios/OTAZipUtils.swift +103 -0
  35. package/ios/ota_public_key.pem +9 -0
  36. package/lib/NativeOTAUpdate.d.ts +77 -0
  37. package/lib/NativeOTAUpdate.js +59 -0
  38. package/lib/OTAClient.d.ts +27 -0
  39. package/lib/OTAClient.js +101 -0
  40. package/lib/config.d.ts +14 -0
  41. package/lib/config.js +29 -0
  42. package/lib/devtools.d.ts +10 -0
  43. package/lib/devtools.js +54 -0
  44. package/lib/index.d.ts +15 -0
  45. package/lib/index.js +32 -0
  46. package/lib/spec/NativeOTAUpdate.d.ts +16 -0
  47. package/lib/spec/NativeOTAUpdate.js +4 -0
  48. package/package.json +82 -0
  49. package/react-native-ota.podspec +21 -0
  50. package/scripts/run-bin.js +67 -0
  51. package/src/NativeOTAUpdate.ts +144 -0
  52. package/src/OTAClient.ts +151 -0
  53. package/src/config.ts +41 -0
  54. package/src/devtools.ts +64 -0
  55. package/src/index.ts +69 -0
  56. package/src/spec/NativeOTAUpdate.ts +21 -0
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # @viettelpost/react-native-ota
2
+
3
+ Standalone React Native OTA runtime and deploy CLI.
4
+
5
+ This package contains:
6
+
7
+ - `ota deploy`: build, zip assets, hash, sign, and upload OTA release files.
8
+ - JS API: `OTA.configure()`, `OTA.sync()`, status helpers, and dev utilities.
9
+ - Native runtime: Android Kotlin and iOS Swift/ObjC++ bundle resolver, installer, verification, rollback, and cleanup.
10
+
11
+ This package does not integrate itself into a host app. The host app must wire the native bundle resolver at startup before OTA bundles can run.
12
+
13
+ ## CLI
14
+
15
+ ```sh
16
+ ota deploy --platform android --dry-run
17
+ ota deploy --platform ios --dry-run
18
+ ota sign --platform ios --bundle ./build/main.jsbundle --version 2026.06.26-001
19
+ ```
20
+
21
+ Non-dry-run deploys require the backend upload endpoint documented in `docs/BACKEND_CONTRACT.md`.
22
+
23
+ ## JS API
24
+
25
+ ```ts
26
+ import { OTA } from '@viettelpost/react-native-ota';
27
+
28
+ OTA.configure({
29
+ baseURL: 'https://ota.example.com',
30
+ headers: () => ({ Authorization: `Bearer ${token}` }),
31
+ });
32
+
33
+ const result = await OTA.sync();
34
+ ```
35
+
36
+ ## Security
37
+
38
+ The client verifies SHA-256 for the bundle and optional `assets.zip`, then verifies an RSA-SHA256 signature over the canonical metadata payload. The private key must never be committed.
@@ -0,0 +1,48 @@
1
+ buildscript {
2
+ repositories {
3
+ google()
4
+ mavenCentral()
5
+ }
6
+ }
7
+
8
+ plugins {
9
+ id "com.android.library"
10
+ id "org.jetbrains.kotlin.android"
11
+ }
12
+
13
+ def getExtOrDefault(name) {
14
+ if (rootProject.ext.has(name)) {
15
+ return rootProject.ext.get(name)
16
+ }
17
+ if (project.hasProperty("ReactNativeOta_" + name)) {
18
+ return project.properties["ReactNativeOta_" + name]
19
+ }
20
+ if (name == "compileSdkVersion") return 35
21
+ if (name == "targetSdkVersion") return 35
22
+ if (name == "minSdkVersion") return 23
23
+ throw new GradleException("Missing ReactNativeOta Android property: " + name)
24
+ }
25
+
26
+ android {
27
+ namespace "com.viettelpost.otakit"
28
+ compileSdkVersion getExtOrDefault("compileSdkVersion").toInteger()
29
+
30
+ defaultConfig {
31
+ minSdkVersion getExtOrDefault("minSdkVersion").toInteger()
32
+ targetSdkVersion getExtOrDefault("targetSdkVersion").toInteger()
33
+ }
34
+
35
+ sourceSets {
36
+ main.java.srcDirs += ["src/main/java"]
37
+ }
38
+ }
39
+
40
+ repositories {
41
+ google()
42
+ mavenCentral()
43
+ }
44
+
45
+ dependencies {
46
+ implementation "com.facebook.react:react-android"
47
+ implementation "org.jetbrains.kotlin:kotlin-stdlib"
48
+ }
@@ -0,0 +1,21 @@
1
+ package com.viettelpost.otakit
2
+
3
+ import java.io.File
4
+ import java.security.MessageDigest
5
+
6
+ object OTAHashUtils {
7
+ fun sha256(file: File): String {
8
+ val digest = MessageDigest.getInstance("SHA-256")
9
+ file.inputStream().use { input ->
10
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
11
+ while (true) {
12
+ val bytesRead = input.read(buffer)
13
+ if (bytesRead <= 0) {
14
+ break
15
+ }
16
+ digest.update(buffer, 0, bytesRead)
17
+ }
18
+ }
19
+ return digest.digest().joinToString(separator = "") { byte -> "%02x".format(byte) }
20
+ }
21
+ }
@@ -0,0 +1,51 @@
1
+ package com.viettelpost.otakit
2
+
3
+ import android.content.BroadcastReceiver
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.util.Log
7
+
8
+ class OTATestReceiver : BroadcastReceiver() {
9
+ override fun onReceive(context: Context, intent: Intent) {
10
+ val action = intent.action ?: return
11
+
12
+ try {
13
+ when (action) {
14
+ ACTION_PREPARE -> {
15
+ val version = intent.getStringExtra(EXTRA_VERSION)
16
+ ?: intent.getStringExtra(EXTRA_BUNDLE_VERSION)
17
+ ?: ""
18
+ val didPrepare = OTAUpdateBundleResolver.prepareManualInstall(context, version)
19
+ Log.d(TAG, "OTA test prepare result=$didPrepare, version=$version")
20
+ }
21
+ ACTION_CONFIRM -> {
22
+ val didConfirm = OTAUpdateBundleResolver.markSuccess(context)
23
+ Log.d(TAG, "OTA test confirm result=$didConfirm")
24
+ }
25
+ ACTION_INFO -> {
26
+ val metadata = OTAUpdateStorage.readMetadata(context)
27
+ val bundleInfo = OTAUpdateBundleResolver.getCurrentBundleInfo(context)
28
+ Log.d(TAG, "OTA test metadata=${metadata.toJson()}")
29
+ Log.d(TAG, "OTA test bundleInfo=$bundleInfo")
30
+ }
31
+ ACTION_RESET -> {
32
+ val metadata = OTAUpdateStorage.resetMetadata(context)
33
+ Log.d(TAG, "OTA test reset metadata=${metadata.toJson()}")
34
+ }
35
+ }
36
+ } catch (error: Exception) {
37
+ Log.e(TAG, "OTA test receiver failed; action=$action", error)
38
+ }
39
+ }
40
+
41
+ companion object {
42
+ private const val TAG = "OTAKit"
43
+ private const val EXTRA_VERSION = "version"
44
+ private const val EXTRA_BUNDLE_VERSION = "bundleVersion"
45
+
46
+ const val ACTION_PREPARE = "com.viettelpost.otakit.OTA_TEST_PREPARE"
47
+ const val ACTION_CONFIRM = "com.viettelpost.otakit.OTA_TEST_CONFIRM"
48
+ const val ACTION_INFO = "com.viettelpost.otakit.OTA_TEST_INFO"
49
+ const val ACTION_RESET = "com.viettelpost.otakit.OTA_TEST_RESET"
50
+ }
51
+ }
@@ -0,0 +1,405 @@
1
+ package com.viettelpost.otakit
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import org.json.JSONObject
6
+ import java.io.File
7
+ import java.io.IOException
8
+ import java.text.SimpleDateFormat
9
+ import java.util.Date
10
+ import java.util.Locale
11
+ import java.util.TimeZone
12
+
13
+ object OTAUpdateBundleResolver {
14
+ private const val TAG = "OTAKit"
15
+ private const val EMBEDDED_VERSION = "embedded"
16
+ private const val STATUS_ACTIVE = "active"
17
+ private const val STATUS_PENDING = "pending"
18
+ private const val STATUS_FAILED = "failed"
19
+ private const val STATUS_ROLLED_BACK = "rolled_back"
20
+
21
+ private const val REASON_MISSING_PENDING_BUNDLE = "missing_pending_bundle"
22
+ private const val REASON_PENDING_BUNDLE_FILE_MISSING = "pending_bundle_file_missing"
23
+ private const val REASON_MARK_SUCCESS_NOT_CALLED = "mark_success_not_called"
24
+ private const val REASON_ACTIVE_BUNDLE_FILE_MISSING = "active_bundle_file_missing"
25
+
26
+ fun otaDirectory(context: Context): File = File(context.filesDir, "ota")
27
+
28
+ @Throws(IOException::class)
29
+ fun ensureOTADirectory(context: Context): File {
30
+ val directory = otaDirectory(context)
31
+ if (!directory.exists() && !directory.mkdirs()) {
32
+ throw IOException("Unable to create OTA directory")
33
+ }
34
+ return directory
35
+ }
36
+
37
+ fun resolveBundlePath(context: Context): String? =
38
+ try {
39
+ Log.d(
40
+ TAG,
41
+ "resolveBundlePath entered; metadataFile=${OTAUpdateStorage.getMetadataPath(context).absolutePath}",
42
+ )
43
+ resolveBundlePathOrThrow(context)
44
+ } catch (error: Exception) {
45
+ Log.e(TAG, "resolveBundlePath failed; using embedded bundle", error)
46
+ logBundleSelection(
47
+ source = "fallback",
48
+ version = EMBEDDED_VERSION,
49
+ status = STATUS_FAILED,
50
+ path = null,
51
+ reason = error.message ?: "resolve_exception",
52
+ )
53
+ null
54
+ }
55
+
56
+ // Manual test hook: the bundle file must already be copied into private OTA storage.
57
+ fun prepareManualInstall(context: Context, bundleVersion: String): Boolean {
58
+ val normalizedVersion = bundleVersion.trim()
59
+ if (normalizedVersion.isEmpty()) {
60
+ throw IllegalArgumentException("bundleVersion must not be empty")
61
+ }
62
+
63
+ val bundleFile = bundleFile(context, normalizedVersion)
64
+ if (!bundleFile.exists()) {
65
+ throw OTABundleFileNotFoundException(bundleFile.absolutePath)
66
+ }
67
+
68
+ val metadata = OTAUpdateStorage.readMetadata(context)
69
+ OTAUpdateStorage.writeMetadata(
70
+ context,
71
+ metadata.copy(
72
+ previousBundleVersion = metadata.activeBundleVersion,
73
+ pendingBundleVersion = normalizedVersion,
74
+ status = STATUS_PENDING,
75
+ launchCountForPending = 0,
76
+ lastFailureReason = null,
77
+ ),
78
+ )
79
+ return true
80
+ }
81
+
82
+ // Manual test hook: promote only the pending bundle that actually launched.
83
+ fun markSuccess(context: Context): Boolean {
84
+ val metadata = OTAUpdateStorage.readMetadata(context)
85
+ val pendingVersion = metadata.pendingBundleVersion
86
+
87
+ if (metadata.status != STATUS_PENDING ||
88
+ pendingVersion.isNullOrBlank() ||
89
+ metadata.runningBundleVersion != pendingVersion
90
+ ) {
91
+ Log.w(
92
+ TAG,
93
+ "OTA mark success rejected; status=${metadata.status}, " +
94
+ "running=${metadata.runningBundleVersion}, pending=${pendingVersion ?: "none"}",
95
+ )
96
+ throw OTAMarkSuccessRejectedException(
97
+ metadata.runningBundleVersion,
98
+ pendingVersion,
99
+ )
100
+ }
101
+
102
+ OTAUpdateStorage.writeMetadata(
103
+ context,
104
+ metadata.copy(
105
+ previousBundleVersion = metadata.activeBundleVersion,
106
+ activeBundleVersion = pendingVersion,
107
+ pendingBundleVersion = null,
108
+ status = STATUS_ACTIVE,
109
+ launchCountForPending = 0,
110
+ lastSuccessfulLaunchAt = currentIsoTimestamp(),
111
+ lastFailureReason = null,
112
+ ),
113
+ )
114
+ // Metadata is saved before cleanup so a cleanup warning cannot undo a confirmed update.
115
+ OTAUpdateCleanup.cleanupAfterSuccessfulActivation(context, pendingVersion)
116
+ OTAUpdateCleanup.cleanupAllTemp(context)
117
+ Log.d(
118
+ TAG,
119
+ "OTA mark success accepted; active=$pendingVersion, previous=${metadata.activeBundleVersion}",
120
+ )
121
+ return true
122
+ }
123
+
124
+ fun getCurrentBundleInfo(context: Context): JSONObject {
125
+ val metadata = OTAUpdateStorage.readMetadata(context)
126
+ val runningVersion = metadata.runningBundleVersion
127
+ val isEmbedded = isEmbeddedVersion(runningVersion)
128
+ val bundlePath = if (isEmbedded) {
129
+ null
130
+ } else {
131
+ bundleFile(context, runningVersion).takeIf { it.exists() }?.absolutePath
132
+ }
133
+ val assetsDirectory = if (isEmbedded) {
134
+ null
135
+ } else {
136
+ bundleDirectory(context, runningVersion)
137
+ }
138
+
139
+ return JSONObject().apply {
140
+ put("runningBundleVersion", runningVersion)
141
+ put("activeBundleVersion", metadata.activeBundleVersion)
142
+ putNullable("pendingBundleVersion", metadata.pendingBundleVersion)
143
+ put("status", metadata.status)
144
+ putNullable("bundlePath", bundlePath)
145
+ putNullable("assetsDirectoryPath", assetsDirectory?.absolutePath)
146
+ put("assetsDirectoryExists", assetsDirectory?.exists() == true)
147
+ put("isEmbedded", isEmbedded)
148
+ }
149
+ }
150
+
151
+ private fun resolveBundlePathOrThrow(context: Context): String? {
152
+ val metadata = OTAUpdateStorage.readMetadata(context)
153
+ Log.d(TAG, "OTA metadata loaded: ${metadata.toLogString()}")
154
+ if (!metadata.lastFailureReason.isNullOrBlank()) {
155
+ Log.w(
156
+ TAG,
157
+ "OTA metadata contains previous failure reason=${metadata.lastFailureReason}, " +
158
+ "failed=${metadata.failedBundleVersion ?: "none"}",
159
+ )
160
+ }
161
+
162
+ if (metadata.status == STATUS_PENDING) {
163
+ return resolvePendingBundle(context, metadata)
164
+ }
165
+
166
+ return resolveActiveBundle(context, metadata)
167
+ }
168
+
169
+ private fun resolvePendingBundle(context: Context, metadata: OTAUpdateMetadata): String? {
170
+ val pendingVersion = metadata.pendingBundleVersion
171
+ if (pendingVersion.isNullOrBlank()) {
172
+ Log.w(
173
+ TAG,
174
+ "Pending OTA status has no pending version; reason=$REASON_MISSING_PENDING_BUNDLE; using embedded bundle",
175
+ )
176
+ OTAUpdateStorage.writeMetadata(
177
+ context,
178
+ metadata.copy(
179
+ status = STATUS_FAILED,
180
+ pendingBundleVersion = null,
181
+ runningBundleVersion = EMBEDDED_VERSION,
182
+ launchCountForPending = 0,
183
+ lastFailureReason = REASON_MISSING_PENDING_BUNDLE,
184
+ ),
185
+ )
186
+ logBundleSelection(
187
+ source = "fallback",
188
+ version = EMBEDDED_VERSION,
189
+ status = STATUS_FAILED,
190
+ path = null,
191
+ reason = REASON_MISSING_PENDING_BUNDLE,
192
+ )
193
+ return null
194
+ }
195
+
196
+ val pendingFile = bundleFile(context, pendingVersion)
197
+ if (!pendingFile.exists()) {
198
+ val runningVersion = validActiveBundleVersion(context, metadata) ?: EMBEDDED_VERSION
199
+ Log.w(
200
+ TAG,
201
+ "Pending OTA bundle missing; pending=$pendingVersion, path=${pendingFile.absolutePath}, " +
202
+ "reason=$REASON_PENDING_BUNDLE_FILE_MISSING, fallback=$runningVersion",
203
+ )
204
+ OTAUpdateStorage.writeMetadata(
205
+ context,
206
+ metadata.copy(
207
+ status = STATUS_FAILED,
208
+ pendingBundleVersion = null,
209
+ failedBundleVersion = pendingVersion,
210
+ runningBundleVersion = runningVersion,
211
+ launchCountForPending = 0,
212
+ lastFailureReason = REASON_PENDING_BUNDLE_FILE_MISSING,
213
+ ),
214
+ )
215
+ // The pending version cannot run, so its package and temp files are safe to remove.
216
+ OTAUpdateCleanup.cleanupFailedPendingBundle(context, pendingVersion)
217
+ val fallbackPath = bundlePathForVersion(context, runningVersion)
218
+ logBundleSelection(
219
+ source = if (isEmbeddedVersion(runningVersion)) "fallback" else "active_ota",
220
+ version = runningVersion,
221
+ status = STATUS_FAILED,
222
+ path = fallbackPath,
223
+ reason = REASON_PENDING_BUNDLE_FILE_MISSING,
224
+ )
225
+ return fallbackPath
226
+ }
227
+
228
+ if (metadata.launchCountForPending > 0) {
229
+ val runningVersion = validActiveBundleVersion(context, metadata) ?: EMBEDDED_VERSION
230
+ Log.w(
231
+ TAG,
232
+ "Pending OTA bundle did not confirm success; pending=$pendingVersion, " +
233
+ "launchCount=${metadata.launchCountForPending}, reason=$REASON_MARK_SUCCESS_NOT_CALLED, " +
234
+ "rollback=$runningVersion",
235
+ )
236
+ OTAUpdateStorage.writeMetadata(
237
+ context,
238
+ metadata.copy(
239
+ status = STATUS_ROLLED_BACK,
240
+ pendingBundleVersion = null,
241
+ failedBundleVersion = pendingVersion,
242
+ runningBundleVersion = runningVersion,
243
+ launchCountForPending = 0,
244
+ lastFailureReason = REASON_MARK_SUCCESS_NOT_CALLED,
245
+ ),
246
+ )
247
+ // Save rollback metadata first; cleanup failure must not prevent fallback startup.
248
+ OTAUpdateCleanup.cleanupFailedPendingBundle(context, pendingVersion)
249
+ val rollbackPath = bundlePathForVersion(context, runningVersion)
250
+ logBundleSelection(
251
+ source = if (isEmbeddedVersion(runningVersion)) "fallback" else "active_ota",
252
+ version = runningVersion,
253
+ status = STATUS_ROLLED_BACK,
254
+ path = rollbackPath,
255
+ reason = REASON_MARK_SUCCESS_NOT_CALLED,
256
+ )
257
+ return rollbackPath
258
+ }
259
+
260
+ OTAUpdateStorage.writeMetadata(
261
+ context,
262
+ metadata.copy(
263
+ runningBundleVersion = pendingVersion,
264
+ launchCountForPending = metadata.launchCountForPending + 1,
265
+ ),
266
+ )
267
+ Log.d(
268
+ TAG,
269
+ "Selected pending OTA bundle; version=$pendingVersion, path=${pendingFile.absolutePath}, " +
270
+ "launchCount=${metadata.launchCountForPending + 1}",
271
+ )
272
+ logBundleSelection(
273
+ source = "pending_ota",
274
+ version = pendingVersion,
275
+ status = STATUS_PENDING,
276
+ path = pendingFile.absolutePath,
277
+ )
278
+ return pendingFile.absolutePath
279
+ }
280
+
281
+ private fun resolveActiveBundle(context: Context, metadata: OTAUpdateMetadata): String? {
282
+ val activeVersion = metadata.activeBundleVersion
283
+ if (!isEmbeddedVersion(activeVersion)) {
284
+ val activeFile = bundleFile(context, activeVersion)
285
+ if (activeFile.exists()) {
286
+ OTAUpdateStorage.writeMetadata(
287
+ context,
288
+ metadata.copy(runningBundleVersion = activeVersion),
289
+ )
290
+ Log.d(
291
+ TAG,
292
+ "Selected active OTA bundle; version=$activeVersion, path=${activeFile.absolutePath}",
293
+ )
294
+ logBundleSelection(
295
+ source = "active_ota",
296
+ version = activeVersion,
297
+ status = metadata.status,
298
+ path = activeFile.absolutePath,
299
+ )
300
+ return activeFile.absolutePath
301
+ }
302
+
303
+ Log.w(
304
+ TAG,
305
+ "Active OTA bundle missing; active=$activeVersion, path=${activeFile.absolutePath}, " +
306
+ "reason=$REASON_ACTIVE_BUNDLE_FILE_MISSING; using embedded bundle",
307
+ )
308
+ OTAUpdateStorage.writeMetadata(
309
+ context,
310
+ metadata.copy(
311
+ status = STATUS_FAILED,
312
+ failedBundleVersion = activeVersion,
313
+ runningBundleVersion = EMBEDDED_VERSION,
314
+ lastFailureReason = REASON_ACTIVE_BUNDLE_FILE_MISSING,
315
+ ),
316
+ )
317
+ logBundleSelection(
318
+ source = "fallback",
319
+ version = EMBEDDED_VERSION,
320
+ status = STATUS_FAILED,
321
+ path = null,
322
+ reason = REASON_ACTIVE_BUNDLE_FILE_MISSING,
323
+ )
324
+ return null
325
+ }
326
+
327
+ Log.d(TAG, "No active OTA bundle selected; using embedded bundle")
328
+ OTAUpdateStorage.writeMetadata(
329
+ context,
330
+ metadata.copy(runningBundleVersion = EMBEDDED_VERSION),
331
+ )
332
+ logBundleSelection(
333
+ source = "apk_bundled",
334
+ version = EMBEDDED_VERSION,
335
+ status = metadata.status,
336
+ path = null,
337
+ )
338
+ return null
339
+ }
340
+
341
+ private fun validActiveBundleVersion(context: Context, metadata: OTAUpdateMetadata): String? {
342
+ val activeVersion = metadata.activeBundleVersion
343
+ if (isEmbeddedVersion(activeVersion)) {
344
+ return null
345
+ }
346
+ return activeVersion.takeIf { bundleFile(context, it).exists() }
347
+ }
348
+
349
+ private fun bundlePathForVersion(context: Context, bundleVersion: String): String? {
350
+ if (isEmbeddedVersion(bundleVersion)) {
351
+ return null
352
+ }
353
+ return bundleFile(context, bundleVersion).absolutePath
354
+ }
355
+
356
+ private fun bundleFile(context: Context, bundleVersion: String): File =
357
+ File(bundleDirectory(context, bundleVersion), "index.android.bundle")
358
+
359
+ private fun bundleDirectory(context: Context, bundleVersion: String): File =
360
+ File(otaDirectory(context), "bundles/$bundleVersion")
361
+
362
+ private fun isEmbeddedVersion(bundleVersion: String?): Boolean =
363
+ bundleVersion.isNullOrBlank() || bundleVersion == EMBEDDED_VERSION
364
+
365
+ private fun currentIsoTimestamp(): String {
366
+ val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
367
+ formatter.timeZone = TimeZone.getTimeZone("UTC")
368
+ return formatter.format(Date())
369
+ }
370
+
371
+ private fun logBundleSelection(
372
+ source: String,
373
+ version: String,
374
+ status: String,
375
+ path: String?,
376
+ reason: String? = null,
377
+ ) {
378
+ Log.d(
379
+ TAG,
380
+ "OTA startup selection; source=$source, version=$version, status=$status, " +
381
+ "path=${path ?: "apk"}, assetsPath=${path?.let { File(it).parent } ?: "apk"}, " +
382
+ "reason=${reason ?: "none"}",
383
+ )
384
+ }
385
+
386
+ private fun OTAUpdateMetadata.toLogString(): String =
387
+ "status=$status, active=$activeBundleVersion, pending=${pendingBundleVersion ?: "none"}, " +
388
+ "running=$runningBundleVersion, failed=${failedBundleVersion ?: "none"}, " +
389
+ "launchCount=$launchCountForPending, lastFailure=${lastFailureReason ?: "none"}"
390
+ }
391
+
392
+ class OTABundleFileNotFoundException(path: String) :
393
+ IllegalStateException("OTA bundle file was not found at $path")
394
+
395
+ class OTAMarkSuccessRejectedException(
396
+ runningVersion: String,
397
+ pendingVersion: String?,
398
+ ) : IllegalStateException(
399
+ "OTA success can only be confirmed while running the pending OTA bundle. " +
400
+ "running=$runningVersion, pending=${pendingVersion ?: "none"}",
401
+ )
402
+
403
+ private fun JSONObject.putNullable(name: String, value: String?) {
404
+ put(name, value ?: JSONObject.NULL)
405
+ }