@poolse/react-native-calls 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +33 -2
  2. package/android/build.gradle +51 -0
  3. package/android/src/main/AndroidManifest.xml +40 -0
  4. package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +257 -0
  5. package/android/src/main/java/dev/poolse/callcore/CallMachine.kt +292 -0
  6. package/android/src/main/java/dev/poolse/callcore/Ports.kt +94 -0
  7. package/android/src/main/java/dev/poolse/callcore/android/AndroidRingSurface.kt +183 -0
  8. package/android/src/main/java/dev/poolse/callcore/android/CallActionReceiver.kt +28 -0
  9. package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +166 -0
  10. package/android/src/main/java/dev/poolse/callcore/android/CallForegroundService.kt +80 -0
  11. package/android/src/main/java/dev/poolse/callcore/android/CallHttpClient.kt +151 -0
  12. package/android/src/main/java/dev/poolse/callcore/android/CallMessagingService.kt +99 -0
  13. package/android/src/main/java/dev/poolse/callcore/android/HandlerTimerPort.kt +42 -0
  14. package/android/src/main/java/dev/poolse/callcore/android/IncomingCallActivity.kt +209 -0
  15. package/android/src/main/java/dev/poolse/callcore/android/RingBundle.kt +42 -0
  16. package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +57 -0
  17. package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +109 -0
  18. package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +108 -0
  19. package/dist/index.cjs +45 -0
  20. package/dist/index.cjs.map +1 -0
  21. package/dist/index.d.cts +51 -0
  22. package/dist/index.d.ts +51 -0
  23. package/dist/index.js +32 -0
  24. package/dist/index.js.map +1 -0
  25. package/expo-module.config.json +6 -0
  26. package/package.json +39 -4
  27. package/src/expo-modules-core.d.ts +11 -0
  28. package/src/index.ts +111 -0
@@ -0,0 +1,109 @@
1
+ package expo.modules.poolsecalls
2
+
3
+ import dev.poolse.callcore.android.CallCore
4
+ import dev.poolse.callcore.android.toSnapshot
5
+ import expo.modules.kotlin.modules.Module
6
+ import expo.modules.kotlin.modules.ModuleDefinition
7
+
8
+ /**
9
+ * The React Native face of the native call core. Deliberately thin: it
10
+ * forwards commands to [CallCore] and streams snapshots out. All call
11
+ * logic lives in the core; this class only crosses the JS boundary.
12
+ *
13
+ * `configure` is the single required setup call — the host passes the
14
+ * API base URL; the icon and ringback resources are resolved by name so
15
+ * the JS side needs no R references. The SFU factory defaults to the
16
+ * shipped LiveKit adapter when present, else a no-op (audio-less), so a
17
+ * build without the LiveKit source set still rings and signals.
18
+ */
19
+ class PoolseCallsModule : Module() {
20
+
21
+ override fun definition() = ModuleDefinition {
22
+ Name("PoolseCalls")
23
+
24
+ Events("onCallState")
25
+
26
+ // Snapshots → the single JS event. Null (idle) becomes an explicit
27
+ // cleared payload so the JS side always has a definite state.
28
+ OnStartObserving {
29
+ CallCore.get(context).snapshotListener = { state ->
30
+ sendEvent("onCallState", mapOf("snapshot" to state.toSnapshot()))
31
+ }
32
+ }
33
+ OnStopObserving {
34
+ CallCore.get(context).snapshotListener = null
35
+ }
36
+
37
+ AsyncFunction("configure") { apiBaseUrl: String, options: Map<String, Any?>? ->
38
+ CallCore.configure(buildConfig(apiBaseUrl, options))
39
+ }
40
+
41
+ Function("getCallState") {
42
+ mapOf("snapshot" to CallCore.get(context).snapshot().toSnapshot())
43
+ }
44
+
45
+ AsyncFunction("place") { conversationId: String, media: String, sessionToken: String ->
46
+ CallCore.get(context).place(conversationId, media, sessionToken)
47
+ }
48
+ AsyncFunction("join") { callId: String, conversationId: String?, sessionToken: String ->
49
+ CallCore.get(context).join(callId, conversationId, sessionToken)
50
+ }
51
+ Function("answer") { CallCore.get(context).answer() }
52
+ Function("decline") { CallCore.get(context).decline() }
53
+ Function("hangUp") { CallCore.get(context).hangUp() }
54
+ Function("setMuted") { muted: Boolean -> CallCore.get(context).setMuted(muted) }
55
+ Function("setCamera") { on: Boolean -> CallCore.get(context).setCamera(on) }
56
+ Function("switchCamera") { CallCore.get(context).switchCamera() }
57
+ }
58
+
59
+ private val context get() = requireNotNull(appContext.reactContext) { "no react context" }
60
+
61
+ private fun buildConfig(apiBaseUrl: String, options: Map<String, Any?>?) =
62
+ dev.poolse.callcore.android.CallCoreConfig(
63
+ apiBaseUrl = apiBaseUrl.trimEnd('/'),
64
+ smallIcon = resolveDrawable(options?.get("smallIcon") as? String),
65
+ ringbackRawRes = resolveRaw(options?.get("ringback") as? String),
66
+ mediaFactory = resolveMediaFactory(),
67
+ )
68
+
69
+ private fun resolveDrawable(name: String?): Int {
70
+ val ctx = context
71
+ val id = name?.let { ctx.resources.getIdentifier(it, "drawable", ctx.packageName) } ?: 0
72
+ // Fall back to the app's launcher icon rather than crash on a typo.
73
+ return if (id != 0) id else ctx.applicationInfo.icon
74
+ }
75
+
76
+ private fun resolveRaw(name: String?): Int {
77
+ val ctx = context
78
+ return name?.let { ctx.resources.getIdentifier(it, "raw", ctx.packageName) } ?: 0
79
+ }
80
+
81
+ /**
82
+ * The shipped LiveKit adapter is loaded reflectively so the core
83
+ * library does not statically depend on the SFU source set. Absent
84
+ * (LiveKit not built in), calls still ring and signal; there is just
85
+ * no media — a supported degraded mode, not a crash.
86
+ */
87
+ @Suppress("UNCHECKED_CAST")
88
+ private fun resolveMediaFactory(): (android.content.Context, dev.poolse.callcore.android.MediaEvents) -> dev.poolse.callcore.MediaPort {
89
+ return runCatching {
90
+ val companion = Class.forName("dev.poolse.callcore.livekit.LiveKitMediaPort\$Companion")
91
+ val instance = Class.forName("dev.poolse.callcore.livekit.LiveKitMediaPort")
92
+ .getField("Companion").get(null)
93
+ val getter = companion.getMethod("getFactory")
94
+ getter.invoke(instance) as (android.content.Context, dev.poolse.callcore.android.MediaEvents) -> dev.poolse.callcore.MediaPort
95
+ }.getOrElse {
96
+ { _, _ -> NoMediaPort }
97
+ }
98
+ }
99
+
100
+ /** Signalling-only fallback: the call rings and the state machine
101
+ * runs, there is simply no audio/video transport. */
102
+ private object NoMediaPort : dev.poolse.callcore.MediaPort {
103
+ override fun join(bundle: dev.poolse.callcore.CallSessionBundle) {}
104
+ override fun leave() {}
105
+ override fun setMuted(muted: Boolean) {}
106
+ override fun setCamera(on: Boolean) {}
107
+ override fun switchCamera() {}
108
+ }
109
+ }
@@ -0,0 +1,108 @@
1
+ package dev.poolse.callcore.livekit
2
+
3
+ import android.content.Context
4
+ import dev.poolse.callcore.CallSessionBundle
5
+ import dev.poolse.callcore.MediaPort
6
+ import dev.poolse.callcore.android.MediaEvents
7
+ import dev.poolse.callcore.android.MediaParticipant
8
+ import io.livekit.android.LiveKit
9
+ import io.livekit.android.events.RoomEvent
10
+ import io.livekit.android.events.collect
11
+ import io.livekit.android.room.Room
12
+ import kotlinx.coroutines.CoroutineScope
13
+ import kotlinx.coroutines.Dispatchers
14
+ import kotlinx.coroutines.Job
15
+ import kotlinx.coroutines.SupervisorJob
16
+ import kotlinx.coroutines.cancel
17
+ import kotlinx.coroutines.launch
18
+
19
+ /**
20
+ * The shipped [MediaPort], over LiveKit's native Android SDK. Wire it
21
+ * with [factory] in [dev.poolse.callcore.android.CallCoreConfig].
22
+ *
23
+ * This is the one adapter that depends on an external SFU SDK, so it
24
+ * lives in its own source set (`src/main/livekit`) kept off the core
25
+ * library's classpath. Lifecycle and signaling are not its concern — it
26
+ * joins a room with the server-minted token, publishes the mic (and
27
+ * camera for video), and reports room transitions back through
28
+ * [MediaEvents]. The runtime decides everything else.
29
+ */
30
+ class LiveKitMediaPort(
31
+ private val context: Context,
32
+ private val events: MediaEvents,
33
+ ) : MediaPort {
34
+
35
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
36
+ private var room: Room? = null
37
+ private var eventJob: Job? = null
38
+
39
+ override fun join(bundle: CallSessionBundle) {
40
+ leave()
41
+ val wantsVideo = bundle.media == "video"
42
+ val newRoom = LiveKit.create(appContext = context.applicationContext)
43
+ room = newRoom
44
+ eventJob = scope.launch { observe(newRoom) }
45
+ scope.launch {
46
+ runCatching {
47
+ newRoom.connect(bundle.livekitUrl, bundle.livekitToken)
48
+ newRoom.localParticipant.setMicrophoneEnabled(true)
49
+ if (wantsVideo) newRoom.localParticipant.setCameraEnabled(true)
50
+ }.onFailure { events.onFailed() }
51
+ }
52
+ }
53
+
54
+ override fun leave() {
55
+ eventJob?.cancel()
56
+ eventJob = null
57
+ room?.disconnect()
58
+ room = null
59
+ }
60
+
61
+ override fun setMuted(muted: Boolean) {
62
+ scope.launch { room?.localParticipant?.setMicrophoneEnabled(!muted) }
63
+ }
64
+
65
+ override fun setCamera(on: Boolean) {
66
+ scope.launch { room?.localParticipant?.setCameraEnabled(on) }
67
+ }
68
+
69
+ override fun switchCamera() {
70
+ // The host's VideoTrackView performs the actual flip; exposing the
71
+ // control here keeps the port surface complete.
72
+ }
73
+
74
+ private suspend fun observe(room: Room) {
75
+ room.events.collect { event ->
76
+ when (event) {
77
+ is RoomEvent.Connected -> events.onConnected()
78
+ is RoomEvent.Reconnecting, is RoomEvent.Disconnected -> events.onLost()
79
+ is RoomEvent.Reconnected -> events.onRecovered()
80
+ is RoomEvent.ParticipantConnected -> { events.onRemoteJoined(); emit(room) }
81
+ is RoomEvent.ParticipantDisconnected,
82
+ is RoomEvent.ActiveSpeakersChanged,
83
+ is RoomEvent.TrackMuted,
84
+ is RoomEvent.TrackUnmuted -> emit(room)
85
+ else -> Unit
86
+ }
87
+ }
88
+ }
89
+
90
+ private fun emit(room: Room) {
91
+ events.onParticipants(
92
+ room.remoteParticipants.values.map {
93
+ MediaParticipant(
94
+ identity = it.identity?.value ?: "",
95
+ speaking = it.isSpeaking,
96
+ muted = it.audioTrackPublications.firstOrNull()?.first?.muted ?: true,
97
+ )
98
+ },
99
+ )
100
+ }
101
+
102
+ fun dispose() = scope.cancel()
103
+
104
+ companion object {
105
+ /** Pass as `mediaFactory` in CallCoreConfig. */
106
+ val factory: (Context, MediaEvents) -> MediaPort = { ctx, ev -> LiveKitMediaPort(ctx, ev) }
107
+ }
108
+ }
package/dist/index.cjs ADDED
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ var expoModulesCore = require('expo-modules-core');
4
+
5
+ // src/index.ts
6
+ var native = expoModulesCore.requireOptionalNativeModule("PoolseCalls");
7
+ var nativeCallsAvailable = native != null;
8
+ async function configure(apiBaseUrl, options) {
9
+ await native?.configure(apiBaseUrl, options ?? null);
10
+ }
11
+ function getCallState() {
12
+ return native?.getCallState().snapshot ?? null;
13
+ }
14
+ function onCallState(cb) {
15
+ if (!native) return () => void 0;
16
+ const sub = native.addListener("onCallState", (payload) => cb(payload.snapshot));
17
+ return () => sub.remove();
18
+ }
19
+ function place(conversationId, options) {
20
+ return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();
21
+ }
22
+ function join(callId, conversationId, sessionToken) {
23
+ return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();
24
+ }
25
+ var answer = () => native?.answer();
26
+ var decline = () => native?.decline();
27
+ var hangUp = () => native?.hangUp();
28
+ var setMuted = (muted) => native?.setMuted(muted);
29
+ var setCamera = (on) => native?.setCamera(on);
30
+ var switchCamera = () => native?.switchCamera();
31
+
32
+ exports.answer = answer;
33
+ exports.configure = configure;
34
+ exports.decline = decline;
35
+ exports.getCallState = getCallState;
36
+ exports.hangUp = hangUp;
37
+ exports.join = join;
38
+ exports.nativeCallsAvailable = nativeCallsAvailable;
39
+ exports.onCallState = onCallState;
40
+ exports.place = place;
41
+ exports.setCamera = setCamera;
42
+ exports.setMuted = setMuted;
43
+ exports.switchCamera = switchCamera;
44
+ //# sourceMappingURL=index.cjs.map
45
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":["requireOptionalNativeModule"],"mappings":";;;;;AAqEA,IAAM,MAAA,GAASA,4CAA+C,aAAa,CAAA;AAGpE,IAAM,uBAAuB,MAAA,IAAU;AAG9C,eAAsB,SAAA,CAAU,YAAoB,OAAA,EAA2C;AAC7F,EAAA,MAAM,MAAA,EAAQ,SAAA,CAAU,UAAA,EAAY,OAAA,IAAW,IAAI,CAAA;AACrD;AAGO,SAAS,YAAA,GAAgC;AAC9C,EAAA,OAAO,MAAA,EAAQ,YAAA,EAAa,CAAE,QAAA,IAAY,IAAA;AAC5C;AAKO,SAAS,YAAY,EAAA,EAAsD;AAChF,EAAA,IAAI,CAAC,MAAA,EAAQ,OAAO,MAAM,MAAA;AAC1B,EAAA,MAAM,GAAA,GAAM,OAAO,WAAA,CAAY,aAAA,EAAe,CAAC,OAAA,KAAY,EAAA,CAAG,OAAA,CAAQ,QAAQ,CAAC,CAAA;AAC/E,EAAA,OAAO,MAAM,IAAI,MAAA,EAAO;AAC1B;AAEO,SAAS,KAAA,CAAM,gBAAwB,OAAA,EAAsC;AAClF,EAAA,OAAO,MAAA,EAAQ,MAAM,cAAA,EAAgB,OAAA,CAAQ,OAAO,OAAA,CAAQ,YAAY,CAAA,IAAK,OAAA,CAAQ,OAAA,EAAQ;AAC/F;AAEO,SAAS,IAAA,CACd,MAAA,EACA,cAAA,EACA,YAAA,EACe;AACf,EAAA,OAAO,QAAQ,IAAA,CAAK,MAAA,EAAQ,gBAAgB,YAAY,CAAA,IAAK,QAAQ,OAAA,EAAQ;AAC/E;AAEO,IAAM,MAAA,GAAS,MAAY,MAAA,EAAQ,MAAA;AACnC,IAAM,OAAA,GAAU,MAAY,MAAA,EAAQ,OAAA;AACpC,IAAM,MAAA,GAAS,MAAY,MAAA,EAAQ,MAAA;AACnC,IAAM,QAAA,GAAW,CAAC,KAAA,KAAyB,MAAA,EAAQ,SAAS,KAAK;AACjE,IAAM,SAAA,GAAY,CAAC,EAAA,KAAsB,MAAA,EAAQ,UAAU,EAAE;AAC7D,IAAM,YAAA,GAAe,MAAY,MAAA,EAAQ,YAAA","file":"index.cjs","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { requireOptionalNativeModule } from 'expo-modules-core';\n\n/** A call's complete state. `null` means idle — no call. */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n cameraOn: boolean;\n }>;\n audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n mutedSelf: boolean;\n cameraOn: boolean;\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Raw resource NAME of the ringback tone (Android). */\n ringback?: string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n"]}
@@ -0,0 +1,51 @@
1
+ /** A call's complete state. `null` means idle — no call. */
2
+ interface Snapshot {
3
+ cv: 1;
4
+ callId: string;
5
+ conversationId: string;
6
+ conversationName: string | null;
7
+ direction: 'incoming' | 'outgoing' | 'joined';
8
+ media: 'audio' | 'video';
9
+ phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';
10
+ reason?: 'left' | 'finished' | 'declined_all' | 'busy_all' | 'no_answer' | 'cancelled' | 'connection_lost' | 'failed';
11
+ participants: Array<{
12
+ userId: string;
13
+ externalId: string;
14
+ speaking: boolean;
15
+ muted: boolean;
16
+ cameraOn: boolean;
17
+ }>;
18
+ audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
19
+ mutedSelf: boolean;
20
+ cameraOn: boolean;
21
+ }
22
+ interface ConfigureOptions {
23
+ /** Drawable resource NAME for the call notification's small icon. */
24
+ smallIcon?: string;
25
+ /** Raw resource NAME of the ringback tone (Android). */
26
+ ringback?: string;
27
+ }
28
+ interface PlaceOptions {
29
+ media: 'audio' | 'video';
30
+ /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */
31
+ sessionToken: string;
32
+ }
33
+ /** True when the native call core is present in this build. */
34
+ declare const nativeCallsAvailable: boolean;
35
+ /** Configure once, before any call can arrive. No secrets — just the API origin. */
36
+ declare function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void>;
37
+ /** The current snapshot — safe to read at any moment, including cold answer. */
38
+ declare function getCallState(): Snapshot | null;
39
+ type Unsubscribe = () => void;
40
+ /** Subscribe to call state. Fires a full snapshot on every change. */
41
+ declare function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe;
42
+ declare function place(conversationId: string, options: PlaceOptions): Promise<void>;
43
+ declare function join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;
44
+ declare const answer: () => void;
45
+ declare const decline: () => void;
46
+ declare const hangUp: () => void;
47
+ declare const setMuted: (muted: boolean) => void;
48
+ declare const setCamera: (on: boolean) => void;
49
+ declare const switchCamera: () => void;
50
+
51
+ export { type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, answer, configure, decline, getCallState, hangUp, join, nativeCallsAvailable, onCallState, place, setCamera, setMuted, switchCamera };
@@ -0,0 +1,51 @@
1
+ /** A call's complete state. `null` means idle — no call. */
2
+ interface Snapshot {
3
+ cv: 1;
4
+ callId: string;
5
+ conversationId: string;
6
+ conversationName: string | null;
7
+ direction: 'incoming' | 'outgoing' | 'joined';
8
+ media: 'audio' | 'video';
9
+ phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';
10
+ reason?: 'left' | 'finished' | 'declined_all' | 'busy_all' | 'no_answer' | 'cancelled' | 'connection_lost' | 'failed';
11
+ participants: Array<{
12
+ userId: string;
13
+ externalId: string;
14
+ speaking: boolean;
15
+ muted: boolean;
16
+ cameraOn: boolean;
17
+ }>;
18
+ audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
19
+ mutedSelf: boolean;
20
+ cameraOn: boolean;
21
+ }
22
+ interface ConfigureOptions {
23
+ /** Drawable resource NAME for the call notification's small icon. */
24
+ smallIcon?: string;
25
+ /** Raw resource NAME of the ringback tone (Android). */
26
+ ringback?: string;
27
+ }
28
+ interface PlaceOptions {
29
+ media: 'audio' | 'video';
30
+ /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */
31
+ sessionToken: string;
32
+ }
33
+ /** True when the native call core is present in this build. */
34
+ declare const nativeCallsAvailable: boolean;
35
+ /** Configure once, before any call can arrive. No secrets — just the API origin. */
36
+ declare function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void>;
37
+ /** The current snapshot — safe to read at any moment, including cold answer. */
38
+ declare function getCallState(): Snapshot | null;
39
+ type Unsubscribe = () => void;
40
+ /** Subscribe to call state. Fires a full snapshot on every change. */
41
+ declare function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe;
42
+ declare function place(conversationId: string, options: PlaceOptions): Promise<void>;
43
+ declare function join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;
44
+ declare const answer: () => void;
45
+ declare const decline: () => void;
46
+ declare const hangUp: () => void;
47
+ declare const setMuted: (muted: boolean) => void;
48
+ declare const setCamera: (on: boolean) => void;
49
+ declare const switchCamera: () => void;
50
+
51
+ export { type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, answer, configure, decline, getCallState, hangUp, join, nativeCallsAvailable, onCallState, place, setCamera, setMuted, switchCamera };
package/dist/index.js ADDED
@@ -0,0 +1,32 @@
1
+ import { requireOptionalNativeModule } from 'expo-modules-core';
2
+
3
+ // src/index.ts
4
+ var native = requireOptionalNativeModule("PoolseCalls");
5
+ var nativeCallsAvailable = native != null;
6
+ async function configure(apiBaseUrl, options) {
7
+ await native?.configure(apiBaseUrl, options ?? null);
8
+ }
9
+ function getCallState() {
10
+ return native?.getCallState().snapshot ?? null;
11
+ }
12
+ function onCallState(cb) {
13
+ if (!native) return () => void 0;
14
+ const sub = native.addListener("onCallState", (payload) => cb(payload.snapshot));
15
+ return () => sub.remove();
16
+ }
17
+ function place(conversationId, options) {
18
+ return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();
19
+ }
20
+ function join(callId, conversationId, sessionToken) {
21
+ return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();
22
+ }
23
+ var answer = () => native?.answer();
24
+ var decline = () => native?.decline();
25
+ var hangUp = () => native?.hangUp();
26
+ var setMuted = (muted) => native?.setMuted(muted);
27
+ var setCamera = (on) => native?.setCamera(on);
28
+ var switchCamera = () => native?.switchCamera();
29
+
30
+ export { answer, configure, decline, getCallState, hangUp, join, nativeCallsAvailable, onCallState, place, setCamera, setMuted, switchCamera };
31
+ //# sourceMappingURL=index.js.map
32
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";;;AAqEA,IAAM,MAAA,GAAS,4BAA+C,aAAa,CAAA;AAGpE,IAAM,uBAAuB,MAAA,IAAU;AAG9C,eAAsB,SAAA,CAAU,YAAoB,OAAA,EAA2C;AAC7F,EAAA,MAAM,MAAA,EAAQ,SAAA,CAAU,UAAA,EAAY,OAAA,IAAW,IAAI,CAAA;AACrD;AAGO,SAAS,YAAA,GAAgC;AAC9C,EAAA,OAAO,MAAA,EAAQ,YAAA,EAAa,CAAE,QAAA,IAAY,IAAA;AAC5C;AAKO,SAAS,YAAY,EAAA,EAAsD;AAChF,EAAA,IAAI,CAAC,MAAA,EAAQ,OAAO,MAAM,MAAA;AAC1B,EAAA,MAAM,GAAA,GAAM,OAAO,WAAA,CAAY,aAAA,EAAe,CAAC,OAAA,KAAY,EAAA,CAAG,OAAA,CAAQ,QAAQ,CAAC,CAAA;AAC/E,EAAA,OAAO,MAAM,IAAI,MAAA,EAAO;AAC1B;AAEO,SAAS,KAAA,CAAM,gBAAwB,OAAA,EAAsC;AAClF,EAAA,OAAO,MAAA,EAAQ,MAAM,cAAA,EAAgB,OAAA,CAAQ,OAAO,OAAA,CAAQ,YAAY,CAAA,IAAK,OAAA,CAAQ,OAAA,EAAQ;AAC/F;AAEO,SAAS,IAAA,CACd,MAAA,EACA,cAAA,EACA,YAAA,EACe;AACf,EAAA,OAAO,QAAQ,IAAA,CAAK,MAAA,EAAQ,gBAAgB,YAAY,CAAA,IAAK,QAAQ,OAAA,EAAQ;AAC/E;AAEO,IAAM,MAAA,GAAS,MAAY,MAAA,EAAQ,MAAA;AACnC,IAAM,OAAA,GAAU,MAAY,MAAA,EAAQ,OAAA;AACpC,IAAM,MAAA,GAAS,MAAY,MAAA,EAAQ,MAAA;AACnC,IAAM,QAAA,GAAW,CAAC,KAAA,KAAyB,MAAA,EAAQ,SAAS,KAAK;AACjE,IAAM,SAAA,GAAY,CAAC,EAAA,KAAsB,MAAA,EAAQ,UAAU,EAAE;AAC7D,IAAM,YAAA,GAAe,MAAY,MAAA,EAAQ,YAAA","file":"index.js","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { requireOptionalNativeModule } from 'expo-modules-core';\n\n/** A call's complete state. `null` means idle — no call. */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n cameraOn: boolean;\n }>;\n audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n mutedSelf: boolean;\n cameraOn: boolean;\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Raw resource NAME of the ringback tone (Android). */\n ringback?: string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n"]}
@@ -0,0 +1,6 @@
1
+ {
2
+ "platforms": ["android"],
3
+ "android": {
4
+ "modules": ["expo.modules.poolsecalls.PoolseCallsModule"]
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@poolse/react-native-calls",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Native call core for poolse — Kotlin/Swift calling engine with a thin React Native bridge. Ring, answer and carry audio with the app killed and the phone locked.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.cjs",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "react-native": "./src/index.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "react-native": "./src/index.ts",
15
+ "import": "./dist/index.js",
16
+ "require": "./dist/index.cjs"
17
+ }
18
+ },
19
+ "files": [
20
+ "src",
21
+ "dist",
22
+ "android/build.gradle",
23
+ "android/src",
24
+ "expo-module.config.json",
25
+ "README.md"
26
+ ],
27
+ "scripts": {
28
+ "build": "tsup",
29
+ "prepublishOnly": "tsup",
30
+ "typecheck": "tsc --noEmit"
31
+ },
32
+ "peerDependencies": {
33
+ "expo": "*",
34
+ "react-native": "*"
35
+ },
36
+ "devDependencies": {
37
+ "expo-modules-core": "*",
38
+ "tsup": "^8.3.5",
39
+ "typescript": "^5.6.0"
40
+ }
41
+ }
@@ -0,0 +1,11 @@
1
+ // Minimal ambient declaration for the one `expo-modules-core` function
2
+ // this package calls at runtime. It lets the package type-check and emit
3
+ // its own `.d.ts` without `expo-modules-core`'s full types installed in
4
+ // the monorepo — the host app always supplies the real module at runtime.
5
+ //
6
+ // This shim lives only in the package source; consumers read the emitted
7
+ // `dist/index.d.ts`, which never references `expo-modules-core`, so it
8
+ // cannot collide with the app's own copy of the types.
9
+ declare module 'expo-modules-core' {
10
+ export function requireOptionalNativeModule<T>(name: string): T | null;
11
+ }
package/src/index.ts ADDED
@@ -0,0 +1,111 @@
1
+ // @poolse/react-native-calls — the React Native face of the native
2
+ // call core (CONTRACT.md §8). A thin, typed surface over the native
3
+ // module: commands in, one snapshot event out. All call logic lives in
4
+ // the native cores; nothing here decides anything about a call.
5
+ //
6
+ // On a platform or build without the native module (Expo Go, web,
7
+ // unlinked), every command is a safe no-op and the state stays idle —
8
+ // so a host can render against this unconditionally.
9
+ import { requireOptionalNativeModule } from 'expo-modules-core';
10
+
11
+ /** A call's complete state. `null` means idle — no call. */
12
+ export interface Snapshot {
13
+ cv: 1;
14
+ callId: string;
15
+ conversationId: string;
16
+ conversationName: string | null;
17
+ direction: 'incoming' | 'outgoing' | 'joined';
18
+ media: 'audio' | 'video';
19
+ phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';
20
+ reason?:
21
+ | 'left'
22
+ | 'finished'
23
+ | 'declined_all'
24
+ | 'busy_all'
25
+ | 'no_answer'
26
+ | 'cancelled'
27
+ | 'connection_lost'
28
+ | 'failed';
29
+ participants: Array<{
30
+ userId: string;
31
+ externalId: string;
32
+ speaking: boolean;
33
+ muted: boolean;
34
+ cameraOn: boolean;
35
+ }>;
36
+ audioRoute: 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
37
+ mutedSelf: boolean;
38
+ cameraOn: boolean;
39
+ }
40
+
41
+ export interface ConfigureOptions {
42
+ /** Drawable resource NAME for the call notification's small icon. */
43
+ smallIcon?: string;
44
+ /** Raw resource NAME of the ringback tone (Android). */
45
+ ringback?: string;
46
+ }
47
+
48
+ export interface PlaceOptions {
49
+ media: 'audio' | 'video';
50
+ /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */
51
+ sessionToken: string;
52
+ }
53
+
54
+ type NativeSnapshot = { snapshot: Snapshot | null };
55
+
56
+ interface PoolseCallsNative {
57
+ configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;
58
+ getCallState(): NativeSnapshot;
59
+ place(conversationId: string, media: string, sessionToken: string): Promise<void>;
60
+ join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;
61
+ answer(): void;
62
+ decline(): void;
63
+ hangUp(): void;
64
+ setMuted(muted: boolean): void;
65
+ setCamera(on: boolean): void;
66
+ switchCamera(): void;
67
+ addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };
68
+ }
69
+
70
+ const native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');
71
+
72
+ /** True when the native call core is present in this build. */
73
+ export const nativeCallsAvailable = native != null;
74
+
75
+ /** Configure once, before any call can arrive. No secrets — just the API origin. */
76
+ export async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {
77
+ await native?.configure(apiBaseUrl, options ?? null);
78
+ }
79
+
80
+ /** The current snapshot — safe to read at any moment, including cold answer. */
81
+ export function getCallState(): Snapshot | null {
82
+ return native?.getCallState().snapshot ?? null;
83
+ }
84
+
85
+ export type Unsubscribe = () => void;
86
+
87
+ /** Subscribe to call state. Fires a full snapshot on every change. */
88
+ export function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {
89
+ if (!native) return () => undefined;
90
+ const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));
91
+ return () => sub.remove();
92
+ }
93
+
94
+ export function place(conversationId: string, options: PlaceOptions): Promise<void> {
95
+ return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();
96
+ }
97
+
98
+ export function join(
99
+ callId: string,
100
+ conversationId: string | null,
101
+ sessionToken: string,
102
+ ): Promise<void> {
103
+ return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();
104
+ }
105
+
106
+ export const answer = (): void => native?.answer();
107
+ export const decline = (): void => native?.decline();
108
+ export const hangUp = (): void => native?.hangUp();
109
+ export const setMuted = (muted: boolean): void => native?.setMuted(muted);
110
+ export const setCamera = (on: boolean): void => native?.setCamera(on);
111
+ export const switchCamera = (): void => native?.switchCamera();