@nativetalkcommunications/react-native-call-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/NativetalkCallSdk.podspec +31 -0
  3. package/README.md +494 -0
  4. package/android/build.gradle +58 -0
  5. package/android/gradle.properties +2 -0
  6. package/android/src/main/AndroidManifest.xml +84 -0
  7. package/android/src/main/java/io/nativetalk/callsdk/BackgroundService.kt +149 -0
  8. package/android/src/main/java/io/nativetalk/callsdk/CallActionReceiver.kt +24 -0
  9. package/android/src/main/java/io/nativetalk/callsdk/CallService.kt +45 -0
  10. package/android/src/main/java/io/nativetalk/callsdk/Compatibility.kt +96 -0
  11. package/android/src/main/java/io/nativetalk/callsdk/CoreManager.kt +801 -0
  12. package/android/src/main/java/io/nativetalk/callsdk/NativetalkCallScreeningService.kt +105 -0
  13. package/android/src/main/java/io/nativetalk/callsdk/NativetalkCallSdkModule.kt +205 -0
  14. package/android/src/main/java/io/nativetalk/callsdk/NativetalkCallSdkPackage.kt +18 -0
  15. package/android/src/main/java/io/nativetalk/callsdk/TelephonyMonitor.kt +229 -0
  16. package/android/src/main/java/io/nativetalk/callsdk/Utils.kt +42 -0
  17. package/android/src/main/res/drawable/ic_nativetalk_call.xml +9 -0
  18. package/android/src/main/res/values/strings.xml +9 -0
  19. package/app.plugin.js +1 -0
  20. package/ios/NativetalkCallSdk-Bridging-Header.h +4 -0
  21. package/ios/NativetalkCallSdk.swift +738 -0
  22. package/ios/NativetalkCallSdkBridge.m +35 -0
  23. package/lib/commonjs/CallProvider.js +602 -0
  24. package/lib/commonjs/helpers.js +173 -0
  25. package/lib/commonjs/index.js +96 -0
  26. package/lib/commonjs/native.js +146 -0
  27. package/lib/commonjs/types.js +8 -0
  28. package/lib/commonjs/ui/Avatar.js +29 -0
  29. package/lib/commonjs/ui/Dialer.js +189 -0
  30. package/lib/commonjs/ui/IncomingCallView.js +128 -0
  31. package/lib/commonjs/ui/OutgoingCallView.js +117 -0
  32. package/lib/commonjs/ui/index.js +22 -0
  33. package/lib/commonjs/ui/theme.js +21 -0
  34. package/lib/module/CallProvider.js +573 -0
  35. package/lib/module/helpers.js +161 -0
  36. package/lib/module/index.js +57 -0
  37. package/lib/module/native.js +123 -0
  38. package/lib/module/types.js +7 -0
  39. package/lib/module/ui/Avatar.js +22 -0
  40. package/lib/module/ui/Dialer.js +162 -0
  41. package/lib/module/ui/IncomingCallView.js +101 -0
  42. package/lib/module/ui/OutgoingCallView.js +110 -0
  43. package/lib/module/ui/index.js +13 -0
  44. package/lib/module/ui/theme.js +17 -0
  45. package/lib/typescript/CallProvider.d.ts +46 -0
  46. package/lib/typescript/helpers.d.ts +52 -0
  47. package/lib/typescript/index.d.ts +77 -0
  48. package/lib/typescript/native.d.ts +53 -0
  49. package/lib/typescript/types.d.ts +155 -0
  50. package/lib/typescript/ui/Avatar.d.ts +13 -0
  51. package/lib/typescript/ui/Dialer.d.ts +29 -0
  52. package/lib/typescript/ui/IncomingCallView.d.ts +39 -0
  53. package/lib/typescript/ui/OutgoingCallView.d.ts +28 -0
  54. package/lib/typescript/ui/index.d.ts +13 -0
  55. package/lib/typescript/ui/theme.d.ts +20 -0
  56. package/linphonesw-pod/Sources/LinphoneSdkInfos.swift +4 -0
  57. package/linphonesw-pod/Sources/LinphoneWrapper.swift +42949 -0
  58. package/linphonesw-pod/linphonesw.podspec +46 -0
  59. package/package.json +90 -0
  60. package/plugin/build/index.js +12 -0
  61. package/plugin/build/withAndroid.js +78 -0
  62. package/plugin/build/withIos.js +66 -0
  63. package/src/CallProvider.tsx +675 -0
  64. package/src/helpers.ts +179 -0
  65. package/src/index.ts +84 -0
  66. package/src/native.ts +185 -0
  67. package/src/types.ts +202 -0
  68. package/src/ui/Avatar.tsx +46 -0
  69. package/src/ui/Dialer.tsx +248 -0
  70. package/src/ui/IncomingCallView.tsx +161 -0
  71. package/src/ui/OutgoingCallView.tsx +203 -0
  72. package/src/ui/index.ts +13 -0
  73. package/src/ui/theme.ts +36 -0
  74. package/ui/package.json +6 -0
@@ -0,0 +1,801 @@
1
+ package io.nativetalk.callsdk
2
+
3
+ import android.Manifest
4
+ import android.app.Notification
5
+ import android.app.NotificationChannel
6
+ import android.app.PendingIntent
7
+ import android.app.Service.STOP_FOREGROUND_REMOVE
8
+ import android.content.Context
9
+ import android.content.Intent
10
+ import android.content.pm.PackageManager
11
+ import android.net.Uri
12
+ import android.os.Build
13
+ import android.util.Log
14
+ import androidx.annotation.AnyThread
15
+ import androidx.annotation.MainThread
16
+ import androidx.annotation.WorkerThread
17
+ import androidx.core.app.ActivityCompat
18
+ import androidx.core.app.NotificationCompat
19
+ import androidx.core.app.NotificationManagerCompat
20
+ import androidx.core.app.Person
21
+ import com.facebook.react.bridge.Arguments
22
+ import com.facebook.react.bridge.ReactApplicationContext
23
+ import com.facebook.react.bridge.WritableArray
24
+ import com.facebook.react.bridge.WritableNativeArray
25
+ import com.facebook.react.bridge.WritableNativeMap
26
+ import com.facebook.react.modules.core.DeviceEventManagerModule
27
+ import org.linphone.core.AudioDevice
28
+ import org.linphone.core.Call
29
+ import org.linphone.core.Core
30
+ import org.linphone.core.CoreListener
31
+ import org.linphone.core.CoreListenerStub
32
+ import org.linphone.core.Factory
33
+ import org.linphone.core.LogCollectionState
34
+ import org.linphone.core.Reason
35
+ import java.text.SimpleDateFormat
36
+ import java.util.Date
37
+ import java.util.HashMap
38
+ import java.util.Locale
39
+ import java.util.TimeZone
40
+ import kotlin.math.abs
41
+
42
+ /**
43
+ * Process-wide owner of the Linphone `Core`.
44
+ *
45
+ * ──────────────────────────────────────────────────────────────────────────
46
+ * Why is this an `object` (singleton)?
47
+ * ──────────────────────────────────────────────────────────────────────────
48
+ *
49
+ * Three subsystems need to talk to the SAME Linphone Core:
50
+ *
51
+ * 1. The React Native module ([NativetalkCallSdkModule]) when JS calls
52
+ * dial(), answer(), etc.
53
+ * 2. The foreground [CallService] which owns the call notification.
54
+ * 3. The [BackgroundService] which keeps the SIP socket warm when the
55
+ * app is backgrounded.
56
+ *
57
+ * If any of these spawned its own Core, you'd have two SIP registrations,
58
+ * duplicate notifications, and audio routing fights. By centralising the
59
+ * Core here (process lifetime), all three subsystems share one engine.
60
+ *
61
+ * This also lets an incoming push wake the engine BEFORE React mounts:
62
+ * the push wakes [BackgroundService] → which calls `ensureStarted()` →
63
+ * which registers and accepts the call. Once React boots, [attachReact]
64
+ * replays any in-flight call state into JS.
65
+ *
66
+ * ──────────────────────────────────────────────────────────────────────────
67
+ * React decoupling
68
+ * ──────────────────────────────────────────────────────────────────────────
69
+ * Calling any method here works whether or not React is alive. Events are
70
+ * only forwarded to JS if [attachReact] has been called — otherwise [emit]
71
+ * is a silent no-op. This is what makes push-driven, RN-not-yet-ready
72
+ * flows possible.
73
+ */
74
+ object CoreManager {
75
+ private const val TAG = "NativetalkCallSdk.Core"
76
+
77
+ // Two channels because Android shows them differently:
78
+ // - INCOMING uses IMPORTANCE_HIGH (heads-up notification + sound)
79
+ // - ONGOING uses IMPORTANCE_LOW (silent persistent notification)
80
+ // Trying to reuse one channel for both gives you either spam (high)
81
+ // or invisible incoming calls (low).
82
+ const val CHANNEL_INCOMING = "incoming_calls"
83
+ const val CHANNEL_ONGOING = "ongoing_calls"
84
+
85
+ // Broadcast intent actions for the Answer / Decline buttons that appear
86
+ // on the heads-up notification. Caught by [CallActionReceiver].
87
+ const val ACTION_ANSWER_CALL = "io.nativetalk.callsdk.ACTION_ANSWER"
88
+ const val ACTION_DECLINE_CALL = "io.nativetalk.callsdk.ACTION_DECLINE"
89
+
90
+ // Stable notification IDs. INCOMING_CALL_ID is fixed at 1 so we can
91
+ // always find/cancel the ringing notification; per-call notifications
92
+ // use the call's start timestamp (see [getNotificationIdForCall]) so
93
+ // multiple concurrent calls don't collide.
94
+ private const val INCOMING_CALL_ID = 1
95
+ private const val INTENT_ANSWER_CALL_NOTIF_CODE = 2
96
+ private const val INTENT_DECLINE_CALL_NOTIF_CODE = 3
97
+
98
+ private var core: Core? = null
99
+ private var listener: CoreListener? = null
100
+
101
+ private val callNotificationsMap: HashMap<String, Notifiable> = HashMap()
102
+ private val notificationsMap = HashMap<Int, Notification>()
103
+
104
+ private var callService: CallService? = null
105
+ private var currentInCallServiceNotificationId = -1
106
+ private var inCallServiceForegroundNotificationPublished = false
107
+ private var waitForInCallServiceForegroundToStopIt = false
108
+
109
+ @Volatile
110
+ private var reactContext: ReactApplicationContext? = null
111
+
112
+ private lateinit var notificationManager: NotificationManagerCompat
113
+ private lateinit var context: Context
114
+
115
+ @JvmStatic
116
+ fun core(): Core? = core
117
+
118
+ /**
119
+ * Boot (or no-op if already booted) the Linphone Core.
120
+ *
121
+ * Safe to call from multiple subsystems — the [Synchronized] + double
122
+ * check makes it idempotent. The first caller wins; later callers just
123
+ * receive a reference to the already-running core.
124
+ *
125
+ * After this returns, you can [register], [call], etc. — but events
126
+ * won't reach JS until [attachReact] is also called.
127
+ */
128
+ @Synchronized
129
+ fun ensureStarted(ctx: Context) {
130
+ if (::context.isInitialized && core != null) return
131
+ context = ctx.applicationContext
132
+ notificationManager = NotificationManagerCompat.from(context)
133
+
134
+ val f = Factory.instance()
135
+ // Linphone writes diagnostic logs to disk — useful for debugging
136
+ // production issues over the phone. They land in the app's
137
+ // internal storage (not visible to the user).
138
+ f.setLogCollectionPath(context.filesDir.absolutePath)
139
+ f.enableLogCollection(LogCollectionState.Enabled)
140
+
141
+ core = f.createCore(null, null, context)
142
+ // KeepAlive sends OPTIONS pings to detect dead TCP/TLS sockets so
143
+ // we can re-register before the user notices. Without it a NATted
144
+ // session can silently die after 5+ minutes.
145
+ core?.isKeepAliveEnabled = true
146
+ // Tell Linphone we have a network. Without this it starts in an
147
+ // "unreachable" state and won't even attempt registration.
148
+ core?.isNetworkReachable = true
149
+
150
+ // The CoreListener is how Linphone tells US about state changes.
151
+ // Each override does two things:
152
+ // 1. Update the foreground-service notification (Android UI).
153
+ // 2. Forward the event to React via [emit] (cross-platform UI).
154
+ //
155
+ // Notifications go FIRST so that even if React is dead (e.g. the
156
+ // app was killed and we're being woken by push), the user still
157
+ // sees a ringing notification.
158
+ listener = object : CoreListenerStub() {
159
+ override fun onRegistrationStateChanged(
160
+ c: Core,
161
+ proxy: org.linphone.core.ProxyConfig,
162
+ state: org.linphone.core.RegistrationState,
163
+ message: String
164
+ ) {
165
+ emit("RegistrationChanged", Arguments.createMap().apply {
166
+ putString("state", state.toString())
167
+ putString("message", message)
168
+ })
169
+ Log.d(TAG, "Registration -> $state ($message)")
170
+ }
171
+
172
+ override fun onCallStateChanged(c: Core, call: Call, state: Call.State, message: String) {
173
+ Log.d(TAG, "CallState -> $state ($message)")
174
+ // Always emit the raw state — JS does its own FSM bucketing.
175
+ emit("CallState", Arguments.createMap().apply {
176
+ putString("state", state.toString())
177
+ putString("message", message)
178
+ })
179
+
180
+ when (state) {
181
+ // ── Inbound: phone is ringing ─────────────────────────
182
+ Call.State.IncomingReceived, Call.State.IncomingEarlyMedia -> {
183
+ val addr = call.remoteAddress
184
+ val disp = addr?.displayName ?: ""
185
+ val user = addr?.username ?: ""
186
+ val uri = addr?.asStringUriOnly() ?: addr?.asString() ?: ""
187
+
188
+ Log.d(TAG, "Incoming call from $user ($uri)")
189
+
190
+ // Emit to JS first so the in-app UI can react even if the
191
+ // system notification fails (e.g. POST_NOTIFICATIONS denied).
192
+ emit("CallIncoming", Arguments.createMap().apply {
193
+ putString(
194
+ "from",
195
+ if (disp.isNotEmpty() && disp.lowercase() != "anonymous") disp else user
196
+ )
197
+ putString("displayName", disp)
198
+ putString("username", user)
199
+ putString("uri", uri)
200
+ putString("callId", call.callLog?.callId ?: "")
201
+ })
202
+
203
+ // Then show the heads-up notification so the call is visible
204
+ // when the app is backgrounded. Wrapped in try/catch so a
205
+ // notification failure never suppresses the JS event above.
206
+ try { showCallNotification(call, true) } catch (e: Exception) {
207
+ Log.e(TAG, "showCallNotification failed", e)
208
+ }
209
+ }
210
+
211
+ // ── Outbound: we just placed a call ───────────────────
212
+ Call.State.OutgoingInit -> showCallNotification(call, false)
213
+
214
+ // ── SIP "200 OK" received — call accepted by remote ──
215
+ Call.State.Connected -> {
216
+ if (call.dir == Call.Dir.Incoming) {
217
+ // Incoming call was answered — tear down the
218
+ // ringing foreground notification because the
219
+ // in-call UI is now responsible for foreground.
220
+ stopCallForegroundService()
221
+ } else {
222
+ // Outgoing call connected — upgrade the
223
+ // notification from "calling…" to "in call".
224
+ showCallNotification(call, false)
225
+ }
226
+ }
227
+
228
+ // ── Audio actually flowing both ways ──────────────────
229
+ Call.State.StreamsRunning -> {
230
+ // Re-publish the notification with FOREGROUND_SERVICE_TYPE_MICROPHONE
231
+ // so Android 14+ allows continued mic access. See
232
+ // [startInCallForegroundService] for the type mask logic.
233
+ val notifiable = getNotifiableForCall(call)
234
+ if (notifiable.notificationId == currentInCallServiceNotificationId) {
235
+ startInCallForegroundService(call)
236
+ }
237
+ }
238
+
239
+ // ── Terminal: hangup, error, or fully released ───────
240
+ Call.State.End, Call.State.Released, Call.State.Error -> {
241
+ stopCallForegroundService()
242
+ emit("CallEnded", Arguments.createMap())
243
+ }
244
+
245
+ // Pause, Resume, EarlyMedia, etc. — JS handles the UI;
246
+ // no native-side notification change needed.
247
+ else -> Unit
248
+ }
249
+ }
250
+ }
251
+ core?.addListener(listener)
252
+ core?.start()
253
+
254
+ TelephonyMonitor.start(context)
255
+ }
256
+
257
+ @MainThread
258
+ fun onCallServiceStarted(service: CallService) {
259
+ ensureStarted(service)
260
+ callService = service
261
+ createCallNotificationChannels()
262
+ }
263
+
264
+ fun attachReact(react: ReactApplicationContext) {
265
+ // If a call was already ringing when JS finishes booting, replay it.
266
+ val call = core?.currentCall
267
+ if (call != null && call.dir == Call.Dir.Incoming) {
268
+ val addr = call.remoteAddress
269
+ val disp = addr?.displayName ?: ""
270
+ val user = addr?.username ?: ""
271
+ val uri = addr?.asStringUriOnly() ?: addr?.asString() ?: ""
272
+
273
+ emit("CallIncoming", Arguments.createMap().apply {
274
+ putString("from", if (disp.isNotEmpty() && disp.lowercase() != "anonymous") disp else user)
275
+ putString("displayName", disp)
276
+ putString("username", user)
277
+ putString("uri", uri)
278
+ })
279
+ }
280
+ reactContext = react
281
+ }
282
+
283
+ fun detachReact() {
284
+ reactContext = null
285
+ }
286
+
287
+ // === Notifications ===
288
+
289
+ class Notifiable(val notificationId: Int) {
290
+ var remoteAddress: String? = null
291
+ }
292
+
293
+ private fun createCallNotificationChannels() {
294
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
295
+ val incoming = NotificationChannel(
296
+ CHANNEL_INCOMING,
297
+ "Incoming Calls",
298
+ NotificationManagerCompat.IMPORTANCE_HIGH
299
+ ).apply {
300
+ description = "Incoming SIP/VoIP calls"
301
+ lockscreenVisibility = Notification.VISIBILITY_PUBLIC
302
+ }
303
+ notificationManager.createNotificationChannel(incoming)
304
+
305
+ val ongoing = NotificationChannel(
306
+ CHANNEL_ONGOING,
307
+ "Ongoing Calls",
308
+ NotificationManagerCompat.IMPORTANCE_LOW
309
+ ).apply {
310
+ description = "Active SIP/VoIP calls"
311
+ lockscreenVisibility = Notification.VISIBILITY_PUBLIC
312
+ }
313
+ notificationManager.createNotificationChannel(ongoing)
314
+ }
315
+
316
+ @WorkerThread
317
+ private fun getNotificationIdForCall(call: Call): Int = call.callLog.startDate.toInt()
318
+
319
+ @WorkerThread
320
+ private fun getNotifiableForCall(call: Call): Notifiable {
321
+ val address = call.remoteAddress.asStringUriOnly()
322
+ var n = callNotificationsMap[address]
323
+ if (n == null) {
324
+ n = Notifiable(getNotificationIdForCall(call))
325
+ n.remoteAddress = address
326
+ callNotificationsMap[address] = n
327
+ }
328
+ return n
329
+ }
330
+
331
+ @AnyThread
332
+ private fun callDeclinePendingIntent(notifiable: Notifiable): PendingIntent {
333
+ val i = Intent(context, CallActionReceiver::class.java).apply {
334
+ action = ACTION_DECLINE_CALL
335
+ putExtra("NOTIFICATION_ID", notifiable.notificationId)
336
+ putExtra("REMOTE_ADDRESS", notifiable.remoteAddress)
337
+ }
338
+ return PendingIntent.getBroadcast(
339
+ context,
340
+ INTENT_DECLINE_CALL_NOTIF_CODE,
341
+ i,
342
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
343
+ )
344
+ }
345
+
346
+ @AnyThread
347
+ private fun callAnswerPendingIntent(notifiable: Notifiable): PendingIntent {
348
+ val i = Intent(context, CallActionReceiver::class.java).apply {
349
+ action = ACTION_ANSWER_CALL
350
+ putExtra("NOTIFICATION_ID", notifiable.notificationId)
351
+ putExtra("REMOTE_ADDRESS", notifiable.remoteAddress)
352
+ }
353
+ return PendingIntent.getBroadcast(
354
+ context,
355
+ INTENT_ANSWER_CALL_NOTIF_CODE,
356
+ i,
357
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
358
+ )
359
+ }
360
+
361
+ /**
362
+ * Builds the system call notification.
363
+ *
364
+ * Uses [NotificationCompat.CallStyle] (Android 12+) which gives us the
365
+ * standard pill-shaped Answer / Decline buttons that users recognise
366
+ * from the native dialer. On older Android versions the system falls
367
+ * back to a regular notification with action buttons.
368
+ *
369
+ * Two important details:
370
+ *
371
+ * - [setFullScreenIntent] makes the notification take over the screen
372
+ * when the device is locked (the "ringing on lockscreen" UX). This
373
+ * requires the `USE_FULL_SCREEN_INTENT` permission, which the SDK
374
+ * declares in its manifest.
375
+ * - `setAutoCancel(false) + setOngoing(true)` together prevent the
376
+ * user from swiping the notification away while the call is live.
377
+ */
378
+ @WorkerThread
379
+ private fun createCallNotification(
380
+ call: Call,
381
+ notifiable: Notifiable,
382
+ contentIntent: PendingIntent?,
383
+ isIncoming: Boolean
384
+ ): Notification {
385
+ val decline = callDeclinePendingIntent(notifiable)
386
+ val answer = callAnswerPendingIntent(notifiable)
387
+ val remote = call.callLog.remoteAddress
388
+
389
+ val caller = Person.Builder()
390
+ .setName(Utils.displayNameFor(remote).ifEmpty { "Unknown" })
391
+ .setImportant(false)
392
+ .build()
393
+
394
+ // Incoming style gives BOTH answer and decline actions; ongoing
395
+ // (active call) gives only end-call.
396
+ val style = if (isIncoming) {
397
+ NotificationCompat.CallStyle.forIncomingCall(caller, decline, answer)
398
+ } else {
399
+ NotificationCompat.CallStyle.forOngoingCall(caller, decline)
400
+ }
401
+
402
+ val channelId = if (isIncoming) CHANNEL_INCOMING else CHANNEL_ONGOING
403
+ return NotificationCompat.Builder(context, channelId).apply {
404
+ setColorized(true) // colour the whole notification, not just the icon
405
+ setOnlyAlertOnce(true) // don't re-sound on update
406
+ setStyle(style)
407
+ setSmallIcon(R.drawable.ic_nativetalk_call)
408
+ setCategory(NotificationCompat.CATEGORY_CALL)
409
+ setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
410
+ // PRIORITY_MAX on incoming = the heads-up actually appears.
411
+ // PRIORITY_HIGH on ongoing = persistent but not screaming.
412
+ setPriority(
413
+ if (isIncoming) NotificationCompat.PRIORITY_MAX
414
+ else NotificationCompat.PRIORITY_HIGH
415
+ )
416
+ // Linphone gives startDate in seconds; Android wants ms.
417
+ setWhen(call.callLog.startDate * 1000)
418
+ setAutoCancel(false)
419
+ setOngoing(true)
420
+ setContentIntent(contentIntent)
421
+ setFullScreenIntent(contentIntent, true)
422
+ }.build()
423
+ }
424
+
425
+ private fun showCallNotification(call: Call, isIncoming: Boolean) {
426
+ val notifiable = getNotifiableForCall(call)
427
+ val display = Utils.displayNameFor(call.callLog.remoteAddress).ifEmpty { "Unknown" }
428
+ val initials = display.take(2).uppercase()
429
+ val phone = call.remoteAddress.asStringUriOnly()
430
+
431
+ // Try to open the host app's launcher activity when the notification is
432
+ // tapped. Apps that need to route to a specific screen can listen for
433
+ // the JS `CallIncoming` / `CallEnded` events and navigate themselves.
434
+ val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
435
+ val openIntent = launchIntent ?: Intent()
436
+ openIntent.apply {
437
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
438
+ putExtra("NativetalkCallSdk_call_id", call.callLog?.callId ?: "")
439
+ putExtra("NativetalkCallSdk_phone", phone)
440
+ putExtra("NativetalkCallSdk_displayName", display)
441
+ putExtra("NativetalkCallSdk_initials", initials)
442
+ putExtra(if (isIncoming) "IncomingCall" else "ActiveCall", true)
443
+ }
444
+
445
+ val pendingIntent = PendingIntent.getActivity(
446
+ context,
447
+ 0,
448
+ openIntent,
449
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
450
+ )
451
+
452
+ val notification = createCallNotification(call, notifiable, pendingIntent, isIncoming)
453
+ if (isIncoming) {
454
+ showIncomingCallForegroundServiceNotification(notification)
455
+ } else {
456
+ showInCallForegroundServiceNotification(call, notifiable, notification)
457
+ }
458
+ }
459
+
460
+ private fun showIncomingCallForegroundServiceNotification(notification: Notification) {
461
+ val service = callService ?: return
462
+ if (!Compatibility.isPostNotificationsPermissionGranted(context)) {
463
+ Log.e(TAG, "POST_NOTIFICATIONS not granted — can't start incoming foreground service")
464
+ return
465
+ }
466
+ createCallNotificationChannels()
467
+ Compatibility.startServiceForeground(
468
+ service,
469
+ INCOMING_CALL_ID,
470
+ notification,
471
+ Compatibility.FOREGROUND_SERVICE_TYPE_PHONE_CALL
472
+ )
473
+ }
474
+
475
+ private fun showInCallForegroundServiceNotification(
476
+ call: Call,
477
+ notifiable: Notifiable,
478
+ notification: Notification
479
+ ) {
480
+ val service = callService ?: return
481
+ var mask = Compatibility.FOREGROUND_SERVICE_TYPE_PHONE_CALL
482
+ val state = call.state
483
+ if (!Utils.isCallIncoming(state) && !Utils.isCallOutgoing(state) && !Utils.isCallEnding(state)) {
484
+ if (ActivityCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
485
+ == PackageManager.PERMISSION_GRANTED
486
+ ) {
487
+ mask = mask or Compatibility.FOREGROUND_SERVICE_TYPE_MICROPHONE
488
+ }
489
+ }
490
+ if (Compatibility.isPostNotificationsPermissionGranted(context)) {
491
+ Compatibility.startServiceForeground(service, notifiable.notificationId, notification, mask)
492
+ notificationsMap[notifiable.notificationId] = notification
493
+ currentInCallServiceNotificationId = notifiable.notificationId
494
+ inCallServiceForegroundNotificationPublished = true
495
+ if (waitForInCallServiceForegroundToStopIt) stopCallForegroundService()
496
+ }
497
+ }
498
+
499
+ @WorkerThread
500
+ private fun startInCallForegroundService(call: Call) {
501
+ val service = callService ?: return
502
+ val notifiable = getNotifiableForCall(call)
503
+ val notification = notificationsMap[notifiable.notificationId]
504
+ ?: notificationsMap[INCOMING_CALL_ID]
505
+ ?: return
506
+ showInCallForegroundServiceNotification(call, notifiable, notification)
507
+ }
508
+
509
+ private fun stopCallForegroundService() {
510
+ val service = callService ?: return
511
+ service.stopForeground(STOP_FOREGROUND_REMOVE)
512
+ inCallServiceForegroundNotificationPublished = false
513
+ waitForInCallServiceForegroundToStopIt = false
514
+ }
515
+
516
+ // ══════════════════════════════════════════════════════════════════════
517
+ // Registration & calling
518
+ // ══════════════════════════════════════════════════════════════════════
519
+
520
+ /**
521
+ * Wipe every proxy config and auth info from the core.
522
+ *
523
+ * Linphone is happy to accumulate accounts — call [Core.addAuthInfo]
524
+ * twice with different passwords and BOTH stick around, with no clear
525
+ * "winner". When we switch users (or want to recover from a bad
526
+ * password), we have to scrub the slate first or we'll fail with stale
527
+ * credentials.
528
+ *
529
+ * Each removal is wrapped in [runCatching] because Linphone occasionally
530
+ * throws when you remove a config it's mid-way through using. Best-effort
531
+ * is fine here — the goal is "core is in a clean state when this
532
+ * returns", not "every individual removal succeeded".
533
+ */
534
+ private fun wipeAllAccounts(c: Core) {
535
+ try {
536
+ val proxies = c.proxyConfigList?.toList() ?: emptyList()
537
+ proxies.forEach { pc ->
538
+ runCatching { pc.isRegisterEnabled = false }
539
+ runCatching { c.removeProxyConfig(pc) }
540
+ }
541
+ runCatching { c.defaultProxyConfig = null }
542
+ try { c.clearAllAuthInfo() } catch (_: Throwable) {
543
+ // Fallback for Linphone builds without clearAllAuthInfo().
544
+ c.authInfoList?.toList()?.forEach { ai -> runCatching { c.removeAuthInfo(ai) } }
545
+ }
546
+ runCatching { c.refreshRegisters() }
547
+ } catch (_: Throwable) { /* best effort */ }
548
+ }
549
+
550
+ /**
551
+ * Register the given SIP account, replacing any previous one.
552
+ *
553
+ * Has a fast path for the "same user re-registering" case: if we
554
+ * already have a healthy session with this exact username+domain, we
555
+ * just update the auth (in case the password changed) and refresh.
556
+ * Skipping the wipe matters because [wipeAllAccounts] briefly puts the
557
+ * core into an unregistered state — if you're calling this from a
558
+ * "periodic re-register" loop, you don't want a 200ms outage every time.
559
+ *
560
+ * Falls through to a full wipe + setup if the identity changed.
561
+ */
562
+ fun register(username: String, password: String, domain: String, transport: String?) {
563
+ val c = core ?: return
564
+
565
+ // ── Fast path: same user, already healthy ─────────────────────────
566
+ val current = c.defaultProxyConfig
567
+ val currentIdentity = current?.identityAddress
568
+ val healthy = current?.state == org.linphone.core.RegistrationState.Ok
569
+ if (current != null && currentIdentity != null && healthy &&
570
+ currentIdentity.username == username && currentIdentity.domain == domain
571
+ ) {
572
+ // Password may have changed; the auth info is keyed by username
573
+ // so adding a fresh one replaces the old.
574
+ val auth = Factory.instance().createAuthInfo(username, null, password, null, null, domain)
575
+ c.addAuthInfo(auth)
576
+ c.refreshRegisters()
577
+ return
578
+ }
579
+
580
+ // ── Slow path: new user, or previous session was unhealthy ────────
581
+ try {
582
+ wipeAllAccounts(c)
583
+ val auth = Factory.instance().createAuthInfo(username, null, password, null, null, domain)
584
+ c.addAuthInfo(auth)
585
+
586
+ // Identity = "the SIP address other parties dial to reach us"
587
+ val id = Factory.instance().createAddress("sip:$username@$domain") ?: return
588
+
589
+ // Server URI tells Linphone where to send REGISTER. The
590
+ // ";transport=tcp" parameter is required if your PBX only
591
+ // listens on TCP (most do for security reasons).
592
+ var server = "sip:$domain"
593
+ if (!transport.isNullOrEmpty()) server += ";transport=${transport.lowercase()}"
594
+
595
+ val proxy = c.createProxyConfig().apply {
596
+ identityAddress = id
597
+ serverAddr = server
598
+ isRegisterEnabled = true
599
+ // Re-register every 10 minutes. PBX server controls the
600
+ // actual TTL via the 200 OK response — this is just our
601
+ // requested ceiling.
602
+ expires = 600
603
+ }
604
+ c.addProxyConfig(proxy)
605
+ c.defaultProxyConfig = proxy
606
+ c.refreshRegisters()
607
+ } catch (e: Exception) {
608
+ Log.e(TAG, "Registration failed", e)
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Re-ping the SIP server to confirm we're still registered.
614
+ *
615
+ * If the registration is in the Failed state, toggling
616
+ * [isRegisterEnabled] kicks Linphone into trying again — sometimes
617
+ * `refreshRegisters()` alone won't recover from a 401 or socket reset.
618
+ */
619
+ fun refreshRegisters() {
620
+ val c = core ?: return
621
+ // Tell Linphone the network is back, in case it gave up.
622
+ c.isNetworkReachable = true
623
+ c.refreshRegisters()
624
+ c.defaultProxyConfig?.let { proxy ->
625
+ if (proxy.state == org.linphone.core.RegistrationState.Failed) {
626
+ proxy.isRegisterEnabled = false
627
+ proxy.isRegisterEnabled = true
628
+ }
629
+ }
630
+ }
631
+
632
+ fun setNetworkReachable(reachable: Boolean) {
633
+ core?.isNetworkReachable = reachable
634
+ }
635
+
636
+ fun call(sipUri: String) {
637
+ val c = core ?: return
638
+ try {
639
+ val addr = Factory.instance().createAddress(sipUri) ?: return
640
+ val params = c.createCallParams(null) ?: return
641
+ c.inviteAddressWithParams(addr, params)
642
+ } catch (_: Exception) {}
643
+ }
644
+
645
+ fun answer() { try { core?.currentCall?.accept() } catch (_: Exception) {} }
646
+
647
+ fun decline(reason: Reason = Reason.Declined) {
648
+ val call = core?.currentCall ?: return
649
+ try {
650
+ when (call.state) {
651
+ Call.State.IncomingReceived, Call.State.IncomingEarlyMedia -> call.decline(reason)
652
+ else -> call.terminate()
653
+ }
654
+ } catch (_: Exception) {}
655
+ }
656
+
657
+ fun end() {
658
+ val call = core?.currentCall ?: return
659
+ try {
660
+ when (call.state) {
661
+ Call.State.IncomingReceived, Call.State.IncomingEarlyMedia -> call.decline(Reason.Declined)
662
+ else -> call.terminate()
663
+ }
664
+ } catch (_: Exception) {}
665
+ }
666
+
667
+ fun mute(on: Boolean) { core?.isMicEnabled = !on }
668
+
669
+ fun speaker(on: Boolean) {
670
+ val c = core ?: return
671
+ val dev = c.audioDevices?.let { devices ->
672
+ if (on) devices.firstOrNull { it.type == AudioDevice.Type.Speaker }
673
+ else devices.firstOrNull { it.type == AudioDevice.Type.Earpiece }
674
+ }
675
+ if (dev != null) c.outputAudioDevice = dev
676
+ }
677
+
678
+ fun sendDtmf(d: String) {
679
+ try {
680
+ val b = d.encodeToByteArray().firstOrNull() ?: return
681
+ core?.currentCall?.sendDtmf(b.toInt().toChar())
682
+ } catch (_: Exception) {}
683
+ }
684
+
685
+ fun hold() { try { core?.currentCall?.pause() } catch (_: Exception) {} }
686
+ fun resume() { try { core?.currentCall?.resume() } catch (_: Exception) {} }
687
+
688
+ fun setRegisterEnabled(on: Boolean) {
689
+ val c = core ?: return
690
+ val proxy = c.defaultProxyConfig ?: return
691
+ try {
692
+ proxy.isRegisterEnabled = on
693
+ c.refreshRegisters()
694
+ } catch (_: Exception) {}
695
+ }
696
+
697
+ // === Call logs ===
698
+
699
+ private fun sipUserPart(uri: String): String {
700
+ val match = Regex("sip:([^@]+)@").find(uri)
701
+ return match?.groupValues?.get(1) ?: uri.removePrefix("sip:")
702
+ }
703
+
704
+ private fun guessCallType(direction: String, called: String, mySipUser: String?): String {
705
+ return when {
706
+ direction == "inbound" && called == mySipUser -> "LOCAL"
707
+ direction == "outbound" && called.startsWith("0") -> "DID"
708
+ else -> "STANDARD"
709
+ }
710
+ }
711
+
712
+ private fun dispositionFor(status: String): Map<String, Any> = when {
713
+ status.contains("Success", ignoreCase = true) -> mapOf("text" to "ANSWERED", "code" to 0)
714
+ status.contains("Missed", ignoreCase = true) -> mapOf("text" to "NO ANSWER", "code" to 3)
715
+ status.contains("Declined", ignoreCase = true) ||
716
+ status.contains("Busy", ignoreCase = true) -> mapOf("text" to "BUSY", "code" to 5)
717
+ status.contains("Aborted", ignoreCase = true) ||
718
+ status.contains("EarlyAborted", ignoreCase = true) -> mapOf("text" to "CANCEL", "code" to 4)
719
+ else -> mapOf("text" to "FAILED", "code" to 8)
720
+ }
721
+
722
+ private fun formatDuration(seconds: Int): String {
723
+ val mins = seconds / 60
724
+ val secs = seconds % 60
725
+ return String.format(Locale.US, "%02d:%02d", mins, secs)
726
+ }
727
+
728
+ fun getCallLogs(): WritableArray {
729
+ val c = core
730
+ val logs = c?.callLogs
731
+ if (logs.isNullOrEmpty()) return WritableNativeArray()
732
+
733
+ val iso = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
734
+ timeZone = TimeZone.getTimeZone("UTC")
735
+ }
736
+
737
+ var mySipUser: String? = null
738
+ c.defaultProxyConfig?.identityAddress?.username?.let { if (it.isNotEmpty()) mySipUser = it }
739
+
740
+ val items = WritableNativeArray()
741
+ logs.forEachIndexed { idx, log ->
742
+ val fromRaw = log.fromAddress?.asStringUriOnly() ?: log.fromAddress?.asString() ?: ""
743
+ val toRaw = log.toAddress?.asStringUriOnly() ?: log.toAddress?.asString() ?: ""
744
+ val fromNum = sipUserPart(fromRaw)
745
+ val toNum = sipUserPart(toRaw)
746
+
747
+ val direction = when {
748
+ log.dir.toString().contains("Incoming", ignoreCase = true) -> "inbound"
749
+ log.dir.toString().contains("Outgoing", ignoreCase = true) -> "outbound"
750
+ else -> log.dir.toString().lowercase()
751
+ }
752
+
753
+ val startISO = try {
754
+ iso.format(Date(log.startDate * 1000L))
755
+ } catch (_: Exception) { iso.format(Date()) }
756
+
757
+ val callType = guessCallType(direction, toNum, mySipUser)
758
+ val disposition = dispositionFor(log.status.toString())
759
+ val durationStr = formatDuration(log.duration)
760
+ val destination = if (callType == "LOCAL") "Local" else ""
761
+ val idVal = log.callId?.let { abs(it.hashCode()) } ?: (100000 + idx)
762
+
763
+ items.pushMap(WritableNativeMap().apply {
764
+ putInt("id", idVal)
765
+ putString("call_start", startISO)
766
+ putString("call_type", callType)
767
+ putString("caller_id", "$fromNum <$fromNum>")
768
+ putString("call_direction", direction)
769
+ putString("called_number", toNum)
770
+ putMap("disposition", WritableNativeMap().apply {
771
+ putString("text", disposition["text"] as String)
772
+ putInt("code", disposition["code"] as Int)
773
+ })
774
+ putString("debit", "0.0000")
775
+ putString("duration", durationStr)
776
+ putString("destination", destination)
777
+ putString("sip_user", mySipUser ?: "")
778
+ putString("created_at", startISO)
779
+ putString("updated_at", startISO)
780
+ })
781
+ }
782
+ return items
783
+ }
784
+
785
+ @Synchronized
786
+ fun stop() {
787
+ try {
788
+ listener?.let { core?.removeListener(it) }
789
+ core?.stop()
790
+ } catch (_: Exception) {}
791
+ core = null
792
+ }
793
+
794
+ private fun emit(event: String, body: com.facebook.react.bridge.WritableMap) {
795
+ val rc = reactContext
796
+ if (rc != null && rc.hasActiveCatalystInstance()) {
797
+ rc.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
798
+ .emit(event, body)
799
+ }
800
+ }
801
+ }