@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,72 @@
1
+ package com.viettelpost.otakit
2
+
3
+ import org.json.JSONObject
4
+
5
+ data class OTAUpdateMetadata(
6
+ val schemaVersion: Int = 1,
7
+ val embeddedBundleVersion: String = EMBEDDED_VERSION,
8
+ val activeBundleVersion: String = EMBEDDED_VERSION,
9
+ val previousBundleVersion: String? = null,
10
+ val pendingBundleVersion: String? = null,
11
+ val failedBundleVersion: String? = null,
12
+ val runningBundleVersion: String = EMBEDDED_VERSION,
13
+ val status: String = STATUS_ACTIVE,
14
+ val launchCountForPending: Int = 0,
15
+ val lastSuccessfulLaunchAt: String? = null,
16
+ val lastFailureReason: String? = null,
17
+ ) {
18
+ fun toJson(): JSONObject =
19
+ JSONObject().apply {
20
+ put("schemaVersion", schemaVersion)
21
+ put("embeddedBundleVersion", embeddedBundleVersion)
22
+ put("activeBundleVersion", activeBundleVersion)
23
+ putNullable("previousBundleVersion", previousBundleVersion)
24
+ putNullable("pendingBundleVersion", pendingBundleVersion)
25
+ putNullable("failedBundleVersion", failedBundleVersion)
26
+ put("runningBundleVersion", runningBundleVersion)
27
+ put("status", status)
28
+ put("launchCountForPending", launchCountForPending)
29
+ putNullable("lastSuccessfulLaunchAt", lastSuccessfulLaunchAt)
30
+ putNullable("lastFailureReason", lastFailureReason)
31
+ }
32
+
33
+ companion object {
34
+ private const val EMBEDDED_VERSION = "embedded"
35
+ private const val STATUS_ACTIVE = "active"
36
+ private const val STATUS_FAILED = "failed"
37
+ private const val PARSE_FAILED_REASON = "metadata_parse_failed"
38
+
39
+ fun default(): OTAUpdateMetadata = OTAUpdateMetadata()
40
+
41
+ fun parse(json: String): OTAUpdateMetadata {
42
+ val data = JSONObject(json)
43
+ return OTAUpdateMetadata(
44
+ schemaVersion = data.optInt("schemaVersion", 1),
45
+ embeddedBundleVersion = data.optString("embeddedBundleVersion", EMBEDDED_VERSION),
46
+ activeBundleVersion = data.optString("activeBundleVersion", EMBEDDED_VERSION),
47
+ previousBundleVersion = data.optNullableString("previousBundleVersion"),
48
+ pendingBundleVersion = data.optNullableString("pendingBundleVersion"),
49
+ failedBundleVersion = data.optNullableString("failedBundleVersion"),
50
+ runningBundleVersion = data.optString("runningBundleVersion", EMBEDDED_VERSION),
51
+ status = data.optString("status", STATUS_ACTIVE),
52
+ launchCountForPending = data.optInt("launchCountForPending", 0),
53
+ lastSuccessfulLaunchAt = data.optNullableString("lastSuccessfulLaunchAt"),
54
+ lastFailureReason = data.optNullableString("lastFailureReason"),
55
+ )
56
+ }
57
+
58
+ fun metadataParseFailed(): OTAUpdateMetadata =
59
+ default().copy(status = STATUS_FAILED, lastFailureReason = PARSE_FAILED_REASON)
60
+ }
61
+ }
62
+
63
+ private fun JSONObject.putNullable(name: String, value: String?) {
64
+ put(name, value ?: JSONObject.NULL)
65
+ }
66
+
67
+ private fun JSONObject.optNullableString(name: String): String? {
68
+ if (!has(name) || isNull(name)) {
69
+ return null
70
+ }
71
+ return optString(name)
72
+ }
@@ -0,0 +1,140 @@
1
+ package com.viettelpost.otakit
2
+
3
+ import com.facebook.react.bridge.Promise
4
+ import com.facebook.react.bridge.ReactApplicationContext
5
+ import com.facebook.react.module.annotations.ReactModule
6
+ import com.viettelpost.otakit.NativeOTAUpdateSpec
7
+
8
+ @ReactModule(name = OTAUpdateModule.NAME)
9
+ class OTAUpdateModule(reactContext: ReactApplicationContext) : NativeOTAUpdateSpec(reactContext) {
10
+ override fun getMetadata(promise: Promise) {
11
+ try {
12
+ val metadata = OTAUpdateStorage.readMetadata(reactApplicationContext)
13
+ promise.resolve(metadata.toJson().toString())
14
+ } catch (error: Exception) {
15
+ promise.reject("OTA_GET_METADATA_FAILED", "Failed to read OTA metadata", error)
16
+ }
17
+ }
18
+
19
+ override fun resetMetadata(promise: Promise) {
20
+ try {
21
+ OTAUpdateStorage.resetMetadata(reactApplicationContext)
22
+ promise.resolve(true)
23
+ } catch (error: Exception) {
24
+ promise.reject("OTA_RESET_METADATA_FAILED", "Failed to reset OTA metadata", error)
25
+ }
26
+ }
27
+
28
+ override fun getOTADirectory(promise: Promise) {
29
+ try {
30
+ promise.resolve(OTAUpdateBundleResolver.ensureOTADirectory(reactApplicationContext).absolutePath)
31
+ } catch (error: Exception) {
32
+ promise.reject("OTA_GET_DIRECTORY_FAILED", "Failed to read OTA directory", error)
33
+ }
34
+ }
35
+
36
+ override fun prepareManualInstall(bundleVersion: String, promise: Promise) {
37
+ try {
38
+ promise.resolve(OTAUpdateBundleResolver.prepareManualInstall(reactApplicationContext, bundleVersion))
39
+ } catch (error: OTABundleFileNotFoundException) {
40
+ promise.reject("OTA_BUNDLE_FILE_NOT_FOUND", error.message, error)
41
+ } catch (error: IllegalArgumentException) {
42
+ promise.reject("OTA_INVALID_BUNDLE_VERSION", error.message, error)
43
+ } catch (error: Exception) {
44
+ promise.reject("OTA_PREPARE_MANUAL_INSTALL_FAILED", "Failed to prepare OTA manual install", error)
45
+ }
46
+ }
47
+
48
+ override fun markSuccess(promise: Promise) {
49
+ try {
50
+ promise.resolve(OTAUpdateBundleResolver.markSuccess(reactApplicationContext))
51
+ } catch (error: OTAMarkSuccessRejectedException) {
52
+ promise.reject("OTA_MARK_SUCCESS_REJECTED", error.message, error)
53
+ } catch (error: Exception) {
54
+ promise.reject("OTA_MARK_SUCCESS_FAILED", "Failed to mark OTA success", error)
55
+ }
56
+ }
57
+
58
+ override fun copyBundleFromDocuments(bundleVersion: String, fileName: String, promise: Promise) {
59
+ promise.resolve(false)
60
+ }
61
+
62
+ override fun getCurrentBundleInfo(promise: Promise) {
63
+ try {
64
+ val bundleInfo = OTAUpdateBundleResolver.getCurrentBundleInfo(reactApplicationContext)
65
+ promise.resolve(bundleInfo.toString())
66
+ } catch (error: Exception) {
67
+ promise.reject("OTA_GET_CURRENT_BUNDLE_INFO_FAILED", "Failed to read OTA bundle info", error)
68
+ }
69
+ }
70
+
71
+ override fun downloadAndInstallBundle(updateJson: String, promise: Promise) {
72
+ try {
73
+ promise.resolve(OTAUpdateDownloader.downloadAndInstallBundle(reactApplicationContext, updateJson))
74
+ } catch (error: OTAVersionAlreadyExistsException) {
75
+ promise.reject("OTA_VERSION_ALREADY_EXISTS", error.message, error)
76
+ } catch (error: OTAMissingSha256Exception) {
77
+ promise.reject("OTA_MISSING_SHA256", error.message, error)
78
+ } catch (error: OTAMissingAssetsSha256Exception) {
79
+ promise.reject("OTA_MISSING_ASSETS_SHA256", error.message, error)
80
+ } catch (error: OTAMissingSignatureException) {
81
+ promise.reject("OTA_MISSING_SIGNATURE", error.message, error)
82
+ } catch (error: IllegalArgumentException) {
83
+ promise.reject("OTA_INVALID_UPDATE", error.message, error)
84
+ } catch (error: OTAAssetsDownloadFailedException) {
85
+ promise.reject("OTA_ASSETS_DOWNLOAD_FAILED", error.message, error)
86
+ } catch (error: OTADownloadFailedException) {
87
+ promise.reject("OTA_DOWNLOAD_FAILED", error.message, error)
88
+ } catch (error: OTATempBundleNotFoundException) {
89
+ promise.reject("OTA_TEMP_BUNDLE_NOT_FOUND", error.message, error)
90
+ } catch (error: OTAAssetsZipNotFoundException) {
91
+ promise.reject("OTA_ASSETS_ZIP_NOT_FOUND", error.message, error)
92
+ } catch (error: OTASha256MismatchException) {
93
+ promise.reject("OTA_SHA256_MISMATCH", error.message, error)
94
+ } catch (error: OTAAssetsSha256MismatchException) {
95
+ promise.reject("OTA_ASSETS_SHA256_MISMATCH", error.message, error)
96
+ } catch (error: OTASignatureInvalidException) {
97
+ promise.reject("OTA_SIGNATURE_INVALID", error.message, error)
98
+ } catch (error: OTAUnsafeAssetsZipEntryException) {
99
+ promise.reject("OTA_ASSETS_ZIP_INVALID", error.message, error)
100
+ } catch (error: OTAAssetsInstallFailedException) {
101
+ promise.reject("OTA_ASSETS_EXTRACT_FAILED", error.message, error)
102
+ } catch (error: OTAInstallFailedException) {
103
+ promise.reject("OTA_INSTALL_FAILED", error.message, error)
104
+ } catch (error: Exception) {
105
+ promise.reject("OTA_DOWNLOAD_AND_INSTALL_FAILED", "Failed to download and install OTA bundle", error)
106
+ }
107
+ }
108
+
109
+ override fun getDownloadInfo(version: String, promise: Promise) {
110
+ try {
111
+ val downloadInfo = OTAUpdateDownloader.getDownloadInfo(reactApplicationContext, version)
112
+ promise.resolve(downloadInfo.toString())
113
+ } catch (error: IllegalArgumentException) {
114
+ promise.reject("OTA_INVALID_BUNDLE_VERSION", error.message, error)
115
+ } catch (error: Exception) {
116
+ promise.reject("OTA_GET_DOWNLOAD_INFO_FAILED", "Failed to read OTA download info", error)
117
+ }
118
+ }
119
+
120
+ override fun getOTADiskUsage(promise: Promise) {
121
+ try {
122
+ val diskUsage = OTAUpdateCleanup.getOTADiskUsage(reactApplicationContext)
123
+ promise.resolve(diskUsage.toString())
124
+ } catch (error: Exception) {
125
+ promise.reject("OTA_GET_DISK_USAGE_FAILED", "Failed to read OTA disk usage", error)
126
+ }
127
+ }
128
+
129
+ override fun cleanupOTAStorage(promise: Promise) {
130
+ try {
131
+ promise.resolve(OTAUpdateCleanup.cleanupOTAStorage(reactApplicationContext))
132
+ } catch (error: Exception) {
133
+ promise.reject("OTA_CLEANUP_STORAGE_FAILED", "Failed to cleanup OTA storage", error)
134
+ }
135
+ }
136
+
137
+ companion object {
138
+ const val NAME: String = NativeOTAUpdateSpec.NAME
139
+ }
140
+ }
@@ -0,0 +1,30 @@
1
+ package com.viettelpost.otakit
2
+
3
+ import com.facebook.react.BaseReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.module.model.ReactModuleInfo
7
+ import com.facebook.react.module.model.ReactModuleInfoProvider
8
+
9
+ class OTAUpdatePackage : BaseReactPackage() {
10
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
11
+ if (name == OTAUpdateModule.NAME) {
12
+ OTAUpdateModule(reactContext)
13
+ } else {
14
+ null
15
+ }
16
+
17
+ override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
18
+ val moduleInfos = mapOf(
19
+ OTAUpdateModule.NAME to ReactModuleInfo(
20
+ OTAUpdateModule.NAME,
21
+ OTAUpdateModule::class.java.name,
22
+ false,
23
+ false,
24
+ false,
25
+ true,
26
+ ),
27
+ )
28
+ return ReactModuleInfoProvider { moduleInfos }
29
+ }
30
+ }
@@ -0,0 +1,63 @@
1
+ package com.viettelpost.otakit
2
+
3
+ import android.content.Context
4
+ import android.util.Base64
5
+ import java.security.KeyFactory
6
+ import java.security.PublicKey
7
+ import java.security.Signature
8
+ import java.security.spec.X509EncodedKeySpec
9
+
10
+ object OTAUpdateSignatureVerifier {
11
+ private const val SIGNATURE_ALGORITHM = "SHA256withRSA"
12
+ private const val KEY_ALGORITHM = "RSA"
13
+
14
+ fun canonicalPayload(
15
+ version: String,
16
+ platform: String,
17
+ fileName: String,
18
+ sha256: String,
19
+ assetsSha256: String? = null,
20
+ ): String {
21
+ val lines = mutableListOf(
22
+ "version=$version",
23
+ "platform=$platform",
24
+ "fileName=$fileName",
25
+ "sha256=$sha256",
26
+ )
27
+ if (!assetsSha256.isNullOrBlank()) {
28
+ lines.add("assetsSha256=$assetsSha256")
29
+ }
30
+ return lines.joinToString("\n")
31
+ }
32
+
33
+ fun verifySignature(
34
+ context: Context,
35
+ canonicalPayload: String,
36
+ base64Signature: String,
37
+ ): Boolean {
38
+ val signature = Signature.getInstance(SIGNATURE_ALGORITHM)
39
+ signature.initVerify(loadPublicKey(context))
40
+ signature.update(canonicalPayload.toByteArray(Charsets.UTF_8))
41
+ return signature.verify(Base64.decode(base64Signature, Base64.DEFAULT))
42
+ }
43
+
44
+ private fun loadPublicKey(context: Context): PublicKey {
45
+ // This is a public key. It is safe to ship in the app. Never ship the private key.
46
+ val hostResourceId = context.resources.getIdentifier(
47
+ "ota_public_key",
48
+ "raw",
49
+ context.packageName,
50
+ )
51
+ val publicKeyResourceId = if (hostResourceId != 0) hostResourceId else R.raw.ota_public_key
52
+ val pem = context.resources.openRawResource(publicKeyResourceId)
53
+ .bufferedReader(Charsets.UTF_8)
54
+ .use { it.readText() }
55
+ val normalizedPem = pem
56
+ .replace("-----BEGIN PUBLIC KEY-----", "")
57
+ .replace("-----END PUBLIC KEY-----", "")
58
+ .replace("\\s".toRegex(), "")
59
+ val keyBytes = Base64.decode(normalizedPem, Base64.DEFAULT)
60
+ val keySpec = X509EncodedKeySpec(keyBytes)
61
+ return KeyFactory.getInstance(KEY_ALGORITHM).generatePublic(keySpec)
62
+ }
63
+ }
@@ -0,0 +1,62 @@
1
+ package com.viettelpost.otakit
2
+
3
+ import android.content.Context
4
+ import java.io.File
5
+ import java.io.IOException
6
+
7
+ object OTAUpdateStorage {
8
+ private const val OTA_DIR_NAME = "ota"
9
+ private const val METADATA_FILE_NAME = "metadata.json"
10
+ private const val TEMP_METADATA_FILE_NAME = "metadata.json.tmp"
11
+
12
+ @Synchronized
13
+ @Throws(IOException::class)
14
+ fun readMetadata(context: Context): OTAUpdateMetadata {
15
+ val metadataFile = getMetadataPath(context)
16
+ if (!metadataFile.exists()) {
17
+ return resetMetadata(context)
18
+ }
19
+
20
+ return try {
21
+ OTAUpdateMetadata.parse(metadataFile.readText(Charsets.UTF_8))
22
+ } catch (_: Exception) {
23
+ val fallback = OTAUpdateMetadata.metadataParseFailed()
24
+ writeMetadata(context, fallback)
25
+ fallback
26
+ }
27
+ }
28
+
29
+ @Synchronized
30
+ @Throws(IOException::class)
31
+ fun writeMetadata(context: Context, metadata: OTAUpdateMetadata) {
32
+ val metadataFile = getMetadataPath(context)
33
+ val metadataDir = metadataFile.parentFile
34
+ ?: throw IOException("OTA metadata directory is unavailable")
35
+ if (!metadataDir.exists() && !metadataDir.mkdirs()) {
36
+ throw IOException("Unable to create OTA metadata directory")
37
+ }
38
+
39
+ val tempFile = File(metadataDir, TEMP_METADATA_FILE_NAME)
40
+ tempFile.writeText(metadata.toJson().toString(), Charsets.UTF_8)
41
+
42
+ if (metadataFile.exists() && !metadataFile.delete()) {
43
+ tempFile.delete()
44
+ throw IOException("Unable to replace OTA metadata")
45
+ }
46
+ if (!tempFile.renameTo(metadataFile)) {
47
+ tempFile.delete()
48
+ throw IOException("Unable to move OTA metadata into place")
49
+ }
50
+ }
51
+
52
+ @Synchronized
53
+ @Throws(IOException::class)
54
+ fun resetMetadata(context: Context): OTAUpdateMetadata {
55
+ val metadata = OTAUpdateMetadata.default()
56
+ writeMetadata(context, metadata)
57
+ return metadata
58
+ }
59
+
60
+ fun getMetadataPath(context: Context): File =
61
+ File(File(context.filesDir, OTA_DIR_NAME), METADATA_FILE_NAME)
62
+ }
@@ -0,0 +1,100 @@
1
+ package com.viettelpost.otakit
2
+
3
+ import android.util.Log
4
+ import java.io.BufferedOutputStream
5
+ import java.io.File
6
+ import java.io.FileOutputStream
7
+ import java.io.IOException
8
+ import java.util.Locale
9
+ import java.util.zip.ZipInputStream
10
+
11
+ object OTAZipUtils {
12
+ private const val TAG = "OTAKit"
13
+
14
+ fun validate(zipFile: File) {
15
+ ZipInputStream(zipFile.inputStream()).use { zipInput ->
16
+ while (true) {
17
+ val entry = zipInput.nextEntry ?: break
18
+ ensureZipEntrySafe(entry.name)
19
+ zipInput.closeEntry()
20
+ }
21
+ }
22
+ }
23
+
24
+ fun unzip(zipFile: File, destinationDir: File) {
25
+ val canonicalDestination = destinationDir.canonicalFile
26
+ if (!canonicalDestination.exists() && !canonicalDestination.mkdirs()) {
27
+ throw IOException("Unable to create OTA assets directory ${canonicalDestination.absolutePath}")
28
+ }
29
+
30
+ ZipInputStream(zipFile.inputStream()).use { zipInput ->
31
+ while (true) {
32
+ val entry = zipInput.nextEntry ?: break
33
+ val entryName = entry.name
34
+ ensureZipEntrySafe(entryName)
35
+
36
+ val outputFile = File(canonicalDestination, entryName)
37
+ val canonicalOutput = outputFile.canonicalFile
38
+ if (!isInsideDirectory(canonicalDestination, canonicalOutput)) {
39
+ throw OTAUnsafeAssetsZipEntryException(entryName)
40
+ }
41
+
42
+ if (entry.isDirectory) {
43
+ if (!canonicalOutput.exists() && !canonicalOutput.mkdirs()) {
44
+ throw IOException("Unable to create OTA assets directory ${canonicalOutput.absolutePath}")
45
+ }
46
+ } else {
47
+ val parent = canonicalOutput.parentFile ?: canonicalDestination
48
+ if (!parent.exists() && !parent.mkdirs()) {
49
+ throw IOException("Unable to create OTA assets directory ${parent.absolutePath}")
50
+ }
51
+ BufferedOutputStream(FileOutputStream(canonicalOutput)).use { output ->
52
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
53
+ while (true) {
54
+ val bytesRead = zipInput.read(buffer)
55
+ if (bytesRead == -1) {
56
+ break
57
+ }
58
+ output.write(buffer, 0, bytesRead)
59
+ }
60
+ output.flush()
61
+ }
62
+ }
63
+ zipInput.closeEntry()
64
+ }
65
+ }
66
+ }
67
+
68
+ private fun ensureZipEntrySafe(entryName: String) {
69
+ if (entryName.isBlank() ||
70
+ entryName.startsWith("/") ||
71
+ entryName.startsWith("\\") ||
72
+ entryName.contains("\\") ||
73
+ entryName.contains("../") ||
74
+ entryName == ".." ||
75
+ entryName.endsWith("/..")
76
+ ) {
77
+ Log.w(TAG, "OTA assets zip entry rejected; entry=$entryName, reason=unsafe_path")
78
+ throw OTAUnsafeAssetsZipEntryException(entryName)
79
+ }
80
+
81
+ val parts = entryName.split('/').filter { it.isNotEmpty() }
82
+ if (parts.isEmpty()) {
83
+ Log.w(TAG, "OTA assets zip entry rejected; entry=$entryName, reason=empty_path_parts")
84
+ throw OTAUnsafeAssetsZipEntryException(entryName)
85
+ }
86
+ if (parts.any { it == "." || it == ".." }) {
87
+ Log.w(TAG, "OTA assets zip entry rejected; entry=$entryName, reason=relative_path_part")
88
+ throw OTAUnsafeAssetsZipEntryException(entryName)
89
+ }
90
+
91
+ val root = parts.first().lowercase(Locale.US)
92
+ if (root == "proc" || root == "sys" || root == "data" || root == "etc") {
93
+ Log.w(TAG, "OTA assets zip entry rejected; entry=$entryName, reason=dangerous_root")
94
+ throw OTAUnsafeAssetsZipEntryException(entryName)
95
+ }
96
+ }
97
+
98
+ private fun isInsideDirectory(directory: File, file: File): Boolean =
99
+ file.path == directory.path || file.path.startsWith(directory.path + File.separator)
100
+ }
@@ -0,0 +1,9 @@
1
+ -----BEGIN PUBLIC KEY-----
2
+ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8FidxBN6K3wQeyIGkv0l
3
+ NvxrpkruxpTZgF10Eb2mDVcahDZqZVvEaFELhmz7++nfPdHoE1cuyoki2uLkZVeT
4
+ thAeD2+KMwHtocnn9uELySbm25bf4/ZtvdpaTeiq+qrm9bpgdsFc+GIdGu3P2xmJ
5
+ y/RZu9J6zcjV7SHPgPgRJ0yFXwhecmGfbVKoMYg4NWWAABR6Sr+2JoVmQRKYLkgj
6
+ VVEZgbmS9vrDcxOpbNXILsWWEk4deiGHF/8A2JkTYA17ZFm8swNPnrBjWy+23huA
7
+ UQoDtywNreXJLyh3+R8LAkVF+vplGQXEKViD29ZgJ2Y1xl3k1LzUMp37Qmz2zsGr
8
+ LQIDAQAB
9
+ -----END PUBLIC KEY-----
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+ /**
3
+ * Produces a platform-correct assets.zip.
4
+ *
5
+ * The iOS and Android native OTA runtimes have DIFFERENT zip-entry expectations,
6
+ * enforced by their respective OTAZipUtils validators:
7
+ *
8
+ * iOS (OTAZipUtils.swift):
9
+ * requiredRootDirectory = "assets"
10
+ * validateEntryPath() rejects any entry whose first path component ≠ "assets"
11
+ * → every zip entry MUST start with "assets/"
12
+ * react-native bundle --assets-dest <dir> emits <dir>/assets/... for iOS
13
+ * → zip the CONTENTS of <assetsDir>: entries become assets/node_modules/.../icon.png ✅
14
+ *
15
+ * Android (OTAZipUtils.kt):
16
+ * No required root; ensureZipEntrySafe() only blocks proc/sys/data/etc and path traversal
17
+ * → entries can be at the zip root: drawable-mdpi/icon.png, raw/file.mp3, etc.
18
+ * react-native bundle --assets-dest <dir> emits <dir>/drawable-mdpi/, <dir>/raw/, etc.
19
+ * → zip the CONTENTS of <assetsDir>: entries become drawable-mdpi/icon.png ✅
20
+ *
21
+ * Both platforms use the SAME zipping strategy: archive.directory(assetsDir, false)
22
+ * (no prefix, just the contents of assetsDir). The difference is in how RN lays out
23
+ * the assets-dest folder for each platform — we don't need to do anything special.
24
+ */
25
+
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const archiver = require('archiver');
29
+
30
+ /**
31
+ * Check whether a directory contains any files (recursively).
32
+ * Returns false if the dir doesn't exist.
33
+ */
34
+ function hasFiles(dir) {
35
+ if (!fs.existsSync(dir)) return false;
36
+ const entries = fs.readdirSync(dir);
37
+ for (const entry of entries) {
38
+ const full = path.join(dir, entry);
39
+ const stat = fs.statSync(full);
40
+ if (stat.isFile()) return true;
41
+ if (stat.isDirectory() && hasFiles(full)) return true;
42
+ }
43
+ return false;
44
+ }
45
+
46
+ /**
47
+ * Zip the contents of assetsDir into zipPath.
48
+ * Returns the zipPath on success, or null if there are no assets to zip.
49
+ *
50
+ * @param {object} opts
51
+ * @param {string} opts.assetsDir - RN --assets-dest output directory
52
+ * @param {string} opts.zipPath - destination path for assets.zip
53
+ * @returns {Promise<string|null>} zipPath if created, null if no assets
54
+ */
55
+ function zipAssets({ assetsDir, zipPath }) {
56
+ return new Promise((resolve, reject) => {
57
+ if (!hasFiles(assetsDir)) {
58
+ resolve(null);
59
+ return;
60
+ }
61
+
62
+ const output = fs.createWriteStream(zipPath);
63
+ const archive = archiver('zip', { zlib: { level: 9 } });
64
+
65
+ output.on('close', () => resolve(zipPath));
66
+ archive.on('error', err => reject(err));
67
+ archive.pipe(output);
68
+
69
+ // Archive the contents of assetsDir without a root prefix (false = no dest prefix).
70
+ // iOS: assetsDir contains assets/... so entries become assets/<path> ✅
71
+ // Android: assetsDir contains drawable-*/, raw/ so entries are flat ✅
72
+ archive.directory(assetsDir, false);
73
+ archive.finalize();
74
+ });
75
+ }
76
+
77
+ module.exports = { zipAssets, hasFiles };
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+ /**
3
+ * Wraps `react-native bundle` for a single platform.
4
+ * Outputs the bundle file and an assets directory ready for zipping.
5
+ */
6
+
7
+ const { spawnSync } = require('child_process');
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ /** Platform-specific bundle filenames — must match OTAUpdateDownloader constants. */
12
+ const BUNDLE_NAMES = {
13
+ android: 'index.android.bundle',
14
+ ios: 'main.jsbundle',
15
+ };
16
+
17
+ /**
18
+ * Build the JS bundle and extract assets into workDir.
19
+ *
20
+ * After this call:
21
+ * workDir/<fileName> — the JS bundle
22
+ * workDir/assets-src/ — raw asset tree from RN
23
+ * iOS: assets-src/assets/... (RN emits an assets/ subfolder for iOS)
24
+ * Android: assets-src/drawable-mdpi/, assets-src/raw/, etc. (flat)
25
+ *
26
+ * @param {object} opts
27
+ * @param {'ios'|'android'} opts.platform
28
+ * @param {string} opts.entryFile - JS entry point (e.g. 'index.js')
29
+ * @param {string} opts.workDir - temp directory for this build
30
+ * @returns {{ bundlePath: string, assetsDir: string, fileName: string }}
31
+ */
32
+ function buildBundle({ platform, entryFile, workDir }) {
33
+ const fileName = BUNDLE_NAMES[platform];
34
+ if (!fileName) {
35
+ throw new Error(`Unsupported platform: ${platform}. Must be "ios" or "android".`);
36
+ }
37
+
38
+ const bundlePath = path.join(workDir, fileName);
39
+ const assetsDir = path.join(workDir, 'assets-src');
40
+ fs.mkdirSync(assetsDir, { recursive: true });
41
+
42
+ const args = [
43
+ 'react-native', 'bundle',
44
+ '--platform', platform,
45
+ '--dev', 'false',
46
+ '--entry-file', entryFile,
47
+ '--bundle-output', bundlePath,
48
+ '--assets-dest', assetsDir,
49
+ ];
50
+
51
+ console.log(` $ npx ${args.join(' ')}`);
52
+ const result = spawnSync('npx', args, {
53
+ stdio: 'inherit',
54
+ encoding: 'utf8',
55
+ cwd: process.cwd(),
56
+ });
57
+
58
+ if (result.error) {
59
+ throw new Error(`Failed to spawn react-native bundle: ${result.error.message}`);
60
+ }
61
+ if (result.status !== 0) {
62
+ throw new Error(`react-native bundle failed for ${platform} (exit ${result.status})`);
63
+ }
64
+
65
+ if (!fs.existsSync(bundlePath)) {
66
+ throw new Error(`Bundle file not found after build: ${bundlePath}`);
67
+ }
68
+
69
+ return { bundlePath, assetsDir, fileName };
70
+ }
71
+
72
+ module.exports = { buildBundle, BUNDLE_NAMES };