@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
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
package com.viettelpost.otakit
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import org.json.JSONException
|
|
6
|
+
import org.json.JSONObject
|
|
7
|
+
import java.io.BufferedOutputStream
|
|
8
|
+
import java.io.File
|
|
9
|
+
import java.io.FileOutputStream
|
|
10
|
+
import java.io.IOException
|
|
11
|
+
import java.net.HttpURLConnection
|
|
12
|
+
import java.net.URL
|
|
13
|
+
import java.util.Locale
|
|
14
|
+
|
|
15
|
+
object OTAUpdateDownloader {
|
|
16
|
+
private const val TAG = "OTAKit"
|
|
17
|
+
private const val PLATFORM_ANDROID = "android"
|
|
18
|
+
private const val BUNDLE_FILE_NAME = "index.android.bundle"
|
|
19
|
+
private const val ASSETS_ZIP_FILE_NAME = "assets.zip"
|
|
20
|
+
private const val PARTIAL_SUFFIX = ".download"
|
|
21
|
+
|
|
22
|
+
private const val STATUS_DOWNLOADED = "downloaded"
|
|
23
|
+
private const val STATUS_DOWNLOAD_FAILED = "download_failed"
|
|
24
|
+
private const val STATUS_VERIFIED = "verified"
|
|
25
|
+
private const val STATUS_VERIFYING = "verifying"
|
|
26
|
+
private const val STATUS_VERIFY_FAILED = "verify_failed"
|
|
27
|
+
private const val STATUS_PENDING = "pending"
|
|
28
|
+
private const val REASON_DOWNLOAD_FAILED = "download_failed"
|
|
29
|
+
private const val REASON_ASSETS_DOWNLOAD_FAILED = "assets_download_failed"
|
|
30
|
+
private const val REASON_MISSING_SHA256 = "missing_sha256"
|
|
31
|
+
private const val REASON_MISSING_ASSETS_SHA256 = "missing_assets_sha256"
|
|
32
|
+
private const val REASON_MISSING_SIGNATURE = "missing_signature"
|
|
33
|
+
private const val REASON_SIGNATURE_INVALID = "signature_invalid"
|
|
34
|
+
private const val REASON_SHA256_MISMATCH = "sha256_mismatch"
|
|
35
|
+
private const val REASON_ASSETS_SHA256_MISMATCH = "assets_sha256_mismatch"
|
|
36
|
+
private const val REASON_ASSETS_EXTRACT_FAILED = "assets_extract_failed"
|
|
37
|
+
private const val REASON_UNSAFE_ASSET_ZIP_ENTRY = "unsafe_asset_zip_entry"
|
|
38
|
+
private const val REASON_TEMP_BUNDLE_NOT_FOUND_FOR_VERIFY = "temp_bundle_not_found_for_verify"
|
|
39
|
+
private const val REASON_TEMP_ASSETS_NOT_FOUND_FOR_VERIFY = "temp_assets_not_found_for_verify"
|
|
40
|
+
private const val CONNECT_TIMEOUT_MS = 15_000
|
|
41
|
+
private const val READ_TIMEOUT_MS = 30_000
|
|
42
|
+
|
|
43
|
+
fun downloadBundle(context: Context, updateJson: String): String {
|
|
44
|
+
val update = OTAUpdatePayload.parse(updateJson, requireBundleUrl = true)
|
|
45
|
+
validateVersionCanInstall(context, update.version)
|
|
46
|
+
val tempDir = tempBundleDir(context, update.version)
|
|
47
|
+
val partialFile = File(tempDir, BUNDLE_FILE_NAME + PARTIAL_SUFFIX)
|
|
48
|
+
val tempBundleFile = File(tempDir, BUNDLE_FILE_NAME)
|
|
49
|
+
val partialAssetsFile = File(tempDir, ASSETS_ZIP_FILE_NAME + PARTIAL_SUFFIX)
|
|
50
|
+
val tempAssetsFile = File(tempDir, ASSETS_ZIP_FILE_NAME)
|
|
51
|
+
|
|
52
|
+
OTAUpdateCleanup.cleanupTemp(context, update.version)
|
|
53
|
+
ensureDirectory(tempDir)
|
|
54
|
+
|
|
55
|
+
Log.d(
|
|
56
|
+
TAG,
|
|
57
|
+
"OTA download started; version=${update.version}, bundleUrl=${update.bundleUrl}, " +
|
|
58
|
+
"assetsUrl=${update.assetsUrl ?: "none"}",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
downloadToFile(update.bundleUrl ?: throw IllegalArgumentException("bundleUrl must not be empty"), partialFile)
|
|
63
|
+
if (partialFile.length() <= 0L) {
|
|
64
|
+
throw IOException("Downloaded OTA bundle is empty")
|
|
65
|
+
}
|
|
66
|
+
if (tempBundleFile.exists() && !tempBundleFile.delete()) {
|
|
67
|
+
throw IOException("Unable to replace temp OTA bundle")
|
|
68
|
+
}
|
|
69
|
+
if (!partialFile.renameTo(tempBundleFile)) {
|
|
70
|
+
partialFile.copyTo(tempBundleFile, overwrite = true)
|
|
71
|
+
if (!partialFile.delete()) {
|
|
72
|
+
throw IOException("Unable to delete partial OTA bundle after copy")
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!tempBundleFile.exists() || tempBundleFile.length() <= 0L) {
|
|
76
|
+
throw IOException("Downloaded OTA bundle was not finalized")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!update.assetsUrl.isNullOrEmpty()) {
|
|
80
|
+
try {
|
|
81
|
+
downloadToFile(update.assetsUrl, partialAssetsFile)
|
|
82
|
+
if (partialAssetsFile.length() <= 0L) {
|
|
83
|
+
throw IOException("Downloaded OTA assets zip is empty")
|
|
84
|
+
}
|
|
85
|
+
if (tempAssetsFile.exists() && !tempAssetsFile.delete()) {
|
|
86
|
+
throw IOException("Unable to replace temp OTA assets zip")
|
|
87
|
+
}
|
|
88
|
+
if (!partialAssetsFile.renameTo(tempAssetsFile)) {
|
|
89
|
+
partialAssetsFile.copyTo(tempAssetsFile, overwrite = true)
|
|
90
|
+
if (!partialAssetsFile.delete()) {
|
|
91
|
+
throw IOException("Unable to delete partial OTA assets zip after copy")
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!tempAssetsFile.exists() || tempAssetsFile.length() <= 0L) {
|
|
95
|
+
throw IOException("Downloaded OTA assets zip was not finalized")
|
|
96
|
+
}
|
|
97
|
+
} catch (error: Exception) {
|
|
98
|
+
throw OTAAssetsDownloadFailedException(update.version, error)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
val metadata = OTAUpdateStorage.readMetadata(context)
|
|
103
|
+
OTAUpdateStorage.writeMetadata(
|
|
104
|
+
context,
|
|
105
|
+
metadata.copy(
|
|
106
|
+
status = STATUS_DOWNLOADED,
|
|
107
|
+
failedBundleVersion = null,
|
|
108
|
+
lastFailureReason = null,
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
Log.d(
|
|
112
|
+
TAG,
|
|
113
|
+
"OTA download completed; version=${update.version}, bundlePath=${tempBundleFile.absolutePath}, " +
|
|
114
|
+
"assetsPath=${tempAssetsFile.takeIf { it.exists() }?.absolutePath ?: "none"}",
|
|
115
|
+
)
|
|
116
|
+
return tempBundleFile.absolutePath
|
|
117
|
+
} catch (error: Exception) {
|
|
118
|
+
partialFile.delete()
|
|
119
|
+
partialAssetsFile.delete()
|
|
120
|
+
OTAUpdateCleanup.cleanupTemp(context, update.version)
|
|
121
|
+
val isAssetsDownloadFailure = error is OTAAssetsDownloadFailedException
|
|
122
|
+
val metadata = OTAUpdateStorage.readMetadata(context)
|
|
123
|
+
OTAUpdateStorage.writeMetadata(
|
|
124
|
+
context,
|
|
125
|
+
metadata.copy(
|
|
126
|
+
status = STATUS_DOWNLOAD_FAILED,
|
|
127
|
+
failedBundleVersion = update.version,
|
|
128
|
+
lastFailureReason = if (isAssetsDownloadFailure) {
|
|
129
|
+
REASON_ASSETS_DOWNLOAD_FAILED
|
|
130
|
+
} else {
|
|
131
|
+
REASON_DOWNLOAD_FAILED
|
|
132
|
+
},
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
Log.e(TAG, "OTA download failed; version=${update.version}", error)
|
|
136
|
+
if (isAssetsDownloadFailure) {
|
|
137
|
+
throw error
|
|
138
|
+
}
|
|
139
|
+
throw OTADownloadFailedException(update.version, error)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
fun installDownloadedBundle(context: Context, updateJson: String): Boolean {
|
|
144
|
+
val update = OTAUpdatePayload.parse(
|
|
145
|
+
updateJson,
|
|
146
|
+
requireBundleUrl = false,
|
|
147
|
+
requireSha256 = true,
|
|
148
|
+
requireSignature = true,
|
|
149
|
+
requireSigningFields = true,
|
|
150
|
+
context = context,
|
|
151
|
+
)
|
|
152
|
+
validateVersionCanInstall(context, update.version)
|
|
153
|
+
verifyDownloadedUpdate(context, update)
|
|
154
|
+
|
|
155
|
+
val tempDir = tempBundleDir(context, update.version)
|
|
156
|
+
val tempBundleFile = File(tempDir, BUNDLE_FILE_NAME)
|
|
157
|
+
if (!tempBundleFile.exists() || tempBundleFile.length() <= 0L) {
|
|
158
|
+
OTAUpdateCleanup.cleanupTemp(context, update.version)
|
|
159
|
+
throw OTATempBundleNotFoundException(tempBundleFile.absolutePath)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
val finalDir = finalBundleDir(context, update.version)
|
|
163
|
+
val finalBundleFile = File(finalDir, BUNDLE_FILE_NAME)
|
|
164
|
+
val tempAssetsFile = File(tempDir, ASSETS_ZIP_FILE_NAME)
|
|
165
|
+
val hasAssetsZip = tempAssetsFile.exists()
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
// Install only after a complete temp download exists. Do not replace active metadata here.
|
|
169
|
+
OTAUpdateCleanup.deleteFinalBundleForFailedInstall(context, update.version)
|
|
170
|
+
if (finalDir.exists()) {
|
|
171
|
+
throw IOException("Unable to clear previous OTA bundle directory")
|
|
172
|
+
}
|
|
173
|
+
ensureDirectory(finalDir)
|
|
174
|
+
tempBundleFile.copyTo(finalBundleFile, overwrite = false)
|
|
175
|
+
if (!finalBundleFile.exists() || finalBundleFile.length() <= 0L) {
|
|
176
|
+
throw IOException("Installed OTA bundle is missing or empty")
|
|
177
|
+
}
|
|
178
|
+
if (hasAssetsZip) {
|
|
179
|
+
try {
|
|
180
|
+
OTAZipUtils.unzip(tempAssetsFile, finalDir)
|
|
181
|
+
} catch (error: OTAUnsafeAssetsZipEntryException) {
|
|
182
|
+
markVerifyFailed(context, update.version, REASON_UNSAFE_ASSET_ZIP_ENTRY)
|
|
183
|
+
OTAUpdateCleanup.cleanupTemp(context, update.version)
|
|
184
|
+
OTAUpdateCleanup.deleteFinalBundleForFailedInstall(context, update.version)
|
|
185
|
+
throw error
|
|
186
|
+
} catch (error: Exception) {
|
|
187
|
+
markVerifyFailed(context, update.version, REASON_ASSETS_EXTRACT_FAILED)
|
|
188
|
+
OTAUpdateCleanup.cleanupTemp(context, update.version)
|
|
189
|
+
OTAUpdateCleanup.deleteFinalBundleForFailedInstall(context, update.version)
|
|
190
|
+
throw OTAAssetsInstallFailedException(update.version, error)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
OTAUpdateCleanup.cleanupTemp(context, update.version)
|
|
194
|
+
|
|
195
|
+
val metadata = OTAUpdateStorage.readMetadata(context)
|
|
196
|
+
OTAUpdateStorage.writeMetadata(
|
|
197
|
+
context,
|
|
198
|
+
metadata.copy(
|
|
199
|
+
previousBundleVersion = metadata.activeBundleVersion,
|
|
200
|
+
pendingBundleVersion = update.version,
|
|
201
|
+
status = STATUS_PENDING,
|
|
202
|
+
launchCountForPending = 0,
|
|
203
|
+
failedBundleVersion = null,
|
|
204
|
+
lastFailureReason = null,
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
Log.d(
|
|
208
|
+
TAG,
|
|
209
|
+
"OTA install prepared; version=${update.version}, bundlePath=${finalBundleFile.absolutePath}, " +
|
|
210
|
+
"assetsInstalled=$hasAssetsZip",
|
|
211
|
+
)
|
|
212
|
+
return true
|
|
213
|
+
} catch (error: Exception) {
|
|
214
|
+
// A failed install must not leave a partial bundle that can later be selected.
|
|
215
|
+
OTAUpdateCleanup.cleanupTemp(context, update.version)
|
|
216
|
+
OTAUpdateCleanup.deleteFinalBundleForFailedInstall(context, update.version)
|
|
217
|
+
Log.e(TAG, "OTA install failed; version=${update.version}", error)
|
|
218
|
+
when (error) {
|
|
219
|
+
is OTAUnsafeAssetsZipEntryException,
|
|
220
|
+
is OTAAssetsInstallFailedException -> throw error
|
|
221
|
+
else -> throw OTAInstallFailedException(update.version, error)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
fun downloadAndInstallBundle(context: Context, updateJson: String): Boolean {
|
|
227
|
+
downloadBundle(context, updateJson)
|
|
228
|
+
return installDownloadedBundle(context, updateJson)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
fun getDownloadInfo(context: Context, version: String): JSONObject {
|
|
232
|
+
val normalizedVersion = normalizeVersion(version)
|
|
233
|
+
val tempBundleFile = File(tempBundleDir(context, normalizedVersion), BUNDLE_FILE_NAME)
|
|
234
|
+
val finalBundleFile = File(finalBundleDir(context, normalizedVersion), BUNDLE_FILE_NAME)
|
|
235
|
+
val tempAssetsFile = File(tempBundleDir(context, normalizedVersion), ASSETS_ZIP_FILE_NAME)
|
|
236
|
+
val finalDir = finalBundleDir(context, normalizedVersion)
|
|
237
|
+
val finalAssetFiles = finalDir.walkTopDown()
|
|
238
|
+
.filter { it.isFile && it.name != BUNDLE_FILE_NAME }
|
|
239
|
+
.toList()
|
|
240
|
+
|
|
241
|
+
return JSONObject().apply {
|
|
242
|
+
put("version", normalizedVersion)
|
|
243
|
+
put("tempBundlePath", tempBundleFile.absolutePath)
|
|
244
|
+
put("finalBundlePath", finalBundleFile.absolutePath)
|
|
245
|
+
put("tempAssetsZipPath", tempAssetsFile.absolutePath)
|
|
246
|
+
put("finalAssetsDirectoryPath", finalDir.absolutePath)
|
|
247
|
+
put("tempExists", tempBundleFile.exists())
|
|
248
|
+
put("finalExists", finalBundleFile.exists())
|
|
249
|
+
put("tempAssetsZipExists", tempAssetsFile.exists())
|
|
250
|
+
put("finalAssetFileCount", finalAssetFiles.size)
|
|
251
|
+
put("tempSize", fileSize(tempBundleFile))
|
|
252
|
+
put("finalSize", fileSize(finalBundleFile))
|
|
253
|
+
put("tempAssetsZipSize", fileSize(tempAssetsFile))
|
|
254
|
+
put("finalAssetsSize", finalAssetFiles.sumOf { it.length() })
|
|
255
|
+
putNullable("tempSha256", fileSha256(tempBundleFile))
|
|
256
|
+
putNullable("finalSha256", fileSha256(finalBundleFile))
|
|
257
|
+
putNullable("tempAssetsSha256", fileSha256(tempAssetsFile))
|
|
258
|
+
put("signatureRequired", true)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private fun verifyDownloadedUpdate(context: Context, update: OTAUpdatePayload): Boolean {
|
|
263
|
+
try {
|
|
264
|
+
verifyDownloadedBundle(context, update)
|
|
265
|
+
verifyUpdateSignature(context, update)
|
|
266
|
+
return true
|
|
267
|
+
} catch (error: OTAMissingSignatureException) {
|
|
268
|
+
OTAUpdateCleanup.cleanupTemp(context, update.version)
|
|
269
|
+
throw error
|
|
270
|
+
} catch (error: OTASignatureInvalidException) {
|
|
271
|
+
OTAUpdateCleanup.cleanupTemp(context, update.version)
|
|
272
|
+
throw error
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private fun verifyDownloadedBundle(context: Context, update: OTAUpdatePayload): Boolean {
|
|
277
|
+
val tempDir = tempBundleDir(context, update.version)
|
|
278
|
+
val tempBundleFile = File(tempDir, BUNDLE_FILE_NAME)
|
|
279
|
+
val tempAssetsFile = File(tempDir, ASSETS_ZIP_FILE_NAME)
|
|
280
|
+
val expectedSha256 = update.sha256
|
|
281
|
+
val expectedAssetsSha256 = update.assetsSha256
|
|
282
|
+
|
|
283
|
+
if (expectedSha256.isNullOrEmpty()) {
|
|
284
|
+
markVerifyFailed(context, update.version, REASON_MISSING_SHA256)
|
|
285
|
+
OTAUpdateCleanup.cleanupTemp(context, update.version)
|
|
286
|
+
Log.w(TAG, "OTA verify failed; version=${update.version}, reason=$REASON_MISSING_SHA256")
|
|
287
|
+
throw OTAMissingSha256Exception(update.version)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (!tempBundleFile.exists() || tempBundleFile.length() <= 0L) {
|
|
291
|
+
markVerifyFailed(context, update.version, REASON_TEMP_BUNDLE_NOT_FOUND_FOR_VERIFY)
|
|
292
|
+
OTAUpdateCleanup.cleanupTemp(context, update.version)
|
|
293
|
+
Log.w(TAG, "OTA verify failed; version=${update.version}, reason=$REASON_TEMP_BUNDLE_NOT_FOUND_FOR_VERIFY")
|
|
294
|
+
throw OTATempBundleNotFoundException(tempBundleFile.absolutePath)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!update.assetsUrl.isNullOrEmpty()) {
|
|
298
|
+
if (expectedAssetsSha256.isNullOrEmpty()) {
|
|
299
|
+
markVerifyFailed(context, update.version, REASON_MISSING_ASSETS_SHA256)
|
|
300
|
+
OTAUpdateCleanup.cleanupTemp(context, update.version)
|
|
301
|
+
Log.w(TAG, "OTA verify failed; version=${update.version}, reason=$REASON_MISSING_ASSETS_SHA256")
|
|
302
|
+
throw OTAMissingAssetsSha256Exception(update.version)
|
|
303
|
+
}
|
|
304
|
+
if (!tempAssetsFile.exists() || tempAssetsFile.length() <= 0L) {
|
|
305
|
+
markVerifyFailed(context, update.version, REASON_TEMP_ASSETS_NOT_FOUND_FOR_VERIFY)
|
|
306
|
+
OTAUpdateCleanup.cleanupTemp(context, update.version)
|
|
307
|
+
Log.w(TAG, "OTA verify failed; version=${update.version}, reason=$REASON_TEMP_ASSETS_NOT_FOUND_FOR_VERIFY")
|
|
308
|
+
throw OTAAssetsZipNotFoundException(tempAssetsFile.absolutePath)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
val metadata = OTAUpdateStorage.readMetadata(context)
|
|
313
|
+
OTAUpdateStorage.writeMetadata(
|
|
314
|
+
context,
|
|
315
|
+
metadata.copy(
|
|
316
|
+
status = STATUS_VERIFYING,
|
|
317
|
+
failedBundleVersion = null,
|
|
318
|
+
lastFailureReason = null,
|
|
319
|
+
),
|
|
320
|
+
)
|
|
321
|
+
Log.d(TAG, "OTA verify started; version=${update.version}, path=${tempBundleFile.absolutePath}")
|
|
322
|
+
|
|
323
|
+
val actualSha256 = OTAHashUtils.sha256(tempBundleFile)
|
|
324
|
+
if (!actualSha256.equals(expectedSha256, ignoreCase = true)) {
|
|
325
|
+
OTAUpdateCleanup.cleanupTemp(context, update.version)
|
|
326
|
+
markVerifyFailed(context, update.version, REASON_SHA256_MISMATCH)
|
|
327
|
+
Log.e(
|
|
328
|
+
TAG,
|
|
329
|
+
"OTA verify failed; version=${update.version}, reason=$REASON_SHA256_MISMATCH, expected=$expectedSha256, actual=$actualSha256",
|
|
330
|
+
)
|
|
331
|
+
throw OTASha256MismatchException(update.version)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (tempAssetsFile.exists()) {
|
|
335
|
+
if (expectedAssetsSha256.isNullOrEmpty()) {
|
|
336
|
+
OTAUpdateCleanup.cleanupTemp(context, update.version)
|
|
337
|
+
markVerifyFailed(context, update.version, REASON_MISSING_ASSETS_SHA256)
|
|
338
|
+
throw OTAMissingAssetsSha256Exception(update.version)
|
|
339
|
+
}
|
|
340
|
+
val actualAssetsSha256 = OTAHashUtils.sha256(tempAssetsFile)
|
|
341
|
+
if (!actualAssetsSha256.equals(expectedAssetsSha256, ignoreCase = true)) {
|
|
342
|
+
OTAUpdateCleanup.cleanupTemp(context, update.version)
|
|
343
|
+
markVerifyFailed(context, update.version, REASON_ASSETS_SHA256_MISMATCH)
|
|
344
|
+
Log.e(
|
|
345
|
+
TAG,
|
|
346
|
+
"OTA assets verify failed; version=${update.version}, reason=$REASON_ASSETS_SHA256_MISMATCH, " +
|
|
347
|
+
"expected=$expectedAssetsSha256, actual=$actualAssetsSha256",
|
|
348
|
+
)
|
|
349
|
+
throw OTAAssetsSha256MismatchException(update.version)
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
OTAZipUtils.validate(tempAssetsFile)
|
|
353
|
+
} catch (error: OTAUnsafeAssetsZipEntryException) {
|
|
354
|
+
OTAUpdateCleanup.cleanupTemp(context, update.version)
|
|
355
|
+
markVerifyFailed(context, update.version, REASON_UNSAFE_ASSET_ZIP_ENTRY)
|
|
356
|
+
Log.w(TAG, "OTA assets zip rejected; version=${update.version}", error)
|
|
357
|
+
throw error
|
|
358
|
+
} catch (error: Exception) {
|
|
359
|
+
OTAUpdateCleanup.cleanupTemp(context, update.version)
|
|
360
|
+
markVerifyFailed(context, update.version, REASON_ASSETS_EXTRACT_FAILED)
|
|
361
|
+
Log.w(TAG, "OTA assets zip validation failed; version=${update.version}", error)
|
|
362
|
+
throw OTAAssetsInstallFailedException(update.version, error)
|
|
363
|
+
}
|
|
364
|
+
Log.d(TAG, "OTA assets verify succeeded; version=${update.version}, sha256=$actualAssetsSha256")
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
val verifiedMetadata = OTAUpdateStorage.readMetadata(context)
|
|
368
|
+
OTAUpdateStorage.writeMetadata(
|
|
369
|
+
context,
|
|
370
|
+
verifiedMetadata.copy(
|
|
371
|
+
status = STATUS_VERIFIED,
|
|
372
|
+
failedBundleVersion = null,
|
|
373
|
+
lastFailureReason = null,
|
|
374
|
+
),
|
|
375
|
+
)
|
|
376
|
+
Log.d(TAG, "OTA verify succeeded; version=${update.version}, sha256=$actualSha256")
|
|
377
|
+
return true
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private fun verifyUpdateSignature(context: Context, update: OTAUpdatePayload): Boolean {
|
|
381
|
+
val signature = update.signature
|
|
382
|
+
if (signature.isNullOrEmpty()) {
|
|
383
|
+
markVerifyFailed(context, update.version, REASON_MISSING_SIGNATURE)
|
|
384
|
+
Log.w(TAG, "OTA signature verify failed; version=${update.version}, reason=$REASON_MISSING_SIGNATURE")
|
|
385
|
+
throw OTAMissingSignatureException(update.version)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
val canonicalPayload = OTAUpdateSignatureVerifier.canonicalPayload(
|
|
389
|
+
version = update.version,
|
|
390
|
+
platform = update.platform,
|
|
391
|
+
fileName = update.fileName,
|
|
392
|
+
sha256 = update.sha256 ?: "",
|
|
393
|
+
assetsSha256 = update.assetsSha256,
|
|
394
|
+
)
|
|
395
|
+
val isValid = try {
|
|
396
|
+
OTAUpdateSignatureVerifier.verifySignature(context, canonicalPayload, signature)
|
|
397
|
+
} catch (error: Exception) {
|
|
398
|
+
false
|
|
399
|
+
}
|
|
400
|
+
if (!isValid) {
|
|
401
|
+
markVerifyFailed(context, update.version, REASON_SIGNATURE_INVALID)
|
|
402
|
+
Log.w(
|
|
403
|
+
TAG,
|
|
404
|
+
"OTA signature verify failed; version=${update.version}, reason=$REASON_SIGNATURE_INVALID, " +
|
|
405
|
+
"canonicalPayload=\n$canonicalPayload",
|
|
406
|
+
)
|
|
407
|
+
throw OTASignatureInvalidException(update.version)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
Log.d(TAG, "OTA signature verify succeeded; version=${update.version}")
|
|
411
|
+
return true
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private fun validateVersionCanInstall(context: Context, version: String) {
|
|
415
|
+
val metadata = OTAUpdateStorage.readMetadata(context)
|
|
416
|
+
if (metadata.activeBundleVersion == version || metadata.pendingBundleVersion == version) {
|
|
417
|
+
Log.w(
|
|
418
|
+
TAG,
|
|
419
|
+
"OTA version rejected; version=$version, active=${metadata.activeBundleVersion}, " +
|
|
420
|
+
"pending=${metadata.pendingBundleVersion ?: "none"}",
|
|
421
|
+
)
|
|
422
|
+
throw OTAVersionAlreadyExistsException(version)
|
|
423
|
+
}
|
|
424
|
+
if (metadata.failedBundleVersion == version) {
|
|
425
|
+
Log.w(TAG, "OTA retrying previously failed version=$version")
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private fun downloadToFile(urlString: String, destination: File) {
|
|
430
|
+
val connection = URL(urlString).openConnection() as HttpURLConnection
|
|
431
|
+
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
|
432
|
+
connection.readTimeout = READ_TIMEOUT_MS
|
|
433
|
+
connection.requestMethod = "GET"
|
|
434
|
+
connection.useCaches = false
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
val statusCode = connection.responseCode
|
|
438
|
+
if (statusCode !in 200..299) {
|
|
439
|
+
throw IOException("HTTP $statusCode while downloading OTA bundle")
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
connection.inputStream.use { input ->
|
|
443
|
+
BufferedOutputStream(FileOutputStream(destination)).use { output ->
|
|
444
|
+
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
|
445
|
+
while (true) {
|
|
446
|
+
val bytesRead = input.read(buffer)
|
|
447
|
+
if (bytesRead == -1) {
|
|
448
|
+
break
|
|
449
|
+
}
|
|
450
|
+
output.write(buffer, 0, bytesRead)
|
|
451
|
+
}
|
|
452
|
+
output.flush()
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
} finally {
|
|
456
|
+
connection.disconnect()
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private fun tempBundleDir(context: Context, version: String): File =
|
|
461
|
+
File(File(OTAUpdateBundleResolver.otaDirectory(context), "tmp"), version)
|
|
462
|
+
|
|
463
|
+
private fun finalBundleDir(context: Context, version: String): File =
|
|
464
|
+
File(File(OTAUpdateBundleResolver.otaDirectory(context), "bundles"), version)
|
|
465
|
+
|
|
466
|
+
private fun ensureDirectory(directory: File) {
|
|
467
|
+
if (!directory.exists() && !directory.mkdirs()) {
|
|
468
|
+
throw IOException("Unable to create directory ${directory.absolutePath}")
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private fun fileSize(file: File): Long =
|
|
473
|
+
if (file.exists()) {
|
|
474
|
+
file.length()
|
|
475
|
+
} else {
|
|
476
|
+
0L
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private fun fileSha256(file: File): String? =
|
|
480
|
+
if (file.exists() && file.length() > 0L) {
|
|
481
|
+
OTAHashUtils.sha256(file)
|
|
482
|
+
} else {
|
|
483
|
+
null
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private fun markVerifyFailed(context: Context, version: String, reason: String) {
|
|
487
|
+
val metadata = OTAUpdateStorage.readMetadata(context)
|
|
488
|
+
OTAUpdateStorage.writeMetadata(
|
|
489
|
+
context,
|
|
490
|
+
metadata.copy(
|
|
491
|
+
status = STATUS_VERIFY_FAILED,
|
|
492
|
+
pendingBundleVersion = null,
|
|
493
|
+
failedBundleVersion = version,
|
|
494
|
+
lastFailureReason = reason,
|
|
495
|
+
),
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private fun normalizeVersion(version: String): String {
|
|
500
|
+
val normalizedVersion = version.trim()
|
|
501
|
+
if (normalizedVersion.isEmpty()) {
|
|
502
|
+
throw IllegalArgumentException("version must not be empty")
|
|
503
|
+
}
|
|
504
|
+
if (normalizedVersion.contains("/") || normalizedVersion.contains("\\")) {
|
|
505
|
+
throw IllegalArgumentException("version must not contain path separators")
|
|
506
|
+
}
|
|
507
|
+
return normalizedVersion
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private data class OTAUpdatePayload(
|
|
511
|
+
val version: String,
|
|
512
|
+
val bundleUrl: String?,
|
|
513
|
+
val platform: String,
|
|
514
|
+
val fileName: String,
|
|
515
|
+
val sha256: String?,
|
|
516
|
+
val signature: String?,
|
|
517
|
+
val assetsUrl: String?,
|
|
518
|
+
val assetsSha256: String?,
|
|
519
|
+
) {
|
|
520
|
+
companion object {
|
|
521
|
+
fun parse(
|
|
522
|
+
updateJson: String,
|
|
523
|
+
requireBundleUrl: Boolean,
|
|
524
|
+
requireSha256: Boolean = false,
|
|
525
|
+
requireSignature: Boolean = false,
|
|
526
|
+
requireSigningFields: Boolean = false,
|
|
527
|
+
context: Context? = null,
|
|
528
|
+
): OTAUpdatePayload {
|
|
529
|
+
val data = try {
|
|
530
|
+
JSONObject(updateJson)
|
|
531
|
+
} catch (error: JSONException) {
|
|
532
|
+
throw IllegalArgumentException("updateJson must be valid JSON", error)
|
|
533
|
+
}
|
|
534
|
+
val version = normalizeVersion(data.optString("version", ""))
|
|
535
|
+
val bundleUrl = data.optNullableString("bundleUrl")?.trim()
|
|
536
|
+
if (requireBundleUrl && bundleUrl.isNullOrEmpty()) {
|
|
537
|
+
throw IllegalArgumentException("bundleUrl must not be empty")
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
val sha256 = data.optNullableString("sha256")?.trim()?.lowercase(Locale.US)
|
|
541
|
+
if (requireSha256 && sha256.isNullOrEmpty()) {
|
|
542
|
+
context?.let { markVerifyFailed(it, version, REASON_MISSING_SHA256) }
|
|
543
|
+
context?.let { OTAUpdateCleanup.cleanupTemp(it, version) }
|
|
544
|
+
throw OTAMissingSha256Exception(version)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
val assetsUrl = data.optNullableString("assetsUrl")?.trim()
|
|
548
|
+
?.takeUnless { it.isEmpty() }
|
|
549
|
+
val assetsSha256 = data.optNullableString("assetsSha256")?.trim()?.lowercase(Locale.US)
|
|
550
|
+
if (!assetsUrl.isNullOrEmpty() && requireSha256 && assetsSha256.isNullOrEmpty()) {
|
|
551
|
+
context?.let { markVerifyFailed(it, version, REASON_MISSING_ASSETS_SHA256) }
|
|
552
|
+
context?.let { OTAUpdateCleanup.cleanupTemp(it, version) }
|
|
553
|
+
throw OTAMissingAssetsSha256Exception(version)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
val rawPlatform = data.optNullableString("platform")?.trim()?.lowercase(Locale.US)
|
|
557
|
+
if (requireSigningFields && rawPlatform.isNullOrEmpty()) {
|
|
558
|
+
throw IllegalArgumentException("platform must not be empty")
|
|
559
|
+
}
|
|
560
|
+
if (!rawPlatform.isNullOrEmpty() && rawPlatform != PLATFORM_ANDROID) {
|
|
561
|
+
throw IllegalArgumentException("platform must be android")
|
|
562
|
+
}
|
|
563
|
+
val platform = rawPlatform ?: PLATFORM_ANDROID
|
|
564
|
+
|
|
565
|
+
val rawFileName = data.optNullableString("fileName")?.trim()
|
|
566
|
+
if (requireSigningFields && rawFileName.isNullOrEmpty()) {
|
|
567
|
+
throw IllegalArgumentException("fileName must not be empty")
|
|
568
|
+
}
|
|
569
|
+
if (!rawFileName.isNullOrEmpty() && rawFileName != BUNDLE_FILE_NAME) {
|
|
570
|
+
throw IllegalArgumentException("fileName must be $BUNDLE_FILE_NAME")
|
|
571
|
+
}
|
|
572
|
+
val fileName = rawFileName ?: BUNDLE_FILE_NAME
|
|
573
|
+
|
|
574
|
+
val signature = data.optNullableString("signature")?.trim()
|
|
575
|
+
if (requireSignature && signature.isNullOrEmpty()) {
|
|
576
|
+
context?.let { markVerifyFailed(it, version, REASON_MISSING_SIGNATURE) }
|
|
577
|
+
context?.let { OTAUpdateCleanup.cleanupTemp(it, version) }
|
|
578
|
+
throw OTAMissingSignatureException(version)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return OTAUpdatePayload(
|
|
582
|
+
version = version,
|
|
583
|
+
bundleUrl = bundleUrl,
|
|
584
|
+
platform = platform,
|
|
585
|
+
fileName = fileName,
|
|
586
|
+
sha256 = sha256,
|
|
587
|
+
signature = signature,
|
|
588
|
+
assetsUrl = assetsUrl,
|
|
589
|
+
assetsSha256 = assetsSha256,
|
|
590
|
+
)
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
class OTADownloadFailedException(version: String, cause: Throwable) :
|
|
597
|
+
IOException("Failed to download OTA bundle for version $version", cause)
|
|
598
|
+
|
|
599
|
+
class OTAAssetsDownloadFailedException(version: String, cause: Throwable) :
|
|
600
|
+
IOException("Failed to download OTA assets for version $version", cause)
|
|
601
|
+
|
|
602
|
+
class OTATempBundleNotFoundException(path: String) :
|
|
603
|
+
IllegalStateException("Downloaded OTA temp bundle was not found at $path")
|
|
604
|
+
|
|
605
|
+
class OTAMissingSha256Exception(version: String) :
|
|
606
|
+
IllegalArgumentException("OTA update $version is missing required sha256")
|
|
607
|
+
|
|
608
|
+
class OTASha256MismatchException(version: String) :
|
|
609
|
+
IllegalStateException("OTA SHA-256 verification failed for version $version")
|
|
610
|
+
|
|
611
|
+
class OTAMissingAssetsSha256Exception(version: String) :
|
|
612
|
+
IllegalArgumentException("OTA update $version is missing required assetsSha256")
|
|
613
|
+
|
|
614
|
+
class OTAMissingSignatureException(version: String) :
|
|
615
|
+
IllegalArgumentException("OTA update $version is missing required signature")
|
|
616
|
+
|
|
617
|
+
class OTASignatureInvalidException(version: String) :
|
|
618
|
+
IllegalStateException("OTA signature verification failed for version $version")
|
|
619
|
+
|
|
620
|
+
class OTAAssetsSha256MismatchException(version: String) :
|
|
621
|
+
IllegalStateException("OTA assets ZIP SHA-256 verification failed for version $version")
|
|
622
|
+
|
|
623
|
+
class OTAAssetsZipNotFoundException(path: String) :
|
|
624
|
+
IllegalStateException("Downloaded OTA assets zip was not found at $path")
|
|
625
|
+
|
|
626
|
+
class OTAUnsafeAssetsZipEntryException(entryName: String) :
|
|
627
|
+
IllegalStateException("OTA assets zip contains unsafe entry $entryName")
|
|
628
|
+
|
|
629
|
+
class OTAAssetsInstallFailedException(version: String, cause: Throwable) :
|
|
630
|
+
IOException("Failed to install OTA assets for version $version", cause)
|
|
631
|
+
|
|
632
|
+
class OTAVersionAlreadyExistsException(version: String) :
|
|
633
|
+
IllegalStateException(
|
|
634
|
+
"OTA version already exists as active or pending. Use a new version and recalculate hashes for every rebuild. version=$version",
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
class OTAInstallFailedException(version: String, cause: Throwable) :
|
|
638
|
+
IOException("Failed to install downloaded OTA bundle for version $version", cause)
|
|
639
|
+
|
|
640
|
+
private fun JSONObject.optNullableString(name: String): String? {
|
|
641
|
+
if (!has(name) || isNull(name)) {
|
|
642
|
+
return null
|
|
643
|
+
}
|
|
644
|
+
return optString(name)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private fun JSONObject.putNullable(name: String, value: String?) {
|
|
648
|
+
put(name, value ?: JSONObject.NULL)
|
|
649
|
+
}
|