@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.
- package/LICENSE +21 -0
- package/NativetalkCallSdk.podspec +31 -0
- package/README.md +494 -0
- package/android/build.gradle +58 -0
- package/android/gradle.properties +2 -0
- package/android/src/main/AndroidManifest.xml +84 -0
- package/android/src/main/java/io/nativetalk/callsdk/BackgroundService.kt +149 -0
- package/android/src/main/java/io/nativetalk/callsdk/CallActionReceiver.kt +24 -0
- package/android/src/main/java/io/nativetalk/callsdk/CallService.kt +45 -0
- package/android/src/main/java/io/nativetalk/callsdk/Compatibility.kt +96 -0
- package/android/src/main/java/io/nativetalk/callsdk/CoreManager.kt +801 -0
- package/android/src/main/java/io/nativetalk/callsdk/NativetalkCallScreeningService.kt +105 -0
- package/android/src/main/java/io/nativetalk/callsdk/NativetalkCallSdkModule.kt +205 -0
- package/android/src/main/java/io/nativetalk/callsdk/NativetalkCallSdkPackage.kt +18 -0
- package/android/src/main/java/io/nativetalk/callsdk/TelephonyMonitor.kt +229 -0
- package/android/src/main/java/io/nativetalk/callsdk/Utils.kt +42 -0
- package/android/src/main/res/drawable/ic_nativetalk_call.xml +9 -0
- package/android/src/main/res/values/strings.xml +9 -0
- package/app.plugin.js +1 -0
- package/ios/NativetalkCallSdk-Bridging-Header.h +4 -0
- package/ios/NativetalkCallSdk.swift +738 -0
- package/ios/NativetalkCallSdkBridge.m +35 -0
- package/lib/commonjs/CallProvider.js +602 -0
- package/lib/commonjs/helpers.js +173 -0
- package/lib/commonjs/index.js +96 -0
- package/lib/commonjs/native.js +146 -0
- package/lib/commonjs/types.js +8 -0
- package/lib/commonjs/ui/Avatar.js +29 -0
- package/lib/commonjs/ui/Dialer.js +189 -0
- package/lib/commonjs/ui/IncomingCallView.js +128 -0
- package/lib/commonjs/ui/OutgoingCallView.js +117 -0
- package/lib/commonjs/ui/index.js +22 -0
- package/lib/commonjs/ui/theme.js +21 -0
- package/lib/module/CallProvider.js +573 -0
- package/lib/module/helpers.js +161 -0
- package/lib/module/index.js +57 -0
- package/lib/module/native.js +123 -0
- package/lib/module/types.js +7 -0
- package/lib/module/ui/Avatar.js +22 -0
- package/lib/module/ui/Dialer.js +162 -0
- package/lib/module/ui/IncomingCallView.js +101 -0
- package/lib/module/ui/OutgoingCallView.js +110 -0
- package/lib/module/ui/index.js +13 -0
- package/lib/module/ui/theme.js +17 -0
- package/lib/typescript/CallProvider.d.ts +46 -0
- package/lib/typescript/helpers.d.ts +52 -0
- package/lib/typescript/index.d.ts +77 -0
- package/lib/typescript/native.d.ts +53 -0
- package/lib/typescript/types.d.ts +155 -0
- package/lib/typescript/ui/Avatar.d.ts +13 -0
- package/lib/typescript/ui/Dialer.d.ts +29 -0
- package/lib/typescript/ui/IncomingCallView.d.ts +39 -0
- package/lib/typescript/ui/OutgoingCallView.d.ts +28 -0
- package/lib/typescript/ui/index.d.ts +13 -0
- package/lib/typescript/ui/theme.d.ts +20 -0
- package/linphonesw-pod/Sources/LinphoneSdkInfos.swift +4 -0
- package/linphonesw-pod/Sources/LinphoneWrapper.swift +42949 -0
- package/linphonesw-pod/linphonesw.podspec +46 -0
- package/package.json +90 -0
- package/plugin/build/index.js +12 -0
- package/plugin/build/withAndroid.js +78 -0
- package/plugin/build/withIos.js +66 -0
- package/src/CallProvider.tsx +675 -0
- package/src/helpers.ts +179 -0
- package/src/index.ts +84 -0
- package/src/native.ts +185 -0
- package/src/types.ts +202 -0
- package/src/ui/Avatar.tsx +46 -0
- package/src/ui/Dialer.tsx +248 -0
- package/src/ui/IncomingCallView.tsx +161 -0
- package/src/ui/OutgoingCallView.tsx +203 -0
- package/src/ui/index.ts +13 -0
- package/src/ui/theme.ts +36 -0
- 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');
|