@upscopeio/react-native-sdk 2026.3.0 → 2026.4.2

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.
Files changed (43) hide show
  1. package/README.md +137 -4
  2. package/android/build.gradle.kts +1 -1
  3. package/android/src/main/kotlin/io/upscope/reactnative/UpscopeModule.kt +209 -55
  4. package/ios/UpscopeModule.mm +3 -0
  5. package/ios/UpscopeModule.swift +207 -45
  6. package/lib/commonjs/NativeUpscopeModule.js.map +1 -1
  7. package/lib/commonjs/Upscope.js +59 -18
  8. package/lib/commonjs/Upscope.js.map +1 -1
  9. package/lib/commonjs/hooks.js +45 -2
  10. package/lib/commonjs/hooks.js.map +1 -1
  11. package/lib/commonjs/index.js +12 -0
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/version.js +9 -0
  14. package/lib/commonjs/version.js.map +1 -0
  15. package/lib/module/NativeUpscopeModule.js.map +1 -1
  16. package/lib/module/Upscope.js +60 -18
  17. package/lib/module/Upscope.js.map +1 -1
  18. package/lib/module/hooks.js +43 -2
  19. package/lib/module/hooks.js.map +1 -1
  20. package/lib/module/index.js +4 -4
  21. package/lib/module/index.js.map +1 -1
  22. package/lib/module/version.js +5 -0
  23. package/lib/module/version.js.map +1 -0
  24. package/lib/typescript/NativeUpscopeModule.d.ts +3 -0
  25. package/lib/typescript/NativeUpscopeModule.d.ts.map +1 -1
  26. package/lib/typescript/Upscope.d.ts +20 -2
  27. package/lib/typescript/Upscope.d.ts.map +1 -1
  28. package/lib/typescript/hooks.d.ts +20 -3
  29. package/lib/typescript/hooks.d.ts.map +1 -1
  30. package/lib/typescript/index.d.ts +7 -7
  31. package/lib/typescript/index.d.ts.map +1 -1
  32. package/lib/typescript/types.d.ts +70 -14
  33. package/lib/typescript/types.d.ts.map +1 -1
  34. package/lib/typescript/version.d.ts +2 -0
  35. package/lib/typescript/version.d.ts.map +1 -0
  36. package/package.json +3 -3
  37. package/src/NativeUpscopeModule.ts +5 -0
  38. package/src/Upscope.ts +66 -19
  39. package/src/hooks.ts +56 -3
  40. package/src/index.ts +19 -10
  41. package/src/types.ts +84 -27
  42. package/src/version.ts +2 -0
  43. package/upscopeio-react-native-sdk.podspec +1 -1
package/README.md CHANGED
@@ -28,6 +28,9 @@ npm install @upscopeio/react-native-sdk
28
28
  cd ios && pod install
29
29
  ```
30
30
 
31
+ By default the SDK shares only your app's UI. To let agents view the visitor's
32
+ **entire device**, see [Full device sharing (iOS)](#full-device-sharing-ios).
33
+
31
34
  ### Android
32
35
 
33
36
  No extra steps — the native module and Upscope Android SDK are linked automatically via autolinking.
@@ -52,6 +55,7 @@ Upscope.updateConnection({
52
55
  callName: 'Jane Doe',
53
56
  tags: ['support'],
54
57
  identities: ['jane@example.com'],
58
+ integrationIds: ['intercom:user123'],
55
59
  metadata: { plan: 'premium' },
56
60
  });
57
61
  ```
@@ -117,6 +121,34 @@ function LookupCode() {
117
121
  }
118
122
  ```
119
123
 
124
+ ### `useRemoteControlState`
125
+
126
+ Returns whether an agent currently has remote control of the device.
127
+
128
+ ```tsx
129
+ import { useRemoteControlState } from '@upscopeio/react-native-sdk';
130
+
131
+ function ControlBadge() {
132
+ const state = useRemoteControlState();
133
+ // state: 'inactive' | 'active'
134
+ return <Text>Remote control: {state}</Text>;
135
+ }
136
+ ```
137
+
138
+ ### `useFullDeviceSharingState`
139
+
140
+ Returns whether full-device (broadcast) sharing is currently running.
141
+
142
+ ```tsx
143
+ import { useFullDeviceSharingState } from '@upscopeio/react-native-sdk';
144
+
145
+ function SharingBadge() {
146
+ const state = useFullDeviceSharingState();
147
+ // state: 'inactive' | 'active'
148
+ return <Text>Full-device sharing: {state}</Text>;
149
+ }
150
+ ```
151
+
120
152
  ### `useUpscope`
121
153
 
122
154
  Returns a stable object of imperative action methods — safe to destructure and pass as deps.
@@ -157,6 +189,7 @@ useEffect(() => {
157
189
  | Event name | Payload type | Description |
158
190
  | ------------------------- | ----------------------------- | ---------------------------------------- |
159
191
  | `connectionStateChanged` | `ConnectionStateChangedEvent` | Connection state transitioned |
192
+ | `sessionStateChanged` | `SessionStateChangedEvent` | Session state transitioned (current-value)|
160
193
  | `sessionStarted` | `SessionStartedEvent` | Agent joined and session is active |
161
194
  | `sessionEnded` | `SessionEndedEvent` | Session ended (includes reason) |
162
195
  | `customMessageReceived` | `CustomMessageEvent` | Arbitrary string from an observer |
@@ -166,6 +199,9 @@ useEffect(() => {
166
199
  | `observerCountChanged` | `ObserverCountChangedEvent` | Total observer count changed |
167
200
  | `shortIdChanged` | `ShortIdChangedEvent` | Short ID assigned or rotated |
168
201
  | `lookupCodeChanged` | `LookupCodeChangedEvent` | Lookup code issued or changed |
202
+ | `remoteControlStateChanged` | `RemoteControlStateChangedEvent` | Agent gained or lost remote control |
203
+ | `fullDeviceSharingStateChanged` | `FullDeviceSharingStateChangedEvent` | Full-device sharing started or stopped |
204
+ | `fullDeviceRequest` | `FullDeviceRequestEvent` | Agent requested full-device sharing — answer with `respondToFullDeviceRequest` |
169
205
 
170
206
  ## View Masking
171
207
 
@@ -198,18 +234,112 @@ interface UpscopeConfig {
198
234
  requireAuthorizationForSession?: boolean; // Show a prompt before allowing a session. Default: true.
199
235
  autoConnect?: boolean; // Connect on initialize(). Default: false.
200
236
  region?: string; // Override the server region.
201
- showBanner?: boolean; // Show the native in-session banner.
202
- showTerminateButton?: boolean; // Show an end-session button in the banner.
237
+ showTerminateButton?: boolean; // Show an end-session button during a session.
203
238
  authorizationPromptTitle?: string; // Custom title for the session request dialog.
204
239
  authorizationPromptMessage?: string; // Custom message for the session request dialog.
205
240
  endOfSessionMessage?: string; // Message shown when a session ends.
206
- stopSessionText?: string; // Label for the terminate-session button.
241
+ stopSessionText?: LocalizedString; // Label for the terminate-session button.
207
242
  allowRemoteClick?: boolean; // Allow agent to send tap events. Default: true.
208
243
  allowRemoteScroll?: boolean; // Allow agent to send scroll events. Default: true.
209
244
  requireControlRequest?: boolean; // Require visitor approval before remote control starts.
245
+ broadcastAppGroupId?: string; // iOS only. App Group shared with the Broadcast Upload Extension (enables full-device sharing).
246
+ broadcastExtensionBundleId?: string; // iOS only. Bundle id of the Broadcast Upload Extension.
210
247
  }
248
+
249
+ // Translation fields accept either a plain string or a language-keyed map.
250
+ // Keys are BCP-47 language tags; the native SDK picks the best match for the
251
+ // device's preferred languages.
252
+ type LocalizedString = string | { [language: string]: string };
211
253
  ```
212
254
 
255
+ Example:
256
+
257
+ ```ts
258
+ Upscope.initialize({
259
+ apiKey: "…",
260
+ stopSessionText: { en: "Stop", it: "Ferma", "pt-PT": "Parar" },
261
+ });
262
+ ```
263
+
264
+ ## Full device sharing (iOS)
265
+
266
+ Full device sharing lets the agent see the entire device screen (other apps,
267
+ home screen) via ReplayKit. It requires a Broadcast Upload Extension target in
268
+ your app — npm packages can't add app extension targets, so this is a one-time
269
+ manual setup (~10 minutes). Without it, the agent's "Full app sharing" button
270
+ is hidden.
271
+
272
+ > Android needs no setup: it uses MediaProjection, and the required
273
+ > foreground-service permissions are merged automatically from the SDK's manifest.
274
+
275
+ ### 1. Add the extension target
276
+
277
+ In Xcode: **File → New → Target → Broadcast Upload Extension**. Name it (e.g.
278
+ `YourAppBroadcast`), uncheck "Include UI Extension".
279
+
280
+ ### 2. Replace the generated SampleHandler
281
+
282
+ ```swift
283
+ import UpscopeSDK
284
+
285
+ class SampleHandler: UpscopeSampleHandler {}
286
+ ```
287
+
288
+ ### 3. Add an App Group to BOTH targets
289
+
290
+ In **Signing & Capabilities** for your app target *and* the extension target,
291
+ add the **App Groups** capability with the same group, e.g.
292
+ `group.com.yourcompany.yourapp`.
293
+
294
+ ### 4. Declare the group in the extension's Info.plist
295
+
296
+ ```xml
297
+ <key>UpscopeAppGroupId</key>
298
+ <string>group.com.yourcompany.yourapp</string>
299
+ ```
300
+
301
+ Also make sure `NSExtension > RPBroadcastProcessMode` is `2` (sample-buffer
302
+ mode); some Xcode template versions omit it. See the example app's
303
+ [`Info.plist`](example/ios/UpscopeExampleBroadcast/Info.plist).
304
+
305
+ ### 5. Add the extension pod
306
+
307
+ ```ruby
308
+ # Podfile (top level, alongside your app target)
309
+ target 'YourAppBroadcast' do
310
+ pod 'UpscopeSDK/BroadcastExtension'
311
+ end
312
+ ```
313
+
314
+ Run `pod install`.
315
+
316
+ ### 6. Pass the keys at initialization
317
+
318
+ ```ts
319
+ Upscope.initialize({
320
+ apiKey: 'your-api-key',
321
+ broadcastAppGroupId: 'group.com.yourcompany.yourapp',
322
+ broadcastExtensionBundleId: 'com.yourcompany.yourapp.YourAppBroadcast',
323
+ });
324
+ ```
325
+
326
+ `broadcastAppGroupId` enables the feature (the agent's button appears);
327
+ `broadcastExtensionBundleId` preselects your extension in the iOS picker.
328
+
329
+ ### Notes
330
+
331
+ - The App Group ID must match in **three** places: both targets' entitlements,
332
+ the extension's `Info.plist` (`UpscopeAppGroupId`), and `broadcastAppGroupId`.
333
+ - Test on a **physical device** — ReplayKit broadcasts are unreliable on the
334
+ simulator.
335
+ - Full-device capture cannot mask views. Set
336
+ `disableFullScreenWhenMasked: true` to automatically end full-device sharing
337
+ whenever masked content is on screen.
338
+ - Expo managed workflow is not supported (extension targets require a config
339
+ plugin; not currently provided).
340
+ - A complete working setup is in
341
+ [`example/ios`](example/ios) (`UpscopeExampleBroadcast` target).
342
+
213
343
  ## API Reference
214
344
 
215
345
  ### Initialization
@@ -225,7 +355,7 @@ interface UpscopeConfig {
225
355
 
226
356
  | Method | Description |
227
357
  | ----------------------------------- | --------------------------------------------------- |
228
- | `updateConnection(params)` | Set/update `uniqueId`, `callName`, `tags`, `identities`, `metadata`. |
358
+ | `updateConnection(params)` | Set/update `uniqueId`, `callName`, `tags`, `identities`, `integrationIds`, `metadata`. |
229
359
 
230
360
  ### Session
231
361
 
@@ -234,6 +364,9 @@ interface UpscopeConfig {
234
364
  | `stopSession()` | Terminate the active session. |
235
365
  | `requestAgent()` | Request that an agent join. |
236
366
  | `cancelAgentRequest()` | Cancel a pending agent-join request. |
367
+ | `stopRemoteControl()` | Revoke the agent's remote control, keep the session.|
368
+ | `stopFullDeviceSharing()` | Stop full-device sharing, revert to in-app, keep the session. |
369
+ | `respondToFullDeviceRequest(id, accept)` | Answer a `fullDeviceRequest` event. |
237
370
 
238
371
  ### Data
239
372
 
@@ -32,5 +32,5 @@ android {
32
32
 
33
33
  dependencies {
34
34
  implementation("com.facebook.react:react-android")
35
- implementation("io.github.upscopeio:upscope-android-sdk:2026.3.6")
35
+ implementation("io.github.upscopeio:upscope-android-sdk:2026.5.1")
36
36
  }
@@ -1,22 +1,28 @@
1
1
  package io.upscope.reactnative
2
2
 
3
- import android.app.AlertDialog
4
3
  import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.LifecycleEventListener
5
5
  import com.facebook.react.bridge.Promise
6
6
  import com.facebook.react.bridge.ReactApplicationContext
7
7
  import com.facebook.react.bridge.ReadableMap
8
+ import com.facebook.react.bridge.ReadableType
8
9
  import com.facebook.react.bridge.WritableMap
9
10
  import com.facebook.react.module.annotations.ReactModule
10
11
  import com.facebook.react.modules.core.DeviceEventManagerModule
11
12
  import io.upscope.sdk.Cancellable
12
13
  import io.upscope.sdk.ConnectionState
13
- import io.upscope.sdk.Observer
14
+ import io.upscope.sdk.FullDeviceSharingState
15
+ import io.upscope.sdk.Viewer
16
+ import io.upscope.sdk.RemoteControlState
14
17
  import io.upscope.sdk.SessionEndReason
15
18
  import io.upscope.sdk.SessionRequestResponse
19
+ import io.upscope.sdk.SessionState
16
20
  import io.upscope.sdk.Upscope
17
21
  import io.upscope.sdk.UpscopeConfiguration
18
22
  import io.upscope.sdk.UpscopeError
19
23
  import io.upscope.sdk.UpscopeListener
24
+ import java.util.UUID
25
+ import java.util.concurrent.ConcurrentHashMap
20
26
  import kotlinx.coroutines.CoroutineScope
21
27
  import kotlinx.coroutines.Dispatchers
22
28
  import kotlinx.coroutines.Job
@@ -36,6 +42,28 @@ class UpscopeModule(
36
42
  private var listenerCount = 0
37
43
  private var shortIdJob: Job? = null
38
44
  private var lookupCodeJob: Job? = null
45
+ private var sessionStateJob: Job? = null
46
+ private var remoteControlStateJob: Job? = null
47
+ private var fullDeviceSharingStateJob: Job? = null
48
+
49
+ /** Pending full-device requests awaiting a JS response, keyed by request id. */
50
+ private val pendingFullDeviceRequests = ConcurrentHashMap<String, SessionRequestResponse>()
51
+
52
+ /**
53
+ * Refreshes the SDK's current activity whenever the host resumes. In-app
54
+ * capture needs a non-null current activity to produce frames; registering
55
+ * once at init can race RN startup or go stale after activity changes
56
+ * (Bug #5 — "Waiting for first frame").
57
+ */
58
+ private val lifecycleListener = object : LifecycleEventListener {
59
+ override fun onHostResume() {
60
+ reactContext.currentActivity?.let { Upscope.setCurrentActivity(it) }
61
+ }
62
+
63
+ override fun onHostPause() {}
64
+
65
+ override fun onHostDestroy() {}
66
+ }
39
67
 
40
68
  private val upscopeListener = object : UpscopeListener {
41
69
  override fun onConnectionStateChanged(state: ConnectionState) {
@@ -65,10 +93,10 @@ class UpscopeModule(
65
93
  sendEvent("sessionEnded", params)
66
94
  }
67
95
 
68
- override fun onCustomMessageReceived(message: String, observerId: String) {
96
+ override fun onCustomMessageReceived(message: String, viewerId: String) {
69
97
  val params = Arguments.createMap().apply {
70
98
  putString("message", message)
71
- putString("observerId", observerId)
99
+ putString("viewerId", viewerId)
72
100
  }
73
101
  sendEvent("customMessageReceived", params)
74
102
  }
@@ -77,23 +105,31 @@ class UpscopeModule(
77
105
  sendEvent("error", serializeError(error))
78
106
  }
79
107
 
80
- override fun onObserverJoined(observer: Observer) {
81
- sendEvent("observerJoined", serializeObserver(observer))
108
+ override fun onViewerJoined(viewer: Viewer) {
109
+ sendEvent("viewerJoined", serializeViewer(viewer))
82
110
  }
83
111
 
84
- override fun onObserverLeft(observerId: String) {
112
+ override fun onViewerLeft(viewerId: String) {
85
113
  val params = Arguments.createMap().apply {
86
- putString("observerId", observerId)
114
+ putString("viewerId", viewerId)
87
115
  }
88
- sendEvent("observerLeft", params)
116
+ sendEvent("viewerLeft", params)
89
117
  }
90
118
 
91
- override fun onObserverCountChanged(count: Int) {
119
+ override fun onViewerCountChanged(count: Int) {
92
120
  val params = Arguments.createMap().apply {
93
121
  putInt("count", count)
94
122
  }
95
- sendEvent("observerCountChanged", params)
123
+ sendEvent("viewerCountChanged", params)
96
124
  }
125
+
126
+ // Remote-control and full-device-sharing state are emitted from their
127
+ // StateFlows in addListener() (current-value, so fresh subscriptions get
128
+ // the real state). The listener overrides would duplicate those events.
129
+ }
130
+
131
+ init {
132
+ reactContext.addLifecycleEventListener(lifecycleListener)
97
133
  }
98
134
 
99
135
  override fun initialize(config: ReadableMap) {
@@ -103,6 +139,9 @@ class UpscopeModule(
103
139
  val builder = UpscopeConfiguration.Builder(apiKey)
104
140
  .wrapper("react-native")
105
141
 
142
+ if (config.hasKey("wrapperVersion")) {
143
+ builder.wrapperVersion(checkNotNull(config.getString("wrapperVersion")))
144
+ }
106
145
  if (config.hasKey("requireAuthorizationForSession")) {
107
146
  builder.requireAuthorizationForSession(config.getBoolean("requireAuthorizationForSession"))
108
147
  }
@@ -112,6 +151,12 @@ class UpscopeModule(
112
151
  if (config.hasKey("region")) {
113
152
  builder.region(checkNotNull(config.getString("region")))
114
153
  }
154
+ if (config.hasKey("baseDomain")) {
155
+ builder.baseDomain(checkNotNull(config.getString("baseDomain")))
156
+ }
157
+ if (config.hasKey("disableFullScreenWhenMasked")) {
158
+ builder.disableFullScreenWhenMasked(config.getBoolean("disableFullScreenWhenMasked"))
159
+ }
115
160
  if (config.hasKey("showTerminateButton")) {
116
161
  builder.showTerminateButton(config.getBoolean("showTerminateButton"))
117
162
  }
@@ -128,9 +173,12 @@ class UpscopeModule(
128
173
  if (config.hasKey("endOfSessionMessage")) {
129
174
  builder.endOfSessionMessage(checkNotNull(config.getString("endOfSessionMessage")))
130
175
  }
131
- if (config.hasKey("stopSessionText")) {
132
- builder.stopSessionText(checkNotNull(config.getString("stopSessionText")))
133
- }
176
+ applyLocalized(
177
+ config,
178
+ "stopSessionText",
179
+ setString = { builder.stopSessionText(it) },
180
+ setMap = { builder.stopSessionText(it) },
181
+ )
134
182
  if (config.hasKey("allowRemoteClick")) {
135
183
  builder.allowRemoteClick(config.getBoolean("allowRemoteClick"))
136
184
  }
@@ -140,38 +188,50 @@ class UpscopeModule(
140
188
  if (config.hasKey("requireControlRequest")) {
141
189
  builder.requireControlRequest(config.getBoolean("requireControlRequest"))
142
190
  }
191
+ if (config.hasKey("controlRequestTitle")) {
192
+ builder.controlRequestTitle(checkNotNull(config.getString("controlRequestTitle")))
193
+ }
194
+ if (config.hasKey("controlRequestMessage")) {
195
+ builder.controlRequestMessage(checkNotNull(config.getString("controlRequestMessage")))
196
+ }
197
+ if (config.hasKey("enableLookupCodeOnShake")) {
198
+ builder.enableLookupCodeOnShake(config.getBoolean("enableLookupCodeOnShake"))
199
+ }
200
+ if (config.hasKey("lookupCodeKeyTitle")) {
201
+ builder.lookupCodeKeyTitle(checkNotNull(config.getString("lookupCodeKeyTitle")))
202
+ }
203
+ if (config.hasKey("lookupCodeKeyMessage")) {
204
+ builder.lookupCodeKeyMessage(checkNotNull(config.getString("lookupCodeKeyMessage")))
205
+ }
206
+ applyLocalized(
207
+ config,
208
+ "translationsYes",
209
+ setString = { builder.translationsYes(it) },
210
+ setMap = { builder.translationsYes(it) },
211
+ )
212
+ applyLocalized(
213
+ config,
214
+ "translationsNo",
215
+ setString = { builder.translationsNo(it) },
216
+ setMap = { builder.translationsNo(it) },
217
+ )
218
+ applyLocalized(
219
+ config,
220
+ "translationsOk",
221
+ setString = { builder.translationsOk(it) },
222
+ setMap = { builder.translationsOk(it) },
223
+ )
143
224
 
144
- val promptTitle = config.getString("authorizationPromptTitle") ?: "Screen Sharing Request"
145
- val promptMessage = config.getString("authorizationPromptMessage")
146
- ?: "An agent wants to view your screen. Do you accept?"
147
- val yesLabel = config.getString("translationYes") ?: "Yes"
148
- val noLabel = config.getString("translationNo") ?: "No"
149
-
150
- builder.onSessionRequest { response: SessionRequestResponse, agentName: String? ->
151
- val activity = reactContext.currentActivity
152
- if (activity == null) {
153
- response.reject()
154
- return@onSessionRequest null
155
- }
156
-
157
- val appLabel = activity.packageManager
158
- .getApplicationLabel(activity.applicationInfo)
159
- .toString()
160
-
161
- fun String.fillTemplates() = this
162
- .replace("{%agentName%}", agentName ?: "")
163
- .replace("{%currentDomain%}", appLabel)
164
-
165
- val dialog = AlertDialog.Builder(activity)
166
- .setTitle(promptTitle.fillTemplates())
167
- .setMessage(promptMessage.fillTemplates())
168
- .setCancelable(false)
169
- .setPositiveButton(yesLabel) { _, _ -> response.accept() }
170
- .setNegativeButton(noLabel) { _, _ -> response.reject() }
171
- .create()
172
-
173
- dialog.show()
174
- Cancellable { dialog.dismiss() }
225
+ // Bridge the native full-device request hook to the JS event layer:
226
+ // store the response keyed by a generated id, emit `fullDeviceRequest`,
227
+ // and return a Cancellable that clears the entry if the SDK cancels the
228
+ // request externally. JS answers via respondToFullDeviceRequest(id, accept).
229
+ builder.onFullDeviceRequest { response ->
230
+ val requestId = UUID.randomUUID().toString()
231
+ pendingFullDeviceRequests[requestId] = response
232
+ val params = Arguments.createMap().apply { putString("requestId", requestId) }
233
+ sendEvent("fullDeviceRequest", params)
234
+ Cancellable { pendingFullDeviceRequests.remove(requestId) }
175
235
  }
176
236
 
177
237
  val configuration = builder.build()
@@ -194,6 +254,7 @@ class UpscopeModule(
194
254
  callName = if (params.hasKey("callName")) params.getString("callName") else null,
195
255
  tags = if (params.hasKey("tags")) params.getArray("tags")?.toArrayList()?.filterIsInstance<String>() else null,
196
256
  identities = if (params.hasKey("identities")) params.getArray("identities")?.toArrayList()?.filterIsInstance<String>() else null,
257
+ integrationIds = if (params.hasKey("integrationIds")) params.getArray("integrationIds")?.toArrayList()?.filterIsInstance<String>() else null,
197
258
  metadata = if (params.hasKey("metadata")) {
198
259
  val map = params.getMap("metadata")
199
260
  val result = mutableMapOf<String, String>()
@@ -211,6 +272,18 @@ class UpscopeModule(
211
272
 
212
273
  override fun cancelAgentRequest() { Upscope.cancelAgentRequest() }
213
274
 
275
+ override fun stopRemoteControl() { Upscope.stopRemoteControl() }
276
+
277
+ override fun stopFullDeviceSharing() { Upscope.stopFullDeviceSharing() }
278
+
279
+ override fun respondToFullDeviceRequest(requestId: String, accept: Boolean) {
280
+ val response = pendingFullDeviceRequests.remove(requestId)
281
+ if (response == null) {
282
+ return
283
+ }
284
+ if (accept) response.accept() else response.reject()
285
+ }
286
+
214
287
  override fun getLookupCode() { Upscope.getLookupCode() }
215
288
 
216
289
  override fun getShortId(promise: Promise) { promise.resolve(Upscope.getShortId()) }
@@ -221,8 +294,9 @@ class UpscopeModule(
221
294
 
222
295
  /**
223
296
  * Called by RN when the JS side adds an event listener. Starts collecting Flow emissions
224
- * for shortId and lookupCode on the first listener so we only run these coroutines when
225
- * something is actually subscribed.
297
+ * on the first listener so we only run these coroutines when something is actually
298
+ * subscribed. State flows are current-value (StateFlow), so a fresh subscription
299
+ * immediately receives the real current state — the fix for state stuck on remount.
226
300
  */
227
301
  override fun addListener(eventName: String) {
228
302
  listenerCount++
@@ -240,6 +314,30 @@ class UpscopeModule(
240
314
  sendEvent("lookupCodeChanged", p)
241
315
  }
242
316
  }
317
+ sessionStateJob = scope.launch {
318
+ Upscope.sessionStateFlow.collect { state ->
319
+ val p = Arguments.createMap().apply {
320
+ putString("state", serializeSessionState(state))
321
+ }
322
+ sendEvent("sessionStateChanged", p)
323
+ }
324
+ }
325
+ remoteControlStateJob = scope.launch {
326
+ Upscope.remoteControlStateFlow.collect { state ->
327
+ val p = Arguments.createMap().apply {
328
+ putString("state", serializeRemoteControlState(state))
329
+ }
330
+ sendEvent("remoteControlStateChanged", p)
331
+ }
332
+ }
333
+ fullDeviceSharingStateJob = scope.launch {
334
+ Upscope.fullDeviceSharingStateFlow.collect { state ->
335
+ val p = Arguments.createMap().apply {
336
+ putString("state", serializeFullDeviceSharingState(state))
337
+ }
338
+ sendEvent("fullDeviceSharingStateChanged", p)
339
+ }
340
+ }
243
341
  }
244
342
  }
245
343
 
@@ -253,8 +351,45 @@ class UpscopeModule(
253
351
  listenerCount = 0
254
352
  shortIdJob?.cancel()
255
353
  lookupCodeJob?.cancel()
354
+ sessionStateJob?.cancel()
355
+ remoteControlStateJob?.cancel()
356
+ fullDeviceSharingStateJob?.cancel()
256
357
  shortIdJob = null
257
358
  lookupCodeJob = null
359
+ sessionStateJob = null
360
+ remoteControlStateJob = null
361
+ fullDeviceSharingStateJob = null
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Applies a translation field that may arrive from JS as either a plain string or a
367
+ * language-keyed map. The native SDK exposes a [String] and a [Map] builder overload
368
+ * for each such field; we dispatch to whichever matches the runtime value and silently
369
+ * skip any other type so the server-provided default remains in effect.
370
+ */
371
+ private fun applyLocalized(
372
+ config: ReadableMap,
373
+ key: String,
374
+ setString: (String) -> Unit,
375
+ setMap: (Map<String, String>) -> Unit,
376
+ ) {
377
+ if (!config.hasKey(key)) return
378
+ when (config.getType(key)) {
379
+ ReadableType.String -> config.getString(key)?.let(setString)
380
+ ReadableType.Map -> {
381
+ val map = config.getMap(key) ?: return
382
+ val entries = mutableMapOf<String, String>()
383
+ val iter = map.keySetIterator()
384
+ while (iter.hasNextKey()) {
385
+ val entryKey = iter.nextKey()
386
+ if (map.getType(entryKey) == ReadableType.String) {
387
+ map.getString(entryKey)?.let { entries[entryKey] = it }
388
+ }
389
+ }
390
+ setMap(entries)
391
+ }
392
+ else -> Unit
258
393
  }
259
394
  }
260
395
 
@@ -273,6 +408,24 @@ class UpscopeModule(
273
408
  is ConnectionState.Error -> "error"
274
409
  }
275
410
 
411
+ private fun serializeSessionState(state: SessionState): String = when (state) {
412
+ SessionState.INACTIVE -> "inactive"
413
+ SessionState.PENDING_REQUEST -> "pendingRequest"
414
+ SessionState.ACTIVE -> "active"
415
+ SessionState.PAUSED -> "paused"
416
+ SessionState.ENDED -> "ended"
417
+ }
418
+
419
+ private fun serializeRemoteControlState(state: RemoteControlState): String = when (state) {
420
+ RemoteControlState.INACTIVE -> "inactive"
421
+ RemoteControlState.ACTIVE -> "active"
422
+ }
423
+
424
+ private fun serializeFullDeviceSharingState(state: FullDeviceSharingState): String = when (state) {
425
+ FullDeviceSharingState.INACTIVE -> "inactive"
426
+ FullDeviceSharingState.ACTIVE -> "active"
427
+ }
428
+
276
429
  private fun serializeEndReason(reason: SessionEndReason): String = when (reason) {
277
430
  is SessionEndReason.UserStopped -> "userStopped"
278
431
  is SessionEndReason.AgentStopped -> "agentStopped"
@@ -285,13 +438,14 @@ class UpscopeModule(
285
438
  putString("message", error.message)
286
439
  }
287
440
 
288
- private fun serializeObserver(observer: Observer): WritableMap = Arguments.createMap().apply {
289
- putString("id", observer.id)
290
- putString("name", observer.name)
291
- putDouble("screenWidth", observer.screenWidth)
292
- putDouble("screenHeight", observer.screenHeight)
293
- putDouble("windowWidth", observer.windowWidth)
294
- putDouble("windowHeight", observer.windowHeight)
295
- putBoolean("hasFocus", observer.hasFocus)
441
+ private fun serializeViewer(viewer: Viewer): WritableMap = Arguments.createMap().apply {
442
+ putString("id", viewer.id)
443
+ putString("name", viewer.name)
444
+ putDouble("screenWidth", viewer.screenWidth)
445
+ putDouble("screenHeight", viewer.screenHeight)
446
+ putDouble("screenScale", viewer.screenScale)
447
+ putDouble("windowWidth", viewer.windowWidth)
448
+ putDouble("windowHeight", viewer.windowHeight)
449
+ putBoolean("hasFocus", viewer.hasFocus)
296
450
  }
297
451
  }
@@ -13,6 +13,9 @@ RCT_EXTERN_METHOD(updateConnection:(NSDictionary *)params)
13
13
  RCT_EXTERN_METHOD(stopSession)
14
14
  RCT_EXTERN_METHOD(requestAgent)
15
15
  RCT_EXTERN_METHOD(cancelAgentRequest)
16
+ RCT_EXTERN_METHOD(stopRemoteControl)
17
+ RCT_EXTERN_METHOD(stopFullDeviceSharing)
18
+ RCT_EXTERN_METHOD(respondToFullDeviceRequest:(NSString *)requestId accept:(BOOL)accept)
16
19
  RCT_EXTERN_METHOD(getLookupCode)
17
20
  RCT_EXTERN_METHOD(getShortId:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
18
21
  RCT_EXTERN_METHOD(getWatchLink:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)