@lynxship/expo 0.1.6 → 0.1.8
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 +77 -4
- package/android/build.gradle +1 -0
- package/android/src/main/java/com/lynxship/expo/LynxShipExpoModule.kt +51 -3
- package/android/src/main/java/com/lynxship/expo/LynxShipExpoView.kt +262 -6
- package/app.plugin.cjs +58 -1
- package/asset-sync.cjs.d.cts +43 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +13 -0
- package/dist/index.d.ts +72 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -11
- package/ios/LynxShipExpoModule.swift +39 -3
- package/ios/LynxShipExpoView.swift +234 -7
- package/package.json +12 -5
- package/src/config.ts +36 -0
- package/src/externals.d.ts +14 -1
- package/src/index.ts +103 -10
package/README.md
CHANGED
|
@@ -19,9 +19,10 @@ npx pod-install
|
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
`@lynxship/expo` uses the `expo-modules-core` implementation supplied by the
|
|
22
|
-
installed Expo SDK.
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
installed Expo SDK. It is declared as a peer dependency so Expo Doctor can
|
|
23
|
+
validate the application explicitly; `npx expo install @lynxship/expo` should
|
|
24
|
+
select the SDK-compatible version. The workspace keeps a matching development
|
|
25
|
+
dependency for its own native-module build.
|
|
25
26
|
|
|
26
27
|
When the project uses a static `app.json` or `app.config.json`, the Expo CLI
|
|
27
28
|
automatically adds `@lynxship/expo` to `expo.plugins` during `npx expo install`.
|
|
@@ -43,6 +44,12 @@ options are needed, add the config plugin manually:
|
|
|
43
44
|
"projectId": "00000000-0000-4000-8000-000000000000",
|
|
44
45
|
"channel": "production",
|
|
45
46
|
"runtimeVersion": "lynx-runtime-2026-01",
|
|
47
|
+
"notifications": {
|
|
48
|
+
"enabled": true,
|
|
49
|
+
"enableBackgroundRemoteNotifications": true,
|
|
50
|
+
"communicationNotifications": true,
|
|
51
|
+
"android": { "defaultChannel": "default" }
|
|
52
|
+
},
|
|
46
53
|
"publicKeys": {
|
|
47
54
|
"release-key-1": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
|
|
48
55
|
},
|
|
@@ -90,6 +97,32 @@ you intentionally operate a pinned native compatibility lane; do not put
|
|
|
90
97
|
`latest` in a production lockfile without reviewing the resulting native
|
|
91
98
|
build.
|
|
92
99
|
|
|
100
|
+
## Optional push notifications
|
|
101
|
+
|
|
102
|
+
When an Expo app also uses `@lynxship/notifications/expo`, set
|
|
103
|
+
`notifications.enabled` to `true` in this plugin. LynxShip delegates the
|
|
104
|
+
native permission, FCM/APNs token acquisition and build-time configuration to
|
|
105
|
+
the official `expo-notifications` package, then the JavaScript adapter sends
|
|
106
|
+
the token to the authenticated backend. Install the SDK-selected native
|
|
107
|
+
package first:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
npx expo install expo-notifications expo-constants
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The app must call `LynxShipNotifications.register(...)` with its authenticated
|
|
114
|
+
user/project identity and HTTPS registration endpoint. This is intentionally
|
|
115
|
+
not guessed by the plugin: app identity and backend authorization are
|
|
116
|
+
application data. FCM/APNs credentials also remain in EAS/CI secrets.
|
|
117
|
+
|
|
118
|
+
For message or presence notifications with profile images, pass an HTTPS
|
|
119
|
+
`imageUrl` from the backend payload. Android can render the image directly
|
|
120
|
+
through FCM. iOS requires a separate Notification Service Extension target;
|
|
121
|
+
use the template published by `@lynxship/notifications` and add it to the Expo
|
|
122
|
+
iOS project before an EAS build. The extension is a separately signed native
|
|
123
|
+
target, so it cannot be created safely by the JavaScript `LynxView` component
|
|
124
|
+
at runtime.
|
|
125
|
+
|
|
93
126
|
## Use the view
|
|
94
127
|
|
|
95
128
|
```tsx
|
|
@@ -101,12 +134,47 @@ export function LynxScreen() {
|
|
|
101
134
|
style={{ flex: 1 }}
|
|
102
135
|
bundle="main.lynx.bundle"
|
|
103
136
|
initialData="{}"
|
|
137
|
+
globalProps={{ theme: "dark" }}
|
|
138
|
+
autoGlobalProps
|
|
104
139
|
reloadOnUpdate
|
|
105
140
|
/>
|
|
106
141
|
);
|
|
107
142
|
}
|
|
108
143
|
```
|
|
109
144
|
|
|
145
|
+
`globalProps` is passed to `lynx.__globalProps` during the native template
|
|
146
|
+
load. It can also be changed without remounting the React Native screen. The
|
|
147
|
+
native ref exposes the same host controls on Android and iOS:
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
150
|
+
const viewRef = useRef<LynxViewRef>(null);
|
|
151
|
+
|
|
152
|
+
viewRef.current?.updateData('{"screen":"inbox"}');
|
|
153
|
+
viewRef.current?.updateGlobalProps({ theme: "light" });
|
|
154
|
+
viewRef.current?.updateGlobalPropsByIncrement({ unreadCount: 3 });
|
|
155
|
+
viewRef.current?.sendGlobalEvent("accountChanged", [{ id: "user-1" }]);
|
|
156
|
+
await viewRef.current?.reload();
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`updateData` uses Lynx's official host-data update API and accepts a JSON
|
|
160
|
+
string up to 8 MiB without remounting the view. Its optional second argument
|
|
161
|
+
selects a registered Lynx data processor. `show`, `hide` and
|
|
162
|
+
`updateViewport({ width, height })` are also available.
|
|
163
|
+
`updateGlobalPropsByIncrement` applies only the supplied keys and is useful for
|
|
164
|
+
small host-state changes; `updateGlobalProps` remains the full replacement API.
|
|
165
|
+
The ref also exposes `getContainerId()`, `getLoadState()` and
|
|
166
|
+
`isLoadSuccess()`, matching the native container state contract without
|
|
167
|
+
requiring a React remount.
|
|
168
|
+
The viewport is updated automatically from the native view layout, so the
|
|
169
|
+
usual `style={{ flex: 1 }}` remains the recommended layout. The native view
|
|
170
|
+
owns the Lynx lifecycle and is destroyed when Expo releases the view; this
|
|
171
|
+
prevents an old page/runtime from surviving React reconciliation.
|
|
172
|
+
|
|
173
|
+
The view also exposes lifecycle events that can drive application-owned UI:
|
|
174
|
+
`onLoadStart`, `onResourceFetchStart`, `onLoadSuccess`, `onReady` (first screen), `onError`, `onUpdate` (OTA or non-remounting data update),
|
|
175
|
+
`onShow` and `onHide`. The events are notifications only; they do not replace
|
|
176
|
+
the native permission, navigation or OTA policy owned by the host.
|
|
177
|
+
|
|
110
178
|
The native module initializes Lynx, creates the official `LynxView`, and
|
|
111
179
|
provides a template provider. The provider first reads the verified active OTA
|
|
112
180
|
asset and falls back to the embedded bundle. An OTA release is accepted only
|
|
@@ -157,8 +225,13 @@ the provider is the integration point for the LynxShip OTA cache.
|
|
|
157
225
|
|
|
158
226
|
References:
|
|
159
227
|
|
|
160
|
-
- [Lynx
|
|
228
|
+
- [Lynx/Rspeedy existing-app integration](https://lynxjs.org/next/rspeedy/start/integrate-with-existing-apps)
|
|
229
|
+
- [Lynx native `LynxView` API](https://lynxjs.org/next/api/lynx-native-api/lynx-view/lynx-view.html)
|
|
230
|
+
- [Lynx global props](https://lynxjs.org/next/api/lynx-api/lynx/lynx-global-props.html)
|
|
231
|
+
- [Lynx global events](https://lynxjs.org/next/api/lynx-native-api/lynx-view/send-global-event.html)
|
|
232
|
+
- [Lynx native viewport API](https://lynxjs.org/next/api/lynx-native-api/lynx-view/update-viewport.html)
|
|
161
233
|
- [Expo native view modules](https://docs.expo.dev/modules/native-view-tutorial/)
|
|
234
|
+
- [Expo view refs and async functions](https://docs.expo.dev/modules/module-api/)
|
|
162
235
|
- [Expo module configuration](https://docs.expo.dev/modules/module-config/)
|
|
163
236
|
- [LynxShip OTA security and compatibility](../../docs/compatibility.md)
|
|
164
237
|
|
package/android/build.gradle
CHANGED
|
@@ -42,6 +42,7 @@ android {
|
|
|
42
42
|
|
|
43
43
|
dependencies {
|
|
44
44
|
implementation "org.lynxsdk.lynx:lynx:${lynxVersion}"
|
|
45
|
+
implementation "androidx.annotation:annotation:1.10.0"
|
|
45
46
|
implementation "org.lynxsdk.lynx:lynx-jssdk:${lynxVersion}"
|
|
46
47
|
implementation "org.lynxsdk.lynx:lynx-trace:${lynxVersion}"
|
|
47
48
|
implementation "org.lynxsdk.lynx:primjs:${lynxVersion}"
|
|
@@ -8,14 +8,22 @@ class LynxShipExpoModule : Module() {
|
|
|
8
8
|
Name("LynxShip")
|
|
9
9
|
|
|
10
10
|
View(LynxShipExpoView::class) {
|
|
11
|
-
Events("onReady", "onError", "onUpdate")
|
|
11
|
+
Events("onReady", "onLoadStart", "onResourceFetchStart", "onLoadSuccess", "onError", "onUpdate", "onShow", "onHide")
|
|
12
12
|
|
|
13
13
|
Prop("bundle") { view: LynxShipExpoView, value: String? ->
|
|
14
|
-
view.
|
|
14
|
+
view.setBundleName(value ?: "main.lynx.bundle")
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
Prop("initialData") { view: LynxShipExpoView, value: String? ->
|
|
18
|
-
view.
|
|
18
|
+
view.setInitialData(value ?: "")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
Prop("globalProps") { view: LynxShipExpoView, value: Map<String, Any?>? ->
|
|
22
|
+
view.updateGlobalProps(value ?: emptyMap())
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
Prop("autoGlobalProps") { view: LynxShipExpoView, value: Boolean? ->
|
|
26
|
+
view.autoGlobalProps = value ?: true
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
Prop("reloadOnUpdate") { view: LynxShipExpoView, value: Boolean? ->
|
|
@@ -25,6 +33,46 @@ class LynxShipExpoModule : Module() {
|
|
|
25
33
|
AsyncFunction("reload") { view: LynxShipExpoView ->
|
|
26
34
|
view.reload()
|
|
27
35
|
}
|
|
36
|
+
|
|
37
|
+
AsyncFunction("getContainerId") { view: LynxShipExpoView ->
|
|
38
|
+
view.getContainerId()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
AsyncFunction("getLoadState") { view: LynxShipExpoView ->
|
|
42
|
+
view.getLoadState()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
AsyncFunction("isLoadSuccess") { view: LynxShipExpoView ->
|
|
46
|
+
view.isLoadSuccess()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
AsyncFunction("updateData") { view: LynxShipExpoView, data: String, processorName: String? ->
|
|
50
|
+
view.updateData(data, processorName)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
AsyncFunction("updateGlobalProps") { view: LynxShipExpoView, props: Map<String, Any?> ->
|
|
54
|
+
view.updateGlobalProps(props)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
AsyncFunction("updateGlobalPropsByIncrement") { view: LynxShipExpoView, props: Map<String, Any?> ->
|
|
58
|
+
view.updateGlobalPropsByIncrement(props)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
AsyncFunction("sendGlobalEvent") { view: LynxShipExpoView, eventName: String, params: List<Any?> ->
|
|
62
|
+
view.sendGlobalEvent(eventName, params)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
AsyncFunction("show") { view: LynxShipExpoView -> view.show() }
|
|
66
|
+
|
|
67
|
+
AsyncFunction("hide") { view: LynxShipExpoView -> view.hide() }
|
|
68
|
+
|
|
69
|
+
AsyncFunction("updateViewport") { view: LynxShipExpoView, viewport: Map<String, Double> ->
|
|
70
|
+
view.updateViewport(viewport["width"] ?: 0.0, viewport["height"] ?: 0.0)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
OnViewDestroys { view: LynxShipExpoView ->
|
|
74
|
+
view.release()
|
|
75
|
+
}
|
|
28
76
|
}
|
|
29
77
|
}
|
|
30
78
|
}
|
|
@@ -3,8 +3,16 @@ package com.lynxship.expo
|
|
|
3
3
|
import android.app.Application
|
|
4
4
|
import android.content.Context
|
|
5
5
|
import android.content.SharedPreferences
|
|
6
|
+
import android.content.res.Configuration
|
|
6
7
|
import android.os.Bundle
|
|
8
|
+
import android.os.Build
|
|
9
|
+
import android.os.PowerManager
|
|
7
10
|
import android.provider.Settings
|
|
11
|
+
import android.view.View
|
|
12
|
+
import android.view.View.MeasureSpec
|
|
13
|
+
import android.view.WindowInsets
|
|
14
|
+
import android.view.accessibility.AccessibilityManager
|
|
15
|
+
import com.lynx.react.bridge.JavaOnlyArray
|
|
8
16
|
import com.facebook.drawee.backends.pipeline.Fresco
|
|
9
17
|
import com.facebook.imagepipeline.core.ImagePipelineConfig
|
|
10
18
|
import com.facebook.imagepipeline.memory.PoolConfig
|
|
@@ -13,6 +21,10 @@ import com.lynx.service.http.LynxHttpService
|
|
|
13
21
|
import com.lynx.service.image.LynxImageService
|
|
14
22
|
import com.lynx.service.log.LynxLogService
|
|
15
23
|
import com.lynx.tasm.LynxEnv
|
|
24
|
+
import com.lynx.tasm.LynxError
|
|
25
|
+
import com.lynx.tasm.LynxLoadMeta
|
|
26
|
+
import com.lynx.tasm.LynxUpdateMeta
|
|
27
|
+
import com.lynx.tasm.TemplateData
|
|
16
28
|
import com.lynx.tasm.LynxView
|
|
17
29
|
import com.lynx.tasm.LynxViewClient
|
|
18
30
|
import com.lynx.tasm.LynxViewBuilder
|
|
@@ -30,13 +42,19 @@ import java.util.UUID
|
|
|
30
42
|
class LynxShipExpoView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
|
31
43
|
private val applicationContext = context.applicationContext
|
|
32
44
|
private val onReady by EventDispatcher()
|
|
45
|
+
private val onLoadStart by EventDispatcher()
|
|
46
|
+
private val onResourceFetchStart by EventDispatcher()
|
|
47
|
+
private val onLoadSuccess by EventDispatcher()
|
|
33
48
|
private val onError by EventDispatcher()
|
|
34
49
|
private val onUpdate by EventDispatcher()
|
|
50
|
+
private val onShow by EventDispatcher()
|
|
51
|
+
private val onHide by EventDispatcher()
|
|
35
52
|
private val preferences: SharedPreferences = applicationContext.getSharedPreferences("lynxship-expo", Context.MODE_PRIVATE)
|
|
36
53
|
private val metadata: Bundle = applicationContext.applicationInfo.metaData ?: Bundle()
|
|
37
54
|
private val otaClient = createOtaClient(applicationContext)
|
|
38
55
|
private val templateProvider = object : AbsTemplateProvider() {
|
|
39
56
|
override fun loadTemplate(uri: String, callback: Callback) {
|
|
57
|
+
post { onResourceFetchStart(mapOf("bundle" to uri)) }
|
|
40
58
|
Thread {
|
|
41
59
|
try {
|
|
42
60
|
val bytes = otaClient?.openActiveAsset(uri) ?: readEmbeddedAsset(uri)
|
|
@@ -49,21 +67,48 @@ class LynxShipExpoView(context: Context, appContext: AppContext) : ExpoView(cont
|
|
|
49
67
|
}
|
|
50
68
|
}
|
|
51
69
|
private val lynxView: LynxView
|
|
52
|
-
var
|
|
53
|
-
var
|
|
70
|
+
private var bundleNameValue: String = metadata.getString("com.lynxship.expo.embeddedBundle", "main.lynx.bundle")
|
|
71
|
+
private var initialDataValue: String = ""
|
|
72
|
+
private var globalProps: Map<String, Any?> = emptyMap()
|
|
54
73
|
var reloadOnUpdate: Boolean = true
|
|
74
|
+
var autoGlobalProps: Boolean = true
|
|
75
|
+
set(value) {
|
|
76
|
+
field = value
|
|
77
|
+
if (hasRendered) pushGlobalProps()
|
|
78
|
+
}
|
|
79
|
+
private var hasRendered = false
|
|
80
|
+
private var loadState = "idle"
|
|
81
|
+
private var started = false
|
|
82
|
+
private val containerId = UUID.randomUUID().toString()
|
|
83
|
+
private val containerInitTime = System.currentTimeMillis().toString()
|
|
84
|
+
private var appInBackground = false
|
|
55
85
|
|
|
56
86
|
init {
|
|
57
87
|
initializeLynx(context)
|
|
58
|
-
|
|
88
|
+
// The Lynx view must be created with the host/view context. Keep the
|
|
89
|
+
// application context only for process-wide services and storage.
|
|
90
|
+
lynxView = LynxViewBuilder().setTemplateProvider(templateProvider).build(context)
|
|
59
91
|
lynxView.addLynxViewClient(object : LynxViewClient() {
|
|
92
|
+
override fun onPageStart(url: String?) {
|
|
93
|
+
loadState = "loading"
|
|
94
|
+
onLoadStart(mapOf("bundle" to (url ?: bundleNameValue)))
|
|
95
|
+
}
|
|
96
|
+
|
|
60
97
|
override fun onFirstScreen() {
|
|
98
|
+
loadState = "loaded"
|
|
61
99
|
try {
|
|
62
100
|
otaClient?.markLaunchSuccess()
|
|
63
101
|
} catch (error: IOException) {
|
|
64
102
|
onError(mapOf("message" to (error.message ?: "Could not record Lynx launch"), "recoverable" to true))
|
|
65
103
|
}
|
|
66
|
-
|
|
104
|
+
val event = mapOf("bundle" to bundleNameValue, "sequence" to (otaClient?.activeSequence() ?: 0))
|
|
105
|
+
onLoadSuccess(event)
|
|
106
|
+
onReady(event)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
override fun onReceivedError(error: LynxError) {
|
|
110
|
+
loadState = "failed"
|
|
111
|
+
onError(mapOf("message" to error.toString(), "recoverable" to false))
|
|
67
112
|
}
|
|
68
113
|
})
|
|
69
114
|
addView(lynxView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
|
@@ -71,27 +116,238 @@ class LynxShipExpoView(context: Context, appContext: AppContext) : ExpoView(cont
|
|
|
71
116
|
|
|
72
117
|
override fun onAttachedToWindow() {
|
|
73
118
|
super.onAttachedToWindow()
|
|
119
|
+
lynxView.onEnterForeground()
|
|
120
|
+
onShow(emptyMap<String, Any>())
|
|
121
|
+
if (started) return
|
|
122
|
+
started = true
|
|
74
123
|
try {
|
|
75
124
|
otaClient?.beginLaunch()
|
|
76
125
|
installUpdateInBackground()
|
|
77
|
-
render()
|
|
126
|
+
if (!hasRendered) render()
|
|
78
127
|
} catch (error: Exception) {
|
|
79
128
|
onError(mapOf("message" to (error.message ?: "Lynx startup failed")))
|
|
80
129
|
}
|
|
81
130
|
}
|
|
82
131
|
|
|
132
|
+
override fun onDetachedFromWindow() {
|
|
133
|
+
lynxView.onEnterBackground()
|
|
134
|
+
onHide(emptyMap<String, Any>())
|
|
135
|
+
super.onDetachedFromWindow()
|
|
136
|
+
}
|
|
137
|
+
|
|
83
138
|
fun reload() {
|
|
84
139
|
render()
|
|
85
140
|
}
|
|
86
141
|
|
|
142
|
+
fun getContainerId(): String = containerId
|
|
143
|
+
|
|
144
|
+
fun getLoadState(): String = loadState
|
|
145
|
+
|
|
146
|
+
fun isLoadSuccess(): Boolean = loadState == "loaded"
|
|
147
|
+
|
|
148
|
+
fun updateData(data: String, processorName: String? = null) {
|
|
149
|
+
require(data.length <= 8 * 1024 * 1024) { "Lynx update data is larger than 8 MiB" }
|
|
150
|
+
check(hasRendered) { "Lynx view has not loaded a bundle" }
|
|
151
|
+
if (processorName != null) {
|
|
152
|
+
require(processorName.isNotBlank() && processorName.length <= 256 && processorName.none { it.isISOControl() }) {
|
|
153
|
+
"Lynx data processor name is invalid"
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
val templateData = TemplateData.fromString(data)
|
|
157
|
+
processorName?.let(templateData::markState)
|
|
158
|
+
val builder = LynxUpdateMeta.Builder()
|
|
159
|
+
builder.setUpdatedData(templateData)
|
|
160
|
+
lynxView.updateMetaData(builder.build())
|
|
161
|
+
initialDataValue = data
|
|
162
|
+
onUpdate(mapOf("bundle" to bundleNameValue, "reason" to "data"))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
fun setBundleName(value: String) {
|
|
166
|
+
if (value == bundleNameValue) return
|
|
167
|
+
bundleNameValue = value
|
|
168
|
+
if (started) render()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
fun setInitialData(value: String) {
|
|
172
|
+
if (value == initialDataValue) return
|
|
173
|
+
initialDataValue = value
|
|
174
|
+
if (started) render()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
fun updateGlobalProps(props: Map<String, Any?>) {
|
|
178
|
+
globalProps = props.toMap()
|
|
179
|
+
pushGlobalProps()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
fun updateGlobalPropsByIncrement(props: Map<String, Any?>) {
|
|
183
|
+
if (props.isEmpty()) return
|
|
184
|
+
globalProps = buildMap {
|
|
185
|
+
putAll(globalProps)
|
|
186
|
+
putAll(props)
|
|
187
|
+
}
|
|
188
|
+
lynxView.updateGlobalProps(TemplateData.fromMap(props))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
fun sendGlobalEvent(eventName: String, params: List<Any?>) {
|
|
192
|
+
require(eventName.isNotBlank()) { "Lynx global event name cannot be blank" }
|
|
193
|
+
require(eventName.length <= 256) { "Lynx global event name is too long" }
|
|
194
|
+
require(params.size <= 256) { "Lynx global event payload is too large" }
|
|
195
|
+
lynxView.sendGlobalEvent(
|
|
196
|
+
eventName,
|
|
197
|
+
JavaOnlyArray.from(params),
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
fun show() {
|
|
202
|
+
visibility = View.VISIBLE
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
fun hide() {
|
|
206
|
+
visibility = View.INVISIBLE
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
fun updateViewport(width: Double, height: Double) {
|
|
210
|
+
require(width.isFinite() && height.isFinite() && width >= 0 && height >= 0) {
|
|
211
|
+
"Lynx viewport dimensions must be finite and non-negative"
|
|
212
|
+
}
|
|
213
|
+
lynxView.updateViewport(
|
|
214
|
+
MeasureSpec.makeMeasureSpec(width.toInt(), MeasureSpec.EXACTLY),
|
|
215
|
+
MeasureSpec.makeMeasureSpec(height.toInt(), MeasureSpec.EXACTLY),
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
87
219
|
private fun render() {
|
|
88
220
|
try {
|
|
89
|
-
|
|
221
|
+
loadState = "loading"
|
|
222
|
+
val builder = LynxLoadMeta.Builder()
|
|
223
|
+
builder.setUrl(bundleNameValue)
|
|
224
|
+
if (initialDataValue.isNotEmpty()) {
|
|
225
|
+
builder.setInitialData(TemplateData.fromString(initialDataValue))
|
|
226
|
+
}
|
|
227
|
+
if (autoGlobalProps || globalProps.isNotEmpty()) {
|
|
228
|
+
builder.setGlobalProps(TemplateData.fromMap(effectiveGlobalProps()))
|
|
229
|
+
}
|
|
230
|
+
lynxView.loadTemplate(builder.build())
|
|
231
|
+
hasRendered = true
|
|
90
232
|
} catch (error: Exception) {
|
|
233
|
+
loadState = "failed"
|
|
91
234
|
onError(mapOf("message" to (error.message ?: "Lynx render failed")))
|
|
92
235
|
}
|
|
93
236
|
}
|
|
94
237
|
|
|
238
|
+
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
|
|
239
|
+
super.onSizeChanged(width, height, oldWidth, oldHeight)
|
|
240
|
+
if (width > 0 && height > 0) {
|
|
241
|
+
lynxView.updateViewport(
|
|
242
|
+
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
|
243
|
+
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
|
|
244
|
+
)
|
|
245
|
+
if (hasRendered && autoGlobalProps) pushGlobalProps()
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
override fun onWindowVisibilityChanged(visibility: Int) {
|
|
250
|
+
super.onWindowVisibilityChanged(visibility)
|
|
251
|
+
val nextBackground = visibility != View.VISIBLE
|
|
252
|
+
if (nextBackground == appInBackground) return
|
|
253
|
+
appInBackground = nextBackground
|
|
254
|
+
if (hasRendered && autoGlobalProps) pushGlobalProps()
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private fun pushGlobalProps() {
|
|
258
|
+
if (!autoGlobalProps && globalProps.isEmpty()) return
|
|
259
|
+
lynxView.updateGlobalProps(TemplateData.fromMap(effectiveGlobalProps()))
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private fun effectiveGlobalProps(): Map<String, Any?> {
|
|
263
|
+
if (!autoGlobalProps) return globalProps
|
|
264
|
+
val metrics = resources.displayMetrics
|
|
265
|
+
val density = metrics.density.takeIf { it > 0f } ?: 1f
|
|
266
|
+
val screenWidth = metrics.widthPixels.toDouble() / density
|
|
267
|
+
val screenHeight = metrics.heightPixels.toDouble() / density
|
|
268
|
+
val contentWidth = width.toDouble() / density
|
|
269
|
+
val contentHeight = height.toDouble() / density
|
|
270
|
+
val insets = systemInsets()
|
|
271
|
+
val orientation = if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
|
272
|
+
"landscape"
|
|
273
|
+
} else {
|
|
274
|
+
"portrait"
|
|
275
|
+
}
|
|
276
|
+
val night = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
|
277
|
+
val theme = if (night == Configuration.UI_MODE_NIGHT_YES) "dark" else "light"
|
|
278
|
+
val locale = resources.configuration.locales[0]?.toLanguageTag() ?: "en-US"
|
|
279
|
+
val powerManager = getContext().getSystemService(Context.POWER_SERVICE) as? PowerManager
|
|
280
|
+
val hasCutout = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && rootWindowInsets?.displayCutout != null
|
|
281
|
+
val isTablet = resources.configuration.smallestScreenWidthDp >= 600
|
|
282
|
+
return buildMap {
|
|
283
|
+
putAll(globalProps)
|
|
284
|
+
put("os", "android")
|
|
285
|
+
put("osVersion", Build.VERSION.RELEASE ?: "unknown")
|
|
286
|
+
put("deviceModel", Build.MODEL ?: "unknown")
|
|
287
|
+
put("containerID", containerId)
|
|
288
|
+
put("containerInitTime", containerInitTime)
|
|
289
|
+
put("screenWidth", screenWidth)
|
|
290
|
+
put("screenHeight", screenHeight)
|
|
291
|
+
put("contentWidth", contentWidth.coerceAtLeast(0.0))
|
|
292
|
+
put("contentHeight", contentHeight.coerceAtLeast(0.0))
|
|
293
|
+
put("safeAreaInsets", insets)
|
|
294
|
+
put("pixelRatio", density.toDouble())
|
|
295
|
+
put("accessibleMode", accessibilityMode())
|
|
296
|
+
put("isIPhoneX", 0)
|
|
297
|
+
put("isIPhoneXMax", 0)
|
|
298
|
+
put("isPad", if (isTablet) 1 else 0)
|
|
299
|
+
put("isNotchScreen", hasCutout)
|
|
300
|
+
put("isLowPowerMode", if (powerManager?.isPowerSaveMode == true) 1 else 0)
|
|
301
|
+
put("orientation", orientation)
|
|
302
|
+
put("screenOrientation", orientation)
|
|
303
|
+
put("theme", theme)
|
|
304
|
+
put("appLanguage", locale.substringBefore('-'))
|
|
305
|
+
put("appLocale", locale)
|
|
306
|
+
put("isAppBackground", appInBackground)
|
|
307
|
+
put("queryItems", emptyMap<String, String>())
|
|
308
|
+
put("statusBarHeight", insets["top"] ?: 0.0)
|
|
309
|
+
put("navigationBarHeight", insets["bottom"] ?: 0.0)
|
|
310
|
+
put("safeAreaHeight", insets["top"] ?: 0.0)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private fun systemInsets(): Map<String, Double> {
|
|
315
|
+
val insets = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
316
|
+
rootWindowInsets?.getInsets(WindowInsets.Type.systemBars())
|
|
317
|
+
} else {
|
|
318
|
+
null
|
|
319
|
+
}
|
|
320
|
+
val density = resources.displayMetrics.density.takeIf { it > 0f } ?: 1f
|
|
321
|
+
if (insets != null) {
|
|
322
|
+
return mapOf(
|
|
323
|
+
"top" to (insets.top / density).toDouble(),
|
|
324
|
+
"right" to (insets.right / density).toDouble(),
|
|
325
|
+
"bottom" to (insets.bottom / density).toDouble(),
|
|
326
|
+
"left" to (insets.left / density).toDouble(),
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
val legacy = rootWindowInsets
|
|
330
|
+
return mapOf(
|
|
331
|
+
"top" to (((legacy?.systemWindowInsetTop ?: 0) / density).toDouble()),
|
|
332
|
+
"right" to (((legacy?.systemWindowInsetRight ?: 0) / density).toDouble()),
|
|
333
|
+
"bottom" to (((legacy?.systemWindowInsetBottom ?: 0) / density).toDouble()),
|
|
334
|
+
"left" to (((legacy?.systemWindowInsetLeft ?: 0) / density).toDouble()),
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private fun accessibilityMode(): Int {
|
|
339
|
+
val manager = getContext().getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
|
|
340
|
+
return if (manager?.isTouchExplorationEnabled == true) 1 else 0
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
fun release() {
|
|
344
|
+
if (hasRendered) {
|
|
345
|
+
lynxView.destroy()
|
|
346
|
+
hasRendered = false
|
|
347
|
+
}
|
|
348
|
+
loadState = "released"
|
|
349
|
+
}
|
|
350
|
+
|
|
95
351
|
private fun installUpdateInBackground() {
|
|
96
352
|
otaClient?.checkAndInstallAsync(object : LynxShipOtaClient.Listener {
|
|
97
353
|
override fun onSuccess(updateAvailable: Boolean) {
|
package/app.plugin.cjs
CHANGED
|
@@ -7,6 +7,7 @@ const {
|
|
|
7
7
|
withSettingsGradle,
|
|
8
8
|
withXcodeProject,
|
|
9
9
|
IOSConfig,
|
|
10
|
+
withPlugins,
|
|
10
11
|
} = require("@expo/config-plugins");
|
|
11
12
|
const path = require("node:path");
|
|
12
13
|
const { syncLynxAssets } = require("./asset-sync.cjs");
|
|
@@ -78,6 +79,31 @@ function assertOptions(options) {
|
|
|
78
79
|
typeof options.syncBundle !== "boolean"
|
|
79
80
|
)
|
|
80
81
|
throw new Error("@lynxship/expo syncBundle must be a boolean");
|
|
82
|
+
if (options.notifications !== undefined) {
|
|
83
|
+
if (
|
|
84
|
+
typeof options.notifications !== "object" ||
|
|
85
|
+
options.notifications === null ||
|
|
86
|
+
typeof options.notifications.enabled !== "boolean"
|
|
87
|
+
)
|
|
88
|
+
throw new Error(
|
|
89
|
+
"@lynxship/expo notifications must be an object with boolean enabled",
|
|
90
|
+
);
|
|
91
|
+
if (
|
|
92
|
+
options.notifications.enableBackgroundRemoteNotifications !== undefined &&
|
|
93
|
+
typeof options.notifications.enableBackgroundRemoteNotifications !==
|
|
94
|
+
"boolean"
|
|
95
|
+
)
|
|
96
|
+
throw new Error(
|
|
97
|
+
"@lynxship/expo enableBackgroundRemoteNotifications must be a boolean",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (
|
|
101
|
+
options.notifications?.communicationNotifications !== undefined &&
|
|
102
|
+
typeof options.notifications.communicationNotifications !== "boolean"
|
|
103
|
+
)
|
|
104
|
+
throw new Error(
|
|
105
|
+
"@lynxship/expo communicationNotifications must be a boolean",
|
|
106
|
+
);
|
|
81
107
|
if (
|
|
82
108
|
options.embeddedBundle !== undefined &&
|
|
83
109
|
(typeof options.embeddedBundle !== "string" ||
|
|
@@ -157,6 +183,14 @@ function withLynxShipInfoPlist(config, options) {
|
|
|
157
183
|
publicKeys: options.publicKeys || {},
|
|
158
184
|
maxReleaseBytes: options.maxReleaseBytes || 104857600,
|
|
159
185
|
};
|
|
186
|
+
if (options.notifications?.communicationNotifications) {
|
|
187
|
+
const activityTypes = Array.isArray(value.modResults.NSUserActivityTypes)
|
|
188
|
+
? value.modResults.NSUserActivityTypes
|
|
189
|
+
: [];
|
|
190
|
+
if (!activityTypes.includes("INSendMessageIntent"))
|
|
191
|
+
activityTypes.push("INSendMessageIntent");
|
|
192
|
+
value.modResults.NSUserActivityTypes = activityTypes;
|
|
193
|
+
}
|
|
160
194
|
return value;
|
|
161
195
|
});
|
|
162
196
|
}
|
|
@@ -286,6 +320,28 @@ function withLynxShipIosAssets(config, options) {
|
|
|
286
320
|
});
|
|
287
321
|
}
|
|
288
322
|
|
|
323
|
+
function withLynxShipNotifications(config, options) {
|
|
324
|
+
const notifications = options.notifications;
|
|
325
|
+
if (!notifications?.enabled) return config;
|
|
326
|
+
try {
|
|
327
|
+
return withPlugins(config, [
|
|
328
|
+
[
|
|
329
|
+
"expo-notifications",
|
|
330
|
+
{
|
|
331
|
+
...(notifications.android || {}),
|
|
332
|
+
enableBackgroundRemoteNotifications:
|
|
333
|
+
notifications.enableBackgroundRemoteNotifications || false,
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
]);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
"@lynxship/expo notifications requires expo-notifications. Install it with `npx expo install expo-notifications`.",
|
|
340
|
+
{ cause: error },
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
289
345
|
function withLynxShipExpo(config, props = {}) {
|
|
290
346
|
const options = assertOptions({ ...getOptions(config), ...props });
|
|
291
347
|
let result = withLynxShipAndroidManifest(config, options);
|
|
@@ -294,7 +350,8 @@ function withLynxShipExpo(config, props = {}) {
|
|
|
294
350
|
result = withLynxShipAndroidSdk(result);
|
|
295
351
|
result = withLynxShipAndroidAssets(result, options);
|
|
296
352
|
result = withLynxShipIosAssets(result, options);
|
|
297
|
-
|
|
353
|
+
result = withLynxShipPodfile(result, options);
|
|
354
|
+
return withLynxShipNotifications(result, options);
|
|
298
355
|
}
|
|
299
356
|
|
|
300
357
|
module.exports = withLynxShipExpo;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
declare const assetSync: {
|
|
2
|
+
createBundlePlan(
|
|
3
|
+
projectRoot: string,
|
|
4
|
+
options?: {
|
|
5
|
+
bundlePath?: string;
|
|
6
|
+
embeddedBundle?: string;
|
|
7
|
+
},
|
|
8
|
+
): Promise<{
|
|
9
|
+
sourceBundle: string;
|
|
10
|
+
sourceDirectory: string;
|
|
11
|
+
bundlePath: string;
|
|
12
|
+
embeddedBundle: string;
|
|
13
|
+
files: Array<{
|
|
14
|
+
absolute: string;
|
|
15
|
+
relative: string;
|
|
16
|
+
destination: string;
|
|
17
|
+
}>;
|
|
18
|
+
}>;
|
|
19
|
+
syncBundleDirectory(options: {
|
|
20
|
+
projectRoot: string;
|
|
21
|
+
plan: Awaited<ReturnType<typeof assetSync.createBundlePlan>>;
|
|
22
|
+
destinationRoot: string;
|
|
23
|
+
manifestPath: string;
|
|
24
|
+
platform: "android" | "ios";
|
|
25
|
+
}): Promise<{
|
|
26
|
+
platform: "android" | "ios";
|
|
27
|
+
sourceBundle: string;
|
|
28
|
+
destinationRoot: string;
|
|
29
|
+
manifestPath: string;
|
|
30
|
+
files: Array<{ path: string; size: number; sha256: string }>;
|
|
31
|
+
}>;
|
|
32
|
+
syncLynxAssets(
|
|
33
|
+
projectRoot: string,
|
|
34
|
+
options?: {
|
|
35
|
+
platform?: "android" | "ios";
|
|
36
|
+
bundlePath?: string;
|
|
37
|
+
embeddedBundle?: string;
|
|
38
|
+
iosSourceRoot?: string;
|
|
39
|
+
},
|
|
40
|
+
): Promise<unknown>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export = assetSync;
|