@otaupdate/react-native 1.0.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 +321 -0
- package/android/build.gradle +70 -0
- package/android/src/expo/java/com/otaupdate/OtaUpdateExpoPackage.kt +37 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/otaupdate/OtaUpdate.kt +98 -0
- package/android/src/main/java/com/otaupdate/OtaUpdateInstaller.kt +211 -0
- package/android/src/main/java/com/otaupdate/OtaUpdateModule.kt +278 -0
- package/android/src/main/java/com/otaupdate/OtaUpdatePackage.kt +15 -0
- package/android/src/main/java/com/otaupdate/OtaUpdateStore.kt +268 -0
- package/app.plugin.js +3 -0
- package/expo-module.config.json +6 -0
- package/ios/OtaUpdate.h +46 -0
- package/ios/OtaUpdate.m +302 -0
- package/ios/OtaUpdateInstaller.h +25 -0
- package/ios/OtaUpdateInstaller.m +278 -0
- package/ios/OtaUpdateStore.h +69 -0
- package/ios/OtaUpdateStore.m +283 -0
- package/lib/OtaUpdate.d.ts +28 -0
- package/lib/OtaUpdate.d.ts.map +1 -0
- package/lib/OtaUpdate.js +254 -0
- package/lib/OtaUpdate.js.map +1 -0
- package/lib/api.d.ts +36 -0
- package/lib/api.d.ts.map +1 -0
- package/lib/api.js +89 -0
- package/lib/api.js.map +1 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +37 -0
- package/lib/index.js.map +1 -0
- package/lib/native.d.ts +34 -0
- package/lib/native.d.ts.map +1 -0
- package/lib/native.js +34 -0
- package/lib/native.js.map +1 -0
- package/lib/types.d.ts +112 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +27 -0
- package/lib/types.js.map +1 -0
- package/lib/useOtaUpdate.d.ts +17 -0
- package/lib/useOtaUpdate.d.ts.map +1 -0
- package/lib/useOtaUpdate.js +81 -0
- package/lib/useOtaUpdate.js.map +1 -0
- package/lib/withOtaUpdate.d.ts +10 -0
- package/lib/withOtaUpdate.d.ts.map +1 -0
- package/lib/withOtaUpdate.js +21 -0
- package/lib/withOtaUpdate.js.map +1 -0
- package/package.json +54 -0
- package/plugin/build/index.d.ts +11 -0
- package/plugin/build/index.js +97 -0
- package/react-native-ota-update.podspec +43 -0
- package/react-native.config.js +21 -0
- package/src/OtaUpdate.ts +293 -0
- package/src/api.ts +122 -0
- package/src/index.ts +55 -0
- package/src/native.ts +64 -0
- package/src/types.ts +125 -0
- package/src/useOtaUpdate.ts +96 -0
- package/src/withOtaUpdate.tsx +22 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
package com.otaupdate
|
|
2
|
+
|
|
3
|
+
import android.util.Log
|
|
4
|
+
import java.io.File
|
|
5
|
+
import java.io.FileOutputStream
|
|
6
|
+
import java.net.HttpURLConnection
|
|
7
|
+
import java.net.URL
|
|
8
|
+
import java.security.MessageDigest
|
|
9
|
+
import java.util.zip.ZipEntry
|
|
10
|
+
import java.util.zip.ZipInputStream
|
|
11
|
+
|
|
12
|
+
/** Thrown for any recoverable download/verify/unzip failure. */
|
|
13
|
+
class OtaInstallException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
|
14
|
+
|
|
15
|
+
data class InstalledPackage(val bundlePath: String, val packageHash: String, val size: Long)
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Downloads a release zip, verifies its SHA-256 against the value the server
|
|
19
|
+
* published, and unzips it into the store. Verification happens before
|
|
20
|
+
* anything is unpacked, so a corrupted or tampered payload never reaches disk
|
|
21
|
+
* as a runnable bundle.
|
|
22
|
+
*/
|
|
23
|
+
object OtaUpdateInstaller {
|
|
24
|
+
|
|
25
|
+
private const val BUFFER_SIZE = 8 * 1024
|
|
26
|
+
private const val CONNECT_TIMEOUT_MS = 15_000
|
|
27
|
+
private const val READ_TIMEOUT_MS = 60_000
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Decompression bomb guards. A verified SHA-256 proves the archive is the one
|
|
31
|
+
* the server published — it says nothing about how much it expands to. A
|
|
32
|
+
* hostile or corrupt package could otherwise fill the device.
|
|
33
|
+
*/
|
|
34
|
+
private const val MAX_COMPRESSION_RATIO = 120L
|
|
35
|
+
private const val MAX_TOTAL_EXTRACTED_BYTES = 600L * 1024 * 1024
|
|
36
|
+
private const val MAX_ENTRIES = 20_000
|
|
37
|
+
|
|
38
|
+
/** Filenames React Native will accept as the JS bundle. */
|
|
39
|
+
private val BUNDLE_CANDIDATES = listOf("index.android.bundle", "main.jsbundle", "index.bundle")
|
|
40
|
+
|
|
41
|
+
fun install(
|
|
42
|
+
store: OtaUpdateStore,
|
|
43
|
+
downloadUrl: String,
|
|
44
|
+
expectedHash: String,
|
|
45
|
+
onProgress: (received: Long, total: Long) -> Unit,
|
|
46
|
+
): InstalledPackage {
|
|
47
|
+
if (store.hasFailed(expectedHash)) {
|
|
48
|
+
throw OtaInstallException(
|
|
49
|
+
"Refusing to reinstall $expectedHash — a previous attempt failed to boot",
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
val targetDir = store.packageDir(expectedHash)
|
|
54
|
+
// Already downloaded and verified on an earlier attempt.
|
|
55
|
+
resolveBundle(targetDir)?.let { existing ->
|
|
56
|
+
return InstalledPackage(existing.absolutePath, expectedHash, dirSize(targetDir))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
val tempZip = File.createTempFile("ota-", ".zip", store.packageDir("").parentFile)
|
|
60
|
+
try {
|
|
61
|
+
val actualHash = download(downloadUrl, tempZip, onProgress)
|
|
62
|
+
if (!actualHash.equals(expectedHash, ignoreCase = true)) {
|
|
63
|
+
throw OtaInstallException(
|
|
64
|
+
"Bundle integrity check failed: expected $expectedHash but downloaded $actualHash",
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
targetDir.deleteRecursively()
|
|
69
|
+
targetDir.mkdirs()
|
|
70
|
+
unzip(tempZip, targetDir, tempZip.length())
|
|
71
|
+
|
|
72
|
+
val bundle = resolveBundle(targetDir)
|
|
73
|
+
?: throw OtaInstallException(
|
|
74
|
+
"The release package contains no JS bundle (looked for ${BUNDLE_CANDIDATES.joinToString(", ")})",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return InstalledPackage(bundle.absolutePath, expectedHash, dirSize(targetDir))
|
|
78
|
+
} catch (e: Exception) {
|
|
79
|
+
targetDir.deleteRecursively()
|
|
80
|
+
throw if (e is OtaInstallException) e else OtaInstallException(e.message ?: "Update failed", e)
|
|
81
|
+
} finally {
|
|
82
|
+
tempZip.delete()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Streams to disk while hashing, so the payload is never held in memory. */
|
|
87
|
+
private fun download(
|
|
88
|
+
url: String,
|
|
89
|
+
destination: File,
|
|
90
|
+
onProgress: (received: Long, total: Long) -> Unit,
|
|
91
|
+
): String {
|
|
92
|
+
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
|
93
|
+
connectTimeout = CONNECT_TIMEOUT_MS
|
|
94
|
+
readTimeout = READ_TIMEOUT_MS
|
|
95
|
+
instanceFollowRedirects = true
|
|
96
|
+
requestMethod = "GET"
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
val status = connection.responseCode
|
|
101
|
+
if (status !in 200..299) {
|
|
102
|
+
throw OtaInstallException("Bundle download failed with HTTP $status")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
val total = connection.contentLengthLong.takeIf { it > 0 } ?: -1L
|
|
106
|
+
val digest = MessageDigest.getInstance("SHA-256")
|
|
107
|
+
var received = 0L
|
|
108
|
+
var lastReported = 0L
|
|
109
|
+
|
|
110
|
+
connection.inputStream.use { input ->
|
|
111
|
+
FileOutputStream(destination).use { output ->
|
|
112
|
+
val buffer = ByteArray(BUFFER_SIZE)
|
|
113
|
+
while (true) {
|
|
114
|
+
val read = input.read(buffer)
|
|
115
|
+
if (read == -1) break
|
|
116
|
+
digest.update(buffer, 0, read)
|
|
117
|
+
output.write(buffer, 0, read)
|
|
118
|
+
received += read
|
|
119
|
+
// Throttle to ~1% steps; the bridge cannot keep up with per-chunk.
|
|
120
|
+
if (total <= 0 || received - lastReported >= total / 100 || received == total) {
|
|
121
|
+
lastReported = received
|
|
122
|
+
onProgress(received, total)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return digest.digest().joinToString("") { "%02x".format(it) }
|
|
129
|
+
} finally {
|
|
130
|
+
connection.disconnect()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private fun unzip(zip: File, targetDir: File, archiveBytes: Long) {
|
|
135
|
+
// Cap on total output, derived from the archive's own size. Streaming
|
|
136
|
+
// enforcement is required: an entry's declared size is attacker-controlled,
|
|
137
|
+
// so the only trustworthy number is what we have actually written.
|
|
138
|
+
val ceiling = minOf(
|
|
139
|
+
MAX_TOTAL_EXTRACTED_BYTES,
|
|
140
|
+
maxOf(archiveBytes, 1L) * MAX_COMPRESSION_RATIO,
|
|
141
|
+
)
|
|
142
|
+
var totalWritten = 0L
|
|
143
|
+
var entries = 0
|
|
144
|
+
|
|
145
|
+
ZipInputStream(zip.inputStream().buffered()).use { input ->
|
|
146
|
+
var entry: ZipEntry? = input.nextEntry
|
|
147
|
+
while (entry != null) {
|
|
148
|
+
if (++entries > MAX_ENTRIES) {
|
|
149
|
+
throw OtaInstallException("Release package has too many entries (> $MAX_ENTRIES)")
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
val outFile = File(targetDir, entry.name)
|
|
153
|
+
// Zip-slip guard: an entry name like ../../x would otherwise write
|
|
154
|
+
// outside the package directory.
|
|
155
|
+
if (!outFile.canonicalPath.startsWith(targetDir.canonicalPath + File.separator)) {
|
|
156
|
+
throw OtaInstallException("Refusing to extract unsafe zip entry: ${entry.name}")
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (entry.isDirectory) {
|
|
160
|
+
outFile.mkdirs()
|
|
161
|
+
} else {
|
|
162
|
+
outFile.parentFile?.mkdirs()
|
|
163
|
+
FileOutputStream(outFile).use { output ->
|
|
164
|
+
val buffer = ByteArray(BUFFER_SIZE)
|
|
165
|
+
while (true) {
|
|
166
|
+
val read = input.read(buffer)
|
|
167
|
+
if (read == -1) break
|
|
168
|
+
totalWritten += read
|
|
169
|
+
if (totalWritten > ceiling) {
|
|
170
|
+
throw OtaInstallException(
|
|
171
|
+
"Release package expands beyond the safe limit (> $ceiling bytes) — refusing to extract",
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
output.write(buffer, 0, read)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
input.closeEntry()
|
|
179
|
+
entry = input.nextEntry
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Finds the JS bundle inside an extracted package. Tolerates archives that
|
|
186
|
+
* wrap everything in a single top-level folder, which is what most zip tools
|
|
187
|
+
* produce by default.
|
|
188
|
+
*/
|
|
189
|
+
private fun resolveBundle(dir: File): File? {
|
|
190
|
+
if (!dir.isDirectory) return null
|
|
191
|
+
|
|
192
|
+
for (name in BUNDLE_CANDIDATES) {
|
|
193
|
+
val direct = File(dir, name)
|
|
194
|
+
if (direct.isFile) return direct
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
val children = dir.listFiles() ?: return null
|
|
198
|
+
val onlyDir = children.singleOrNull()?.takeIf { it.isDirectory }
|
|
199
|
+
if (onlyDir != null) {
|
|
200
|
+
resolveBundle(onlyDir)?.let { return it }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return dir.walkTopDown()
|
|
204
|
+
.maxDepth(4)
|
|
205
|
+
.firstOrNull { it.isFile && (it.name.endsWith(".bundle") || it.name.endsWith(".jsbundle")) }
|
|
206
|
+
?.also { Log.i(OtaUpdateStore.TAG, "using bundle ${it.name} found at depth") }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private fun dirSize(dir: File): Long =
|
|
210
|
+
dir.walkTopDown().filter { it.isFile }.sumOf { it.length() }
|
|
211
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
package com.otaupdate
|
|
2
|
+
|
|
3
|
+
import android.os.Handler
|
|
4
|
+
import android.os.Looper
|
|
5
|
+
import android.util.Log
|
|
6
|
+
import com.facebook.react.ReactApplication
|
|
7
|
+
import com.facebook.react.bridge.Arguments
|
|
8
|
+
import com.facebook.react.bridge.LifecycleEventListener
|
|
9
|
+
import com.facebook.react.bridge.Promise
|
|
10
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
11
|
+
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
12
|
+
import com.facebook.react.bridge.ReactMethod
|
|
13
|
+
import com.facebook.react.bridge.ReadableMap
|
|
14
|
+
import com.facebook.react.bridge.WritableMap
|
|
15
|
+
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
16
|
+
import java.util.concurrent.Executors
|
|
17
|
+
|
|
18
|
+
class OtaUpdateModule(private val reactContext: ReactApplicationContext) :
|
|
19
|
+
ReactContextBaseJavaModule(reactContext), LifecycleEventListener {
|
|
20
|
+
|
|
21
|
+
companion object {
|
|
22
|
+
const val NAME = "OtaUpdate"
|
|
23
|
+
private const val PROGRESS_EVENT = "OtaUpdateDownloadProgress"
|
|
24
|
+
|
|
25
|
+
const val INSTALL_MODE_ON_NEXT_RESTART = 0
|
|
26
|
+
const val INSTALL_MODE_ON_NEXT_RESUME = 1
|
|
27
|
+
const val INSTALL_MODE_IMMEDIATE = 2
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private val executor = Executors.newSingleThreadExecutor()
|
|
31
|
+
private val store: OtaUpdateStore by lazy { OtaUpdate.store(reactContext) }
|
|
32
|
+
|
|
33
|
+
/** Set when an ON_NEXT_RESUME install is waiting for the app to background. */
|
|
34
|
+
private var restartOnResume = false
|
|
35
|
+
private var minimumBackgroundDurationMs = 0L
|
|
36
|
+
private var backgroundedAt = 0L
|
|
37
|
+
|
|
38
|
+
init {
|
|
39
|
+
reactContext.addLifecycleEventListener(this)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
override fun getName(): String = NAME
|
|
43
|
+
|
|
44
|
+
override fun getConstants(): Map<String, Any> = mapOf(
|
|
45
|
+
"INSTALL_MODE_ON_NEXT_RESTART" to INSTALL_MODE_ON_NEXT_RESTART,
|
|
46
|
+
"INSTALL_MODE_ON_NEXT_RESUME" to INSTALL_MODE_ON_NEXT_RESUME,
|
|
47
|
+
"INSTALL_MODE_IMMEDIATE" to INSTALL_MODE_IMMEDIATE,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
// NativeEventEmitter requires these to exist, even though the native side
|
|
51
|
+
// does the subscribing itself.
|
|
52
|
+
@ReactMethod fun addListener(eventName: String) = Unit
|
|
53
|
+
|
|
54
|
+
@ReactMethod fun removeListeners(count: Int) = Unit
|
|
55
|
+
|
|
56
|
+
// --- Configuration --------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
@ReactMethod
|
|
59
|
+
fun getConfiguration(promise: Promise) {
|
|
60
|
+
try {
|
|
61
|
+
val map = Arguments.createMap().apply {
|
|
62
|
+
putString("deploymentKey", OtaUpdate.deploymentKey(reactContext))
|
|
63
|
+
putString("serverUrl", OtaUpdate.serverUrl(reactContext))
|
|
64
|
+
putString("appVersion", OtaUpdate.appVersion(reactContext))
|
|
65
|
+
putString("clientUniqueId", store.clientUniqueId())
|
|
66
|
+
putString("packageHash", store.currentHash)
|
|
67
|
+
putString("label", store.packageInfo(store.currentHash)?.optString("label"))
|
|
68
|
+
// Read from the native runtime, not settable from JS — this is what
|
|
69
|
+
// makes backend bundle-identifier enforcement meaningful.
|
|
70
|
+
putString("bundleIdentifier", reactContext.packageName)
|
|
71
|
+
}
|
|
72
|
+
promise.resolve(map)
|
|
73
|
+
} catch (e: Exception) {
|
|
74
|
+
promise.reject("ota_config_error", e.message, e)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@ReactMethod
|
|
79
|
+
fun getCurrentPackage(promise: Promise) {
|
|
80
|
+
try {
|
|
81
|
+
val hash = store.currentHash
|
|
82
|
+
val info = store.packageInfo(hash)
|
|
83
|
+
if (hash == null || info == null) {
|
|
84
|
+
promise.resolve(null)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
val map = Arguments.createMap().apply {
|
|
88
|
+
putString("packageHash", hash)
|
|
89
|
+
putString("label", info.optString("label"))
|
|
90
|
+
putString("description", info.optString("description").takeIf { it.isNotEmpty() })
|
|
91
|
+
putBoolean("isMandatory", info.optBoolean("isMandatory"))
|
|
92
|
+
putString("bundlePath", info.optString("bundlePath"))
|
|
93
|
+
putDouble("size", info.optLong("size").toDouble())
|
|
94
|
+
putString("appVersion", OtaUpdate.appVersion(reactContext))
|
|
95
|
+
putBoolean("isPending", store.isPending())
|
|
96
|
+
putBoolean("isFirstRun", OtaUpdateStore.isFirstRunOfUpdate())
|
|
97
|
+
}
|
|
98
|
+
promise.resolve(map)
|
|
99
|
+
} catch (e: Exception) {
|
|
100
|
+
promise.reject("ota_current_package_error", e.message, e)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@ReactMethod
|
|
105
|
+
fun isFirstRun(packageHash: String, promise: Promise) {
|
|
106
|
+
promise.resolve(OtaUpdateStore.isFirstRunOfUpdate() && store.currentHash == packageHash)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- Download -------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
@ReactMethod
|
|
112
|
+
fun downloadUpdate(update: ReadableMap, promise: Promise) {
|
|
113
|
+
val downloadUrl = update.getString("downloadUrl")
|
|
114
|
+
val packageHash = update.getString("packageHash")
|
|
115
|
+
val label = update.getString("label") ?: ""
|
|
116
|
+
|
|
117
|
+
if (downloadUrl.isNullOrEmpty() || packageHash.isNullOrEmpty()) {
|
|
118
|
+
promise.reject("ota_invalid_update", "downloadUrl and packageHash are required")
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Networking and unzipping must stay off the UI and JS threads.
|
|
123
|
+
executor.execute {
|
|
124
|
+
try {
|
|
125
|
+
val installed = OtaUpdateInstaller.install(store, downloadUrl, packageHash) { received, total ->
|
|
126
|
+
emitProgress(received, total)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
store.recordPackage(
|
|
130
|
+
hash = packageHash,
|
|
131
|
+
label = label,
|
|
132
|
+
bundlePath = installed.bundlePath,
|
|
133
|
+
description = if (update.hasKey("description")) update.getString("description") else null,
|
|
134
|
+
isMandatory = update.hasKey("isMandatory") && update.getBoolean("isMandatory"),
|
|
135
|
+
size = installed.size,
|
|
136
|
+
appVersion = OtaUpdate.appVersion(reactContext),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
val result = Arguments.createMap().apply {
|
|
140
|
+
putString("bundlePath", installed.bundlePath)
|
|
141
|
+
putString("packageHash", packageHash)
|
|
142
|
+
putString("label", label)
|
|
143
|
+
putDouble("size", installed.size.toDouble())
|
|
144
|
+
}
|
|
145
|
+
promise.resolve(result)
|
|
146
|
+
} catch (e: Exception) {
|
|
147
|
+
Log.e(OtaUpdateStore.TAG, "download failed", e)
|
|
148
|
+
promise.reject("ota_download_failed", e.message, e)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private fun emitProgress(received: Long, total: Long) {
|
|
154
|
+
if (!reactContext.hasActiveReactInstance()) return
|
|
155
|
+
val payload: WritableMap = Arguments.createMap().apply {
|
|
156
|
+
putDouble("receivedBytes", received.toDouble())
|
|
157
|
+
putDouble("totalBytes", total.toDouble())
|
|
158
|
+
}
|
|
159
|
+
reactContext
|
|
160
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
161
|
+
.emit(PROGRESS_EVENT, payload)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- Install --------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
@ReactMethod
|
|
167
|
+
fun installUpdate(packageHash: String, installMode: Int, minimumBackgroundDuration: Int, promise: Promise) {
|
|
168
|
+
try {
|
|
169
|
+
store.markPending(packageHash)
|
|
170
|
+
|
|
171
|
+
when (installMode) {
|
|
172
|
+
INSTALL_MODE_IMMEDIATE -> {
|
|
173
|
+
promise.resolve(null)
|
|
174
|
+
Handler(Looper.getMainLooper()).post { reload() }
|
|
175
|
+
}
|
|
176
|
+
INSTALL_MODE_ON_NEXT_RESUME -> {
|
|
177
|
+
restartOnResume = true
|
|
178
|
+
minimumBackgroundDurationMs = minimumBackgroundDuration * 1000L
|
|
179
|
+
promise.resolve(null)
|
|
180
|
+
}
|
|
181
|
+
else -> {
|
|
182
|
+
// ON_NEXT_RESTART: nothing more to do — getJSBundleFile picks it up.
|
|
183
|
+
promise.resolve(null)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} catch (e: Exception) {
|
|
187
|
+
promise.reject("ota_install_failed", e.message, e)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
@ReactMethod
|
|
192
|
+
fun notifyApplicationReady(promise: Promise) {
|
|
193
|
+
try {
|
|
194
|
+
store.notifyApplicationReady()
|
|
195
|
+
// Reclaim disk from superseded packages once we know the current one works.
|
|
196
|
+
executor.execute { runCatching { store.pruneOldPackages() } }
|
|
197
|
+
promise.resolve(null)
|
|
198
|
+
} catch (e: Exception) {
|
|
199
|
+
promise.reject("ota_notify_failed", e.message, e)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
@ReactMethod
|
|
204
|
+
fun restartApp(onlyIfUpdateIsPending: Boolean, promise: Promise) {
|
|
205
|
+
if (onlyIfUpdateIsPending && !store.isPending()) {
|
|
206
|
+
promise.resolve(false)
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
promise.resolve(true)
|
|
210
|
+
Handler(Looper.getMainLooper()).post { reload() }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
@ReactMethod
|
|
214
|
+
fun clearUpdates(promise: Promise) {
|
|
215
|
+
try {
|
|
216
|
+
store.reset()
|
|
217
|
+
promise.resolve(null)
|
|
218
|
+
} catch (e: Exception) {
|
|
219
|
+
promise.reject("ota_clear_failed", e.message, e)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Recreates the React context so the new bundle is loaded. Handles both the
|
|
225
|
+
* bridge and bridgeless (new architecture) hosts; the bridgeless path is
|
|
226
|
+
* reached reflectively so this module still compiles against RN 0.71.
|
|
227
|
+
*/
|
|
228
|
+
private fun reload() {
|
|
229
|
+
val application = reactContext.applicationContext
|
|
230
|
+
if (application !is ReactApplication) {
|
|
231
|
+
Log.e(OtaUpdateStore.TAG, "Application does not implement ReactApplication — cannot restart")
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
val reactHost = runCatching {
|
|
237
|
+
ReactApplication::class.java.getMethod("getReactHost").invoke(application)
|
|
238
|
+
}.getOrNull()
|
|
239
|
+
|
|
240
|
+
if (reactHost != null) {
|
|
241
|
+
reactHost.javaClass.methods
|
|
242
|
+
.firstOrNull { it.name == "reload" && it.parameterTypes.size == 1 }
|
|
243
|
+
?.invoke(reactHost, "OTA update applied")
|
|
244
|
+
?: Log.e(OtaUpdateStore.TAG, "ReactHost has no reload(String) method")
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
application.reactNativeHost.reactInstanceManager.recreateReactContextInBackground()
|
|
249
|
+
} catch (e: Exception) {
|
|
250
|
+
Log.e(OtaUpdateStore.TAG, "failed to restart the React context", e)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// --- ON_NEXT_RESUME wiring -----------------------------------------------
|
|
255
|
+
|
|
256
|
+
override fun onHostResume() {
|
|
257
|
+
if (!restartOnResume) return
|
|
258
|
+
val backgroundedFor = System.currentTimeMillis() - backgroundedAt
|
|
259
|
+
if (backgroundedAt > 0 && backgroundedFor >= minimumBackgroundDurationMs) {
|
|
260
|
+
restartOnResume = false
|
|
261
|
+
Handler(Looper.getMainLooper()).post { reload() }
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
override fun onHostPause() {
|
|
266
|
+
backgroundedAt = System.currentTimeMillis()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
override fun onHostDestroy() {
|
|
270
|
+
backgroundedAt = 0
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
override fun invalidate() {
|
|
274
|
+
reactContext.removeLifecycleEventListener(this)
|
|
275
|
+
executor.shutdown()
|
|
276
|
+
super.invalidate()
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
package com.otaupdate
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.ReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.uimanager.ViewManager
|
|
7
|
+
|
|
8
|
+
/** Registered automatically by React Native autolinking. */
|
|
9
|
+
class OtaUpdatePackage : ReactPackage {
|
|
10
|
+
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
|
11
|
+
listOf(OtaUpdateModule(reactContext))
|
|
12
|
+
|
|
13
|
+
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
|
|
14
|
+
emptyList()
|
|
15
|
+
}
|