@stream-io/react-native-callingx 0.1.0-beta.6 → 0.1.1-beta.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 (65) hide show
  1. package/android/build.gradle +7 -1
  2. package/android/src/main/AndroidManifest.xml +31 -1
  3. package/android/src/main/java/io/getstream/rn/callingx/CallEventBroadcastReceiver.kt +17 -0
  4. package/android/src/main/java/io/getstream/rn/callingx/CallRegistrationStore.kt +176 -0
  5. package/android/src/main/java/io/getstream/rn/callingx/CallService.kt +302 -80
  6. package/android/src/main/java/io/getstream/rn/callingx/CallingxModuleImpl.kt +176 -191
  7. package/android/src/main/java/io/getstream/rn/callingx/StreamMessagingService.kt +48 -0
  8. package/android/src/main/java/io/getstream/rn/callingx/model/Call.kt +1 -0
  9. package/android/src/main/java/io/getstream/rn/callingx/notifications/CallNotificationManager.kt +196 -46
  10. package/android/src/main/java/io/getstream/rn/callingx/notifications/NotificationIntentFactory.kt +14 -8
  11. package/android/src/main/java/io/getstream/rn/callingx/notifications/NotificationReceiverActivity.kt +12 -1
  12. package/android/src/main/java/io/getstream/rn/callingx/notifications/NotificationReceiverService.kt +7 -0
  13. package/android/src/main/java/io/getstream/rn/callingx/repo/CallRepository.kt +38 -19
  14. package/android/src/main/java/io/getstream/rn/callingx/repo/LegacyCallRepository.kt +64 -55
  15. package/android/src/main/java/io/getstream/rn/callingx/repo/TelecomCallRepository.kt +241 -195
  16. package/android/src/main/java/io/getstream/rn/callingx/utils/CallEventBus.kt +61 -0
  17. package/android/src/main/java/io/getstream/rn/callingx/utils/SettingsStore.kt +51 -0
  18. package/android/src/newarch/java/io/getstream/rn/callingx/CallingxModule.kt +12 -3
  19. package/android/src/oldarch/java/io/getstream/rn/callingx/CallingxModule.kt +13 -3
  20. package/dist/module/CallingxModule.js +13 -10
  21. package/dist/module/CallingxModule.js.map +1 -1
  22. package/dist/module/spec/NativeCallingx.js.map +1 -1
  23. package/dist/module/utils/constants.js +24 -13
  24. package/dist/module/utils/constants.js.map +1 -1
  25. package/dist/typescript/src/CallingxModule.d.ts +3 -0
  26. package/dist/typescript/src/CallingxModule.d.ts.map +1 -1
  27. package/dist/typescript/src/spec/NativeCallingx.d.ts +7 -1
  28. package/dist/typescript/src/spec/NativeCallingx.d.ts.map +1 -1
  29. package/dist/typescript/src/types.d.ts +31 -0
  30. package/dist/typescript/src/types.d.ts.map +1 -1
  31. package/dist/typescript/src/utils/constants.d.ts +1 -1
  32. package/dist/typescript/src/utils/constants.d.ts.map +1 -1
  33. package/ios/AudioSessionManager.swift +2 -2
  34. package/ios/Callingx.mm +41 -17
  35. package/ios/CallingxImpl.swift +213 -83
  36. package/ios/Settings.swift +2 -2
  37. package/ios/UUIDStorage.swift +10 -10
  38. package/ios/VoipNotificationsManager.swift +8 -8
  39. package/package.json +4 -2
  40. package/src/CallingxModule.ts +14 -10
  41. package/src/spec/NativeCallingx.ts +10 -3
  42. package/src/types.ts +34 -0
  43. package/src/utils/constants.ts +23 -9
  44. package/android/bin/build/generated/source/buildConfig/debug/io/getstream/rn/callingx/BuildConfig.class +0 -0
  45. package/android/bin/build/generated/source/codegen/java/io/getstream/rn/callingx/NativeCallingxSpec.class +0 -0
  46. package/android/bin/build/generated/source/codegen/jni/CMakeLists.txt +0 -28
  47. package/android/bin/build/generated/source/codegen/jni/CallingxSpec-generated.cpp +0 -167
  48. package/android/bin/build/generated/source/codegen/jni/CallingxSpec.h +0 -31
  49. package/android/bin/build/generated/source/codegen/jni/react/renderer/components/CallingxSpec/CallingxSpecJSI-generated.cpp +0 -196
  50. package/android/bin/build/generated/source/codegen/jni/react/renderer/components/CallingxSpec/CallingxSpecJSI.h +0 -283
  51. package/android/bin/build/generated/source/codegen/jni/react/renderer/components/CallingxSpec/ComponentDescriptors.cpp +0 -22
  52. package/android/bin/build/generated/source/codegen/jni/react/renderer/components/CallingxSpec/ComponentDescriptors.h +0 -24
  53. package/android/bin/build/generated/source/codegen/jni/react/renderer/components/CallingxSpec/EventEmitters.cpp +0 -16
  54. package/android/bin/build/generated/source/codegen/jni/react/renderer/components/CallingxSpec/EventEmitters.h +0 -17
  55. package/android/bin/build/generated/source/codegen/jni/react/renderer/components/CallingxSpec/Props.cpp +0 -19
  56. package/android/bin/build/generated/source/codegen/jni/react/renderer/components/CallingxSpec/Props.h +0 -18
  57. package/android/bin/build/generated/source/codegen/jni/react/renderer/components/CallingxSpec/ShadowNodes.cpp +0 -17
  58. package/android/bin/build/generated/source/codegen/jni/react/renderer/components/CallingxSpec/ShadowNodes.h +0 -23
  59. package/android/bin/build/generated/source/codegen/jni/react/renderer/components/CallingxSpec/States.cpp +0 -16
  60. package/android/bin/build/generated/source/codegen/jni/react/renderer/components/CallingxSpec/States.h +0 -20
  61. package/android/bin/build/generated/source/codegen/schema.json +0 -1
  62. package/android/bin/src/main/AndroidManifest.xml +0 -29
  63. package/android/bin/src/main/java/io/getstream/rn/callingx/notifications/NotificationChannelsManager.kt +0 -104
  64. /package/android/src/main/java/io/getstream/rn/callingx/{ResourceUtils.kt → utils/ResourceUtils.kt} +0 -0
  65. /package/android/src/main/java/io/getstream/rn/callingx/{Utils.kt → utils/Utils.kt} +0 -0
@@ -2,7 +2,10 @@ package io.getstream.rn.callingx
2
2
 
3
3
  import android.app.Notification
4
4
  import android.app.Service
5
+ import android.content.BroadcastReceiver
6
+ import android.content.Context
5
7
  import android.content.Intent
8
+ import android.content.IntentFilter
6
9
  import android.content.pm.ServiceInfo
7
10
  import android.net.Uri
8
11
  import android.os.Binder
@@ -11,11 +14,16 @@ import android.os.Bundle
11
14
  import android.os.IBinder
12
15
  import android.telecom.DisconnectCause
13
16
  import android.util.Log
17
+ import androidx.core.content.ContextCompat
18
+ import androidx.core.net.toUri
14
19
  import io.getstream.rn.callingx.model.Call
15
20
  import io.getstream.rn.callingx.model.CallAction
16
21
  import io.getstream.rn.callingx.notifications.CallNotificationManager
22
+ import io.getstream.rn.callingx.notifications.NotificationChannelsManager
23
+ import io.getstream.rn.callingx.notifications.NotificationsConfig
17
24
  import io.getstream.rn.callingx.repo.CallRepository
18
25
  import io.getstream.rn.callingx.repo.CallRepositoryFactory
26
+ import io.getstream.rn.callingx.utils.SettingsStore
19
27
  import kotlinx.coroutines.CoroutineScope
20
28
  import kotlinx.coroutines.SupervisorJob
21
29
  import kotlinx.coroutines.cancel
@@ -59,6 +67,59 @@ class CallService : Service(), CallRepository.Listener {
59
67
  internal const val ACTION_STOP_BACKGROUND_TASK = "stop_background_task"
60
68
  internal const val ACTION_STOP_SERVICE = "stop_service"
61
69
  internal const val ACTION_REGISTRATION_FAILED = "registration_failed"
70
+
71
+ fun startIncomingCallFromPush(context: Context, data: Map<String, String>) {
72
+ debugLog(TAG, "[service] startIncomingCallFromPush: Starting incoming call from push")
73
+
74
+ // Check if we are allowed to post call notifications (moved from JS layer).
75
+ val notificationsConfig = NotificationsConfig.loadNotificationsConfig(context)
76
+ val notificationChannelsManager =
77
+ NotificationChannelsManager(context).apply {
78
+ setNotificationsConfig(notificationsConfig)
79
+ }
80
+ val notificationStatus = notificationChannelsManager.getNotificationStatus()
81
+ if (!notificationStatus.canPost) {
82
+ debugLog(
83
+ TAG,
84
+ "[service] startIncomingCallFromPush: Cannot post notifications, skipping incoming call"
85
+ )
86
+ return
87
+ }
88
+
89
+ val shouldRejectCallWhenBusy = SettingsStore.shouldRejectCallWhenBusy(context)
90
+ if (shouldRejectCallWhenBusy && CallRegistrationStore.hasRegisteredCall()) {
91
+ debugLog(
92
+ TAG,
93
+ "[service] startIncomingCallFromPush: Registered call found and rejectCallWhenBusy is enabled, skipping incoming call"
94
+ )
95
+ return
96
+ }
97
+
98
+ val callCid = data["call_cid"]
99
+ if (callCid.isNullOrEmpty()) {
100
+ debugLog(
101
+ TAG,
102
+ "[service] startIncomingCallFromPush: Call CID is null or empty, skipping"
103
+ )
104
+ return
105
+ }
106
+
107
+ val callName = data["created_by_display_name"].orEmpty()
108
+ val isVideo = data["video"] == "true"
109
+
110
+ CallRegistrationStore.trackCallRegistration(callCid, null)
111
+
112
+ val intent =
113
+ Intent(context, CallService::class.java).apply {
114
+ action = ACTION_INCOMING_CALL
115
+ putExtra(EXTRA_CALL_ID, callCid)
116
+ putExtra(EXTRA_URI, callCid.toUri())
117
+ putExtra(EXTRA_NAME, callName)
118
+ putExtra(EXTRA_IS_VIDEO, isVideo)
119
+ }
120
+
121
+ ContextCompat.startForegroundService(context, intent)
122
+ }
62
123
  }
63
124
 
64
125
  inner class CallServiceBinder : Binder() {
@@ -71,9 +132,72 @@ class CallService : Service(), CallRepository.Listener {
71
132
 
72
133
  private val binder = CallServiceBinder()
73
134
  private val scope: CoroutineScope = CoroutineScope(SupervisorJob())
135
+ private val actionProcessingLock = Object()
74
136
 
75
137
  private var isInForeground = false
76
138
 
139
+ private val optimisticNotificationReceiver =
140
+ object : BroadcastReceiver() {
141
+ override fun onReceive(context: Context, intent: Intent) {
142
+ val callId = intent.getStringExtra(CallingxModuleImpl.EXTRA_CALL_ID) ?: return
143
+ when (intent.action) {
144
+ CallingxModuleImpl.CALL_OPTIMISTIC_ACCEPT_ACTION -> {
145
+ debugLog(
146
+ TAG,
147
+ "[service] optimisticReceiver: Optimistic accept for $callId"
148
+ )
149
+ notificationManager.stopRingtone()
150
+ notificationManager.setOptimisticState(
151
+ callId,
152
+ CallNotificationManager.OptimisticState.ACCEPTING
153
+ )
154
+ val call = callRepository.getCall(callId)
155
+ if (call != null) {
156
+ notificationManager.updateCallNotification(callId, call)
157
+ }
158
+ }
159
+ CallingxModuleImpl.CALL_END_ACTION -> {
160
+ val source = intent.getStringExtra(CallingxModuleImpl.EXTRA_SOURCE)
161
+ val cause =
162
+ intent.getStringExtra(CallingxModuleImpl.EXTRA_DISCONNECT_CAUSE)
163
+ val rejectedCause =
164
+ getDisconnectCauseString(
165
+ DisconnectCause(DisconnectCause.REJECTED)
166
+ )
167
+ val call = callRepository.getCall(callId)
168
+
169
+ val isSysSource =
170
+ source == CallRepository.EventSource.SYS.name.lowercase()
171
+
172
+ // we handle optimistic updates only if incoming call (non-answered) was rejected within notification action
173
+ if (!isSysSource ||
174
+ cause != rejectedCause ||
175
+ call == null ||
176
+ !call.isIncoming() ||
177
+ call.isActive
178
+ ) {
179
+ debugLog(
180
+ TAG,
181
+ "[service] optimisticReceiver: Skipping optimistic reject for $callId"
182
+ )
183
+ return
184
+ }
185
+
186
+ debugLog(
187
+ TAG,
188
+ "[service] optimisticReceiver: Optimistic reject for $callId"
189
+ )
190
+ notificationManager.stopRingtone()
191
+ notificationManager.setOptimisticState(
192
+ callId,
193
+ CallNotificationManager.OptimisticState.REJECTING
194
+ )
195
+ notificationManager.updateCallNotification(callId, call)
196
+ }
197
+ }
198
+ }
199
+ }
200
+
77
201
  override fun onCreate() {
78
202
  super.onCreate()
79
203
  debugLog(TAG, "[service] onCreate: TelecomCallService created")
@@ -83,6 +207,18 @@ class CallService : Service(), CallRepository.Listener {
83
207
  callRepository = CallRepositoryFactory.create(applicationContext)
84
208
  callRepository.setListener(this)
85
209
 
210
+ val filter =
211
+ IntentFilter().apply {
212
+ addAction(CallingxModuleImpl.CALL_OPTIMISTIC_ACCEPT_ACTION)
213
+ addAction(CallingxModuleImpl.CALL_END_ACTION)
214
+ }
215
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
216
+ registerReceiver(optimisticNotificationReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
217
+ } else {
218
+ @Suppress("UnspecifiedRegisterReceiverFlag")
219
+ registerReceiver(optimisticNotificationReceiver, filter)
220
+ }
221
+
86
222
  sendBroadcastEvent(CallingxModuleImpl.SERVICE_READY_ACTION)
87
223
  }
88
224
 
@@ -90,7 +226,9 @@ class CallService : Service(), CallRepository.Listener {
90
226
  super.onDestroy()
91
227
  debugLog(TAG, "[service] onDestroy: TelecomCallService destroyed")
92
228
 
93
- notificationManager.cancelNotifications()
229
+ unregisterReceiver(optimisticNotificationReceiver)
230
+
231
+ notificationManager.cancelAllNotifications()
94
232
  notificationManager.stopRingtone()
95
233
  callRepository.release()
96
234
  headlessJSManager.release()
@@ -124,23 +262,25 @@ class CallService : Service(), CallRepository.Listener {
124
262
  registerCall(intent, false)
125
263
  }
126
264
  ACTION_START_BACKGROUND_TASK -> {
127
- if (!isInForeground) {
128
- debugLog(TAG, "[service] onStartCommand: Starting foreground for background task")
129
- // for now bg task is intended to be used after a call registered and
130
- // notification is shown, so we don't need to show a separate notification for
131
- // bg task
132
- // startForeground(CallNotificationManager.NOTIFICATION_ID, notification)
133
- // isInForeground = true
134
- }
135
-
136
265
  startBackgroundTask(intent)
266
+ return START_NOT_STICKY
137
267
  }
138
268
  ACTION_STOP_BACKGROUND_TASK -> {
139
269
  stopBackgroundTask()
270
+ return START_NOT_STICKY
140
271
  }
141
272
  ACTION_UPDATE_CALL -> {
142
273
  updateCall(intent)
143
274
  }
275
+ ACTION_STOP_SERVICE -> {
276
+ if (isInForeground) {
277
+ stopForeground(STOP_FOREGROUND_REMOVE)
278
+ isInForeground = false
279
+ }
280
+ notificationManager.cancelAllNotifications()
281
+ notificationManager.stopRingtone()
282
+ stopSelf()
283
+ }
144
284
  else -> {
145
285
  Log.e(TAG, "[service] onStartCommand: Unknown action: ${intent.action}")
146
286
  stopSelf()
@@ -158,51 +298,61 @@ class CallService : Service(), CallRepository.Listener {
158
298
  return super.onUnbind(intent)
159
299
  }
160
300
 
161
- override fun onCallStateChanged(call: Call) {
162
- debugLog(TAG, "[service] onCallStateChanged: Call state changed: ${call::class.simpleName}")
301
+ override fun onCallStateChanged(callId: String, call: Call) {
302
+ debugLog(
303
+ TAG,
304
+ "[service] onCallStateChanged[$callId]: Call state changed: ${call::class.simpleName}"
305
+ )
163
306
  when (call) {
164
307
  is Call.Registered -> {
165
308
  debugLog(
166
309
  TAG,
167
- "[service] updateServiceState: Call registered - Active: ${call.isActive}, OnHold: ${call.isOnHold}, Muted: ${call.isMuted}"
310
+ "[service] onCallStateChanged[$callId]: Call registered - Active: ${call.isActive}, OnHold: ${call.isOnHold}, Muted: ${call.isMuted}"
168
311
  )
169
312
 
313
+ val shouldStopExecution = processPendingActions(call)
314
+ if (shouldStopExecution) {
315
+ return
316
+ }
317
+
170
318
  if (call.isIncoming()) {
171
- if (!call.isActive) notificationManager.startRingtone()
172
- else notificationManager.stopRingtone()
319
+ // Play ringtone only if there is no active call
320
+ if (!call.isActive && !callRepository.hasActiveCall(excludeCallId = callId)) {
321
+ notificationManager.startRingtone()
322
+ } else {
323
+ notificationManager.stopRingtone()
324
+ }
173
325
  }
174
- // Update the call state.
326
+ // Update the call notification
327
+ val notificationId = notificationManager.getOrCreateNotificationId(callId)
175
328
  if (isInForeground) {
176
- notificationManager.updateCallNotification(call)
329
+ notificationManager.updateCallNotification(callId, call)
177
330
  } else {
178
331
  debugLog(
179
332
  TAG,
180
- "[service] updateServiceState: Fallback starting foreground for call: ${call.id}"
333
+ "[service] onCallStateChanged[$callId]: Starting foreground for call"
181
334
  )
182
- //fallback if for some reason startForeground method is not called in onStartCommand method
183
- val notification = notificationManager.createNotification(call)
184
- startForegroundSafely(notification)
335
+ notificationManager.resetOptimisticState(callId)
336
+ val notification = notificationManager.createNotification(callId, call)
337
+ startForegroundSafely(notificationId, notification)
185
338
  }
186
339
  }
187
- is Call.Unregistered -> {
188
- notificationManager.updateCallNotification(call)
340
+ is Call.None, is Call.Unregistered -> {
341
+ repromoteForegroundIfNeeded(callId)
342
+ if (!callRepository.hasRingingCall()) notificationManager.stopRingtone()
189
343
 
190
- if (isInForeground) {
191
- stopForeground(STOP_FOREGROUND_REMOVE)
192
- isInForeground = false
193
- }
194
-
195
- notificationManager.stopRingtone()
196
- stopSelf()
197
- }
198
- is Call.None -> {
199
- notificationManager.updateCallNotification(call)
200
-
201
- if (isInForeground) {
202
- stopForeground(STOP_FOREGROUND_REMOVE)
203
- isInForeground = false
344
+ // Stop service only when no calls remain
345
+ if (!callRepository.hasAnyCalls()) {
346
+ debugLog(
347
+ TAG,
348
+ "[service] onCallStateChanged[$callId]: No more calls, stopping service"
349
+ )
350
+ if (isInForeground) {
351
+ stopForeground(STOP_FOREGROUND_REMOVE)
352
+ isInForeground = false
353
+ }
354
+ stopSelf()
204
355
  }
205
- notificationManager.stopRingtone()
206
356
  }
207
357
  }
208
358
  }
@@ -219,8 +369,6 @@ class CallService : Service(), CallRepository.Listener {
219
369
  cause: DisconnectCause,
220
370
  source: CallRepository.EventSource
221
371
  ) {
222
- // we're not passing the callId here to prevent infinite loops
223
- // callEnd event with callId will sent only when after interaction with notification buttons
224
372
  sendBroadcastEvent(CallingxModuleImpl.CALL_END_ACTION) {
225
373
  if (callId != null) {
226
374
  putExtra(CallingxModuleImpl.EXTRA_CALL_ID, callId)
@@ -268,21 +416,34 @@ class CallService : Service(), CallRepository.Listener {
268
416
  }
269
417
  }
270
418
 
271
- public fun hasRegisteredCall(): Boolean {
272
- val currentCall = callRepository.currentCall.value
273
- return currentCall is Call.Registered
274
- }
275
-
276
419
  public fun processAction(callId: String, action: CallAction) {
277
- debugLog(TAG, "[service] processAction: Processing action: ${action::class.simpleName}")
278
- val currentCall = callRepository.currentCall.value
279
- if (currentCall is Call.Registered && currentCall.id == callId) {
280
- currentCall.processAction(action)
281
- } else {
282
- Log.e(
283
- TAG,
284
- "[service] processAction: Call not registered or not the current call, ignoring action"
285
- )
420
+ debugLog(
421
+ TAG,
422
+ "[service] processAction[$callId]: Processing action: ${action::class.simpleName}"
423
+ )
424
+ synchronized(actionProcessingLock) {
425
+ val call = callRepository.getCall(callId)
426
+ if (call != null && !call.isPending) {
427
+ call.processAction(action)
428
+ } else {
429
+ // this solves race condition, when action is requested before the call is
430
+ // registered in Telecom
431
+ if (action is CallAction.Disconnect) {
432
+ debugLog(TAG, "[service] storing pending disconnect for $callId")
433
+ CallRegistrationStore.setPendingDisconnect(callId, action.cause.code)
434
+ } else if (action is CallAction.Answer) {
435
+ debugLog(TAG, "[service] storing pending answer for $callId")
436
+ CallRegistrationStore.setPendingAnswer(callId, action.isAudioCall)
437
+ } else if (action is CallAction.ToggleMute) {
438
+ debugLog(TAG, "[service] storing pending mute for $callId")
439
+ CallRegistrationStore.setPendingMute(callId, action.isMute)
440
+ } else {
441
+ Log.w(
442
+ TAG,
443
+ "[service] processAction[$callId]: Call not registered, ignoring action"
444
+ )
445
+ }
446
+ }
286
447
  }
287
448
  }
288
449
 
@@ -299,12 +460,16 @@ class CallService : Service(), CallRepository.Listener {
299
460
 
300
461
  private fun registerCall(intent: Intent, incoming: Boolean) {
301
462
  debugLog(TAG, "[service] registerCall: ${if (incoming) "in" else "out"} call")
463
+
302
464
  val callInfo = extractIntentParams(intent)
303
465
 
304
- // If we have an ongoing call, notify the module that registration is
305
- // already done (so the pending promise resolves) and skip re-registration.
306
- if (callRepository.currentCall.value is Call.Registered) {
307
- Log.w(TAG, "[service] registerCall: Call already registered, ignoring new call request")
466
+ // If this specific call is already registered, just notify
467
+ val existingCall = callRepository.getCall(callInfo.callId)
468
+ if (existingCall != null) {
469
+ Log.w(
470
+ TAG,
471
+ "[service] registerCall: Call ${callInfo.callId} already registered, notifying"
472
+ )
308
473
  if (incoming) {
309
474
  sendBroadcastEvent(CallingxModuleImpl.CALL_REGISTERED_INCOMING_ACTION) {
310
475
  putExtra(CallingxModuleImpl.EXTRA_CALL_ID, callInfo.callId)
@@ -316,17 +481,8 @@ class CallService : Service(), CallRepository.Listener {
316
481
  }
317
482
  return
318
483
  }
319
- val tempCall = callRepository.getTempCall(callInfo, incoming)
320
484
 
321
- //it is better to invoke startForeground method synchronously inside onStartCommand method
322
- if (!isInForeground) {
323
- debugLog(
324
- TAG,
325
- "[service] registerCall: Starting foreground for call: ${callInfo.callId}"
326
- )
327
- val notification = notificationManager.createNotification(tempCall)
328
- startForegroundSafely(notification)
329
- }
485
+ startForegroundForCall(callInfo, incoming)
330
486
 
331
487
  scope.launch {
332
488
  try {
@@ -345,34 +501,66 @@ class CallService : Service(), CallRepository.Listener {
345
501
  putExtra(CallingxModuleImpl.EXTRA_CALL_ID, callInfo.callId)
346
502
  }
347
503
 
348
- if (isInForeground) {
349
- stopForeground(STOP_FOREGROUND_REMOVE)
350
- isInForeground = false
504
+ repromoteForegroundIfNeeded(callInfo.callId)
505
+
506
+ // Only stop foreground/service when no other calls remain
507
+ if (!callRepository.hasAnyCalls()) {
508
+ if (isInForeground) {
509
+ stopForeground(STOP_FOREGROUND_REMOVE)
510
+ isInForeground = false
511
+ }
512
+ notificationManager.stopRingtone()
513
+ stopSelf()
351
514
  }
515
+ }
516
+ }
517
+ }
352
518
 
353
- notificationManager.cancelNotifications()
354
- notificationManager.stopRingtone()
355
- stopSelf()
519
+ private fun processPendingActions(call: Call.Registered): Boolean {
520
+ synchronized(actionProcessingLock) {
521
+ val pendingCauseCode = CallRegistrationStore.takePendingDisconnect(call.id)
522
+ val pendingAnswer = CallRegistrationStore.takePendingAnswer(call.id)
523
+ val pendingMute = CallRegistrationStore.takePendingMute(call.id)
524
+
525
+ if (pendingCauseCode != null) {
526
+ debugLog(
527
+ TAG,
528
+ "[service] onCallStateChanged: Executing pending disconnect for ${call.id}"
529
+ )
530
+ call.processAction(CallAction.Disconnect(DisconnectCause(pendingCauseCode)))
531
+ return true
356
532
  }
533
+
534
+ if (pendingAnswer != null) {
535
+ debugLog(
536
+ TAG,
537
+ "[service] onCallStateChanged: Executing pending answer for ${call.id}"
538
+ )
539
+ call.processAction(CallAction.Answer(pendingAnswer))
540
+ }
541
+
542
+ if (pendingMute != null) {
543
+ debugLog(TAG, "[service] onCallStateChanged: Executing pending mute for ${call.id}")
544
+ call.processAction(CallAction.ToggleMute(pendingMute))
545
+ }
546
+
547
+ return false
357
548
  }
358
549
  }
359
550
 
360
- private fun startForegroundSafely(notification: Notification) {
551
+ private fun startForegroundSafely(notificationId: Int, notification: Notification) {
361
552
  try {
362
553
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
363
554
  startForeground(
364
- CallNotificationManager.NOTIFICATION_ID,
555
+ notificationId,
365
556
  notification,
366
557
  ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
367
558
  )
368
559
  } else {
369
- startForeground(CallNotificationManager.NOTIFICATION_ID, notification)
560
+ startForeground(notificationId, notification)
370
561
  }
371
562
  isInForeground = true
372
563
  } catch (e: Exception) {
373
- // If starting the foreground service fails (for example due to background start
374
- // restrictions or notification issues), we log the error but avoid crashing the
375
- // process so the rest of the call flow can continue and be recovered by Telecom.
376
564
  Log.e(
377
565
  TAG,
378
566
  "[service] startForegroundSafely: Failed to start foreground service: ${e.message}",
@@ -381,6 +569,40 @@ class CallService : Service(), CallRepository.Listener {
381
569
  }
382
570
  }
383
571
 
572
+ /**
573
+ * Cancels the notification for [callId]. If that notification was the foreground one
574
+ * and other calls remain, re-promotes the service with the next call's notification.
575
+ */
576
+ private fun repromoteForegroundIfNeeded(callId: String) {
577
+ val newForegroundNotificationId = notificationManager.cancelNotification(callId)
578
+ if (newForegroundNotificationId != null && isInForeground) {
579
+ val newForegroundCallId = notificationManager.getForegroundCallId()
580
+ val call = if (newForegroundCallId != null) callRepository.getCall(newForegroundCallId) else null
581
+ if (call != null && newForegroundCallId != null) {
582
+ debugLog(TAG, "[service] repromoteForegroundIfNeeded: Re-promoting with call $newForegroundCallId (notificationId=$newForegroundNotificationId)")
583
+ val notification = notificationManager.createNotification(newForegroundCallId, call)
584
+ startForegroundSafely(newForegroundNotificationId, notification)
585
+ }
586
+ }
587
+ }
588
+
589
+ private fun startForegroundForCall(callInfo: CallInfo, incoming: Boolean) {
590
+ val tempCall = callRepository.getTempCall(callInfo, incoming)
591
+ val notificationId = notificationManager.getOrCreateNotificationId(callInfo.callId)
592
+ if (!isInForeground) {
593
+ debugLog(
594
+ TAG,
595
+ "[service] registerCall: Starting foreground for call: ${callInfo.callId}"
596
+ )
597
+ val notification = notificationManager.createNotification(callInfo.callId, tempCall)
598
+ startForegroundSafely(notificationId, notification)
599
+ } else {
600
+ // Already in foreground from another call — just post the notification
601
+ val notification = notificationManager.createNotification(callInfo.callId, tempCall)
602
+ notificationManager.postNotification(callInfo.callId, notification)
603
+ }
604
+ }
605
+
384
606
  private fun updateCall(intent: Intent) {
385
607
  val callInfo = extractIntentParams(intent)
386
608
  callRepository.updateCall(