@poolse/react-native-calls 0.1.2 → 0.2.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.
@@ -50,7 +50,12 @@ sealed interface Event {
50
50
  ) : Event
51
51
 
52
52
  data class RingEnded(val callId: String, val reason: String) : Event
53
- data class CallFinished(val callId: String) : Event
53
+ /**
54
+ * The server finished the call. [reason] is the push's own reason
55
+ * (`finished`, `declined_all`, `no_answer`, `cancelled`); it only
56
+ * colours the caller's ended screen when the call never got answered.
57
+ */
58
+ data class CallFinished(val callId: String, val reason: String? = null) : Event
54
59
 
55
60
  data object UserAnswer : Event
56
61
  data object UserDecline : Event
@@ -62,10 +67,18 @@ sealed interface Event {
62
67
  data class HttpOk(val created: Boolean = true) : Event
63
68
  data object HttpGone : Event
64
69
  data object HttpTimeout : Event
70
+ /**
71
+ * Terminal refusal that is NOT "the call is over": 401/403 (grant
72
+ * spent, wrong device), 503, or an unparseable 2xx. Ends as `failed`,
73
+ * never as `finished`, so the UI does not claim the other side hung up.
74
+ */
75
+ data class HttpFailed(val code: String = "failed") : Event
65
76
 
66
77
  data object MediaConnected : Event
67
78
  data object MediaFailed : Event
68
79
  data object MediaLost : Event
80
+ /** The SFU is recovering the session on its own (ICE restart, resume). Show it; do not rejoin. */
81
+ data object MediaReconnecting : Event
69
82
  data object MediaRecovered : Event
70
83
  data object RemoteJoined : Event
71
84
 
@@ -74,6 +87,8 @@ sealed interface Event {
74
87
 
75
88
  data object RingDeadline : Event
76
89
  data object ReconnectBudgetElapsed : Event
90
+ /** CONNECTING watchdog: neither the server nor the SFU got us in. */
91
+ data object ConnectTimeout : Event
77
92
  data object LingerElapsed : Event
78
93
  }
79
94
 
@@ -115,17 +130,37 @@ object CallMachine {
115
130
 
116
131
  is Event.CallFinished ->
117
132
  if (e.callId == s.callId) when (s.phase) {
118
- Phase.ACTIVE, Phase.RECONNECTING, Phase.RINGING_OUT -> Transition(
133
+ // The finish push can overtake (or replace) ring_ended: a ring
134
+ // for a finished call is over either way.
135
+ Phase.RINGING_IN -> Transition(MachineState.IDLE, listOf(Effect.RingtoneStop, Effect.DismissRing))
136
+ // Caller still ringing out: nobody ever joined, so the push's
137
+ // reason (no_answer / declined_all / cancelled) is the truth the
138
+ // ended screen should show. The server already finished the call
139
+ // — no hangup to send.
140
+ Phase.RINGING_OUT -> Transition(
141
+ s.copy(phase = Phase.ENDED, reason = ringOutcome(e.reason)),
142
+ listOf(Effect.RingbackStop, Effect.MediaLeave, Effect.CallSessionStop),
143
+ )
144
+ Phase.ACTIVE, Phase.RECONNECTING -> Transition(
119
145
  s.copy(phase = Phase.ENDED, reason = EndReason.FINISHED),
120
- buildList {
121
- if (s.phase == Phase.RINGING_OUT) add(Effect.RingbackStop)
122
- add(Effect.MediaLeave); add(Effect.CallSessionStop)
123
- },
146
+ listOf(Effect.MediaLeave, Effect.CallSessionStop),
124
147
  )
148
+ // CONNECTING may already have joined media (HTTP_OK came back):
149
+ // leave it, or the room outlives the call.
125
150
  Phase.CONNECTING -> Transition(
126
151
  s.copy(phase = Phase.ENDED, reason = EndReason.FINISHED),
127
- listOf(Effect.CallSessionStop),
152
+ listOf(Effect.MediaLeave, Effect.CallSessionStop),
128
153
  )
154
+ // Lingering "ended": a later finish with a real outcome (the FCM
155
+ // push after a reason-less socket event, or vice versa) corrects
156
+ // an unanswered outgoing call's generic "finished" to what
157
+ // actually happened. Display only — nothing left to tear down.
158
+ Phase.ENDED -> {
159
+ val outcome = ringOutcome(e.reason)
160
+ if (s.direction == Direction.OUTGOING && s.reason == EndReason.FINISHED &&
161
+ outcome != EndReason.FINISHED
162
+ ) Transition(s.copy(reason = outcome), emptyList()) else noop(s)
163
+ }
129
164
  else -> noop(s)
130
165
  } else noop(s)
131
166
 
@@ -199,8 +234,9 @@ object CallMachine {
199
234
  else -> noop(s)
200
235
  }
201
236
 
202
- Event.HttpTimeout ->
203
- if (s.phase == Phase.CONNECTING && s.httpAttempts < 2) Transition(
237
+ Event.HttpTimeout -> when {
238
+ s.phase != Phase.CONNECTING -> noop(s)
239
+ s.httpAttempts < 2 -> Transition(
204
240
  s.copy(httpAttempts = s.httpAttempts + 1),
205
241
  listOf(
206
242
  when (s.direction) {
@@ -209,24 +245,73 @@ object CallMachine {
209
245
  else -> Effect.HttpAnswer
210
246
  },
211
247
  ),
212
- ) else noop(s) // out of retries: media join (or its absence) decides
248
+ )
249
+ // Out of retries. A timeout only ever answers the CONNECTING
250
+ // request, so there is no bundle and media can never join:
251
+ // waiting on it was the "stuck in connecting forever" bug.
252
+ else -> Transition(
253
+ s.copy(phase = Phase.ENDED, reason = EndReason.FAILED),
254
+ listOf(Effect.HttpHangup, Effect.CallSessionStop),
255
+ )
256
+ }
257
+
258
+ is Event.HttpFailed -> when (s.phase) {
259
+ // HTTP_HANGUP still runs: with no bundle it resolves the ring
260
+ // grant, so an incoming caller is not left ringing a dead answer.
261
+ Phase.CONNECTING -> Transition(
262
+ s.copy(phase = Phase.ENDED, reason = EndReason.FAILED),
263
+ listOf(Effect.HttpHangup, Effect.CallSessionStop),
264
+ )
265
+ // Reconnect refused for auth: the control token is dead, so a
266
+ // hangup with it would be refused too — just leave.
267
+ Phase.RECONNECTING -> Transition(
268
+ s.copy(phase = Phase.ENDED, reason = EndReason.FAILED),
269
+ listOf(Effect.MediaLeave, Effect.CallSessionStop),
270
+ )
271
+ else -> noop(s)
272
+ }
273
+
274
+ Event.ConnectTimeout ->
275
+ if (s.phase == Phase.CONNECTING) Transition(
276
+ s.copy(phase = Phase.ENDED, reason = EndReason.FAILED),
277
+ listOf(Effect.MediaLeave, Effect.HttpHangup, Effect.CallSessionStop),
278
+ ) else noop(s)
213
279
 
214
280
  Event.MediaConnected -> when (s.phase) {
215
281
  Phase.CONNECTING -> Transition(s.copy(phase = Phase.ACTIVE), emptyList())
282
+ // A rejoin after /reconnect lands as a fresh connect.
283
+ Phase.RECONNECTING -> Transition(s.copy(phase = Phase.ACTIVE), emptyList())
216
284
  else -> noop(s) // ringing_out keeps ringing until a remote joins
217
285
  }
218
286
 
219
- Event.MediaFailed ->
220
- if (s.phase == Phase.CONNECTING) Transition(
287
+ Event.MediaFailed -> when (s.phase) {
288
+ Phase.CONNECTING -> Transition(
221
289
  s.copy(phase = Phase.ENDED, reason = EndReason.FAILED),
222
290
  listOf(Effect.HttpHangup, Effect.CallSessionStop),
223
- ) else noop(s)
291
+ )
292
+ // The caller's own media never came up: the callees would answer
293
+ // into an empty room. Give up and tell the server.
294
+ Phase.RINGING_OUT -> Transition(
295
+ s.copy(phase = Phase.ENDED, reason = EndReason.FAILED),
296
+ listOf(Effect.RingbackStop, Effect.MediaLeave, Effect.HttpHangup, Effect.CallSessionStop),
297
+ )
298
+ else -> noop(s) // reconnecting: the budget timer is the backstop
299
+ }
224
300
 
225
- Event.MediaLost ->
226
- if (s.phase == Phase.ACTIVE) Transition(
227
- s.copy(phase = Phase.RECONNECTING),
228
- listOf(Effect.HttpReconnect),
229
- ) else noop(s)
301
+ // Lost = the SFU gave up (unexpected disconnect). Ask the server for
302
+ // a fresh token and rejoin — also from RECONNECTING, where the SFU's
303
+ // own recovery just failed.
304
+ Event.MediaLost -> when (s.phase) {
305
+ Phase.ACTIVE -> Transition(s.copy(phase = Phase.RECONNECTING), listOf(Effect.HttpReconnect))
306
+ Phase.RECONNECTING -> Transition(s, listOf(Effect.HttpReconnect))
307
+ else -> noop(s)
308
+ }
309
+
310
+ // The SFU is resuming the same session itself: only reflect it.
311
+ // Tearing the room down here fought LiveKit's own recovery.
312
+ Event.MediaReconnecting ->
313
+ if (s.phase == Phase.ACTIVE) Transition(s.copy(phase = Phase.RECONNECTING), emptyList())
314
+ else noop(s)
230
315
 
231
316
  Event.MediaRecovered ->
232
317
  if (s.phase == Phase.RECONNECTING) Transition(s.copy(phase = Phase.ACTIVE), emptyList())
@@ -245,6 +330,7 @@ object CallMachine {
245
330
  reason = when (e.outcome) {
246
331
  "busy_all" -> EndReason.BUSY_ALL
247
332
  "no_answer" -> EndReason.NO_ANSWER
333
+ "cancelled" -> EndReason.CANCELLED
248
334
  else -> EndReason.DECLINED_ALL
249
335
  },
250
336
  ),
@@ -288,5 +374,14 @@ object CallMachine {
288
374
  else -> Transition(s, listOf(Effect.HttpDeclineBusy(e.callId)))
289
375
  }
290
376
 
377
+ /** A finish push's reason, read as the caller's unanswered outcome. */
378
+ private fun ringOutcome(reason: String?): EndReason = when (reason) {
379
+ "no_answer", "timeout" -> EndReason.NO_ANSWER
380
+ "declined_all", "declined" -> EndReason.DECLINED_ALL
381
+ "busy_all" -> EndReason.BUSY_ALL
382
+ "cancelled" -> EndReason.CANCELLED
383
+ else -> EndReason.FINISHED
384
+ }
385
+
291
386
  private fun noop(s: MachineState) = Transition(s, emptyList())
292
387
  }
@@ -19,8 +19,13 @@ interface HttpPort {
19
19
  fun decline(callId: String, auth: String, busy: Boolean)
20
20
  /** POST /calls/:id/hangup with the call-control token; fire-and-forget with retry. */
21
21
  fun hangup(callId: String, controlToken: String)
22
- /** POST /calls/:id/reconnect with the call-control token. */
23
- fun reconnect(callId: String, controlToken: String, cb: (HttpResult) -> Unit)
22
+ /**
23
+ * POST /calls/:id/reconnect with the call-control token. The server
24
+ * answers with the FULL bundle (same shape as answer/join); [current]
25
+ * is passed so an older server's `{livekit:{url,token}}` refresh can
26
+ * still be applied onto it.
27
+ */
28
+ fun reconnect(current: CallSessionBundle, cb: (HttpResult) -> Unit)
24
29
  }
25
30
 
26
31
  sealed interface HttpResult {
@@ -32,6 +37,11 @@ sealed interface HttpResult {
32
37
  data object Timeout : HttpResult
33
38
  /** 401/403 — grant spent, expired, or wrong device. Terminal. */
34
39
  data class AuthFailed(val code: String) : HttpResult
40
+ /**
41
+ * Terminal but not auth: 503 (no SFU capacity), another 4xx, or a 2xx
42
+ * whose body would not parse. Retrying would only repeat it.
43
+ */
44
+ data class Failed(val code: String) : HttpResult
35
45
  }
36
46
 
37
47
  data class CallSessionBundle(
@@ -52,6 +62,11 @@ interface MediaPort {
52
62
  fun setMuted(muted: Boolean)
53
63
  fun setCamera(on: Boolean)
54
64
  fun switchCamera()
65
+ /**
66
+ * Route call audio to the loudspeaker (`true`) or back to the private
67
+ * route — earpiece, or a connected headset/Bluetooth device (`false`).
68
+ */
69
+ fun setSpeaker(on: Boolean)
55
70
  }
56
71
 
57
72
  /** The ring UX: CallStyle notification + full-screen activity + tones. */
@@ -10,6 +10,7 @@ import android.media.MediaPlayer
10
10
  import android.media.Ringtone
11
11
  import android.media.RingtoneManager
12
12
  import android.os.Build
13
+ import android.util.Log
13
14
  import androidx.core.app.NotificationCompat
14
15
  import androidx.core.app.NotificationManagerCompat
15
16
  import androidx.core.app.Person
@@ -26,7 +27,10 @@ import dev.poolse.callcore.RingSurfacePort
26
27
  * on the loudspeaker and the ringtone in the earpiece. Both players use
27
28
  * explicit [AudioAttributes]: ringtone on the ringtone stream (loud,
28
29
  * speaker), ringback on voice-communication (quiet, follows the call
29
- * route).
30
+ * route). The ringback waits for [setCommunicationReady]: before the
31
+ * SFU's audio handler has switched the device into communication mode,
32
+ * a voice-communication stream still routes to the loudspeaker — the
33
+ * "ringback blares from the speaker" bug.
30
34
  *
31
35
  * Tap actions resolve to broadcasts handled by [CallActionReceiver],
32
36
  * which forwards into the single native entry point so that notification
@@ -35,11 +39,25 @@ import dev.poolse.callcore.RingSurfacePort
35
39
  */
36
40
  internal class AndroidRingSurface(
37
41
  private val context: Context,
38
- private val config: RingSurfaceConfig,
42
+ /** Read per use, so a re-configure (icon, ringback) applies at once. */
43
+ private val configOf: () -> RingSurfaceConfig,
39
44
  ) : RingSurfacePort {
40
45
 
46
+ private val config get() = configOf()
47
+
41
48
  private var ringtone: Ringtone? = null
42
49
  private var ringback: MediaPlayer? = null
50
+ private var ringbackWanted = false
51
+ private var communicationReady = false
52
+
53
+ /**
54
+ * The call's audio path is (or is no longer) in communication mode.
55
+ * A ringback requested earlier starts now, through the call route.
56
+ */
57
+ fun setCommunicationReady(ready: Boolean) {
58
+ communicationReady = ready
59
+ if (ready && ringbackWanted && ringback == null) playRingback()
60
+ }
43
61
 
44
62
  override fun show(ring: RingContext) {
45
63
  ensureChannel()
@@ -57,14 +75,28 @@ internal class AndroidRingSurface(
57
75
  )
58
76
  }
59
77
 
60
- val fullScreen = PendingIntent.getActivity(
61
- context,
62
- ring.callId.hashCode(),
63
- Intent(context, IncomingCallActivity::class.java)
78
+ fun incomingActivity(autoAnswer: Boolean, requestOffset: Int): PendingIntent {
79
+ val intent = Intent(context, IncomingCallActivity::class.java)
64
80
  .putExtra(EXTRA_RING, extras)
65
- .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
66
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
67
- )
81
+ .putExtra(EXTRA_AUTO_ANSWER, autoAnswer)
82
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
83
+ return PendingIntent.getActivity(
84
+ context,
85
+ ring.callId.hashCode() + requestOffset,
86
+ intent,
87
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
88
+ )
89
+ }
90
+
91
+ val fullScreen = incomingActivity(autoAnswer = false, requestOffset = 0)
92
+
93
+ // Answer launches an ACTIVITY, not a broadcast: a background receiver
94
+ // cannot start an activity (Android 10+), so answering from the
95
+ // notification used to connect audio while the app stayed hidden. An
96
+ // activity launched from a notification tap is allowed to come
97
+ // forward — it answers and brings the app up. Decline stays a
98
+ // broadcast; it needs no UI.
99
+ val answer = incomingActivity(autoAnswer = true, requestOffset = 2)
68
100
 
69
101
  val caller = Person.Builder()
70
102
  .setName(ring.conversationName ?: ring.callerName ?: "Incoming call")
@@ -79,22 +111,38 @@ internal class AndroidRingSurface(
79
111
  .setOngoing(true)
80
112
  .setAutoCancel(false)
81
113
  .setFullScreenIntent(fullScreen, true)
114
+ // Tapping the heads-up (not a button) opens the ring screen too;
115
+ // without this the tap did nothing.
116
+ .setContentIntent(fullScreen)
82
117
  .setStyle(
83
118
  NotificationCompat.CallStyle.forIncomingCall(
84
119
  caller,
85
120
  actionPending(ACTION_DECLINE, 1),
86
- actionPending(ACTION_ANSWER, 2),
121
+ answer,
87
122
  ),
88
123
  )
89
124
  .build()
90
125
 
91
- runCatching {
126
+ // The ring-mode foreground service adopts this exact notification.
127
+ shownRing = ring.callId to notification
128
+ try {
92
129
  NotificationManagerCompat.from(context).notify(ring.callId.notificationId(), notification)
130
+ } catch (e: SecurityException) {
131
+ // POST_NOTIFICATIONS denied: the ringtone still plays, but there is
132
+ // no surface. Loud in the log — this is a support question.
133
+ Log.e(TAG, "ring notification refused (POST_NOTIFICATIONS?)", e)
93
134
  }
94
135
  }
95
136
 
96
137
  override fun dismiss(callId: String) {
97
- NotificationManagerCompat.from(context).cancel(callId.notificationId())
138
+ if (shownRing?.first == callId) shownRing = null
139
+ runCatching { NotificationManagerCompat.from(context).cancel(callId.notificationId()) }
140
+ // The full-screen activity is its own window: tell it to close.
141
+ runCatching {
142
+ context.sendBroadcast(
143
+ Intent(ACTION_RING_CLOSED).setPackage(context.packageName).putExtra(EXTRA_CALL_ID, callId),
144
+ )
145
+ }
98
146
  }
99
147
 
100
148
  override fun ringtoneStart() {
@@ -119,6 +167,11 @@ internal class AndroidRingSurface(
119
167
 
120
168
  override fun ringbackStart() {
121
169
  ringbackStop()
170
+ ringbackWanted = true
171
+ if (communicationReady) playRingback()
172
+ }
173
+
174
+ private fun playRingback() {
122
175
  val resId = config.ringbackRawRes
123
176
  if (resId == 0) return
124
177
  runCatching {
@@ -138,6 +191,7 @@ internal class AndroidRingSurface(
138
191
  }
139
192
 
140
193
  override fun ringbackStop() {
194
+ ringbackWanted = false
141
195
  runCatching {
142
196
  ringback?.stop()
143
197
  ringback?.release()
@@ -169,6 +223,14 @@ internal class AndroidRingSurface(
169
223
  const val ACTION_DECLINE = "DECLINE"
170
224
  const val ACTION_OPEN = "OPEN"
171
225
  const val EXTRA_RING = "poolse_ring"
226
+ const val EXTRA_AUTO_ANSWER = "poolse_auto_answer"
227
+ /** Package-local broadcast: the ring for [EXTRA_CALL_ID] is over. */
228
+ const val ACTION_RING_CLOSED = "dev.poolse.callcore.RING_CLOSED"
229
+ const val EXTRA_CALL_ID = "poolse_call_id"
230
+ private const val TAG = "PoolseRingSurface"
231
+
232
+ /** The CallStyle notification on screen, for the ring-mode service to wear. */
233
+ @Volatile internal var shownRing: Pair<String, android.app.Notification>? = null
172
234
 
173
235
  /** Stable per-call notification id, so teardown targets the right one. */
174
236
  fun String.notificationId(): Int = hashCode()
@@ -19,10 +19,11 @@ import dev.poolse.callcore.android.AndroidRingSurface.Companion.EXTRA_RING
19
19
  class CallActionReceiver : BroadcastReceiver() {
20
20
  override fun onReceive(context: Context, intent: Intent) {
21
21
  val ring = intent.getBundleExtra(EXTRA_RING)?.toRingContext() ?: return
22
+ val core = CallCore.getOrNull(context) ?: return
22
23
  when (intent.action) {
23
- "$ACTION_PREFIX.$ACTION_ANSWER" -> CallCore.get(context).onUserAnswer(ring)
24
- "$ACTION_PREFIX.$ACTION_DECLINE" -> CallCore.get(context).onUserDecline(ring)
25
- "$ACTION_PREFIX.$ACTION_OPEN" -> CallCore.get(context).onUserOpen(ring)
24
+ "$ACTION_PREFIX.$ACTION_ANSWER" -> core.onUserAnswer(ring)
25
+ "$ACTION_PREFIX.$ACTION_DECLINE" -> core.onUserDecline(ring)
26
+ "$ACTION_PREFIX.$ACTION_OPEN" -> core.onUserOpen(ring)
26
27
  }
27
28
  }
28
29
  }