react-native-ota-client 0.0.0-stage → 0.1.1
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/LICENSE +20 -0
- package/OtaSdk.podspec +20 -0
- package/README.md +136 -2
- package/android/build.gradle +37 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/otasdk/OtaActivationPolicy.kt +74 -0
- package/android/src/main/java/com/otasdk/OtaBundleInstaller.kt +280 -0
- package/android/src/main/java/com/otasdk/OtaReleaseManifest.kt +187 -0
- package/android/src/main/java/com/otasdk/OtaSdkConfig.kt +22 -0
- package/android/src/main/java/com/otasdk/OtaSdkModule.kt +15 -0
- package/android/src/main/java/com/otasdk/OtaSdkPackage.kt +34 -0
- package/android/src/main/java/com/otasdk/OtaSdkUpdates.kt +51 -0
- package/android/src/main/java/com/otasdk/OtaUpdateManager.kt +384 -0
- package/android/src/main/java/com/otasdk/OtaUpdatesModule.kt +70 -0
- package/android/src/test/java/com/otasdk/OtaActivationPolicyTest.kt +47 -0
- package/android/src/test/java/com/otasdk/OtaReleaseManifestTest.kt +48 -0
- package/ios/OtaSdk.h +5 -0
- package/ios/OtaSdk.mm +21 -0
- package/lib/module/NativeOTAUpdates.js +5 -0
- package/lib/module/NativeOTAUpdates.js.map +1 -0
- package/lib/module/NativeOtaSdk.js +5 -0
- package/lib/module/NativeOtaSdk.js.map +1 -0
- package/lib/module/index.js +5 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/multiply.js +6 -0
- package/lib/module/multiply.js.map +1 -0
- package/lib/module/multiply.native.js +7 -0
- package/lib/module/multiply.native.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativeOTAUpdates.d.ts +22 -0
- package/lib/typescript/src/NativeOTAUpdates.d.ts.map +1 -0
- package/lib/typescript/src/NativeOtaSdk.d.ts +7 -0
- package/lib/typescript/src/NativeOtaSdk.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +4 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/multiply.d.ts +2 -0
- package/lib/typescript/src/multiply.d.ts.map +1 -0
- package/lib/typescript/src/multiply.native.d.ts +2 -0
- package/lib/typescript/src/multiply.native.d.ts.map +1 -0
- package/package.json +181 -4
- package/src/NativeOTAUpdates.ts +24 -0
- package/src/NativeOtaSdk.ts +7 -0
- package/src/index.tsx +6 -0
- package/src/multiply.native.tsx +5 -0
- package/src/multiply.tsx +3 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 boyka
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
5
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
6
|
+
in the Software without restriction, including without limitation the rights
|
|
7
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
9
|
+
furnished to do so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all
|
|
12
|
+
copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
16
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
17
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
18
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
19
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
20
|
+
SOFTWARE.
|
package/OtaSdk.podspec
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = "OtaSdk"
|
|
7
|
+
s.version = package["version"]
|
|
8
|
+
s.summary = package["description"]
|
|
9
|
+
s.homepage = package["homepage"]
|
|
10
|
+
s.license = package["license"]
|
|
11
|
+
s.authors = package["author"]
|
|
12
|
+
|
|
13
|
+
s.platforms = { :ios => min_ios_version_supported }
|
|
14
|
+
s.source = { :git => "https://github.com/arjunnarangali/react-native-ota-client.git", :tag => "v#{s.version}" }
|
|
15
|
+
|
|
16
|
+
s.source_files = "ios/**/*.{h,m,mm,swift,cpp}"
|
|
17
|
+
s.private_header_files = "ios/**/*.h"
|
|
18
|
+
|
|
19
|
+
install_modules_dependencies(s)
|
|
20
|
+
end
|
package/README.md
CHANGED
|
@@ -1,3 +1,137 @@
|
|
|
1
|
-
#
|
|
1
|
+
# react-native-ota-client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Android OTA update support for React Native applications using the New Architecture and Hermes. The SDK checks for signed releases, verifies the release manifest and packaged bundle, stages verified updates, selects the bundle on cold start, and rolls back a release that does not confirm healthy.
|
|
4
|
+
|
|
5
|
+
OTA can update JavaScript and bundled assets. Native code, permissions, React Native/Hermes versions, and Codegen contract changes require a new APK.
|
|
6
|
+
|
|
7
|
+
## Requirements and support
|
|
8
|
+
|
|
9
|
+
- Android only; iOS OTA support is not implemented.
|
|
10
|
+
- React Native 0.88.x, Android New Architecture, and Hermes.
|
|
11
|
+
- Android min SDK 24.
|
|
12
|
+
- A host application with a release-signing setup and a public verification key.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm install react-native-ota-client
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
React Native autolinking discovers the Android package. Rebuild the native application after installation; Metro alone cannot add native code.
|
|
21
|
+
|
|
22
|
+
For local development, the example workspace consumes the SDK source directly. In a host app, install the package using the command above.
|
|
23
|
+
|
|
24
|
+
## JavaScript API
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { OTAUpdates, type OtaUpdateStatus } from 'react-native-ota-client';
|
|
28
|
+
|
|
29
|
+
const status: OtaUpdateStatus = await OTAUpdates.getStatus();
|
|
30
|
+
const result = await OTAUpdates.checkForUpdate();
|
|
31
|
+
await OTAUpdates.markUpdateSuccessful();
|
|
32
|
+
await OTAUpdates.closeAppToApplyUpdate();
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`markUpdateSuccessful()` should run after the first React screen has mounted. `closeAppToApplyUpdate()` only succeeds when a verified update is staged and waiting for a restart. Debug applications report OTA activation as disabled because Metro supplies the JavaScript bundle.
|
|
36
|
+
|
|
37
|
+
The package also retains the generated-template `multiply()` example; it is not part of the OTA API.
|
|
38
|
+
|
|
39
|
+
## Android configuration
|
|
40
|
+
|
|
41
|
+
Configure the SDK once in the host application's `Application` class. Initialize it in `onCreate()` before calling `loadReactNative()`, and provide the selected bundle path to React Native's host configuration. The SDK's own `BuildConfig.DEBUG` is not the consumer app's build type.
|
|
42
|
+
|
|
43
|
+
```kotlin
|
|
44
|
+
import android.app.Application
|
|
45
|
+
import com.facebook.react.PackageList
|
|
46
|
+
import com.facebook.react.ReactApplication
|
|
47
|
+
import com.facebook.react.ReactHost
|
|
48
|
+
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
|
49
|
+
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
|
|
50
|
+
import com.otasdk.OtaSdkUpdates
|
|
51
|
+
|
|
52
|
+
class MainApplication : Application(), ReactApplication {
|
|
53
|
+
override val reactHost: ReactHost by lazy {
|
|
54
|
+
getDefaultReactHost(
|
|
55
|
+
context = applicationContext,
|
|
56
|
+
packageList = PackageList(this).packages,
|
|
57
|
+
jsBundleFilePath = OtaSdkUpdates.bundlePathForReactHost(),
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
override fun onCreate() {
|
|
62
|
+
super.onCreate()
|
|
63
|
+
val publicKeyPem = resources.openRawResource(R.raw.ota_publisher_public).use { it.readBytes() }
|
|
64
|
+
val config = otaSdkConfigForCurrentVariant(publicKeyPem)
|
|
65
|
+
OtaSdkUpdates.initialize(this, config)
|
|
66
|
+
OtaSdkUpdates.beginProcess()
|
|
67
|
+
loadReactNative(this)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Call `beginProcess()` before `loadReactNative()` so the selected bundle is ready when the JS runtime starts. `bundlePathForReactHost()` returns `null` when the APK bundle should be used.
|
|
73
|
+
|
|
74
|
+
The `ota_publisher_public` Android raw resource contains only the verification key. It may be placed in `src/main/res/raw/` when shared by all variants, or in a flavor-specific source set such as `src/development/res/raw/` when variants use different keys. Never package the publisher's signing private key in the app or SDK.
|
|
75
|
+
|
|
76
|
+
### Flavor-specific configuration
|
|
77
|
+
|
|
78
|
+
Define each environment as an Android `productFlavor` and provide the SDK values as flavor-specific `BuildConfig` fields. The flavor controls the app ID, runtime version, channel, base version, and public key source set; use distinct runtime versions and channels when environments must receive separate updates. For example:
|
|
79
|
+
|
|
80
|
+
```groovy
|
|
81
|
+
android {
|
|
82
|
+
flavorDimensions "environment"
|
|
83
|
+
productFlavors {
|
|
84
|
+
development {
|
|
85
|
+
dimension "environment"
|
|
86
|
+
applicationIdSuffix ".dev"
|
|
87
|
+
buildConfigField "String", "OTA_RUNTIME_VERSION", '"rn-0.88-android-dev"'
|
|
88
|
+
buildConfigField "String", "OTA_CHANNEL", '"development"'
|
|
89
|
+
buildConfigField "int", "OTA_BASE_VERSION", "0"
|
|
90
|
+
}
|
|
91
|
+
staging {
|
|
92
|
+
dimension "environment"
|
|
93
|
+
applicationIdSuffix ".staging"
|
|
94
|
+
buildConfigField "String", "OTA_RUNTIME_VERSION", '"rn-0.88-android-staging"'
|
|
95
|
+
buildConfigField "String", "OTA_CHANNEL", '"staging"'
|
|
96
|
+
buildConfigField "int", "OTA_BASE_VERSION", "0"
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The host application's configuration helper should import `com.otasdk.OtaSdkConfig` and build a fully populated config for the selected variant. The `OTA_*` field names and types must match those read by that helper. Set `react.debuggableVariants` for every flavor's debug variants (for example, `developmentDebug` and `stagingDebug`) so React Native uses Metro for those builds. `BuildConfig.DEBUG` is supplied by the selected build type, while `BuildConfig.APPLICATION_ID` includes any flavor suffix.
|
|
103
|
+
|
|
104
|
+
## Native startup operations
|
|
105
|
+
|
|
106
|
+
`OtaSdkUpdates` exposes the Android startup and host integration API:
|
|
107
|
+
|
|
108
|
+
- `initialize(context, config)` — configure the manager once before use.
|
|
109
|
+
- `beginProcess()` — select/rollback the startup bundle before React Native starts.
|
|
110
|
+
- `bundlePathForReactHost()` — return the selected bundle path, or `null` for the APK bundle.
|
|
111
|
+
- `status()`, `checkForUpdate()`, `markHealthy()`, and `closeAppForUpdate(activity)` — operations used by the TurboModule implementation.
|
|
112
|
+
|
|
113
|
+
The SDK autolinks a single `OtaSdkPackage` containing both the sample `OtaSdk` TurboModule and the OTA `OTAUpdates` TurboModule. Do not add another manual package registration in the host app.
|
|
114
|
+
|
|
115
|
+
## Development and verification
|
|
116
|
+
|
|
117
|
+
For implementation architecture, behavioral invariants, suggested change workflow, and verification commands, see the [coding-agent implementation guide](agent.md).
|
|
118
|
+
|
|
119
|
+
From the SDK package directory, install the locked Yarn dependencies with the bundled Yarn executable and build its example:
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
node .yarn/releases/yarn-4.18.1.cjs install
|
|
123
|
+
node .yarn/releases/yarn-4.18.1.cjs workspace react-native-ota-client-example build:android
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The library unit tests run through the example's Gradle project:
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
cd example/android
|
|
130
|
+
./gradlew :react-native-ota-client:testDebugUnitTest
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Build a development debug app to verify native linking and Metro. OTA activation requires a release APK; debug builds use Metro and report activation as disabled.
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
ext.OtaSdk = [
|
|
2
|
+
minSdkVersion: 24,
|
|
3
|
+
compileSdkVersion: 36
|
|
4
|
+
]
|
|
5
|
+
|
|
6
|
+
ext.getExtOrDefault = { prop ->
|
|
7
|
+
if (rootProject.ext.has(prop)) {
|
|
8
|
+
return rootProject.ext.get(prop)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return OtaSdk[prop]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
apply plugin: "com.android.library"
|
|
16
|
+
|
|
17
|
+
if (project.extensions.findByName("kotlin") == null) {
|
|
18
|
+
apply plugin: "kotlin-android"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
apply plugin: "com.facebook.react"
|
|
23
|
+
|
|
24
|
+
android {
|
|
25
|
+
namespace "com.otasdk"
|
|
26
|
+
|
|
27
|
+
compileSdkVersion getExtOrDefault("compileSdkVersion")
|
|
28
|
+
|
|
29
|
+
defaultConfig {
|
|
30
|
+
minSdkVersion getExtOrDefault("minSdkVersion")
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
dependencies {
|
|
35
|
+
implementation "com.facebook.react:react-android"
|
|
36
|
+
testImplementation("junit:junit:4.13.2")
|
|
37
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
package com.otasdk
|
|
2
|
+
|
|
3
|
+
/** Pure state transitions for selecting a pending update and reverting an unconfirmed boot. */
|
|
4
|
+
internal data class OtaActivationState(
|
|
5
|
+
val activeReleaseId: String? = null,
|
|
6
|
+
val activeVersion: Int,
|
|
7
|
+
val pendingReleaseId: String? = null,
|
|
8
|
+
val pendingVersion: Int? = null,
|
|
9
|
+
val previousReleaseId: String? = null,
|
|
10
|
+
val previousVersion: Int? = null,
|
|
11
|
+
val pendingLaunchAttempted: Boolean = false,
|
|
12
|
+
val failedReleaseId: String? = null,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
internal data class OtaStartupTransition(
|
|
16
|
+
val state: OtaActivationState,
|
|
17
|
+
val selectedReleaseId: String?,
|
|
18
|
+
val failedReleaseId: String? = null,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
internal object OtaActivationPolicy {
|
|
22
|
+
fun beginStartup(state: OtaActivationState): OtaStartupTransition {
|
|
23
|
+
val pendingId = state.pendingReleaseId ?: return OtaStartupTransition(state, state.activeReleaseId)
|
|
24
|
+
|
|
25
|
+
if (!state.pendingLaunchAttempted) {
|
|
26
|
+
return OtaStartupTransition(
|
|
27
|
+
state = state.copy(pendingLaunchAttempted = true),
|
|
28
|
+
selectedReleaseId = pendingId,
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
val rolledBack =
|
|
33
|
+
state.copy(
|
|
34
|
+
activeReleaseId = state.previousReleaseId,
|
|
35
|
+
activeVersion = state.previousVersion ?: state.activeVersion,
|
|
36
|
+
pendingReleaseId = null,
|
|
37
|
+
pendingVersion = null,
|
|
38
|
+
previousReleaseId = null,
|
|
39
|
+
previousVersion = null,
|
|
40
|
+
pendingLaunchAttempted = false,
|
|
41
|
+
failedReleaseId = pendingId,
|
|
42
|
+
)
|
|
43
|
+
return OtaStartupTransition(
|
|
44
|
+
state = rolledBack,
|
|
45
|
+
selectedReleaseId = rolledBack.activeReleaseId,
|
|
46
|
+
failedReleaseId = pendingId,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
fun stage(state: OtaActivationState, releaseId: String, version: Int): OtaActivationState =
|
|
51
|
+
state.copy(
|
|
52
|
+
pendingReleaseId = releaseId,
|
|
53
|
+
pendingVersion = version,
|
|
54
|
+
previousReleaseId = state.activeReleaseId,
|
|
55
|
+
previousVersion = state.activeVersion,
|
|
56
|
+
pendingLaunchAttempted = false,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
fun markHealthy(state: OtaActivationState): OtaActivationState {
|
|
60
|
+
val pendingId = state.pendingReleaseId ?: return state
|
|
61
|
+
if (!state.pendingLaunchAttempted) return state
|
|
62
|
+
|
|
63
|
+
return state.copy(
|
|
64
|
+
activeReleaseId = pendingId,
|
|
65
|
+
activeVersion = state.pendingVersion ?: state.activeVersion,
|
|
66
|
+
pendingReleaseId = null,
|
|
67
|
+
pendingVersion = null,
|
|
68
|
+
previousReleaseId = null,
|
|
69
|
+
previousVersion = null,
|
|
70
|
+
pendingLaunchAttempted = false,
|
|
71
|
+
failedReleaseId = null,
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
package com.otasdk
|
|
2
|
+
|
|
3
|
+
import java.io.BufferedInputStream
|
|
4
|
+
import java.io.File
|
|
5
|
+
import java.io.FileOutputStream
|
|
6
|
+
import java.security.MessageDigest
|
|
7
|
+
import java.util.Enumeration
|
|
8
|
+
import java.util.Locale
|
|
9
|
+
import java.util.zip.ZipEntry
|
|
10
|
+
import java.util.zip.ZipFile
|
|
11
|
+
import org.json.JSONArray
|
|
12
|
+
import org.json.JSONObject
|
|
13
|
+
|
|
14
|
+
internal object OtaBundleInstaller {
|
|
15
|
+
private const val ENTRYPOINT = "index.android.bundle"
|
|
16
|
+
private const val PACKAGE_MANIFEST = "ota-manifest.json"
|
|
17
|
+
private const val MAX_PACKAGE_MANIFEST_BYTES = 1024L * 1024L
|
|
18
|
+
private const val MAX_UNCOMPRESSED_BYTES = 500L * 1024L * 1024L
|
|
19
|
+
private const val MAX_ARCHIVE_ENTRIES = 10_000
|
|
20
|
+
|
|
21
|
+
fun releaseDirectory(updatesDirectory: File, releaseId: String): File =
|
|
22
|
+
File(updatesDirectory, releaseId)
|
|
23
|
+
|
|
24
|
+
fun verifyExisting(
|
|
25
|
+
releaseDirectory: File,
|
|
26
|
+
release: VerifiedOtaRelease,
|
|
27
|
+
): Boolean {
|
|
28
|
+
return try {
|
|
29
|
+
if (!releaseDirectory.isDirectory) return false
|
|
30
|
+
if (!File(releaseDirectory, "release-manifest.json").readBytes().contentEquals(
|
|
31
|
+
release.manifest.canonicalBytes()
|
|
32
|
+
)) return false
|
|
33
|
+
if (File(releaseDirectory, "release-signature.txt").readText(Charsets.US_ASCII) != release.signature) {
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
val internalManifest = JSONObject(File(releaseDirectory, PACKAGE_MANIFEST).readText(Charsets.UTF_8))
|
|
37
|
+
validateExtractedFiles(releaseDirectory, internalManifest)
|
|
38
|
+
} catch (_: Exception) {
|
|
39
|
+
false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fun install(
|
|
44
|
+
archiveFile: File,
|
|
45
|
+
updatesDirectory: File,
|
|
46
|
+
release: VerifiedOtaRelease,
|
|
47
|
+
): File {
|
|
48
|
+
val finalDirectory = releaseDirectory(updatesDirectory, release.manifest.releaseId)
|
|
49
|
+
if (finalDirectory.exists()) {
|
|
50
|
+
if (verifyExisting(finalDirectory, release)) return finalDirectory
|
|
51
|
+
throw OtaVerificationException("A stored release with this ID failed local verification")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
updatesDirectory.mkdirs()
|
|
55
|
+
val stagingDirectory = File(updatesDirectory, ".${release.manifest.releaseId}.${java.util.UUID.randomUUID()}.staging")
|
|
56
|
+
if (!stagingDirectory.mkdirs()) throw OtaVerificationException("Cannot create update staging directory")
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
val internalManifest = extractAndValidate(archiveFile, stagingDirectory)
|
|
60
|
+
File(stagingDirectory, "release-manifest.json").writeBytes(release.manifest.canonicalBytes())
|
|
61
|
+
File(stagingDirectory, "release-signature.txt").writeText(release.signature, Charsets.US_ASCII)
|
|
62
|
+
|
|
63
|
+
if (finalDirectory.exists()) {
|
|
64
|
+
if (verifyExisting(finalDirectory, release)) return finalDirectory
|
|
65
|
+
throw OtaVerificationException("A stored release with this ID failed local verification")
|
|
66
|
+
}
|
|
67
|
+
if (!stagingDirectory.renameTo(finalDirectory)) {
|
|
68
|
+
throw OtaVerificationException("Cannot atomically activate the staged bundle files")
|
|
69
|
+
}
|
|
70
|
+
if (!validateExtractedFiles(finalDirectory, internalManifest)) {
|
|
71
|
+
finalDirectory.deleteRecursively()
|
|
72
|
+
throw OtaVerificationException("Installed bundle failed its final integrity check")
|
|
73
|
+
}
|
|
74
|
+
return finalDirectory
|
|
75
|
+
} catch (exception: Exception) {
|
|
76
|
+
stagingDirectory.deleteRecursively()
|
|
77
|
+
throw exception
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private fun extractAndValidate(archiveFile: File, destination: File): JSONObject {
|
|
82
|
+
ZipFile(archiveFile).use { archive ->
|
|
83
|
+
val entries = entriesAsList(archive.entries())
|
|
84
|
+
if (entries.size !in 2..MAX_ARCHIVE_ENTRIES) {
|
|
85
|
+
throw OtaVerificationException("Bundle ZIP has an invalid number of entries")
|
|
86
|
+
}
|
|
87
|
+
if (entries.any { it.size < 0L }) {
|
|
88
|
+
throw OtaVerificationException("Bundle ZIP contains an entry with unknown size")
|
|
89
|
+
}
|
|
90
|
+
val names = entries.map { it.name }
|
|
91
|
+
if (names.size != names.toSet().size) throw OtaVerificationException("Bundle ZIP has duplicate entries")
|
|
92
|
+
entries.forEach { entry ->
|
|
93
|
+
if (entry.isDirectory || !isSafeRelativePath(entry.name)) {
|
|
94
|
+
throw OtaVerificationException("Bundle ZIP contains an unsafe path")
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (entries.sumOf { it.size.coerceAtLeast(0L) } > MAX_UNCOMPRESSED_BYTES) {
|
|
98
|
+
throw OtaVerificationException("Bundle ZIP expands beyond the allowed size")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
val packageManifestEntry = archive.getEntry(PACKAGE_MANIFEST)
|
|
102
|
+
?: throw OtaVerificationException("Bundle package manifest is missing")
|
|
103
|
+
if (packageManifestEntry.size !in 1..MAX_PACKAGE_MANIFEST_BYTES) {
|
|
104
|
+
throw OtaVerificationException("Bundle package manifest has an invalid size")
|
|
105
|
+
}
|
|
106
|
+
val packageManifestBytes = archive.getInputStream(packageManifestEntry).use { it.readBytes() }
|
|
107
|
+
val packageManifest = JSONObject(String(packageManifestBytes, Charsets.UTF_8))
|
|
108
|
+
val expectedFiles = expectedPackageFiles(packageManifest)
|
|
109
|
+
if (expectedFiles != names.toSet()) {
|
|
110
|
+
throw OtaVerificationException("Bundle ZIP does not match its internal package manifest")
|
|
111
|
+
}
|
|
112
|
+
if (entries.sumOf { it.size } > MAX_UNCOMPRESSED_BYTES) {
|
|
113
|
+
throw OtaVerificationException("Bundle ZIP expands beyond the allowed size")
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
entries.filter { it.name != PACKAGE_MANIFEST }.forEach { entry ->
|
|
117
|
+
val targetRelativePath =
|
|
118
|
+
if (entry.name == ENTRYPOINT) ENTRYPOINT else entry.name.removePrefix("assets/")
|
|
119
|
+
val output = safeOutputFile(destination, targetRelativePath)
|
|
120
|
+
output.parentFile?.mkdirs()
|
|
121
|
+
if (!output.createNewFile()) throw OtaVerificationException("Duplicate package output path")
|
|
122
|
+
val expectedHash =
|
|
123
|
+
if (entry.name == ENTRYPOINT) {
|
|
124
|
+
packageManifest.getString("entrypoint_sha256")
|
|
125
|
+
} else {
|
|
126
|
+
assetForPath(packageManifest.getJSONArray("assets"), entry.name).getString("sha256")
|
|
127
|
+
}
|
|
128
|
+
val expectedSize =
|
|
129
|
+
if (entry.name == ENTRYPOINT) {
|
|
130
|
+
entry.size
|
|
131
|
+
} else {
|
|
132
|
+
assetForPath(packageManifest.getJSONArray("assets"), entry.name).getLong("size_bytes")
|
|
133
|
+
}
|
|
134
|
+
copyAndVerify(archive.getInputStream(entry), output, expectedHash, expectedSize)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
File(destination, PACKAGE_MANIFEST).writeBytes(packageManifestBytes)
|
|
138
|
+
if (!validateExtractedFiles(destination, packageManifest)) {
|
|
139
|
+
throw OtaVerificationException("Bundle package contents failed integrity verification")
|
|
140
|
+
}
|
|
141
|
+
return packageManifest
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private fun expectedPackageFiles(packageManifest: JSONObject): Set<String> {
|
|
146
|
+
if (packageManifest.optInt("format_version") != 1) {
|
|
147
|
+
throw OtaVerificationException("Unsupported bundle package format")
|
|
148
|
+
}
|
|
149
|
+
if (packageManifest.optString("entrypoint") != ENTRYPOINT ||
|
|
150
|
+
packageManifest.optString("entrypoint_format") != "hermes-bytecode") {
|
|
151
|
+
throw OtaVerificationException("Bundle package must contain the Android Hermes bytecode entrypoint")
|
|
152
|
+
}
|
|
153
|
+
val entrypointHash = packageManifest.optString("entrypoint_sha256")
|
|
154
|
+
if (!isSha256(entrypointHash)) throw OtaVerificationException("Bundle entrypoint hash is invalid")
|
|
155
|
+
|
|
156
|
+
val expected = mutableSetOf(PACKAGE_MANIFEST, ENTRYPOINT)
|
|
157
|
+
val assets = packageManifest.optJSONArray("assets")
|
|
158
|
+
?: throw OtaVerificationException("Bundle asset manifest is missing")
|
|
159
|
+
for (index in 0 until assets.length()) {
|
|
160
|
+
val asset = assets.optJSONObject(index)
|
|
161
|
+
?: throw OtaVerificationException("Bundle asset metadata is invalid")
|
|
162
|
+
val path = asset.optString("path")
|
|
163
|
+
if (!isSafeAssetPath(path) || !isSha256(asset.optString("sha256"))) {
|
|
164
|
+
throw OtaVerificationException("Bundle asset metadata is invalid")
|
|
165
|
+
}
|
|
166
|
+
if (asset.optLong("size_bytes", -1L) < 0L || !expected.add(path)) {
|
|
167
|
+
throw OtaVerificationException("Bundle asset metadata contains duplicate or invalid entries")
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return expected
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private fun validateExtractedFiles(directory: File, manifest: JSONObject): Boolean {
|
|
174
|
+
val bundle = File(directory, ENTRYPOINT)
|
|
175
|
+
if (!bundle.isFile || !bundleHasHermesBytecodeHeader(bundle)) return false
|
|
176
|
+
if (sha256(bundle) != manifest.optString("entrypoint_sha256")) return false
|
|
177
|
+
|
|
178
|
+
val assets = manifest.optJSONArray("assets") ?: return false
|
|
179
|
+
for (index in 0 until assets.length()) {
|
|
180
|
+
val asset = assets.optJSONObject(index) ?: return false
|
|
181
|
+
val zipPath = asset.optString("path")
|
|
182
|
+
if (!isSafeAssetPath(zipPath)) return false
|
|
183
|
+
val localPath = zipPath.removePrefix("assets/")
|
|
184
|
+
val assetFile = safeOutputFile(directory, localPath)
|
|
185
|
+
if (!assetFile.isFile || assetFile.length() != asset.optLong("size_bytes", -1L)) return false
|
|
186
|
+
if (sha256(assetFile) != asset.optString("sha256")) return false
|
|
187
|
+
}
|
|
188
|
+
return true
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private fun copyAndVerify(
|
|
192
|
+
input: java.io.InputStream,
|
|
193
|
+
output: File,
|
|
194
|
+
expectedSha256: String,
|
|
195
|
+
expectedSize: Long,
|
|
196
|
+
) {
|
|
197
|
+
val digest = MessageDigest.getInstance("SHA-256")
|
|
198
|
+
var total = 0L
|
|
199
|
+
input.use { source ->
|
|
200
|
+
BufferedInputStream(source).use { buffered ->
|
|
201
|
+
FileOutputStream(output).use { destination ->
|
|
202
|
+
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
|
203
|
+
while (true) {
|
|
204
|
+
val count = buffered.read(buffer)
|
|
205
|
+
if (count < 0) break
|
|
206
|
+
total += count
|
|
207
|
+
if (total > MAX_UNCOMPRESSED_BYTES) {
|
|
208
|
+
throw OtaVerificationException("Bundle entry exceeds the allowed size")
|
|
209
|
+
}
|
|
210
|
+
digest.update(buffer, 0, count)
|
|
211
|
+
destination.write(buffer, 0, count)
|
|
212
|
+
}
|
|
213
|
+
destination.fd.sync()
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (total != expectedSize || digest.digest().toHex() != expectedSha256) {
|
|
218
|
+
throw OtaVerificationException("Bundle entry hash or size does not match package manifest")
|
|
219
|
+
}
|
|
220
|
+
if (output.name == ENTRYPOINT && !bundleHasHermesBytecodeHeader(output)) {
|
|
221
|
+
throw OtaVerificationException("Bundle entrypoint is not Hermes bytecode")
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private fun assetForPath(assets: JSONArray, path: String): JSONObject {
|
|
226
|
+
for (index in 0 until assets.length()) {
|
|
227
|
+
val item = assets.getJSONObject(index)
|
|
228
|
+
if (item.getString("path") == path) return item
|
|
229
|
+
}
|
|
230
|
+
throw OtaVerificationException("Bundle ZIP contains an undeclared asset")
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private fun entriesAsList(entries: Enumeration<out ZipEntry>): List<ZipEntry> =
|
|
234
|
+
buildList {
|
|
235
|
+
while (entries.hasMoreElements()) add(entries.nextElement())
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private fun safeOutputFile(root: File, relativePath: String): File {
|
|
239
|
+
if (!isSafeRelativePath(relativePath)) throw OtaVerificationException("Bundle path is unsafe")
|
|
240
|
+
val rootPath = root.canonicalPath + File.separator
|
|
241
|
+
val file = File(root, relativePath)
|
|
242
|
+
if (!file.canonicalPath.startsWith(rootPath)) throw OtaVerificationException("Bundle path escaped staging directory")
|
|
243
|
+
return file
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private fun isSafeRelativePath(path: String): Boolean {
|
|
247
|
+
if (path.isBlank() || path.startsWith('/') || path.contains('\\') || path.contains(':')) return false
|
|
248
|
+
return path.split('/').all { it.isNotEmpty() && it != "." && it != ".." }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private fun isSafeAssetPath(path: String): Boolean {
|
|
252
|
+
if (!path.startsWith("assets/") || !isSafeRelativePath(path)) return false
|
|
253
|
+
return path.removePrefix("assets/") !in
|
|
254
|
+
setOf(ENTRYPOINT, PACKAGE_MANIFEST, "release-manifest.json", "release-signature.txt")
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private fun bundleHasHermesBytecodeHeader(file: File): Boolean {
|
|
258
|
+
if (file.length() < 4L) return false
|
|
259
|
+
return file.inputStream().use { input ->
|
|
260
|
+
input.read() == 0xC6 && input.read() == 0x1F && input.read() == 0xBC && input.read() == 0x03
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private fun isSha256(value: String): Boolean = value.matches(Regex("^[a-f0-9]{64}$"))
|
|
265
|
+
|
|
266
|
+
private fun sha256(file: File): String {
|
|
267
|
+
val digest = MessageDigest.getInstance("SHA-256")
|
|
268
|
+
file.inputStream().use { input ->
|
|
269
|
+
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
|
270
|
+
while (true) {
|
|
271
|
+
val count = input.read(buffer)
|
|
272
|
+
if (count < 0) break
|
|
273
|
+
digest.update(buffer, 0, count)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return digest.digest().toHex()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private fun ByteArray.toHex(): String = joinToString("") { byte -> "%02x".format(Locale.ROOT, byte) }
|
|
280
|
+
}
|