@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.
- package/README.md +38 -0
- package/android/build.gradle +48 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAHashUtils.kt +21 -0
- package/android/src/main/java/com/viettelpost/otakit/OTATestReceiver.kt +51 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateBundleResolver.kt +405 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateCleanup.kt +186 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateDownloader.kt +649 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateMetadata.kt +72 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateModule.kt +140 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdatePackage.kt +30 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateSignatureVerifier.kt +63 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateStorage.kt +62 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAZipUtils.kt +100 -0
- package/android/src/main/res/raw/ota_public_key.pem +9 -0
- package/bin/cli/assets-zip.js +77 -0
- package/bin/cli/bundle.js +72 -0
- package/bin/cli/deploy.js +224 -0
- package/bin/cli/sign.js +97 -0
- package/bin/cli/upload.js +109 -0
- package/bin/ota.js +200 -0
- package/docs/BACKEND_CONTRACT.md +93 -0
- package/docs/DEPLOY_CLI.md +39 -0
- package/docs/INTEGRATION_ANDROID.md +20 -0
- package/docs/INTEGRATION_IOS.md +21 -0
- package/docs/RELEASE_WORKFLOW.md +14 -0
- package/ios/OTAHashUtils.swift +22 -0
- package/ios/OTAUpdateBundleResolver.swift +359 -0
- package/ios/OTAUpdateCleanup.swift +269 -0
- package/ios/OTAUpdateDownloader.swift +709 -0
- package/ios/OTAUpdateMetadata.swift +47 -0
- package/ios/OTAUpdateModule.mm +190 -0
- package/ios/OTAUpdateSignatureVerifier.swift +81 -0
- package/ios/OTAUpdateStorage.swift +83 -0
- package/ios/OTAZipUtils.swift +103 -0
- package/ios/ota_public_key.pem +9 -0
- package/lib/NativeOTAUpdate.d.ts +77 -0
- package/lib/NativeOTAUpdate.js +59 -0
- package/lib/OTAClient.d.ts +27 -0
- package/lib/OTAClient.js +101 -0
- package/lib/config.d.ts +14 -0
- package/lib/config.js +29 -0
- package/lib/devtools.d.ts +10 -0
- package/lib/devtools.js +54 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.js +32 -0
- package/lib/spec/NativeOTAUpdate.d.ts +16 -0
- package/lib/spec/NativeOTAUpdate.js +4 -0
- package/package.json +82 -0
- package/react-native-ota.podspec +21 -0
- package/scripts/run-bin.js +67 -0
- package/src/NativeOTAUpdate.ts +144 -0
- package/src/OTAClient.ts +151 -0
- package/src/config.ts +41 -0
- package/src/devtools.ts +64 -0
- package/src/index.ts +69 -0
- 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
|
+
}
|