@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,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
|
+
}
|