expo-callkit-telecom 0.3.9 → 0.4.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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.3.9"
2
+ ".": "0.4.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -5,6 +5,15 @@ All notable changes to `expo-callkit-telecom` are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0](https://github.com/mfairley/expo-callkit-telecom/compare/v0.3.9...v0.4.0) (2026-06-16)
9
+
10
+
11
+ ### Features
12
+
13
+ * **android:** broadcast call-ended when no JS observer can receive it ([a98b6ee](https://github.com/mfairley/expo-callkit-telecom/commit/a98b6ee20cd4ddd06cb0d520e0604328a33312a3))
14
+ * **android:** embed session in call events and broadcast undelivered events ([24dd9e0](https://github.com/mfairley/expo-callkit-telecom/commit/24dd9e00f16b746d2aa71591ec98b4d607bf0726))
15
+ * **example:** demonstrate the call-ended broadcast receiver ([a032691](https://github.com/mfairley/expo-callkit-telecom/commit/a032691b5d804f7e6d3b9ba18448bd3637a4353c))
16
+
8
17
  ## [0.3.9](https://github.com/mfairley/expo-callkit-telecom/compare/v0.3.8...v0.3.9) (2026-06-04)
9
18
 
10
19
 
@@ -1,8 +1,11 @@
1
1
  package expo.modules.callkittelecom.events
2
2
 
3
+ import android.content.Context
4
+ import android.content.Intent
3
5
  import expo.modules.callkittelecom.utils.CallKitTelecomLog
4
6
  import java.time.Instant
5
7
  import java.time.format.DateTimeFormatter
8
+ import org.json.JSONObject
6
9
 
7
10
  private data class QueuedEvent(val body: Map<String, Any?>, val timestamp: Instant)
8
11
 
@@ -13,12 +16,21 @@ private data class QueuedEvent(val body: Map<String, Any?>, val timestamp: Insta
13
16
  * - Tracks which individual events are being observed by JS
14
17
  * - Queues events that arrive before observers mount
15
18
  * - Flushes queued events with `{ meta: { flushed: true } }`
19
+ * - Broadcasts events that can't reach JS at all (dropped, not queued) when a broadcast context is
20
+ * set, so a JS-less process can still react (see [setBroadcastContext])
16
21
  *
17
22
  * All mutable state is guarded by [lock] for thread safety.
18
23
  */
19
24
  object CallEventEmitter {
20
25
  private const val TAG = "ExpoCallKitTelecom.Emitter"
21
26
 
27
+ /**
28
+ * Package-internal broadcast action for call events that couldn't reach a live JS observer.
29
+ * Apps receive it with a manifest receiver, registered via the `androidEventReceiver`
30
+ * config-plugin prop.
31
+ */
32
+ const val ACTION_CALL_EVENT = "expo.modules.callkittelecom.ACTION_CALL_EVENT"
33
+
22
34
  private val lock = Any()
23
35
  private val observingEvents = mutableSetOf<String>()
24
36
  private val eventQueues = mutableMapOf<String, MutableList<QueuedEvent>>()
@@ -28,11 +40,30 @@ object CallEventEmitter {
28
40
 
29
41
  @Volatile private var sender: ((String, Map<String, Any?>) -> Unit)? = null
30
42
 
43
+ @Volatile private var broadcastContext: Context? = null
44
+
31
45
  /** Sets or clears the active event sender provided by the Expo module. */
32
46
  fun setSender(eventSender: ((String, Map<String, Any?>) -> Unit)?) {
33
47
  sender = eventSender
34
48
  }
35
49
 
50
+ /**
51
+ * Enables (or disables, with `null`) the broadcast path by providing an application context.
52
+ *
53
+ * When set, any event that [send] *drops* — i.e. one that can't reach a live JS observer and
54
+ * isn't queued for cold-start replay (queue limit 0) — is emitted as a package-internal
55
+ * [ACTION_CALL_EVENT] broadcast, so a process started by a push or Telecom with no React
56
+ * context can still react (e.g. notify a backend of a killed-app decline). Queued events are
57
+ * not broadcast: they flush to JS once observed, so the queue already covers them.
58
+ *
59
+ * Broadcasting sits next to the queue — the emitter's existing handling for events JS can't
60
+ * receive yet — rather than behind an injected hook, since the actual consumer (the app's
61
+ * manifest receiver) is already decoupled by the OS and there is nothing to inject.
62
+ */
63
+ fun setBroadcastContext(context: Context?) {
64
+ broadcastContext = context?.applicationContext
65
+ }
66
+
36
67
  /**
37
68
  * Configures queue size for a specific event.
38
69
  *
@@ -47,8 +78,15 @@ object CallEventEmitter {
47
78
  *
48
79
  * All delivered events are augmented with a `meta` object containing timestamp and flush
49
80
  * status.
81
+ *
82
+ * @return true when the event was delivered to a live JS observer, false when it was queued (or
83
+ * dropped by the event's queue limit). When the event was *dropped* (it will never reach JS)
84
+ * and a broadcast context is set (see [setBroadcastContext]), it is also broadcast so native
85
+ * code can deliver out-of-band — e.g. the app process was started by a push or Telecom with
86
+ * no React context. Events that were queued are not broadcast: they flush to JS once
87
+ * observed, so broadcasting them too would double-deliver.
50
88
  */
51
- fun send(eventName: String, body: Map<String, Any?>) {
89
+ fun send(eventName: String, body: Map<String, Any?>): Boolean {
52
90
  val timestamp = Instant.now()
53
91
  val senderRef = sender
54
92
  val isObserving = synchronized(lock) { observingEvents.contains(eventName) }
@@ -56,9 +94,43 @@ object CallEventEmitter {
56
94
  if (senderRef != null && isObserving) {
57
95
  CallKitTelecomLog.d(TAG) { "Sending event to JS - name: $eventName" }
58
96
  senderRef(eventName, buildEventBody(body, flushed = false, timestamp = timestamp))
59
- return
97
+ return true
98
+ }
99
+ val queued = queueEvent(eventName, body, timestamp)
100
+ if (!queued) {
101
+ broadcastContext?.let { context ->
102
+ broadcastUndelivered(
103
+ context,
104
+ eventName,
105
+ buildEventBody(body, flushed = false, timestamp = timestamp),
106
+ )
107
+ }
108
+ }
109
+ return false
110
+ }
111
+
112
+ /**
113
+ * Broadcasts an undelivered event so a JS-less process can still react.
114
+ *
115
+ * Fires a package-internal ([Intent.setPackage]) [ACTION_CALL_EVENT] broadcast carrying the
116
+ * event name and the JS-shaped body (the same one JS would have received) as a JSON `payload`.
117
+ * Receivers discriminate on the `eventName` extra. Failures are swallowed (warn-logged): a
118
+ * dropped broadcast must never break event emission.
119
+ */
120
+ private fun broadcastUndelivered(context: Context, eventName: String, body: Map<String, Any?>) {
121
+ try {
122
+ val intent =
123
+ Intent(ACTION_CALL_EVENT)
124
+ .setPackage(context.packageName)
125
+ .putExtra("eventName", eventName)
126
+ .putExtra("payload", JSONObject(body).toString())
127
+ context.sendBroadcast(intent)
128
+ CallKitTelecomLog.d(TAG) {
129
+ "Broadcast undelivered event (no live JS observer) - name: $eventName"
130
+ }
131
+ } catch (e: Exception) {
132
+ CallKitTelecomLog.w(TAG) { "Event broadcast failed - name: $eventName: ${e.message}" }
60
133
  }
61
- queueEvent(eventName, body, timestamp)
62
134
  }
63
135
 
64
136
  /** Marks an event as observed and flushes any pending queue for that event. */
@@ -95,13 +167,18 @@ object CallEventEmitter {
95
167
  return result
96
168
  }
97
169
 
98
- /** Queues an event and enforces per-event queue limits (drop oldest first). */
99
- private fun queueEvent(name: String, body: Map<String, Any?>, timestamp: Instant) {
170
+ /**
171
+ * Queues an event and enforces per-event queue limits (drop oldest first).
172
+ *
173
+ * @return true when the event was queued (and will flush to JS once observed), false when
174
+ * queueing is disabled for it (limit 0) so it was dropped and will never reach JS.
175
+ */
176
+ private fun queueEvent(name: String, body: Map<String, Any?>, timestamp: Instant): Boolean =
100
177
  synchronized(lock) {
101
178
  val limit = queueLimits[name] ?: defaultQueueLimit
102
179
  if (limit == 0) {
103
180
  CallKitTelecomLog.d(TAG) { "Dropping event (queueing disabled) - name: $name" }
104
- return
181
+ return@synchronized false
105
182
  }
106
183
 
107
184
  val queue = eventQueues.getOrPut(name) { mutableListOf() }
@@ -118,8 +195,8 @@ object CallEventEmitter {
118
195
  "Queueing event (JS not listening) - name: $name, queueSize: ${queue.size}"
119
196
  }
120
197
  }
198
+ true
121
199
  }
122
- }
123
200
 
124
201
  /** Flushes all queued events for a single event name. */
125
202
  private fun flushQueue(eventName: String) {
@@ -114,6 +114,10 @@ class CallManager private constructor() {
114
114
  CallEventEmitter.setQueueLimit(CALL_ANSWERED, 1)
115
115
  CallEventEmitter.setQueueLimit(VOIP_PUSH_TOKEN_UPDATED, 1)
116
116
 
117
+ // Broadcast any event that can't reach a live JS observer (e.g. killed-app decline),
118
+ // so a JS-less process can still react via a manifest BroadcastReceiver.
119
+ CallEventEmitter.setBroadcastContext(context)
120
+
117
121
  callsManager = CallsManager(context)
118
122
  callsManager.registerAppWithTelecom(
119
123
  CallsManager.CAPABILITY_BASELINE or CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING
@@ -496,14 +500,18 @@ class CallManager private constructor() {
496
500
  CallStore.updateStatus(id, CallSessionStatus.ENDED)
497
501
  }
498
502
 
503
+ // Read the just-updated session back from the store (the source of truth) so the
504
+ // embedded session reflects the terminal state. Read before remove().
505
+ val endedSession = CallStore.session(id) ?: existingSession
506
+
499
507
  if (emitEnded) {
500
- CallEventEmitter.send(CallEvents.CALL_ENDED, mapOf("id" to id.toString()))
508
+ CallEventEmitter.send(CallEvents.CALL_ENDED, sessionEventBody(endedSession))
501
509
  }
502
510
 
503
511
  if (reportedReason != null) {
504
512
  CallEventEmitter.send(
505
513
  CallEvents.CALL_REPORTED_ENDED,
506
- mapOf("id" to id.toString(), "reason" to reportedReason.value),
514
+ sessionEventBody(endedSession, "reason" to reportedReason.value),
507
515
  )
508
516
  }
509
517
 
@@ -519,6 +527,22 @@ class CallManager private constructor() {
519
527
  }
520
528
  }
521
529
 
530
+ /**
531
+ * Builds an event body carrying the call id plus a full session snapshot.
532
+ *
533
+ * Terminal events embed the session so JS consumers — and the [CallEventEmitter] broadcast for
534
+ * a JS-less process — get full context (e.g. `serverCallId`/`metadata` via `incomingCallEvent`)
535
+ * without a separate lookup.
536
+ */
537
+ private fun sessionEventBody(
538
+ session: CallSession,
539
+ vararg extra: Pair<String, Any?>,
540
+ ): Map<String, Any?> = buildMap {
541
+ put("id", session.id.toString())
542
+ put("session", session.toMap())
543
+ putAll(extra)
544
+ }
545
+
522
546
  /**
523
547
  * Safety-net cleanup called from the addCall finally block.
524
548
  *
@@ -540,7 +564,9 @@ class CallManager private constructor() {
540
564
  if (session.status != CallSessionStatus.ENDED) {
541
565
  CallStore.updateStatus(id, CallSessionStatus.ENDED)
542
566
  }
543
- CallEventEmitter.send(CallEvents.CALL_ENDED, mapOf("id" to id.toString()))
567
+ // Read the just-updated session back from the store (the source of truth). Before remove().
568
+ val endedSession = CallStore.session(id) ?: session
569
+ CallEventEmitter.send(CallEvents.CALL_ENDED, sessionEventBody(endedSession))
544
570
  CallStore.remove(id)
545
571
 
546
572
  if (CallStore.allSessions().isEmpty()) {
@@ -244,6 +244,21 @@ export interface CaptureSession {
244
244
  export interface CallActionEvent extends NativeEvent {
245
245
  id: string;
246
246
  }
247
+ /**
248
+ * Mixin for events that carry a full {@link CallSession} snapshot alongside the
249
+ * event-specific fields.
250
+ *
251
+ * Terminal events embed the session so consumers without access to the session
252
+ * store still get full context. On Android this is also what makes the
253
+ * package-internal call-event broadcast self-contained when no JS observer is
254
+ * alive (see "Call ended while the app is killed" in the docs) — the broadcast
255
+ * payload is exactly this event body.
256
+ *
257
+ * @category Call Events
258
+ */
259
+ export interface WithSession {
260
+ session: CallSession;
261
+ }
247
262
  /**
248
263
  * Fired after `startOutgoingCall`, once the OS has accepted the call request.
249
264
  *
@@ -301,7 +316,7 @@ export interface CallAnsweredEvent extends CallActionEvent {
301
316
  *
302
317
  * @category Call Events
303
318
  */
304
- export interface CallEndedEvent extends CallActionEvent {
319
+ export interface CallEndedEvent extends CallActionEvent, WithSession {
305
320
  }
306
321
  /**
307
322
  * Reason a call was ended, reported on {@link CallReportedEnded}.
@@ -315,7 +330,7 @@ export type CallEndedReason = "failed" | "remoteEnded" | "unanswered" | "answere
315
330
  *
316
331
  * @category Call Events
317
332
  */
318
- export interface CallReportedEnded extends CallActionEvent {
333
+ export interface CallReportedEnded extends CallActionEvent, WithSession {
319
334
  reason: CallEndedReason;
320
335
  }
321
336
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"Calls.types.d.ts","sourceRoot":"","sources":["../src/Calls.types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"Calls.types.d.ts","sourceRoot":"","sources":["../src/Calls.types.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"Calls.types.js","sourceRoot":"","sources":["../src/Calls.types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * Type of VoIP push token reported by `getVoIPPushToken`.\n *\n * - `\"APNS_VOIP\"` — Apple Push Notification service VoIP channel (iOS)\n * - `\"FCM\"` — Firebase Cloud Messaging (Android)\n *\n * @category VoIP Push\n */\nexport type PushTokenType = \"APNS_VOIP\" | \"FCM\";\n\n// ============================================================================\n// Native event infrastructure\n// ============================================================================\n\n/**\n * Metadata attached to every native event.\n *\n * @category Core\n */\nexport interface NativeEventMeta {\n /** Whether the event was flushed from queue (true) or sent in real-time (false) */\n flushed: boolean;\n /** ISO8601 timestamp of when the event was created */\n timestamp: string;\n}\n\n/**\n * Base shape extended by every native event — carries a {@link NativeEventMeta} envelope.\n *\n * @category Core\n */\nexport interface NativeEvent {\n meta: NativeEventMeta;\n}\n\n// ============================================================================\n// Call sessions\n// ============================================================================\n\n/**\n * Active call as tracked by the module.\n *\n * Represents one in-flight call. Mirrors the OS-side `CXCall` (iOS) /\n * `CallControlScope` (Android) plus app-level state (origin, participants,\n * incoming-call payload, mute/hold).\n *\n * @category Sessions\n */\nexport interface CallSession {\n id: string;\n options: CallOptions;\n origin: CallSessionOrigin;\n remoteParticipants: CallParticipant[];\n incomingCallEvent?: IncomingCallEvent;\n status: CallSessionStatus;\n connectedAt?: string;\n isMuted: boolean;\n isOnHold: boolean;\n dtmfDigits?: string;\n}\n\n/**\n * Per-call options set at session start.\n *\n * @category Sessions\n */\nexport interface CallOptions {\n hasVideo: boolean;\n}\n\n/**\n * Where a call session originated.\n *\n * - `incoming` — Reported from a VoIP push (or directly via `reportIncomingCall`).\n * - `outgoingApp` — Started by your app via `startOutgoingCall`.\n * - `outgoingSystem` — Started by the OS via a call intent (Recents, Siri).\n *\n * @category Sessions\n */\nexport type CallSessionOrigin = \"incoming\" | \"outgoingApp\" | \"outgoingSystem\";\n\n/**\n * Identity for a remote party on a call.\n *\n * @category Sessions\n */\nexport interface CallParticipant {\n /** Opaque, stable app identifier for this participant. */\n id: string;\n /** Display name. */\n displayName?: string;\n /** Avatar URL. */\n avatarUrl?: string;\n /** Phone number in E.164 (e.g. \"+14155551234\"). When present on iOS, the\n * CallKit handle is set to this number, enabling Recents and Siri. */\n phoneNumber?: string;\n /** Email address. */\n email?: string;\n}\n\n/**\n * Call session status representing the lifecycle of a call.\n *\n * Outgoing call flow: requesting → connecting → connected → ended\n * Incoming call flow: ringing → connecting → connected → ended\n *\n * - `requesting` — Outgoing only. The call request has been submitted to\n * CallKit/Telecom and is awaiting system acceptance.\n * - `ringing` — Incoming only. The call has been reported to CallKit/Telecom\n * and the user sees the native incoming call UI or notification.\n * - `connecting` — Both directions. For outgoing calls, the system accepted the\n * call and the dialtone is playing while waiting for the remote party to answer.\n * For incoming calls, the user answered and media is being established.\n * - `connected` — Both directions. Media is flowing and the call is active.\n * - `ended` — Both directions. Transient state during teardown before the\n * session is removed from the store.\n *\n * @category Sessions\n */\nexport type CallSessionStatus =\n | \"requesting\"\n | \"connecting\"\n | \"ringing\"\n | \"connected\"\n | \"ended\";\n\n/**\n * Fired when a new {@link CallSession} is created (outgoing request or incoming report).\n *\n * @category Sessions\n */\nexport interface CallSessionAddedEvent extends NativeEvent {\n session: CallSession;\n}\n\n/**\n * Fired when an existing {@link CallSession}'s state changes (status, mute, hold, etc.).\n *\n * @category Sessions\n */\nexport interface CallSessionUpdatedEvent extends NativeEvent {\n session: CallSession;\n}\n\n/**\n * Fired when a {@link CallSession} is removed after the call has ended and been cleaned up.\n *\n * @category Sessions\n */\nexport interface CallSessionRemovedEvent extends NativeEvent {\n id: string;\n}\n\n// ============================================================================\n// Permissions\n// ============================================================================\n\n/**\n * Permission status for microphone and camera, reported on {@link AudioSession}\n * and {@link CaptureSession}.\n *\n * @category Permissions\n */\nexport type PermissionStatus =\n | \"granted\"\n | \"denied\"\n | \"undetermined\"\n | \"restricted\"\n | \"unknown\";\n\n// ============================================================================\n// Audio session\n// ============================================================================\n\n/**\n * Snapshot of the current audio session, including activation state, route,\n * and (on iOS) the WebRTC `RTCAudioSession` coordination flags.\n *\n * @category Audio\n */\nexport interface AudioSession {\n isActive: boolean;\n /** iOS only: whether the WebRTC RTCAudioSession is active. */\n rtcSessionIsActive?: boolean;\n /** iOS only: whether the AVAudioSession is active. */\n avSessionIsActive?: boolean;\n /** iOS only: whether the RTCAudioSession audio track is enabled. */\n isAudioEnabled?: boolean;\n /** iOS only: whether manual audio mode is enabled on RTCAudioSession. */\n useManualAudio?: boolean;\n isOtherAudioPlaying: boolean;\n category: string;\n mode: string;\n /** iOS only: AVAudioSession category options. */\n categoryOptions?: string[];\n sampleRate: number;\n ioBufferDuration: number;\n inputNumberOfChannels: number;\n outputNumberOfChannels: number;\n microphonePermission: PermissionStatus;\n currentRoute: AudioRoute;\n /** Available audio output devices. Populated on Android; undefined on iOS. */\n availableRoutes?: AudioPort[];\n}\n\n/**\n * Currently-selected audio inputs and outputs.\n *\n * @category Audio\n */\nexport interface AudioRoute {\n inputs: AudioPort[];\n outputs: AudioPort[];\n}\n\n/**\n * A single audio input or output (earpiece, speaker, headphones, Bluetooth device, etc.).\n *\n * @category Audio\n */\nexport interface AudioPort {\n portType: AudioOutputPortType;\n portName: string;\n uid: string;\n}\n\n/**\n * Cross-platform audio output port type identifiers.\n * Both iOS and Android map their native audio device types to these shared values.\n *\n * @category Audio\n */\nexport type AudioOutputPortType =\n | \"builtInReceiver\" // Earpiece\n | \"builtInSpeaker\" // Speaker\n | \"headphones\" // Wired headphones\n | \"bluetoothA2DP\" // Bluetooth A2DP audio\n | \"bluetoothLE\" // Bluetooth Low Energy audio\n | \"bluetoothHFP\" // Bluetooth Hands-Free Profile\n | \"airPlay\" // AirPlay\n | \"hdmi\" // HDMI output\n | \"carAudio\" // CarPlay\n | \"usbAudio\" // USB audio\n | \"lineOut\" // Line out\n | (string & {}); // Allow other unknown port types\n\n/**\n * Brief summary of one call associated with an audio-session activation event.\n *\n * @category Audio Events\n */\nexport interface AudioSessionCallInfo {\n id: string;\n status: CallSessionStatus;\n}\n\n/**\n * Fired when the system activates the audio session for a call.\n *\n * @category Audio Events\n */\nexport interface AudioSessionActivatedEvent extends NativeEvent {\n calls: AudioSessionCallInfo[];\n}\n\n/**\n * Fired when the system deactivates the audio session after a call.\n *\n * @category Audio Events\n */\nexport interface AudioSessionDeactivatedEvent extends NativeEvent {\n calls: AudioSessionCallInfo[];\n}\n\n/**\n * Fired when the active audio route changes (e.g. AirPods connected, speaker toggled).\n *\n * @category Audio Events\n */\nexport interface AudioRouteChangedEvent extends NativeEvent {\n currentRoute: AudioRoute;\n /** Available audio output devices. Populated on Android; undefined on iOS. */\n availableRoutes?: AudioPort[];\n}\n\n// ============================================================================\n// Capture session (camera)\n// ============================================================================\n\n/**\n * Snapshot of camera-related state, including permission and (on iOS 16+)\n * multitasking-camera availability.\n *\n * @category Capture\n */\nexport interface CaptureSession {\n cameraPermission: PermissionStatus;\n /** Whether the device supports multitasking camera access (iOS 16+). */\n isMultitaskingCameraAccessSupported?: boolean;\n}\n\n// ============================================================================\n// Call action events\n// ============================================================================\n\n/**\n * Base shape for any event carrying a {@link CallSession.id}.\n *\n * @category Call Events\n */\nexport interface CallActionEvent extends NativeEvent {\n id: string;\n}\n\n/**\n * Fired after `startOutgoingCall`, once the OS has accepted the call request.\n *\n * @category Call Events\n */\nexport interface OutgoingCallStartedEvent extends CallActionEvent {}\n\n/**\n * Payload describing one incoming call.\n *\n * Delivered both inside a VoIP push (parsed natively by the module) and on\n * {@link CallSession.incomingCallEvent} for any incoming-origin session.\n *\n * @category VoIP Push\n */\nexport interface IncomingCallEvent {\n /** Unique event identifier (UUID). Used for dedup. */\n eventId: string;\n /** Your backend's id for this call. Distinct from {@link CallSession.id},\n * which is the OS-assigned native call UUID. Use this id to talk to your\n * server about the call (e.g. POST /calls/:serverCallId/answer). */\n serverCallId: string;\n /** True for video calls, false for audio. */\n hasVideo: boolean;\n /** RFC 3339 timestamp of when the call was placed. Optional; defaults to now. */\n startedAt?: string;\n /** Caller identity and addressing. */\n caller: CallParticipant;\n /**\n * App-defined extra fields, forwarded verbatim from the push payload.\n *\n * The library treats this as opaque — put whatever your app needs here\n * (chatId, tenantId, room name, etc.). Cast to your own type at the\n * read site.\n */\n metadata?: Record<string, unknown>;\n}\n\n/**\n * Fired after `reportIncomingCall`, once the OS has accepted the incoming-call report.\n *\n * @category Call Events\n */\nexport interface IncomingCallReportedEvent extends CallActionEvent {}\n\n/**\n * Fired when the user answers an incoming call from the system UI.\n *\n * @category Call Events\n */\nexport interface CallAnsweredEvent extends CallActionEvent {\n requestId: string;\n}\n\n/**\n * Fired when the user ends a call from the system UI, or the OS ends the call for any reason.\n *\n * @category Call Events\n */\nexport interface CallEndedEvent extends CallActionEvent {}\n\n/**\n * Reason a call was ended, reported on {@link CallReportedEnded}.\n *\n * @category Call Events\n */\nexport type CallEndedReason =\n | \"failed\"\n | \"remoteEnded\"\n | \"unanswered\"\n | \"answeredElsewhere\"\n | \"declinedElsewhere\"\n | \"unknown\";\n\n/**\n * Fired when the app calls `reportCallEnded` to inform the OS the call has ended\n * externally (e.g. remote hang-up).\n *\n * @category Call Events\n */\nexport interface CallReportedEnded extends CallActionEvent {\n reason: CallEndedReason;\n}\n\n/**\n * Fired when the system requests a mute-state change (e.g. user pressed the\n * mute button in the CallKit UI). Apply the change to your media connection.\n *\n * @category Call Events\n */\nexport interface SetMutedActionEvent extends CallActionEvent {\n isMuted: boolean;\n}\n\n/**\n * Fired when video state changes on a call.\n *\n * @category Call Events\n */\nexport interface VideoChangedEvent extends CallActionEvent {\n hasVideo: boolean;\n}\n\n/**\n * Fired when the system requests a hold-state change. Apply the change to your media connection.\n *\n * @category Call Events\n */\nexport interface SetHeldActionEvent extends CallActionEvent {\n isOnHold: boolean;\n}\n\n/**\n * Fired when the system requests DTMF tones be played on the call.\n *\n * @category Call Events\n */\nexport interface DTMFEvent extends CallActionEvent {\n digits: string;\n}\n\n// ============================================================================\n// Call intents (iOS Recents, Siri \"call X\")\n// ============================================================================\n\n/**\n * Kind of handle attached to a call intent (Recents tap, Siri).\n *\n * @category Call Events\n */\nexport type CallIntentHandleType = \"phoneNumber\" | \"email\" | \"unknown\";\n\n/**\n * Fired when the OS routes a \"start call\" intent to the app — e.g. the user\n * tapped a Recents entry or said \"call Jane\" to Siri.\n *\n * @category Call Events\n */\nexport interface CallIntentReceivedEvent extends NativeEvent {\n handle: string;\n handleType: CallIntentHandleType;\n hasVideo: boolean;\n}\n\n// ============================================================================\n// VoIP push\n// ============================================================================\n\n/**\n * A VoIP push token bundled with its transport type.\n *\n * @category VoIP Push\n */\nexport interface VoIPPushToken {\n /** The VoIP push token string. */\n token: string;\n /** The type of token this platform provides. */\n type: PushTokenType;\n}\n\n/**\n * Fired when the VoIP push token is received, refreshed, or invalidated.\n *\n * @category VoIP Push\n */\nexport interface VoIPPushTokenUpdatedEvent extends NativeEvent {\n /** The VoIP push token string, or undefined if invalidated */\n token?: string;\n /** The type of VoIP push token (e.g. \"APNS_VOIP\", \"FCM\"). */\n type: PushTokenType;\n}\n"]}
1
+ {"version":3,"file":"Calls.types.js","sourceRoot":"","sources":["../src/Calls.types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * Type of VoIP push token reported by `getVoIPPushToken`.\n *\n * - `\"APNS_VOIP\"` — Apple Push Notification service VoIP channel (iOS)\n * - `\"FCM\"` — Firebase Cloud Messaging (Android)\n *\n * @category VoIP Push\n */\nexport type PushTokenType = \"APNS_VOIP\" | \"FCM\";\n\n// ============================================================================\n// Native event infrastructure\n// ============================================================================\n\n/**\n * Metadata attached to every native event.\n *\n * @category Core\n */\nexport interface NativeEventMeta {\n /** Whether the event was flushed from queue (true) or sent in real-time (false) */\n flushed: boolean;\n /** ISO8601 timestamp of when the event was created */\n timestamp: string;\n}\n\n/**\n * Base shape extended by every native event — carries a {@link NativeEventMeta} envelope.\n *\n * @category Core\n */\nexport interface NativeEvent {\n meta: NativeEventMeta;\n}\n\n// ============================================================================\n// Call sessions\n// ============================================================================\n\n/**\n * Active call as tracked by the module.\n *\n * Represents one in-flight call. Mirrors the OS-side `CXCall` (iOS) /\n * `CallControlScope` (Android) plus app-level state (origin, participants,\n * incoming-call payload, mute/hold).\n *\n * @category Sessions\n */\nexport interface CallSession {\n id: string;\n options: CallOptions;\n origin: CallSessionOrigin;\n remoteParticipants: CallParticipant[];\n incomingCallEvent?: IncomingCallEvent;\n status: CallSessionStatus;\n connectedAt?: string;\n isMuted: boolean;\n isOnHold: boolean;\n dtmfDigits?: string;\n}\n\n/**\n * Per-call options set at session start.\n *\n * @category Sessions\n */\nexport interface CallOptions {\n hasVideo: boolean;\n}\n\n/**\n * Where a call session originated.\n *\n * - `incoming` — Reported from a VoIP push (or directly via `reportIncomingCall`).\n * - `outgoingApp` — Started by your app via `startOutgoingCall`.\n * - `outgoingSystem` — Started by the OS via a call intent (Recents, Siri).\n *\n * @category Sessions\n */\nexport type CallSessionOrigin = \"incoming\" | \"outgoingApp\" | \"outgoingSystem\";\n\n/**\n * Identity for a remote party on a call.\n *\n * @category Sessions\n */\nexport interface CallParticipant {\n /** Opaque, stable app identifier for this participant. */\n id: string;\n /** Display name. */\n displayName?: string;\n /** Avatar URL. */\n avatarUrl?: string;\n /** Phone number in E.164 (e.g. \"+14155551234\"). When present on iOS, the\n * CallKit handle is set to this number, enabling Recents and Siri. */\n phoneNumber?: string;\n /** Email address. */\n email?: string;\n}\n\n/**\n * Call session status representing the lifecycle of a call.\n *\n * Outgoing call flow: requesting → connecting → connected → ended\n * Incoming call flow: ringing → connecting → connected → ended\n *\n * - `requesting` — Outgoing only. The call request has been submitted to\n * CallKit/Telecom and is awaiting system acceptance.\n * - `ringing` — Incoming only. The call has been reported to CallKit/Telecom\n * and the user sees the native incoming call UI or notification.\n * - `connecting` — Both directions. For outgoing calls, the system accepted the\n * call and the dialtone is playing while waiting for the remote party to answer.\n * For incoming calls, the user answered and media is being established.\n * - `connected` — Both directions. Media is flowing and the call is active.\n * - `ended` — Both directions. Transient state during teardown before the\n * session is removed from the store.\n *\n * @category Sessions\n */\nexport type CallSessionStatus =\n | \"requesting\"\n | \"connecting\"\n | \"ringing\"\n | \"connected\"\n | \"ended\";\n\n/**\n * Fired when a new {@link CallSession} is created (outgoing request or incoming report).\n *\n * @category Sessions\n */\nexport interface CallSessionAddedEvent extends NativeEvent {\n session: CallSession;\n}\n\n/**\n * Fired when an existing {@link CallSession}'s state changes (status, mute, hold, etc.).\n *\n * @category Sessions\n */\nexport interface CallSessionUpdatedEvent extends NativeEvent {\n session: CallSession;\n}\n\n/**\n * Fired when a {@link CallSession} is removed after the call has ended and been cleaned up.\n *\n * @category Sessions\n */\nexport interface CallSessionRemovedEvent extends NativeEvent {\n id: string;\n}\n\n// ============================================================================\n// Permissions\n// ============================================================================\n\n/**\n * Permission status for microphone and camera, reported on {@link AudioSession}\n * and {@link CaptureSession}.\n *\n * @category Permissions\n */\nexport type PermissionStatus =\n | \"granted\"\n | \"denied\"\n | \"undetermined\"\n | \"restricted\"\n | \"unknown\";\n\n// ============================================================================\n// Audio session\n// ============================================================================\n\n/**\n * Snapshot of the current audio session, including activation state, route,\n * and (on iOS) the WebRTC `RTCAudioSession` coordination flags.\n *\n * @category Audio\n */\nexport interface AudioSession {\n isActive: boolean;\n /** iOS only: whether the WebRTC RTCAudioSession is active. */\n rtcSessionIsActive?: boolean;\n /** iOS only: whether the AVAudioSession is active. */\n avSessionIsActive?: boolean;\n /** iOS only: whether the RTCAudioSession audio track is enabled. */\n isAudioEnabled?: boolean;\n /** iOS only: whether manual audio mode is enabled on RTCAudioSession. */\n useManualAudio?: boolean;\n isOtherAudioPlaying: boolean;\n category: string;\n mode: string;\n /** iOS only: AVAudioSession category options. */\n categoryOptions?: string[];\n sampleRate: number;\n ioBufferDuration: number;\n inputNumberOfChannels: number;\n outputNumberOfChannels: number;\n microphonePermission: PermissionStatus;\n currentRoute: AudioRoute;\n /** Available audio output devices. Populated on Android; undefined on iOS. */\n availableRoutes?: AudioPort[];\n}\n\n/**\n * Currently-selected audio inputs and outputs.\n *\n * @category Audio\n */\nexport interface AudioRoute {\n inputs: AudioPort[];\n outputs: AudioPort[];\n}\n\n/**\n * A single audio input or output (earpiece, speaker, headphones, Bluetooth device, etc.).\n *\n * @category Audio\n */\nexport interface AudioPort {\n portType: AudioOutputPortType;\n portName: string;\n uid: string;\n}\n\n/**\n * Cross-platform audio output port type identifiers.\n * Both iOS and Android map their native audio device types to these shared values.\n *\n * @category Audio\n */\nexport type AudioOutputPortType =\n | \"builtInReceiver\" // Earpiece\n | \"builtInSpeaker\" // Speaker\n | \"headphones\" // Wired headphones\n | \"bluetoothA2DP\" // Bluetooth A2DP audio\n | \"bluetoothLE\" // Bluetooth Low Energy audio\n | \"bluetoothHFP\" // Bluetooth Hands-Free Profile\n | \"airPlay\" // AirPlay\n | \"hdmi\" // HDMI output\n | \"carAudio\" // CarPlay\n | \"usbAudio\" // USB audio\n | \"lineOut\" // Line out\n | (string & {}); // Allow other unknown port types\n\n/**\n * Brief summary of one call associated with an audio-session activation event.\n *\n * @category Audio Events\n */\nexport interface AudioSessionCallInfo {\n id: string;\n status: CallSessionStatus;\n}\n\n/**\n * Fired when the system activates the audio session for a call.\n *\n * @category Audio Events\n */\nexport interface AudioSessionActivatedEvent extends NativeEvent {\n calls: AudioSessionCallInfo[];\n}\n\n/**\n * Fired when the system deactivates the audio session after a call.\n *\n * @category Audio Events\n */\nexport interface AudioSessionDeactivatedEvent extends NativeEvent {\n calls: AudioSessionCallInfo[];\n}\n\n/**\n * Fired when the active audio route changes (e.g. AirPods connected, speaker toggled).\n *\n * @category Audio Events\n */\nexport interface AudioRouteChangedEvent extends NativeEvent {\n currentRoute: AudioRoute;\n /** Available audio output devices. Populated on Android; undefined on iOS. */\n availableRoutes?: AudioPort[];\n}\n\n// ============================================================================\n// Capture session (camera)\n// ============================================================================\n\n/**\n * Snapshot of camera-related state, including permission and (on iOS 16+)\n * multitasking-camera availability.\n *\n * @category Capture\n */\nexport interface CaptureSession {\n cameraPermission: PermissionStatus;\n /** Whether the device supports multitasking camera access (iOS 16+). */\n isMultitaskingCameraAccessSupported?: boolean;\n}\n\n// ============================================================================\n// Call action events\n// ============================================================================\n\n/**\n * Base shape for any event carrying a {@link CallSession.id}.\n *\n * @category Call Events\n */\nexport interface CallActionEvent extends NativeEvent {\n id: string;\n}\n\n/**\n * Mixin for events that carry a full {@link CallSession} snapshot alongside the\n * event-specific fields.\n *\n * Terminal events embed the session so consumers without access to the session\n * store still get full context. On Android this is also what makes the\n * package-internal call-event broadcast self-contained when no JS observer is\n * alive (see \"Call ended while the app is killed\" in the docs) — the broadcast\n * payload is exactly this event body.\n *\n * @category Call Events\n */\nexport interface WithSession {\n session: CallSession;\n}\n\n/**\n * Fired after `startOutgoingCall`, once the OS has accepted the call request.\n *\n * @category Call Events\n */\nexport interface OutgoingCallStartedEvent extends CallActionEvent {}\n\n/**\n * Payload describing one incoming call.\n *\n * Delivered both inside a VoIP push (parsed natively by the module) and on\n * {@link CallSession.incomingCallEvent} for any incoming-origin session.\n *\n * @category VoIP Push\n */\nexport interface IncomingCallEvent {\n /** Unique event identifier (UUID). Used for dedup. */\n eventId: string;\n /** Your backend's id for this call. Distinct from {@link CallSession.id},\n * which is the OS-assigned native call UUID. Use this id to talk to your\n * server about the call (e.g. POST /calls/:serverCallId/answer). */\n serverCallId: string;\n /** True for video calls, false for audio. */\n hasVideo: boolean;\n /** RFC 3339 timestamp of when the call was placed. Optional; defaults to now. */\n startedAt?: string;\n /** Caller identity and addressing. */\n caller: CallParticipant;\n /**\n * App-defined extra fields, forwarded verbatim from the push payload.\n *\n * The library treats this as opaque — put whatever your app needs here\n * (chatId, tenantId, room name, etc.). Cast to your own type at the\n * read site.\n */\n metadata?: Record<string, unknown>;\n}\n\n/**\n * Fired after `reportIncomingCall`, once the OS has accepted the incoming-call report.\n *\n * @category Call Events\n */\nexport interface IncomingCallReportedEvent extends CallActionEvent {}\n\n/**\n * Fired when the user answers an incoming call from the system UI.\n *\n * @category Call Events\n */\nexport interface CallAnsweredEvent extends CallActionEvent {\n requestId: string;\n}\n\n/**\n * Fired when the user ends a call from the system UI, or the OS ends the call for any reason.\n *\n * @category Call Events\n */\nexport interface CallEndedEvent extends CallActionEvent, WithSession {}\n\n/**\n * Reason a call was ended, reported on {@link CallReportedEnded}.\n *\n * @category Call Events\n */\nexport type CallEndedReason =\n | \"failed\"\n | \"remoteEnded\"\n | \"unanswered\"\n | \"answeredElsewhere\"\n | \"declinedElsewhere\"\n | \"unknown\";\n\n/**\n * Fired when the app calls `reportCallEnded` to inform the OS the call has ended\n * externally (e.g. remote hang-up).\n *\n * @category Call Events\n */\nexport interface CallReportedEnded extends CallActionEvent, WithSession {\n reason: CallEndedReason;\n}\n\n/**\n * Fired when the system requests a mute-state change (e.g. user pressed the\n * mute button in the CallKit UI). Apply the change to your media connection.\n *\n * @category Call Events\n */\nexport interface SetMutedActionEvent extends CallActionEvent {\n isMuted: boolean;\n}\n\n/**\n * Fired when video state changes on a call.\n *\n * @category Call Events\n */\nexport interface VideoChangedEvent extends CallActionEvent {\n hasVideo: boolean;\n}\n\n/**\n * Fired when the system requests a hold-state change. Apply the change to your media connection.\n *\n * @category Call Events\n */\nexport interface SetHeldActionEvent extends CallActionEvent {\n isOnHold: boolean;\n}\n\n/**\n * Fired when the system requests DTMF tones be played on the call.\n *\n * @category Call Events\n */\nexport interface DTMFEvent extends CallActionEvent {\n digits: string;\n}\n\n// ============================================================================\n// Call intents (iOS Recents, Siri \"call X\")\n// ============================================================================\n\n/**\n * Kind of handle attached to a call intent (Recents tap, Siri).\n *\n * @category Call Events\n */\nexport type CallIntentHandleType = \"phoneNumber\" | \"email\" | \"unknown\";\n\n/**\n * Fired when the OS routes a \"start call\" intent to the app — e.g. the user\n * tapped a Recents entry or said \"call Jane\" to Siri.\n *\n * @category Call Events\n */\nexport interface CallIntentReceivedEvent extends NativeEvent {\n handle: string;\n handleType: CallIntentHandleType;\n hasVideo: boolean;\n}\n\n// ============================================================================\n// VoIP push\n// ============================================================================\n\n/**\n * A VoIP push token bundled with its transport type.\n *\n * @category VoIP Push\n */\nexport interface VoIPPushToken {\n /** The VoIP push token string. */\n token: string;\n /** The type of token this platform provides. */\n type: PushTokenType;\n}\n\n/**\n * Fired when the VoIP push token is received, refreshed, or invalidated.\n *\n * @category VoIP Push\n */\nexport interface VoIPPushTokenUpdatedEvent extends NativeEvent {\n /** The VoIP push token string, or undefined if invalidated */\n token?: string;\n /** The type of VoIP push token (e.g. \"APNS_VOIP\", \"FCM\"). */\n type: PushTokenType;\n}\n"]}
@@ -104,8 +104,13 @@ extension CallManager: CXProviderDelegate {
104
104
  cancelCallTimeout(for: action.callUUID)
105
105
 
106
106
  Task {
107
- await MainActor.run {
108
- CallEventEmitter.shared.send(CallEndedEvent(id: action.callUUID))
107
+ // Snapshot the session as ended so the embedded session reflects the terminal state.
108
+ if var session = await store.session(for: action.callUUID) {
109
+ session.status = .ended
110
+ await MainActor.run {
111
+ CallEventEmitter.shared.send(
112
+ CallEndedEvent(id: action.callUUID, session: session))
113
+ }
109
114
  }
110
115
 
111
116
  await store.remove(for: action.callUUID)
@@ -566,8 +566,13 @@ class CallManager: NSObject {
566
566
 
567
567
  provider.reportCall(with: id, endedAt: Date(), reason: reason)
568
568
 
569
- await MainActor.run {
570
- CallEventEmitter.shared.send(CallReportedEnded(id: id, reason: reason))
569
+ // Snapshot the session as ended so the embedded session reflects the terminal state.
570
+ if var session = await store.session(for: id) {
571
+ session.status = .ended
572
+ await MainActor.run {
573
+ CallEventEmitter.shared.send(
574
+ CallReportedEnded(id: id, reason: reason, session: session))
575
+ }
571
576
  }
572
577
 
573
578
  await store.remove(for: id)
@@ -159,9 +159,13 @@ struct CallEndedEvent: CallEvent {
159
159
  static let name = "onCallEnded"
160
160
 
161
161
  let id: UUID
162
+ let session: CallSession
162
163
 
163
164
  var body: [String: Any] {
164
- [CallEventKeys.id: id.uuidString]
165
+ [
166
+ CallEventKeys.id: id.uuidString,
167
+ "session": session.toDictionary(),
168
+ ]
165
169
  }
166
170
  }
167
171
 
@@ -170,11 +174,13 @@ struct CallReportedEnded: CallEvent {
170
174
 
171
175
  let id: UUID
172
176
  let reason: CXCallEndedReason
177
+ let session: CallSession
173
178
 
174
179
  var body: [String: Any] {
175
180
  [
176
181
  CallEventKeys.id: id.uuidString,
177
182
  "reason": Self.reasonString(for: reason),
183
+ "session": session.toDictionary(),
178
184
  ]
179
185
  }
180
186
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-callkit-telecom",
3
- "version": "0.3.9",
3
+ "version": "0.4.0",
4
4
  "description": "CallKit + Jetpack Core-Telecom for Expo / React Native — native call UI, VoIP push, LiveKit-friendly audio. A modern react-native-callkeep alternative.",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -15,6 +15,7 @@
15
15
  },
16
16
  "scripts": {
17
17
  "build": "expo-module build && expo-module build plugin",
18
+ "build:plugin": "expo-module build plugin",
18
19
  "clean": "expo-module clean",
19
20
  "lint": "expo-module lint",
20
21
  "test": "expo-module test",
@@ -1,3 +1,8 @@
1
1
  export declare const DEFAULT_INCOMING_CALL_TIMEOUT = 45;
2
2
  export declare const DEFAULT_OUTGOING_CALL_TIMEOUT = 60;
3
3
  export declare const DEFAULT_FULFILL_ANSWER_CALL_TIMEOUT = 30;
4
+ /**
5
+ * Action for the package-internal call-event broadcast (Android). The
6
+ * `androidEventReceiver` plugin prop registers the manifest receiver for it.
7
+ */
8
+ export declare const ANDROID_CALL_EVENT_ACTION = "expo.modules.callkittelecom.ACTION_CALL_EVENT";
@@ -1,7 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_FULFILL_ANSWER_CALL_TIMEOUT = exports.DEFAULT_OUTGOING_CALL_TIMEOUT = exports.DEFAULT_INCOMING_CALL_TIMEOUT = void 0;
3
+ exports.ANDROID_CALL_EVENT_ACTION = exports.DEFAULT_FULFILL_ANSWER_CALL_TIMEOUT = exports.DEFAULT_OUTGOING_CALL_TIMEOUT = exports.DEFAULT_INCOMING_CALL_TIMEOUT = void 0;
4
4
  // Default timeout values in seconds
5
5
  exports.DEFAULT_INCOMING_CALL_TIMEOUT = 45;
6
6
  exports.DEFAULT_OUTGOING_CALL_TIMEOUT = 60;
7
7
  exports.DEFAULT_FULFILL_ANSWER_CALL_TIMEOUT = 30;
8
+ /**
9
+ * Action for the package-internal call-event broadcast (Android). The
10
+ * `androidEventReceiver` plugin prop registers the manifest receiver for it.
11
+ */
12
+ exports.ANDROID_CALL_EVENT_ACTION = "expo.modules.callkittelecom.ACTION_CALL_EVENT";
@@ -62,6 +62,19 @@ export type ExpoCallKitTelecomPluginProps = {
62
62
  * @platform android
63
63
  */
64
64
  defaultDialtone?: string;
65
+ /**
66
+ * Class name of a `BroadcastReceiver` to register for the module's call-event
67
+ * broadcast, fired when a call event can't reach a live JS observer (e.g. the
68
+ * user declines an incoming call while the app is killed). The plugin writes
69
+ * the manifest `<receiver>` + `<intent-filter>`. You supply the receiver class
70
+ * (e.g. via a local Expo module); the plugin does not generate it.
71
+ *
72
+ * Accepts a manifest-relative name (`.CallEventReceiver`) or a fully-qualified
73
+ * one (`com.acme.app.CallEventReceiver`). See "Call ended while the app is
74
+ * killed" in the docs.
75
+ * @platform android
76
+ */
77
+ androidEventReceiver?: string;
65
78
  };
66
79
  declare const _default: ConfigPlugin<void | ExpoCallKitTelecomPluginProps>;
67
80
  export default _default;
@@ -163,6 +163,35 @@ const withFirebaseMessagingService = (config) => {
163
163
  return config;
164
164
  });
165
165
  };
166
+ /**
167
+ * Registers a manifest BroadcastReceiver for the module's call-event broadcast.
168
+ *
169
+ * Fired when a call event can't reach a live JS observer (e.g. a killed-app
170
+ * decline). The receiver entry uses {@link ANDROID_CALL_EVENT_ACTION}. The app
171
+ * supplies the receiver class itself (the plugin does not generate it).
172
+ */
173
+ const withEventReceiver = (config, { androidEventReceiver }) => {
174
+ if (!androidEventReceiver) {
175
+ return config;
176
+ }
177
+ return (0, config_plugins_1.withAndroidManifest)(config, (config) => {
178
+ const app = config_plugins_1.AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults);
179
+ // Remove any existing entry first (idempotent across repeated prebuilds).
180
+ app.receiver = (app.receiver ?? []).filter((receiver) => receiver.$["android:name"] !== androidEventReceiver);
181
+ app.receiver.push({
182
+ $: {
183
+ "android:name": androidEventReceiver,
184
+ "android:exported": "false",
185
+ },
186
+ "intent-filter": [
187
+ {
188
+ action: [{ $: { "android:name": constants_1.ANDROID_CALL_EVENT_ACTION } }],
189
+ },
190
+ ],
191
+ });
192
+ return config;
193
+ });
194
+ };
166
195
  const withExpoCallKitTelecomAndroid = (config, props) => {
167
196
  config = withTimeouts(config, props);
168
197
  config = withSounds(config, props);
@@ -172,6 +201,7 @@ const withExpoCallKitTelecomAndroid = (config, props) => {
172
201
  });
173
202
  config = withDefaultDialtone(config, props);
174
203
  config = withFirebaseMessagingService(config, props);
204
+ config = withEventReceiver(config, props);
175
205
  return config;
176
206
  };
177
207
  exports.withExpoCallKitTelecomAndroid = withExpoCallKitTelecomAndroid;
@@ -2,3 +2,10 @@
2
2
  export const DEFAULT_INCOMING_CALL_TIMEOUT = 45;
3
3
  export const DEFAULT_OUTGOING_CALL_TIMEOUT = 60;
4
4
  export const DEFAULT_FULFILL_ANSWER_CALL_TIMEOUT = 30;
5
+
6
+ /**
7
+ * Action for the package-internal call-event broadcast (Android). The
8
+ * `androidEventReceiver` plugin prop registers the manifest receiver for it.
9
+ */
10
+ export const ANDROID_CALL_EVENT_ACTION =
11
+ "expo.modules.callkittelecom.ACTION_CALL_EVENT";
@@ -67,6 +67,19 @@ export type ExpoCallKitTelecomPluginProps = {
67
67
  * @platform android
68
68
  */
69
69
  defaultDialtone?: string;
70
+ /**
71
+ * Class name of a `BroadcastReceiver` to register for the module's call-event
72
+ * broadcast, fired when a call event can't reach a live JS observer (e.g. the
73
+ * user declines an incoming call while the app is killed). The plugin writes
74
+ * the manifest `<receiver>` + `<intent-filter>`. You supply the receiver class
75
+ * (e.g. via a local Expo module); the plugin does not generate it.
76
+ *
77
+ * Accepts a manifest-relative name (`.CallEventReceiver`) or a fully-qualified
78
+ * one (`com.acme.app.CallEventReceiver`). See "Call ended while the app is
79
+ * killed" in the docs.
80
+ * @platform android
81
+ */
82
+ androidEventReceiver?: string;
70
83
  };
71
84
 
72
85
  const withExpoCallKitTelecom: ConfigPlugin<ExpoCallKitTelecomPluginProps | void> = (
@@ -8,6 +8,7 @@ import { copyFileSync, existsSync, mkdirSync } from "fs";
8
8
  import { basename, resolve } from "path";
9
9
 
10
10
  import {
11
+ ANDROID_CALL_EVENT_ACTION,
11
12
  DEFAULT_FULFILL_ANSWER_CALL_TIMEOUT,
12
13
  DEFAULT_INCOMING_CALL_TIMEOUT,
13
14
  DEFAULT_OUTGOING_CALL_TIMEOUT,
@@ -277,6 +278,47 @@ const withFirebaseMessagingService: ConfigPlugin<ExpoCallKitTelecomPluginProps>
277
278
  });
278
279
  };
279
280
 
281
+ /**
282
+ * Registers a manifest BroadcastReceiver for the module's call-event broadcast.
283
+ *
284
+ * Fired when a call event can't reach a live JS observer (e.g. a killed-app
285
+ * decline). The receiver entry uses {@link ANDROID_CALL_EVENT_ACTION}. The app
286
+ * supplies the receiver class itself (the plugin does not generate it).
287
+ */
288
+ const withEventReceiver: ConfigPlugin<{ androidEventReceiver?: string }> = (
289
+ config,
290
+ { androidEventReceiver },
291
+ ) => {
292
+ if (!androidEventReceiver) {
293
+ return config;
294
+ }
295
+
296
+ return withAndroidManifest(config, (config) => {
297
+ const app = AndroidConfig.Manifest.getMainApplicationOrThrow(
298
+ config.modResults,
299
+ );
300
+
301
+ // Remove any existing entry first (idempotent across repeated prebuilds).
302
+ app.receiver = (app.receiver ?? []).filter(
303
+ (receiver) => receiver.$["android:name"] !== androidEventReceiver,
304
+ );
305
+
306
+ app.receiver.push({
307
+ $: {
308
+ "android:name": androidEventReceiver,
309
+ "android:exported": "false",
310
+ },
311
+ "intent-filter": [
312
+ {
313
+ action: [{ $: { "android:name": ANDROID_CALL_EVENT_ACTION } }],
314
+ },
315
+ ],
316
+ });
317
+
318
+ return config;
319
+ });
320
+ };
321
+
280
322
  export const withExpoCallKitTelecomAndroid: ConfigPlugin<ExpoCallKitTelecomPluginProps> = (
281
323
  config,
282
324
  props,
@@ -289,5 +331,6 @@ export const withExpoCallKitTelecomAndroid: ConfigPlugin<ExpoCallKitTelecomPlugi
289
331
  });
290
332
  config = withDefaultDialtone(config, props);
291
333
  config = withFirebaseMessagingService(config, props);
334
+ config = withEventReceiver(config, props);
292
335
  return config;
293
336
  };
@@ -312,6 +312,22 @@ export interface CallActionEvent extends NativeEvent {
312
312
  id: string;
313
313
  }
314
314
 
315
+ /**
316
+ * Mixin for events that carry a full {@link CallSession} snapshot alongside the
317
+ * event-specific fields.
318
+ *
319
+ * Terminal events embed the session so consumers without access to the session
320
+ * store still get full context. On Android this is also what makes the
321
+ * package-internal call-event broadcast self-contained when no JS observer is
322
+ * alive (see "Call ended while the app is killed" in the docs) — the broadcast
323
+ * payload is exactly this event body.
324
+ *
325
+ * @category Call Events
326
+ */
327
+ export interface WithSession {
328
+ session: CallSession;
329
+ }
330
+
315
331
  /**
316
332
  * Fired after `startOutgoingCall`, once the OS has accepted the call request.
317
333
  *
@@ -371,7 +387,7 @@ export interface CallAnsweredEvent extends CallActionEvent {
371
387
  *
372
388
  * @category Call Events
373
389
  */
374
- export interface CallEndedEvent extends CallActionEvent {}
390
+ export interface CallEndedEvent extends CallActionEvent, WithSession {}
375
391
 
376
392
  /**
377
393
  * Reason a call was ended, reported on {@link CallReportedEnded}.
@@ -392,7 +408,7 @@ export type CallEndedReason =
392
408
  *
393
409
  * @category Call Events
394
410
  */
395
- export interface CallReportedEnded extends CallActionEvent {
411
+ export interface CallReportedEnded extends CallActionEvent, WithSession {
396
412
  reason: CallEndedReason;
397
413
  }
398
414