@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,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 };
|