@nebula-rn/host 0.0.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/NebulaHost.podspec +23 -0
- package/android/.gradle/8.9/checksums/checksums.lock +0 -0
- package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
- package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
- package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
- package/android/.gradle/8.9/gc.properties +0 -0
- package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/android/.gradle/vcs-1/gc.properties +0 -0
- package/android/build.gradle +27 -0
- package/android/consumer-rules.pro +1 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/hectorzhuang/nebula/NebulaActivity.kt +290 -0
- package/android/src/main/java/com/hectorzhuang/nebula/NebulaAppManager.kt +134 -0
- package/android/src/main/java/com/hectorzhuang/nebula/NebulaConfig.kt +324 -0
- package/android/src/main/java/com/hectorzhuang/nebula/NebulaEventHub.kt +49 -0
- package/android/src/main/java/com/hectorzhuang/nebula/NebulaHost.kt +145 -0
- package/android/src/main/java/com/hectorzhuang/nebula/NebulaHostModalActivity.kt +178 -0
- package/android/src/main/java/com/hectorzhuang/nebula/NebulaManifestManager.kt +130 -0
- package/android/src/main/java/com/hectorzhuang/nebula/NebulaNativeModule.kt +604 -0
- package/android/src/main/java/com/hectorzhuang/nebula/NebulaPackage.kt +16 -0
- package/android/src/main/java/com/hectorzhuang/nebula/NebulaRouter.kt +300 -0
- package/ios/Nebula/NebulaAppManager.swift +355 -0
- package/ios/Nebula/NebulaConfig.swift +549 -0
- package/ios/Nebula/NebulaContainerController.swift +580 -0
- package/ios/Nebula/NebulaDevLoading.swift +333 -0
- package/ios/Nebula/NebulaHost.swift +611 -0
- package/ios/Nebula/NebulaManifest.swift +214 -0
- package/ios/Nebula/NebulaNativeModule.swift +682 -0
- package/ios/Nebula/NebulaNativeModuleBridge.m +364 -0
- package/ios/Nebula/NebulaPerformanceMonitor.swift +46 -0
- package/ios/Nebula/NebulaRouter.swift +594 -0
- package/ios/Nebula/NebulaRouterBridge.m +19 -0
- package/ios/Nebula/RNInstanceViewController.swift +52 -0
- package/package.json +41 -0
- package/react-native.config.js +14 -0
- package/src/index.ts +9 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
package com.hectorzhuang.nebula
|
|
2
|
+
|
|
3
|
+
import android.app.Application
|
|
4
|
+
import java.io.File
|
|
5
|
+
import java.io.FileOutputStream
|
|
6
|
+
import java.net.URI
|
|
7
|
+
import java.net.URL
|
|
8
|
+
import java.net.URLConnection
|
|
9
|
+
import java.util.zip.ZipInputStream
|
|
10
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
11
|
+
|
|
12
|
+
object NebulaConfig {
|
|
13
|
+
private const val PREFS_NAME = "nebula.config"
|
|
14
|
+
private const val SERVER_BASE_URL_KEY = "_serverBaseURL"
|
|
15
|
+
private val installedApps = ConcurrentHashMap<String, NebulaInstalledApp>()
|
|
16
|
+
private lateinit var application: Application
|
|
17
|
+
|
|
18
|
+
var serverBaseURL: String?
|
|
19
|
+
get() = application.getSharedPreferences(PREFS_NAME, 0).getString(SERVER_BASE_URL_KEY, null)
|
|
20
|
+
set(value) {
|
|
21
|
+
val prefs = application.getSharedPreferences(PREFS_NAME, 0)
|
|
22
|
+
val normalized = normalizeServerBaseURL(value)
|
|
23
|
+
if (normalized == null) {
|
|
24
|
+
prefs.edit().remove(SERVER_BASE_URL_KEY).apply()
|
|
25
|
+
} else {
|
|
26
|
+
prefs.edit().putString(SERVER_BASE_URL_KEY, normalized).apply()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
fun initialize(app: Application) {
|
|
31
|
+
application = app
|
|
32
|
+
restoreInstalledApps()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fun sandboxDir(appId: String): File {
|
|
36
|
+
val dir = File(application.filesDir, "nebula/$appId")
|
|
37
|
+
if (!dir.exists()) {
|
|
38
|
+
dir.mkdirs()
|
|
39
|
+
}
|
|
40
|
+
return dir
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fun bundleFile(appId: String): File = File(sandboxDir(appId), "index.android.bundle")
|
|
44
|
+
|
|
45
|
+
fun installApp(
|
|
46
|
+
appId: String,
|
|
47
|
+
bundleURL: String,
|
|
48
|
+
connectToURLMetroServer: Boolean = false,
|
|
49
|
+
): NebulaInstalledApp {
|
|
50
|
+
val sandboxDir = sandboxDir(appId)
|
|
51
|
+
val bundleFile = bundleFile(appId)
|
|
52
|
+
downloadToFile(bundleURL, bundleFile)
|
|
53
|
+
var manifest: NebulaManifest? = null
|
|
54
|
+
deriveManifestUrl(bundleURL)?.let { manifestURL ->
|
|
55
|
+
runCatching {
|
|
56
|
+
downloadToFile(manifestURL, File(sandboxDir, "app.json"))
|
|
57
|
+
manifest = NebulaManifestManager.loadManifest(appId, sandboxDir)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
val installed =
|
|
62
|
+
NebulaInstalledApp(
|
|
63
|
+
appId = appId,
|
|
64
|
+
mode = if (connectToURLMetroServer) "development" else "production",
|
|
65
|
+
bundlePath = bundleFile.absolutePath,
|
|
66
|
+
sourceUrl = bundleURL,
|
|
67
|
+
version = if (connectToURLMetroServer) null else manifest?.version,
|
|
68
|
+
updateStrategy =
|
|
69
|
+
if (connectToURLMetroServer) {
|
|
70
|
+
"manual"
|
|
71
|
+
} else {
|
|
72
|
+
manifest?.updateStrategy ?: "manual"
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
installedApps[appId] = installed
|
|
76
|
+
persistInstalledApp(installed)
|
|
77
|
+
return installed
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fun installApp(
|
|
81
|
+
appId: String,
|
|
82
|
+
bundleURL: String,
|
|
83
|
+
manifestURL: String,
|
|
84
|
+
assetsURL: String,
|
|
85
|
+
connectToURLMetroServer: Boolean = false,
|
|
86
|
+
): NebulaInstalledApp {
|
|
87
|
+
val sandboxDir = sandboxDir(appId)
|
|
88
|
+
val bundleFile = bundleFile(appId)
|
|
89
|
+
downloadToFile(bundleURL, bundleFile)
|
|
90
|
+
downloadToFile(manifestURL, File(sandboxDir, "app.json"))
|
|
91
|
+
if (!connectToURLMetroServer) {
|
|
92
|
+
downloadAndExtractAssets(assetsURL, sandboxDir)
|
|
93
|
+
}
|
|
94
|
+
val manifest = NebulaManifestManager.loadManifest(appId, sandboxDir)
|
|
95
|
+
val installed =
|
|
96
|
+
NebulaInstalledApp(
|
|
97
|
+
appId = appId,
|
|
98
|
+
mode = if (connectToURLMetroServer) "development" else "production",
|
|
99
|
+
bundlePath = bundleFile.absolutePath,
|
|
100
|
+
sourceUrl = bundleURL,
|
|
101
|
+
version = if (connectToURLMetroServer) null else manifest?.version,
|
|
102
|
+
updateStrategy =
|
|
103
|
+
if (connectToURLMetroServer) {
|
|
104
|
+
"manual"
|
|
105
|
+
} else {
|
|
106
|
+
manifest?.updateStrategy ?: "manual"
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
installedApps[appId] = installed
|
|
110
|
+
persistInstalledApp(installed)
|
|
111
|
+
return installed
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
fun getInstalledApp(appId: String): NebulaInstalledApp? = installedApps[appId]
|
|
115
|
+
|
|
116
|
+
fun isDevelopmentMode(installedApp: NebulaInstalledApp): Boolean {
|
|
117
|
+
return installedApp.mode.equals("development", ignoreCase = true)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
fun getDevServerHost(installedApp: NebulaInstalledApp): String? {
|
|
121
|
+
if (!isDevelopmentMode(installedApp)) {
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
val uri = runCatching { URI(installedApp.sourceUrl) }.getOrNull() ?: return null
|
|
126
|
+
val host = uri.host ?: return null
|
|
127
|
+
val port = uri.port
|
|
128
|
+
return if (port > 0) "$host:$port" else host
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
fun getDevModulePath(installedApp: NebulaInstalledApp): String? {
|
|
132
|
+
if (!isDevelopmentMode(installedApp)) {
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
val uri = runCatching { URI(installedApp.sourceUrl) }.getOrNull() ?: return null
|
|
137
|
+
val path = uri.path?.trim().orEmpty()
|
|
138
|
+
if (path.isEmpty()) {
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
val trimmedPath = path.trimStart('/')
|
|
143
|
+
return if (trimmedPath.endsWith(".bundle")) {
|
|
144
|
+
trimmedPath.removeSuffix(".bundle")
|
|
145
|
+
} else {
|
|
146
|
+
trimmedPath
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
fun installedAppIds(): List<String> = installedApps.keys.sorted()
|
|
151
|
+
|
|
152
|
+
fun clearApp(appId: String) {
|
|
153
|
+
installedApps.remove(appId)
|
|
154
|
+
application.getSharedPreferences(PREFS_NAME, 0).edit().remove(appId).apply()
|
|
155
|
+
NebulaManifestManager.removeManifest(appId)
|
|
156
|
+
sandboxDir(appId).deleteRecursively()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private fun persistInstalledApp(installedApp: NebulaInstalledApp) {
|
|
160
|
+
val json =
|
|
161
|
+
"""
|
|
162
|
+
{"appId":"${installedApp.appId}","mode":"${installedApp.mode}","bundlePath":"${installedApp.bundlePath}","sourceUrl":"${installedApp.sourceUrl}","version":${installedApp.version?.let { "\"$it\"" } ?: "null"},"updateStrategy":"${installedApp.updateStrategy}"}
|
|
163
|
+
""".trimIndent()
|
|
164
|
+
application.getSharedPreferences(PREFS_NAME, 0).edit().putString(installedApp.appId, json)
|
|
165
|
+
.apply()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private fun restoreInstalledApps() {
|
|
169
|
+
val prefs = application.getSharedPreferences(PREFS_NAME, 0).all
|
|
170
|
+
prefs.forEach { (key, value) ->
|
|
171
|
+
if (key == SERVER_BASE_URL_KEY) {
|
|
172
|
+
return@forEach
|
|
173
|
+
}
|
|
174
|
+
val raw = value as? String ?: return@forEach
|
|
175
|
+
runCatching {
|
|
176
|
+
val json = org.json.JSONObject(raw)
|
|
177
|
+
installedApps[key] =
|
|
178
|
+
NebulaInstalledApp(
|
|
179
|
+
appId = json.optString("appId", key),
|
|
180
|
+
mode = json.optString("mode", "production"),
|
|
181
|
+
bundlePath = json.optString("bundlePath"),
|
|
182
|
+
sourceUrl = json.optString("sourceUrl"),
|
|
183
|
+
version = json.optString("version", null),
|
|
184
|
+
updateStrategy = json.optString("updateStrategy", "manual"),
|
|
185
|
+
)
|
|
186
|
+
NebulaManifestManager.loadManifest(key, sandboxDir(key))
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private fun deriveManifestUrl(bundleURL: String): String? {
|
|
192
|
+
return runCatching {
|
|
193
|
+
val uri = URI(bundleURL)
|
|
194
|
+
val path = uri.path ?: return null
|
|
195
|
+
val parentPath = path.substringBeforeLast('/', "")
|
|
196
|
+
if (parentPath.isEmpty()) {
|
|
197
|
+
return null
|
|
198
|
+
}
|
|
199
|
+
URI(
|
|
200
|
+
uri.scheme,
|
|
201
|
+
uri.authority,
|
|
202
|
+
"$parentPath/app.json",
|
|
203
|
+
null,
|
|
204
|
+
null,
|
|
205
|
+
).toString()
|
|
206
|
+
}.getOrNull()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
fun resolveRemoteUrl(rawURL: String): String {
|
|
210
|
+
val trimmedURL = rawURL.trim()
|
|
211
|
+
if (trimmedURL.isEmpty() || trimmedURL.startsWith("file://")) {
|
|
212
|
+
return rawURL
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
val base = serverBaseURL ?: return trimmedURL
|
|
216
|
+
val baseUri = runCatching { URI(base) }.getOrNull() ?: return trimmedURL
|
|
217
|
+
|
|
218
|
+
if (trimmedURL.startsWith("/")) {
|
|
219
|
+
return URI(baseUri.scheme, baseUri.authority, trimmedURL, null, null).toString()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
val remoteUri = runCatching { URI(trimmedURL) }.getOrNull()
|
|
223
|
+
if (remoteUri?.scheme?.lowercase() in listOf("http", "https")) {
|
|
224
|
+
if (shouldReplaceRemoteHost(remoteUri?.host)) {
|
|
225
|
+
return URI(
|
|
226
|
+
baseUri.scheme,
|
|
227
|
+
baseUri.authority,
|
|
228
|
+
remoteUri?.path,
|
|
229
|
+
remoteUri?.query,
|
|
230
|
+
remoteUri?.fragment,
|
|
231
|
+
).toString()
|
|
232
|
+
}
|
|
233
|
+
return trimmedURL
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
val normalizedBasePath = baseUri.path?.trim('/').orEmpty()
|
|
237
|
+
val normalizedRelativePath = trimmedURL.trim('/')
|
|
238
|
+
val mergedPath =
|
|
239
|
+
listOf(normalizedBasePath, normalizedRelativePath).filter { it.isNotEmpty() }
|
|
240
|
+
.joinToString("/")
|
|
241
|
+
return URI(baseUri.scheme, baseUri.authority, "/$mergedPath", null, null).toString()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private fun downloadToFile(source: String, destination: File) {
|
|
245
|
+
destination.parentFile?.mkdirs()
|
|
246
|
+
val connection = openConnection(source)
|
|
247
|
+
connection.getInputStream().use { input ->
|
|
248
|
+
FileOutputStream(destination).use { output ->
|
|
249
|
+
input.copyTo(output)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private fun openConnection(source: String): URLConnection {
|
|
255
|
+
return if (source.startsWith("file://")) {
|
|
256
|
+
URI(source).toURL().openConnection()
|
|
257
|
+
} else if (source.startsWith("/")) {
|
|
258
|
+
File(source).toURI().toURL().openConnection()
|
|
259
|
+
} else {
|
|
260
|
+
URL(source).openConnection().apply {
|
|
261
|
+
connectTimeout = 15000
|
|
262
|
+
readTimeout = 15000
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
fun normalizeServerBaseURL(rawURL: String?): String? {
|
|
268
|
+
val trimmedURL = rawURL?.trim().orEmpty()
|
|
269
|
+
if (trimmedURL.isEmpty()) {
|
|
270
|
+
return null
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
val uri = runCatching { URI(trimmedURL) }.getOrNull() ?: return null
|
|
274
|
+
val scheme = uri.scheme?.lowercase() ?: return null
|
|
275
|
+
if (scheme != "http" && scheme != "https") {
|
|
276
|
+
return null
|
|
277
|
+
}
|
|
278
|
+
val host = uri.host ?: return null
|
|
279
|
+
val normalizedPath = uri.path?.trimEnd('/').orEmpty()
|
|
280
|
+
return URI(
|
|
281
|
+
scheme,
|
|
282
|
+
uri.userInfo,
|
|
283
|
+
host,
|
|
284
|
+
uri.port,
|
|
285
|
+
normalizedPath,
|
|
286
|
+
null,
|
|
287
|
+
null,
|
|
288
|
+
).toString()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private fun shouldReplaceRemoteHost(host: String?): Boolean {
|
|
292
|
+
val normalizedHost = host?.lowercase() ?: return false
|
|
293
|
+
return normalizedHost == "localhost" || normalizedHost == "127.0.0.1" || normalizedHost == "0.0.0.0"
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private fun downloadAndExtractAssets(assetsURL: String, sandboxDir: File) {
|
|
297
|
+
val zipFile = File(sandboxDir, "assets.zip")
|
|
298
|
+
try {
|
|
299
|
+
downloadToFile(assetsURL, zipFile)
|
|
300
|
+
extractZipFile(zipFile, sandboxDir)
|
|
301
|
+
} finally {
|
|
302
|
+
zipFile.delete()
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private fun extractZipFile(zipFile: File, destinationDir: File) {
|
|
307
|
+
ZipInputStream(zipFile.inputStream().buffered()).use { zipStream ->
|
|
308
|
+
var entry = zipStream.nextEntry
|
|
309
|
+
while (entry != null) {
|
|
310
|
+
val entryFile = File(destinationDir, entry.name)
|
|
311
|
+
if (entry.isDirectory) {
|
|
312
|
+
entryFile.mkdirs()
|
|
313
|
+
} else {
|
|
314
|
+
entryFile.parentFile?.mkdirs()
|
|
315
|
+
FileOutputStream(entryFile).use { output ->
|
|
316
|
+
zipStream.copyTo(output)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
zipStream.closeEntry()
|
|
320
|
+
entry = zipStream.nextEntry
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
package com.hectorzhuang.nebula
|
|
2
|
+
|
|
3
|
+
import java.lang.ref.WeakReference
|
|
4
|
+
import java.util.UUID
|
|
5
|
+
import java.util.concurrent.CopyOnWriteArrayList
|
|
6
|
+
|
|
7
|
+
data class NebulaBridgeMessage(
|
|
8
|
+
val direction: String,
|
|
9
|
+
val appId: String,
|
|
10
|
+
val message: Map<String, Any?>,
|
|
11
|
+
val sourceEmitterId: String,
|
|
12
|
+
val timestamp: Double,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
data class NebulaPageLifecycleEvent(
|
|
16
|
+
val appId: String,
|
|
17
|
+
val instanceId: String,
|
|
18
|
+
val routePath: String,
|
|
19
|
+
val type: String,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
object NebulaEventHub {
|
|
23
|
+
private val moduleRefs = CopyOnWriteArrayList<WeakReference<NebulaNativeModule>>()
|
|
24
|
+
|
|
25
|
+
fun nextEmitterId(): String = UUID.randomUUID().toString()
|
|
26
|
+
|
|
27
|
+
fun register(module: NebulaNativeModule) {
|
|
28
|
+
cleanup()
|
|
29
|
+
moduleRefs.add(WeakReference(module))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fun unregister(module: NebulaNativeModule) {
|
|
33
|
+
moduleRefs.removeAll { it.get() == null || it.get() === module }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
fun publishBridgeMessage(message: NebulaBridgeMessage) {
|
|
37
|
+
cleanup()
|
|
38
|
+
moduleRefs.forEach { it.get()?.handleBridgeMessage(message) }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
fun publishPageLifecycle(event: NebulaPageLifecycleEvent) {
|
|
42
|
+
cleanup()
|
|
43
|
+
moduleRefs.forEach { it.get()?.handlePageLifecycle(event) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private fun cleanup() {
|
|
47
|
+
moduleRefs.removeAll { it.get() == null }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
package com.hectorzhuang.nebula
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.app.Application
|
|
5
|
+
import android.content.pm.ApplicationInfo
|
|
6
|
+
import android.os.Bundle
|
|
7
|
+
import android.preference.PreferenceManager
|
|
8
|
+
import com.facebook.react.ReactApplication
|
|
9
|
+
import com.facebook.react.ReactHost
|
|
10
|
+
import com.facebook.react.ReactPackage
|
|
11
|
+
import java.net.URL
|
|
12
|
+
import java.util.concurrent.Executors
|
|
13
|
+
import org.json.JSONObject
|
|
14
|
+
|
|
15
|
+
interface NebulaHostDelegate {
|
|
16
|
+
fun isDebugBuild(): Boolean = false
|
|
17
|
+
|
|
18
|
+
fun createMiniAppPackages(application: Application): List<ReactPackage> = emptyList()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
object NebulaHost {
|
|
22
|
+
private lateinit var application: Application
|
|
23
|
+
private var delegate: NebulaHostDelegate? = null
|
|
24
|
+
@Volatile private var topActivity: Activity? = null
|
|
25
|
+
@Volatile private var hostModalActivity: Activity? = null
|
|
26
|
+
private val ioExecutor = Executors.newSingleThreadExecutor()
|
|
27
|
+
|
|
28
|
+
fun initialize(app: Application, delegate: NebulaHostDelegate? = null) {
|
|
29
|
+
application = app
|
|
30
|
+
this.delegate = delegate
|
|
31
|
+
NebulaConfig.initialize(app)
|
|
32
|
+
NebulaAppManager.initialize(app, delegate)
|
|
33
|
+
app.registerActivityLifecycleCallbacks(
|
|
34
|
+
object : Application.ActivityLifecycleCallbacks {
|
|
35
|
+
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
|
|
36
|
+
override fun onActivityStarted(activity: Activity) = Unit
|
|
37
|
+
override fun onActivityResumed(activity: Activity) {
|
|
38
|
+
topActivity = activity
|
|
39
|
+
}
|
|
40
|
+
override fun onActivityPaused(activity: Activity) = Unit
|
|
41
|
+
override fun onActivityStopped(activity: Activity) = Unit
|
|
42
|
+
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
|
|
43
|
+
override fun onActivityDestroyed(activity: Activity) {
|
|
44
|
+
if (topActivity === activity) {
|
|
45
|
+
topActivity = null
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
fun currentActivity(): Activity? = topActivity
|
|
53
|
+
|
|
54
|
+
fun presentedHostModalActivity(): Activity? = hostModalActivity
|
|
55
|
+
|
|
56
|
+
fun setPresentedHostModalActivity(activity: Activity?) {
|
|
57
|
+
hostModalActivity = activity
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
fun isDebugBuild(): Boolean =
|
|
61
|
+
delegate?.isDebugBuild()
|
|
62
|
+
?: ((application.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0)
|
|
63
|
+
|
|
64
|
+
fun createMiniAppPackages(): List<ReactPackage> = delegate?.createMiniAppPackages(application) ?: emptyList()
|
|
65
|
+
|
|
66
|
+
fun resolveHostReactHost(app: Application = application): ReactHost {
|
|
67
|
+
return when (app) {
|
|
68
|
+
is ReactApplication -> app.reactHost ?: error("ReactApplication.reactHost is null")
|
|
69
|
+
else -> error("Application does not implement ReactApplication")
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fun installApp(
|
|
74
|
+
appId: String,
|
|
75
|
+
bundleURL: String,
|
|
76
|
+
connectToURLMetroServer: Boolean = false,
|
|
77
|
+
): NebulaInstalledApp {
|
|
78
|
+
val installedApp = NebulaConfig.installApp(appId, bundleURL, connectToURLMetroServer)
|
|
79
|
+
NebulaAppManager.invalidate(appId)
|
|
80
|
+
return installedApp
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
fun preloadApp(appId: String) {
|
|
84
|
+
NebulaAppManager.preload(appId)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
fun closeApp(appId: String) {
|
|
88
|
+
NebulaRouter.closeApp(appId).getOrThrow()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
fun uninstallApp(appId: String) {
|
|
92
|
+
NebulaAppManager.invalidate(appId)
|
|
93
|
+
NebulaConfig.clearApp(appId)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fun openApp(activity: Activity, appId: String, initialProps: Map<String, Any?> = emptyMap()) {
|
|
97
|
+
val routePath =
|
|
98
|
+
NebulaManifestManager.getManifest(appId)?.entryPagePath
|
|
99
|
+
?: NebulaManifestManager.loadManifest(appId, NebulaConfig.sandboxDir(appId))?.entryPagePath
|
|
100
|
+
?: "/"
|
|
101
|
+
NebulaRouter.openApp(activity, appId, routePath, initialProps)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
fun installedApps(): List<String> = NebulaConfig.installedAppIds()
|
|
105
|
+
|
|
106
|
+
fun handleIncomingUrl(activity: Activity, url: android.net.Uri?): Boolean {
|
|
107
|
+
if (url == null) {
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
val isReviewInstallLink =
|
|
111
|
+
(url.host == "review" && url.path.orEmpty().startsWith("/install/")) ||
|
|
112
|
+
(url.host == "miniapp" && url.path.orEmpty().startsWith("/install/")) ||
|
|
113
|
+
url.path.orEmpty().startsWith("/review/install/")
|
|
114
|
+
if (!isReviewInstallLink) {
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
val installUrl = url.getQueryParameter("installUrl") ?: return false
|
|
119
|
+
ioExecutor.execute {
|
|
120
|
+
runCatching {
|
|
121
|
+
val resolvedInstallUrl = NebulaConfig.resolveRemoteUrl(installUrl)
|
|
122
|
+
val payload = JSONObject(URL(resolvedInstallUrl).readText())
|
|
123
|
+
val appId = payload.getString("appId")
|
|
124
|
+
val bundles = payload.optJSONObject("bundles")
|
|
125
|
+
val rawBundleUrl =
|
|
126
|
+
bundles?.optString("android")?.takeIf { it.isNotBlank() }
|
|
127
|
+
?: payload.optString("bundleUrl").takeIf { it.isNotBlank() }
|
|
128
|
+
?: error("Missing Android bundle URL in review install payload")
|
|
129
|
+
val bundleUrl = NebulaConfig.resolveRemoteUrl(rawBundleUrl)
|
|
130
|
+
val manifestUrl = NebulaConfig.resolveRemoteUrl(payload.getString("manifestUrl"))
|
|
131
|
+
val assetsUrl =
|
|
132
|
+
payload.optJSONObject("assetsUrl")?.optString("android")?.takeIf { it.isNotBlank() }
|
|
133
|
+
?: error("Missing Android assets URL in review install payload")
|
|
134
|
+
val resolvedAssetsUrl = NebulaConfig.resolveRemoteUrl(assetsUrl)
|
|
135
|
+
NebulaConfig.installApp(appId, bundleUrl, manifestUrl, resolvedAssetsUrl, false)
|
|
136
|
+
activity.runOnUiThread {
|
|
137
|
+
openApp(activity, appId)
|
|
138
|
+
}
|
|
139
|
+
}.onFailure { error ->
|
|
140
|
+
error.printStackTrace()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return true
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
package com.hectorzhuang.nebula
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import android.content.pm.PackageManager
|
|
6
|
+
import android.os.Bundle
|
|
7
|
+
import android.view.View
|
|
8
|
+
import android.widget.FrameLayout
|
|
9
|
+
import androidx.appcompat.app.AppCompatActivity
|
|
10
|
+
import com.facebook.react.ReactHost
|
|
11
|
+
import com.facebook.react.interfaces.fabric.ReactSurface
|
|
12
|
+
import com.facebook.react.modules.core.DefaultHardwareBackBtnHandler
|
|
13
|
+
import com.facebook.react.modules.core.PermissionAwareActivity
|
|
14
|
+
import com.facebook.react.modules.core.PermissionListener
|
|
15
|
+
import org.json.JSONObject
|
|
16
|
+
|
|
17
|
+
class NebulaHostModalActivity :
|
|
18
|
+
AppCompatActivity(),
|
|
19
|
+
DefaultHardwareBackBtnHandler,
|
|
20
|
+
PermissionAwareActivity {
|
|
21
|
+
companion object {
|
|
22
|
+
private const val EXTRA_MODULE_NAME = "nebula.modal.module_name"
|
|
23
|
+
private const val EXTRA_INITIAL_PROPS = "nebula.modal.initial_props"
|
|
24
|
+
|
|
25
|
+
fun createIntent(
|
|
26
|
+
context: Context,
|
|
27
|
+
moduleName: String,
|
|
28
|
+
initialProps: Map<String, Any?>,
|
|
29
|
+
): Intent {
|
|
30
|
+
return Intent(context, NebulaHostModalActivity::class.java).apply {
|
|
31
|
+
putExtra(EXTRA_MODULE_NAME, moduleName)
|
|
32
|
+
putExtra(EXTRA_INITIAL_PROPS, JSONObject(initialProps).toString())
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private lateinit var rootHost: FrameLayout
|
|
38
|
+
private var reactSurface: ReactSurface? = null
|
|
39
|
+
private var permissionListener: PermissionListener? = null
|
|
40
|
+
|
|
41
|
+
override fun onCreate(savedInstanceState: Bundle?) {
|
|
42
|
+
super.onCreate(savedInstanceState)
|
|
43
|
+
|
|
44
|
+
val moduleName = intent.getStringExtra(EXTRA_MODULE_NAME) ?: error("Missing moduleName")
|
|
45
|
+
val initialProps = parseJsonMap(intent.getStringExtra(EXTRA_INITIAL_PROPS)).toBundle()
|
|
46
|
+
|
|
47
|
+
rootHost = FrameLayout(this).apply {
|
|
48
|
+
id = View.generateViewId()
|
|
49
|
+
layoutParams =
|
|
50
|
+
FrameLayout.LayoutParams(
|
|
51
|
+
FrameLayout.LayoutParams.MATCH_PARENT,
|
|
52
|
+
FrameLayout.LayoutParams.MATCH_PARENT,
|
|
53
|
+
)
|
|
54
|
+
setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
setContentView(rootHost)
|
|
58
|
+
|
|
59
|
+
reactSurface = resolveReactHost().createSurface(this, moduleName, initialProps)
|
|
60
|
+
reactSurface?.start()
|
|
61
|
+
reactSurface?.view?.let { view ->
|
|
62
|
+
rootHost.addView(
|
|
63
|
+
view,
|
|
64
|
+
FrameLayout.LayoutParams(
|
|
65
|
+
FrameLayout.LayoutParams.MATCH_PARENT,
|
|
66
|
+
FrameLayout.LayoutParams.MATCH_PARENT,
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
NebulaHost.setPresentedHostModalActivity(this)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
override fun onResume() {
|
|
75
|
+
super.onResume()
|
|
76
|
+
resolveReactHost().onHostResume(this, this)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
override fun onPause() {
|
|
80
|
+
resolveReactHost().onHostPause(this)
|
|
81
|
+
super.onPause()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
override fun onDestroy() {
|
|
85
|
+
reactSurface?.stop()
|
|
86
|
+
reactSurface?.clear()
|
|
87
|
+
reactSurface = null
|
|
88
|
+
resolveReactHost().onHostDestroy(this)
|
|
89
|
+
if (NebulaHost.presentedHostModalActivity() === this) {
|
|
90
|
+
NebulaHost.setPresentedHostModalActivity(null)
|
|
91
|
+
}
|
|
92
|
+
super.onDestroy()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
override fun invokeDefaultOnBackPressed() {
|
|
96
|
+
super.onBackPressed()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
override fun checkPermission(permission: String, pid: Int, uid: Int): Int {
|
|
100
|
+
return super.checkPermission(permission, pid, uid)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
override fun checkSelfPermission(permission: String): Int {
|
|
104
|
+
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
|
|
105
|
+
super.checkSelfPermission(permission)
|
|
106
|
+
} else {
|
|
107
|
+
PackageManager.PERMISSION_GRANTED
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
override fun shouldShowRequestPermissionRationale(permission: String): Boolean {
|
|
112
|
+
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
|
|
113
|
+
super.shouldShowRequestPermissionRationale(permission)
|
|
114
|
+
} else {
|
|
115
|
+
false
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
override fun requestPermissions(
|
|
120
|
+
permissions: Array<String>,
|
|
121
|
+
requestCode: Int,
|
|
122
|
+
listener: PermissionListener?,
|
|
123
|
+
) {
|
|
124
|
+
permissionListener = listener
|
|
125
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
|
|
126
|
+
super.requestPermissions(permissions, requestCode)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
override fun onRequestPermissionsResult(
|
|
131
|
+
requestCode: Int,
|
|
132
|
+
permissions: Array<String>,
|
|
133
|
+
grantResults: IntArray,
|
|
134
|
+
) {
|
|
135
|
+
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
|
136
|
+
val shouldClear =
|
|
137
|
+
permissionListener?.onRequestPermissionsResult(
|
|
138
|
+
requestCode,
|
|
139
|
+
permissions,
|
|
140
|
+
grantResults,
|
|
141
|
+
) ?: false
|
|
142
|
+
if (shouldClear) {
|
|
143
|
+
permissionListener = null
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private fun resolveReactHost(): ReactHost {
|
|
148
|
+
return NebulaHost.resolveHostReactHost(application)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private fun parseJsonMap(raw: String?): Map<String, Any?> {
|
|
152
|
+
if (raw.isNullOrBlank()) {
|
|
153
|
+
return emptyMap()
|
|
154
|
+
}
|
|
155
|
+
return NebulaManifestManager.jsonObjectToMap(JSONObject(raw))
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private fun Map<String, Any?>.toBundle(): Bundle {
|
|
160
|
+
val bundle = Bundle()
|
|
161
|
+
forEach { (key, value) ->
|
|
162
|
+
when (value) {
|
|
163
|
+
null -> bundle.putString(key, null)
|
|
164
|
+
is String -> bundle.putString(key, value)
|
|
165
|
+
is Int -> bundle.putInt(key, value)
|
|
166
|
+
is Double -> bundle.putDouble(key, value)
|
|
167
|
+
is Boolean -> bundle.putBoolean(key, value)
|
|
168
|
+
is Float -> bundle.putFloat(key, value)
|
|
169
|
+
is Long -> bundle.putLong(key, value)
|
|
170
|
+
is Map<*, *> -> {
|
|
171
|
+
@Suppress("UNCHECKED_CAST")
|
|
172
|
+
bundle.putBundle(key, (value as Map<String, Any?>).toBundle())
|
|
173
|
+
}
|
|
174
|
+
else -> bundle.putString(key, value.toString())
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return bundle
|
|
178
|
+
}
|