@poolse/react-native-calls 0.10.0 → 0.10.1

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.
@@ -181,6 +181,19 @@ internal class AndroidRingSurface(
181
181
  )
182
182
  }
183
183
 
184
+ // End & answer launches an ACTIVITY, like the ring's Answer: only an
185
+ // activity started from the tap may bring the app forward (Android
186
+ // 10+), and the call that connects should be seen.
187
+ val answerWaiting = PendingIntent.getActivity(
188
+ context,
189
+ ring.callId.hashCode() + 4,
190
+ Intent(context, IncomingCallActivity::class.java)
191
+ .putExtra(EXTRA_RING, extras)
192
+ .putExtra(EXTRA_WAITING_ANSWER, true)
193
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
194
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
195
+ )
196
+
184
197
  val who = ring.conversationName ?: ring.callerName ?: "Someone"
185
198
  val what = if (ring.media == "video") "video call" else "call"
186
199
  val notification = NotificationCompat.Builder(context, WAITING_CHANNEL_ID)
@@ -195,7 +208,10 @@ internal class AndroidRingSurface(
195
208
  .setOnlyAlertOnce(true)
196
209
  .apply { if (open != null) setContentIntent(open) }
197
210
  .addAction(0, "Decline", actionPending(ACTION_WAITING_DECLINE, 3))
198
- .addAction(0, "End & answer", actionPending(ACTION_WAITING_ANSWER, 4))
211
+ .addAction(0, "End & answer", answerWaiting)
212
+ // Never outlive the ring: if its ring_ended/finished push is lost,
213
+ // the system takes the heads-up down at the deadline anyway.
214
+ .setTimeoutAfter((ring.ringDeadlineEpochMs - System.currentTimeMillis()).coerceAtLeast(10_000L))
199
215
  .build()
200
216
 
201
217
  waitingCallId = ring.callId
@@ -345,6 +361,7 @@ internal class AndroidRingSurface(
345
361
  private const val WAITING_BEEP_VOLUME = 70
346
362
  const val EXTRA_RING = "poolse_ring"
347
363
  const val EXTRA_AUTO_ANSWER = "poolse_auto_answer"
364
+ const val EXTRA_WAITING_ANSWER = "poolse_waiting_answer"
348
365
  /** Package-local broadcast: the ring for [EXTRA_CALL_ID] is over. */
349
366
  const val ACTION_RING_CLOSED = "dev.poolse.callcore.RING_CLOSED"
350
367
  const val EXTRA_CALL_ID = "poolse_call_id"
@@ -122,6 +122,23 @@ class CallCore private constructor(context: Context) {
122
122
  return bringAppToFront(ring.callId, from)
123
123
  }
124
124
 
125
+ /**
126
+ * End & answer from the call-waiting heads-up. Only for the call still
127
+ * waiting (a stale notification is just taken down); brings the app
128
+ * forward from [from], like [onUserAnswer].
129
+ */
130
+ fun onUserAnswerWaiting(ring: RingContext, from: Context? = null): Boolean {
131
+ if (runtime.currentState().waiting?.callId != ring.callId) {
132
+ runCatching {
133
+ androidx.core.app.NotificationManagerCompat.from(appContext)
134
+ .cancel(with(AndroidRingSurface) { ring.callId.notificationId() })
135
+ }
136
+ return false
137
+ }
138
+ runtime.answerWaiting(ring.callId)
139
+ return bringAppToFront(ring.callId, from)
140
+ }
141
+
125
142
  fun onUserDecline(ring: RingContext) {
126
143
  runtime.onRing(ring)
127
144
  runtime.decline()
@@ -205,8 +222,10 @@ class CallCore private constructor(context: Context) {
205
222
  }
206
223
  resetExtras(null, null)
207
224
  } else if (state.callId != extrasCallId) {
208
- // `pending:<conv>` → the server-minted id is the same call.
209
- if (extrasCallId?.startsWith("pending:") == true) extrasCallId = state.callId
225
+ // `pending:<conv>` → the server-minted id is the same call — but
226
+ // only for that conversation's own placed call: a waiting call
227
+ // promoted over a still-pending one is a different call.
228
+ if (isRenameOfPending(extrasCallId, state)) extrasCallId = state.callId
210
229
  else resetExtras(state.callId, state.media)
211
230
  }
212
231
  if (state.phase != Phase.IDLE && state.media != extrasMedia) {
@@ -246,6 +265,11 @@ class CallCore private constructor(context: Context) {
246
265
  runCatching { listener?.invoke(snap) }.onFailure { Log.w(TAG, "snapshot listener failed", it) }
247
266
  }
248
267
 
268
+ private fun isRenameOfPending(previous: String?, state: MachineState): Boolean =
269
+ previous != null && previous.startsWith("pending:") &&
270
+ state.direction != dev.poolse.callcore.Direction.INCOMING &&
271
+ previous == "pending:${state.conversationId}"
272
+
249
273
  private fun resetExtras(callId: String?, media: String?) {
250
274
  extrasCallId = callId
251
275
  extrasMedia = media
@@ -220,14 +220,22 @@ class CallForegroundService : Service() {
220
220
 
221
221
  override fun start(callId: String, media: String) = onMain {
222
222
  desired = Desired(Mode.CALL, callId, media)
223
- launch()
223
+ launchOrSwitch()
224
224
  }
225
225
 
226
226
  override fun stop() = onMain {
227
227
  desired = null
228
- // Only a started instance with nothing pending may stop now; a
229
- // pending start stops itself after startForeground.
230
- if (pendingStarts == 0) instance?.stopNow()
228
+ // Deferred to the end of this main-thread turn: a call handing over
229
+ // to the next (call waiting: end & answer, or a promoted ring) stops
230
+ // and starts in one dispatch, and must switch the RUNNING service
231
+ // in place — a fresh start from the background is refused on
232
+ // Android 12+ (and loses the microphone type on 14+). A start in
233
+ // the same turn sets `desired` again and this does nothing.
234
+ main.post {
235
+ // Only a started instance with nothing pending may stop now; a
236
+ // pending start stops itself after startForeground.
237
+ if (desired == null && pendingStarts == 0) instance?.stopNow()
238
+ }
231
239
  }
232
240
 
233
241
  /** Ring mode for [callId]; a no-op while a call holds the service. */
@@ -236,7 +244,7 @@ class CallForegroundService : Service() {
236
244
  if (d?.mode == Mode.CALL) return@onMain
237
245
  if (d?.mode == Mode.RING && d.callId == callId) return@onMain
238
246
  desired = Desired(Mode.RING, callId, "audio")
239
- launch()
247
+ launchOrSwitch()
240
248
  }
241
249
 
242
250
  /**
@@ -255,6 +263,12 @@ class CallForegroundService : Service() {
255
263
  if (desired?.mode == Mode.RING) stop()
256
264
  }
257
265
 
266
+ /** A running, settled instance switches in place; otherwise start one. */
267
+ private fun launchOrSwitch() {
268
+ val running = instance
269
+ if (running != null && pendingStarts == 0) running.reconcile(desired) else launch()
270
+ }
271
+
258
272
  private fun launch() {
259
273
  if (!::appContext.isInitialized) return
260
274
  val intent = Intent(appContext, CallForegroundService::class.java)
@@ -93,7 +93,14 @@ class IncomingCallActivity : Activity() {
93
93
  // background receiver is not).
94
94
  if (intent.getBooleanExtra(AndroidRingSurface.EXTRA_AUTO_ANSWER, false)) {
95
95
  ring = next
96
- answerAndForward(core, next)
96
+ answerAndForward { core.onUserAnswer(next, this) }
97
+ return
98
+ }
99
+ // End & answer on a call-waiting heads-up: the same, for the call
100
+ // waiting beside the current one.
101
+ if (intent.getBooleanExtra(AndroidRingSurface.EXTRA_WAITING_ANSWER, false)) {
102
+ ring = next
103
+ answerAndForward { core.onUserAnswerWaiting(next, this) }
97
104
  return
98
105
  }
99
106
 
@@ -118,12 +125,12 @@ class IncomingCallActivity : Activity() {
118
125
  * behind the lock screen. onStop (we got covered) finishes us; a
119
126
  * timer is the fallback.
120
127
  */
121
- private fun answerAndForward(core: CallCore, ring: RingContext) {
128
+ private fun answerAndForward(answer: () -> Boolean) {
122
129
  awaitingForward = true
123
130
  pulse?.cancel()
124
131
  pulse = null
125
132
  setContentView(View(this).apply { setBackgroundColor(Color.parseColor("#0B0B14")) })
126
- val launched = core.onUserAnswer(ring, this)
133
+ val launched = answer()
127
134
  if (!launched) { finish(); return }
128
135
  main.postDelayed({ if (!isFinishing) finish() }, FORWARD_FALLBACK_MS)
129
136
  }
@@ -238,7 +245,7 @@ class IncomingCallActivity : Activity() {
238
245
  }
239
246
  val accept = actionButton("Answer", "✓", "#22C55E") {
240
247
  val core = CallCore.getOrNull(this)
241
- if (core == null) finish() else answerAndForward(core, ring)
248
+ if (core == null) finish() else answerAndForward { core.onUserAnswer(ring, this) }
242
249
  }
243
250
  buttons.addView(decline, LinearLayout.LayoutParams(wrap(), wrap()).apply { rightMargin = px(52) })
244
251
  buttons.addView(accept, LinearLayout.LayoutParams(wrap(), wrap()).apply { leftMargin = px(52) })
@@ -234,13 +234,10 @@ final class CallCore {
234
234
  }
235
235
 
236
236
  /// Call waiting: end this call, answer the waiting one. CallKit follows
237
- /// from the resulting state (reconcileCallKit ends the old call, then
238
- /// answers the new one).
237
+ /// from the resulting state: reconcileCallKit reports the old call
238
+ /// ended, then answers the new one.
239
239
  func answerWaiting(callId: String? = nil) {
240
- onMain { [self] in
241
- localEnd = true
242
- runtime.answerWaiting(callId: callId)
243
- }
240
+ onMain { [self] in runtime.answerWaiting(callId: callId) }
244
241
  }
245
242
 
246
243
  /// Call waiting: turn the waiting call away (its caller hears busy).
@@ -314,8 +311,13 @@ final class CallCore {
314
311
  if extrasCallId != nil { mediaPort.leave() }
315
312
  resetExtras(nil, nil)
316
313
  } else if state.callId != extrasCallId {
317
- if extrasCallId?.hasPrefix("pending:") == true {
318
- if let old = extrasCallId, let new = state.callId { callKit.rename(from: old, to: new) }
314
+ // `pending:<conv>` → the server-minted id is the same call — but only
315
+ // for that conversation's own placed call: a waiting call promoted
316
+ // over a still-pending one is a different call (renaming would give
317
+ // CallKit two calls with one id, and the incoming one never answered).
318
+ if let old = extrasCallId, old.hasPrefix("pending:"), state.direction != .incoming,
319
+ old == "pending:\(state.conversationId ?? "")" {
320
+ if let new = state.callId { callKit.rename(from: old, to: new) }
319
321
  extrasCallId = state.callId
320
322
  } else {
321
323
  // A different call (a waiting one promoted): nothing of the last
@@ -485,7 +487,6 @@ extension CallCore: CallKitActions {
485
487
  // End & Accept may answer the waiting call before ending the current
486
488
  // one: answering it ends the current call itself.
487
489
  if s.waiting?.callId == callId {
488
- localEnd = true
489
490
  runtime.answerWaiting(callId: callId)
490
491
  return
491
492
  }
@@ -523,6 +524,9 @@ extension CallCore: CallKitActions {
523
524
  func callKitReset() {
524
525
  let s = runtime.currentState()
525
526
  guard s.phase != .idle && s.phase != .ended else { return }
527
+ // CallKit dropped every call, the waiting one included: turn it away
528
+ // first, or ending the current call would ring it afresh.
529
+ if let w = s.waiting { runtime.declineWaiting(callId: w.callId) }
526
530
  localEnd = true
527
531
  if s.phase == .ringingIn { runtime.decline() } else { runtime.hangUp() }
528
532
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poolse/react-native-calls",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
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
5
  "license": "MIT",
6
6
  "type": "module",