@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,105 @@
1
+ package io.nativetalk.callsdk
2
+
3
+ import android.os.Build
4
+ import android.telecom.Call
5
+ import android.telecom.CallScreeningService
6
+ import android.util.Log
7
+ import com.facebook.react.bridge.Arguments
8
+
9
+ /**
10
+ * Optional call-screening service.
11
+ *
12
+ * Apps that want to be notified about every inbound/outbound device call
13
+ * (e.g. to decide whether to suppress a competing VoIP call) must set this
14
+ * package as the user's call-screener via
15
+ * `TelecomManager.requestRoleOrSomething(ROLE_CALL_SCREENING)`. Otherwise
16
+ * the OS never invokes this class — it's dormant by default.
17
+ *
18
+ * Counterpart: [TelephonyMonitor] watches READ_PHONE_STATE-style call state.
19
+ * This service receives richer per-call metadata BEFORE the call rings.
20
+ */
21
+ class NativetalkCallScreeningService : CallScreeningService() {
22
+ companion object {
23
+ private const val TAG = "NativetalkCallSdk.Screening"
24
+ }
25
+
26
+ override fun onScreenCall(details: Call.Details) {
27
+ try {
28
+ val number = extractPhoneNumber(details)
29
+ val isIncoming = details.callDirection == Call.Details.DIRECTION_INCOMING
30
+ val ts = System.currentTimeMillis()
31
+ // Optional contact lookup — only if we got a number AND the host
32
+ // app has granted READ_CONTACTS. Silently skipped otherwise.
33
+ val contact = if (number.isNotEmpty()) TelephonyMonitor.tryLookupContact(number) else null
34
+
35
+ val payload = Arguments.createMap().apply {
36
+ putString("direction", if (isIncoming) "incoming" else "outgoing")
37
+ putString("number", number)
38
+ putString("callerName", details.callerDisplayName ?: "")
39
+ putDouble("timestamp", ts.toDouble())
40
+ putInt("presentation", details.callerDisplayNamePresentation)
41
+ if (contact != null) putMap("contact", contact)
42
+ }
43
+ TelephonyMonitor.emitToReact("TMPhoneCallInfo", payload)
44
+
45
+ // We're an OBSERVER, not a blocker. All four flags false → let
46
+ // the call proceed normally, show in the log, fire notifications.
47
+ // Apps that want to actually block calls should use a separate
48
+ // dialler-style screener; this SDK only surfaces metadata.
49
+ respondToCall(
50
+ details,
51
+ CallResponse.Builder()
52
+ .setDisallowCall(false)
53
+ .setRejectCall(false)
54
+ .setSkipCallLog(false)
55
+ .setSkipNotification(false)
56
+ .build()
57
+ )
58
+ } catch (t: Throwable) {
59
+ Log.e(TAG, "onScreenCall failed", t)
60
+ // CRITICAL: must respond even on failure. Skipping respondToCall
61
+ // hangs the system dialler — the user would see a perpetually
62
+ // "connecting" call. Better to swallow the error and let the
63
+ // call proceed.
64
+ respondToCall(
65
+ details,
66
+ CallResponse.Builder()
67
+ .setDisallowCall(false)
68
+ .setRejectCall(false)
69
+ .build()
70
+ )
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Three fallback strategies for getting the phone number, in order of
76
+ * reliability. OEM Telecom implementations are inconsistent — some
77
+ * populate `handle`, some only `callerDisplayName`, some only `extras`.
78
+ */
79
+ private fun extractPhoneNumber(details: Call.Details): String {
80
+ // 1. The standard place: `handle` is a tel: URI; we want its body.
81
+ val handle = details.handle
82
+ if (handle != null) {
83
+ val number = handle.schemeSpecificPart
84
+ if (!number.isNullOrEmpty()) return number
85
+ }
86
+ // 2. Some carriers shove the number into the display-name field.
87
+ // Only trust it if it's at least 10 digits — shorter strings are
88
+ // probably a real name like "John" with stray digits.
89
+ details.callerDisplayName?.let { dn ->
90
+ val cleaned = dn.replace(Regex("[^0-9+]"), "")
91
+ if (cleaned.length >= 10) return cleaned
92
+ }
93
+ // 3. Last resort: vendor-specific extras. The three keys below cover
94
+ // Samsung, Xiaomi, and a few Asian carriers respectively.
95
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
96
+ details.extras?.let { extras ->
97
+ val extraNumber = extras.getString("android.telecom.extra.CALL_SUBJECT")
98
+ ?: extras.getString("call_number")
99
+ ?: extras.getString("phone_number")
100
+ if (!extraNumber.isNullOrEmpty()) return extraNumber
101
+ }
102
+ }
103
+ return ""
104
+ }
105
+ }
@@ -0,0 +1,205 @@
1
+ package io.nativetalk.callsdk
2
+
3
+ import android.content.Intent
4
+ import android.media.AudioManager
5
+ import android.media.ToneGenerator
6
+ import android.util.Log
7
+ import com.facebook.react.bridge.*
8
+ import com.facebook.react.modules.core.DeviceEventManagerModule
9
+ import org.linphone.core.Core
10
+ import org.linphone.core.ProxyConfig
11
+ import org.linphone.core.RegistrationState
12
+
13
+ /**
14
+ * Native bridge exposed to JavaScript as `NativeModules.NativetalkCallSdk`.
15
+ *
16
+ * Intentionally thin: every @ReactMethod is a 1-line delegate to
17
+ * [CoreManager] or another singleton. Keeping the engine in [CoreManager]
18
+ * (not here) means push-driven background services can drive calls before
19
+ * React has even mounted.
20
+ *
21
+ * The string `"NativetalkCallSdk"` is the contract with JS — must exactly
22
+ * match `NativeModules.NativetalkCallSdk` and `@objc(NativetalkCallSdk)` on
23
+ * iOS. Renaming this breaks the bridge silently.
24
+ */
25
+ class NativetalkCallSdkModule(private val reactContext: ReactApplicationContext) :
26
+ ReactContextBaseJavaModule(reactContext) {
27
+
28
+ companion object {
29
+ const val NAME = "NativetalkCallSdk"
30
+ private const val TAG = "NativetalkCallSdk"
31
+ }
32
+
33
+ // Lazily-created tone generator. Allocating a ToneGenerator pre-warms
34
+ // the audio HAL (~50ms hit), so we defer until the first key press.
35
+ private var dtmfTone: ToneGenerator? = null
36
+
37
+ override fun getName(): String = NAME
38
+
39
+ // === Lifecycle ===
40
+
41
+ @ReactMethod
42
+ fun init(cfg: ReadableMap?, promise: Promise) {
43
+ try {
44
+ CoreManager.attachReact(reactContext)
45
+ TelephonyMonitor.attachReact(reactContext)
46
+ promise.resolve(null)
47
+ } catch (e: Exception) {
48
+ promise.reject("INIT_FAILED", e)
49
+ }
50
+ }
51
+
52
+ @ReactMethod
53
+ fun startNativeServices() {
54
+ CoreManager.ensureStarted(reactContext)
55
+ BackgroundService.startService(reactContext)
56
+ }
57
+
58
+ @ReactMethod
59
+ fun stopNativeServices(logout: Boolean) {
60
+ if (logout) BackgroundService.shouldRestart = false
61
+ reactContext.stopService(Intent(reactContext, CallService::class.java))
62
+ BackgroundService.stopService(reactContext)
63
+ CoreManager.stop()
64
+ }
65
+
66
+ // === Registration ===
67
+
68
+ @ReactMethod
69
+ fun register(acc: ReadableMap) {
70
+ CoreManager.ensureStarted(reactContext)
71
+ CoreManager.register(
72
+ acc.getString("username") ?: "",
73
+ acc.getString("password") ?: "",
74
+ acc.getString("domain") ?: "",
75
+ acc.getString("transport") ?: "tcp"
76
+ )
77
+ }
78
+
79
+ @ReactMethod
80
+ fun refreshRegisters() {
81
+ CoreManager.refreshRegisters()
82
+ }
83
+
84
+ @ReactMethod
85
+ fun setRegisterEnabled(on: Boolean) {
86
+ CoreManager.setRegisterEnabled(on)
87
+ }
88
+
89
+ @ReactMethod
90
+ fun getRegistrationStatus(promise: Promise) {
91
+ try {
92
+ val core: Core? = CoreManager.core()
93
+ val pc: ProxyConfig? = core?.defaultProxyConfig
94
+ val addr = pc?.identityAddress
95
+
96
+ val diag: String = try {
97
+ pc?.errorInfo?.phrase ?: pc?.errorInfo?.toString() ?: ""
98
+ } catch (_: Throwable) { "" }
99
+
100
+ val map = Arguments.createMap().apply {
101
+ putString("state", regStateToString(pc?.state))
102
+ putString("message", diag)
103
+ putString("username", addr?.username ?: "")
104
+ putString("domain", addr?.domain ?: "")
105
+ putString("displayName", addr?.displayName ?: "")
106
+ }
107
+ promise.resolve(map)
108
+ } catch (e: Throwable) {
109
+ promise.reject("E_STATUS", "Failed to read registration status", e)
110
+ }
111
+ }
112
+
113
+ // === Call control ===
114
+
115
+ @ReactMethod fun call(sipUri: String) = CoreManager.call(sipUri)
116
+ @ReactMethod fun answer() = CoreManager.answer()
117
+ @ReactMethod fun decline(reason: String?) = CoreManager.decline()
118
+ @ReactMethod fun end() = CoreManager.end()
119
+ @ReactMethod fun hangup() = end()
120
+ @ReactMethod fun mute(on: Boolean) = CoreManager.mute(on)
121
+ @ReactMethod fun speaker(on: Boolean) = CoreManager.speaker(on)
122
+ @ReactMethod fun sendDtmf(d: String) = CoreManager.sendDtmf(d)
123
+ @ReactMethod fun hold() = CoreManager.hold()
124
+ @ReactMethod fun resume() = CoreManager.resume()
125
+
126
+ // === Call logs ===
127
+
128
+ @ReactMethod
129
+ fun getCallLogs(promise: Promise) {
130
+ try {
131
+ promise.resolve(CoreManager.getCallLogs())
132
+ } catch (e: Exception) {
133
+ Log.e(TAG, "getCallLogs failed", e)
134
+ promise.reject("GET_CALL_LOGS_FAILED", e.message, e)
135
+ }
136
+ }
137
+
138
+ // === Misc ===
139
+
140
+ /**
141
+ * Plays a local DTMF UI tone — for tactile feedback when the user taps
142
+ * the dial-pad. Does NOT send anything over an active call (that's
143
+ * `sendDtmf`).
144
+ *
145
+ * Unlike the iOS side (which synthesises tones manually via
146
+ * AVAudioEngine), Android's stock `ToneGenerator` already routes
147
+ * correctly through the voice-call audio stream, including BT headsets.
148
+ * The volume parameter (60/100) is moderate — full volume sounds
149
+ * harsh when held to the ear.
150
+ */
151
+ @ReactMethod
152
+ fun playKeyTone(d: String) {
153
+ if (dtmfTone == null) {
154
+ dtmfTone = ToneGenerator(AudioManager.STREAM_VOICE_CALL, 60)
155
+ }
156
+ // Each key has a dedicated constant (TONE_DTMF_0..9, _S for *, _P
157
+ // for #). Anything else falls back to a generic beep so a typo
158
+ // produces SOMETHING audible rather than silence.
159
+ val tone = when (d) {
160
+ "0" -> ToneGenerator.TONE_DTMF_0
161
+ "1" -> ToneGenerator.TONE_DTMF_1
162
+ "2" -> ToneGenerator.TONE_DTMF_2
163
+ "3" -> ToneGenerator.TONE_DTMF_3
164
+ "4" -> ToneGenerator.TONE_DTMF_4
165
+ "5" -> ToneGenerator.TONE_DTMF_5
166
+ "6" -> ToneGenerator.TONE_DTMF_6
167
+ "7" -> ToneGenerator.TONE_DTMF_7
168
+ "8" -> ToneGenerator.TONE_DTMF_8
169
+ "9" -> ToneGenerator.TONE_DTMF_9
170
+ "*" -> ToneGenerator.TONE_DTMF_S
171
+ "#" -> ToneGenerator.TONE_DTMF_P
172
+ else -> ToneGenerator.TONE_PROP_BEEP
173
+ }
174
+ // 120ms — matches the iOS DTMF feedback duration and the stock
175
+ // dialer's feel. Anything longer feels laggy on rapid tapping.
176
+ dtmfTone?.startTone(tone, 120)
177
+ }
178
+
179
+ // RN's NativeEventEmitter calls addListener/removeListeners when JS
180
+ // subscribes/unsubscribes. Required so RN doesn't warn at runtime; we
181
+ // don't track count because we use RCTDeviceEventEmitter directly from
182
+ // [CoreManager], which keeps events flowing regardless of subscriber
183
+ // count.
184
+ @ReactMethod fun addListener(event: String) {}
185
+ @ReactMethod fun removeListeners(count: Int) {}
186
+
187
+ // Called when RN tears down the bridge (app close, hot reload). We
188
+ // null-out the React reference so [CoreManager.emit] becomes a no-op
189
+ // — but the underlying Linphone core keeps running, ready for a fresh
190
+ // React context to attach later.
191
+ override fun onCatalystInstanceDestroy() {
192
+ TelephonyMonitor.detachReact()
193
+ CoreManager.detachReact()
194
+ super.onCatalystInstanceDestroy()
195
+ }
196
+
197
+ private fun regStateToString(s: RegistrationState?): String = when (s) {
198
+ RegistrationState.None -> "none"
199
+ RegistrationState.Progress -> "progress"
200
+ RegistrationState.Ok -> "ok"
201
+ RegistrationState.Cleared -> "cleared"
202
+ RegistrationState.Failed -> "failed"
203
+ else -> "unknown"
204
+ }
205
+ }
@@ -0,0 +1,18 @@
1
+ package io.nativetalk.callsdk
2
+
3
+ import com.facebook.react.ReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.uimanager.ViewManager
7
+
8
+ /**
9
+ * React Native package entry. Registered automatically by autolinking — no
10
+ * manual changes to `MainApplication` needed in apps using React Native 0.71+.
11
+ */
12
+ class NativetalkCallSdkPackage : ReactPackage {
13
+ override fun createNativeModules(rc: ReactApplicationContext): List<NativeModule> =
14
+ listOf(NativetalkCallSdkModule(rc))
15
+
16
+ override fun createViewManagers(rc: ReactApplicationContext): List<ViewManager<*, *>> =
17
+ emptyList()
18
+ }
@@ -0,0 +1,229 @@
1
+ package io.nativetalk.callsdk
2
+
3
+ import android.Manifest
4
+ import android.content.Context
5
+ import android.content.pm.PackageManager
6
+ import android.net.Uri
7
+ import android.os.Build
8
+ import android.os.Handler
9
+ import android.os.Looper
10
+ import android.provider.ContactsContract
11
+ import android.telephony.PhoneStateListener
12
+ import android.telephony.TelephonyCallback
13
+ import android.telephony.TelephonyManager
14
+ import android.util.Log
15
+ import androidx.core.content.ContextCompat
16
+ import com.facebook.react.bridge.Arguments
17
+ import com.facebook.react.bridge.ReactApplicationContext
18
+ import com.facebook.react.bridge.WritableMap
19
+ import com.facebook.react.modules.core.DeviceEventManagerModule
20
+
21
+ /**
22
+ * Observes the device's native GSM/cellular call state and surfaces the
23
+ * transitions to JS as `TMPhoneCallState` and `TMPhoneCallInfo` events.
24
+ *
25
+ * Why this matters: a VoIP app needs to know when the user is on a regular
26
+ * phone call so it can decline the SIP call gracefully instead of letting
27
+ * Linphone fight the cellular audio for the mic.
28
+ *
29
+ * No iOS counterpart — Apple doesn't expose cellular call state to apps;
30
+ * CallKit handles the coexistence automatically.
31
+ *
32
+ * Permission model: requires READ_PHONE_STATE for telephony updates and
33
+ * READ_CONTACTS for caller-name lookups. Both are host-app responsibility
34
+ * — if not granted, this monitor silently no-ops.
35
+ */
36
+ object TelephonyMonitor {
37
+ private const val TAG = "NativetalkCallSdk.Telephony"
38
+
39
+ private var tm: TelephonyManager? = null
40
+ private var rc: ReactApplicationContext? = null
41
+ private var appCtx: Context? = null
42
+
43
+ // Two listener fields because Android 12+ deprecated PhoneStateListener
44
+ // in favour of TelephonyCallback. We use whichever is available at
45
+ // runtime (see [start]).
46
+ private var cb: TelephonyCallback? = null
47
+ private var oldListener: PhoneStateListener? = null
48
+
49
+ // Cached info about the current call. We stash these here because the
50
+ // direction and number can arrive in separate callbacks (RINGING then
51
+ // OFFHOOK) — we have to remember the earlier value to emit a complete
52
+ // event when the later one fires.
53
+ private var currentCallState = TelephonyManager.CALL_STATE_IDLE
54
+ private var pendingCallNumber: String? = null
55
+ private var pendingCallDirection: String? = null
56
+
57
+ private val handler = Handler(Looper.getMainLooper())
58
+
59
+ fun attachReact(reactContext: ReactApplicationContext) {
60
+ rc = reactContext
61
+ }
62
+
63
+ fun detachReact() {
64
+ rc = null
65
+ }
66
+
67
+ fun start(context: Context) {
68
+ try {
69
+ appCtx = context.applicationContext
70
+ tm = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
71
+
72
+ // Fail soft: without READ_PHONE_STATE the OS silently delivers
73
+ // no events, so registering listeners would just waste handles.
74
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE)
75
+ != PackageManager.PERMISSION_GRANTED
76
+ ) {
77
+ Log.w(TAG, "READ_PHONE_STATE not granted; telephony observer inactive")
78
+ return
79
+ }
80
+
81
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
82
+ // API 31+: TelephonyCallback. Note that the modern API
83
+ // does NOT pass the phone number to onCallStateChanged
84
+ // (privacy hardening) — we fill it in from CallScreeningService
85
+ // when available.
86
+ val callback = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
87
+ override fun onCallStateChanged(state: Int) {
88
+ handleCallStateChange(state, null)
89
+ }
90
+ }
91
+ cb = callback
92
+ tm?.registerTelephonyCallback(context.mainExecutor, callback)
93
+ } else {
94
+ // Legacy PhoneStateListener (< API 31). Deprecated but still
95
+ // works and DOES include the phone number directly.
96
+ @Suppress("DEPRECATION")
97
+ val listener = object : PhoneStateListener() {
98
+ override fun onCallStateChanged(state: Int, phoneNumber: String?) {
99
+ handleCallStateChange(state, phoneNumber)
100
+ }
101
+ }
102
+ oldListener = listener
103
+ @Suppress("DEPRECATION")
104
+ tm?.listen(listener, PhoneStateListener.LISTEN_CALL_STATE)
105
+ }
106
+ } catch (e: Exception) {
107
+ Log.e(TAG, "Failed to start telephony monitoring", e)
108
+ }
109
+ }
110
+
111
+ fun stop() {
112
+ try {
113
+ handler.removeCallbacksAndMessages(null)
114
+ tm?.let { mgr ->
115
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
116
+ cb?.let { mgr.unregisterTelephonyCallback(it) }
117
+ cb = null
118
+ } else {
119
+ @Suppress("DEPRECATION")
120
+ oldListener?.let { mgr.listen(it, PhoneStateListener.LISTEN_NONE) }
121
+ oldListener = null
122
+ }
123
+ }
124
+ } catch (e: Exception) {
125
+ Log.e(TAG, "Error stopping telephony monitoring", e)
126
+ }
127
+ }
128
+
129
+ fun setPendingCallInfo(number: String?, direction: String) {
130
+ pendingCallNumber = number
131
+ pendingCallDirection = direction
132
+ }
133
+
134
+ private fun handleCallStateChange(newState: Int, phoneNumber: String?) {
135
+ val previous = currentCallState
136
+ currentCallState = newState
137
+
138
+ if (!phoneNumber.isNullOrEmpty() && phoneNumber != pendingCallNumber) {
139
+ pendingCallNumber = phoneNumber
140
+ }
141
+
142
+ when (newState) {
143
+ TelephonyManager.CALL_STATE_OFFHOOK -> {
144
+ // IDLE → OFFHOOK with no preceding RINGING means we just
145
+ // placed an outgoing call. RINGING → OFFHOOK would mean we
146
+ // answered an incoming one, and pendingCallDirection was
147
+ // already set by the RINGING handler.
148
+ if (previous == TelephonyManager.CALL_STATE_IDLE) {
149
+ pendingCallDirection = "outgoing"
150
+ }
151
+ }
152
+ TelephonyManager.CALL_STATE_IDLE -> {
153
+ // Clear cached call info AFTER a 2s delay so any late-arriving
154
+ // CallScreeningService callback (which can lag the state
155
+ // change by 1–2s on some OEMs) still has access to it.
156
+ handler.postDelayed({
157
+ pendingCallNumber = null
158
+ pendingCallDirection = null
159
+ }, 2000)
160
+ }
161
+ }
162
+ emitState(newState)
163
+ }
164
+
165
+ fun emitToReact(event: String, payload: WritableMap) {
166
+ try {
167
+ val ctx = rc
168
+ if (ctx == null || !ctx.hasActiveCatalystInstance()) return
169
+ ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
170
+ ?.emit(event, payload)
171
+ } catch (e: Exception) {
172
+ Log.e(TAG, "Failed to emit '$event'", e)
173
+ }
174
+ }
175
+
176
+ fun tryLookupContact(number: String): WritableMap? {
177
+ if (number.isBlank()) return null
178
+ val ctx = appCtx ?: return null
179
+ if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.READ_CONTACTS)
180
+ != PackageManager.PERMISSION_GRANTED
181
+ ) return null
182
+
183
+ try {
184
+ val uri = Uri.withAppendedPath(
185
+ ContactsContract.PhoneLookup.CONTENT_FILTER_URI,
186
+ Uri.encode(number)
187
+ )
188
+ val cols = arrayOf(
189
+ ContactsContract.PhoneLookup.DISPLAY_NAME,
190
+ ContactsContract.PhoneLookup.PHOTO_THUMBNAIL_URI
191
+ )
192
+ ctx.contentResolver.query(uri, cols, null, null, null)?.use { cursor ->
193
+ if (cursor.moveToFirst()) {
194
+ return Arguments.createMap().apply {
195
+ putString("name", cursor.getString(0))
196
+ putString("photo", cursor.getString(1))
197
+ }
198
+ }
199
+ }
200
+ } catch (e: Exception) {
201
+ Log.e(TAG, "Contact lookup failed for $number", e)
202
+ }
203
+ return null
204
+ }
205
+
206
+ private fun emitState(state: Int) {
207
+ val stateName = stateName(state)
208
+ try {
209
+ val ctx = rc
210
+ if (ctx == null || !ctx.hasActiveCatalystInstance()) return
211
+ val map = Arguments.createMap().apply {
212
+ putString("state", stateName)
213
+ putInt("code", state)
214
+ putDouble("timestamp", System.currentTimeMillis().toDouble())
215
+ }
216
+ ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
217
+ ?.emit("TMPhoneCallState", map)
218
+ } catch (e: Exception) {
219
+ Log.e(TAG, "Failed to emit telephony state", e)
220
+ }
221
+ }
222
+
223
+ private fun stateName(state: Int) = when (state) {
224
+ TelephonyManager.CALL_STATE_RINGING -> "ringing"
225
+ TelephonyManager.CALL_STATE_OFFHOOK -> "offhook"
226
+ TelephonyManager.CALL_STATE_IDLE -> "idle"
227
+ else -> "unknown_$state"
228
+ }
229
+ }
@@ -0,0 +1,42 @@
1
+ package io.nativetalk.callsdk
2
+
3
+ import androidx.annotation.AnyThread
4
+ import androidx.annotation.WorkerThread
5
+ import org.linphone.core.Address
6
+ import org.linphone.core.Call
7
+
8
+ /** Internal helpers — not exposed to JS. */
9
+ object Utils {
10
+ @WorkerThread
11
+ fun displayNameFor(address: Address?): String {
12
+ if (address == null) return "[null]"
13
+ val dn = address.displayName
14
+ return if (dn.isNullOrEmpty()) address.username ?: address.asString() else dn
15
+ }
16
+
17
+ @AnyThread
18
+ fun isCallIncoming(state: Call.State): Boolean = when (state) {
19
+ Call.State.IncomingReceived, Call.State.IncomingEarlyMedia -> true
20
+ else -> false
21
+ }
22
+
23
+ @AnyThread
24
+ fun isCallOutgoing(state: Call.State, considerEarlyMedia: Boolean = true): Boolean = when (state) {
25
+ Call.State.OutgoingInit, Call.State.OutgoingProgress, Call.State.OutgoingRinging -> true
26
+ Call.State.OutgoingEarlyMedia -> considerEarlyMedia
27
+ else -> false
28
+ }
29
+
30
+ @AnyThread
31
+ fun isCallPaused(state: Call.State): Boolean = when (state) {
32
+ Call.State.Pausing, Call.State.Paused, Call.State.PausedByRemote, Call.State.Resuming -> true
33
+ else -> false
34
+ }
35
+
36
+ @AnyThread
37
+ fun isCallEnding(state: Call.State, considerReleasedAsEnding: Boolean = false): Boolean = when (state) {
38
+ Call.State.End, Call.State.Error -> true
39
+ Call.State.Released -> considerReleasedAsEnding
40
+ else -> false
41
+ }
42
+ }
@@ -0,0 +1,9 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+ android:width="24dp"
3
+ android:height="24dp"
4
+ android:viewportWidth="24"
5
+ android:viewportHeight="24">
6
+ <path
7
+ android:fillColor="#FFFFFFFF"
8
+ android:pathData="M6.62,10.79c1.44,2.83 3.76,5.15 6.59,6.59l2.2,-2.2c0.27,-0.27 0.67,-0.36 1.02,-0.24 1.12,0.37 2.33,0.57 3.57,0.57 0.55,0 1,0.45 1,1V20c0,0.55 -0.45,1 -1,1 -9.39,0 -17,-7.61 -17,-17 0,-0.55 0.45,-1 1,-1h3.5c0.55,0 1,0.45 1,1 0,1.24 0.2,2.45 0.57,3.57 0.11,0.35 0.03,0.74 -0.25,1.02l-2.2,2.2z"/>
9
+ </vector>
@@ -0,0 +1,9 @@
1
+ <resources>
2
+ <string name="nativetalk_call_sdk_app_name" translatable="false">App</string>
3
+ <string name="nativetalk_call_sdk_notif_channel_call_id" translatable="false">nativetalk_calls</string>
4
+ <string name="nativetalk_call_sdk_notif_channel_call_name">Calls</string>
5
+ <string name="nativetalk_call_sdk_notif_channel_background_id" translatable="false">nativetalk_background</string>
6
+ <string name="nativetalk_call_sdk_notif_channel_background_name">Call service</string>
7
+ <string name="nativetalk_call_sdk_ready_title">Ready to receive calls</string>
8
+ <string name="nativetalk_call_sdk_ready_body">Keeping the connection alive</string>
9
+ </resources>
package/app.plugin.js ADDED
@@ -0,0 +1 @@
1
+ module.exports = require('./plugin/build/index.js');
@@ -0,0 +1,4 @@
1
+ // Empty bridging header. Required by Swift compilation when the SDK is built
2
+ // as a static library. The host app's bridging header is unaffected.
3
+ #import <React/RCTBridgeModule.h>
4
+ #import <React/RCTEventEmitter.h>