@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
@@ -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
+ }