@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 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. The package keeps it as a development dependency for its
23
- own native-module build, so installing `@lynxship/expo` does not require a
24
- second manual native dependency in the application.
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 integration with existing apps](https://lynxjs.org/3.8/guide/start/integrate-with-existing-apps.html)
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
 
@@ -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.bundleName = value ?: "main.lynx.bundle"
14
+ view.setBundleName(value ?: "main.lynx.bundle")
15
15
  }
16
16
 
17
17
  Prop("initialData") { view: LynxShipExpoView, value: String? ->
18
- view.initialData = value ?: ""
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 bundleName: String = metadata.getString("com.lynxship.expo.embeddedBundle", "main.lynx.bundle")
53
- var initialData: String = ""
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
- lynxView = LynxViewBuilder().setTemplateProvider(templateProvider).build(applicationContext)
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
- onReady(mapOf("bundle" to bundleName, "sequence" to (otaClient?.activeSequence() ?: 0)))
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
- lynxView.renderTemplateUrl(bundleName, initialData)
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
- return withLynxShipPodfile(result, options);
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;