feat: Working VoIP calling implementation (Flutter + Android)

Working video and audio calls, as well as android integration
This commit is contained in:
Andrew 2025-08-30 18:46:02 +07:00
commit 96a7e211a0
60 changed files with 2445 additions and 0 deletions

View file

@ -0,0 +1,18 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="xyz.nuark.liblinphone_flutter">
<!-- Permissions for SIP calling -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Camera features -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<!-- Microphone feature -->
<uses-feature android:name="android.hardware.microphone" android:required="true" />
</manifest>

View file

@ -0,0 +1,228 @@
package xyz.nuark.liblinphone_flutter
import android.app.Activity
import android.util.Log
import android.util.SparseArray
import android.view.TextureView
import android.view.View
import androidx.core.util.isNotEmpty
import androidx.core.util.size
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import org.linphone.mediastream.video.capture.CaptureTextureView
import xyz.nuark.liblinphone_flutter.views.LocalViewFactory
import xyz.nuark.liblinphone_flutter.views.RemoteViewFactory
/** LiblinphoneFlutterPlugin */
class LiblinphoneFlutterPlugin : FlutterPlugin, ActivityAware, MethodCallHandler {
private lateinit var channel: MethodChannel
private var registrationEventsChannel: EventChannel.EventSink? = null
private var callEventsChannel: EventChannel.EventSink? = null
private lateinit var activity: Activity
private var remoteViewCache = SparseArray<View>(1);
private var localViewCache = SparseArray<View>(1);
private lateinit var linphoneBridge: LinphoneBridge
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
flutterPluginBinding
.platformViewRegistry
.registerViewFactory("liblinphone_flutter.nuark.xyz/remote_view", RemoteViewFactory {
Log.i(TAG, "onAttachedToEngine: caching RemoteView ${it.id}")
remoteViewCache.put(0, it)
})
flutterPluginBinding
.platformViewRegistry
.registerViewFactory("liblinphone_flutter.nuark.xyz/local_view", LocalViewFactory {
Log.i(TAG, "onAttachedToEngine: caching LocalView ${it.id}")
localViewCache.put(0, it)
})
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "liblinphone_flutter")
channel.setMethodCallHandler(this)
EventChannel(
flutterPluginBinding.binaryMessenger,
"liblinphone_flutter.nuark.xyz/registration_events"
).setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(
arguments: Any?,
events: EventChannel.EventSink?
) {
registrationEventsChannel = events
}
override fun onCancel(arguments: Any?) {
registrationEventsChannel = null
}
})
EventChannel(
flutterPluginBinding.binaryMessenger,
"liblinphone_flutter.nuark.xyz/call_events"
).setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(
arguments: Any?,
events: EventChannel.EventSink?
) {
callEventsChannel = events
}
override fun onCancel(arguments: Any?) {
callEventsChannel = null
}
})
}
override fun onMethodCall(
call: MethodCall,
result: Result
) {
when (call.method) {
"checkPermissions" -> {
result.success(linphoneBridge.checkPermissions())
}
"initialize" -> {
try {
linphoneBridge = LinphoneBridge(
activity,
this::acquireRemoteView,
this::acquireLocalView,
{ registrationState ->
registrationEventsChannel?.success(registrationState)
},
{ callState ->
callEventsChannel?.success(callState)
}
)
linphoneBridge.initializeLinphone()
result.success(true)
} catch (e: Exception) {
Log.e(TAG, "initialize: ${e.message}")
result.error("error", e.message, e)
}
}
"register" -> {
try {
val username = call.argument<String>("username")!!
val password = call.argument<String>("password")!!
val serverIp = call.argument<String>("serverIp")!!
val serverPort = call.argument<Int>("serverPort")!!
linphoneBridge.register(username, password, serverIp, serverPort)
result.success(true)
} catch (e: Exception) {
Log.e(TAG, "register: ${e.message}")
result.error("error", e.message, e)
}
}
"unregister" -> {
linphoneBridge.unregister()
result.success(true)
}
"makeCall" -> {
try {
val callTo = call.argument<String>("callTo")!!
val isVideoEnabled = call.argument<Boolean>("isVideoEnabled")!!
linphoneBridge.makeCall(callTo, isVideoEnabled)
result.success(true)
} catch (e: Exception) {
Log.e(TAG, "makeCall: ${e.message}")
result.error("error", e.message, e)
}
}
"answerCall" -> {
val res = linphoneBridge.answerCall()
result.success(res)
}
"hangupCall" -> {
val res = linphoneBridge.hangupCall()
result.success(res)
}
"inCall" -> {
val inCall = linphoneBridge.inCall()
result.success(inCall)
}
"callType" -> {
val callType = linphoneBridge.callType()
result.success(callType.ordinal)
}
"toggleVideo" -> {
val enabled = linphoneBridge.toggleVideo()
result.success(enabled)
}
"toggleMicrophone" -> {
val enabled = linphoneBridge.toggleMicrophone()
result.success(enabled)
}
"stop" -> {
linphoneBridge.stop()
result.success(true)
}
else -> {
result.notImplemented()
}
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
}
override fun onDetachedFromActivityForConfigChanges() {
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
}
override fun onDetachedFromActivity() {
}
internal fun acquireLocalView(): CaptureTextureView? {
Log.i(TAG, "acquireLocalView: ${localViewCache.size}")
if (localViewCache.isNotEmpty()) {
val widget = localViewCache.get(0)
return widget as CaptureTextureView
}
return null
}
internal fun acquireRemoteView(): TextureView? {
Log.i(TAG, "acquireRemoteView: ${remoteViewCache.size}")
if (remoteViewCache.isNotEmpty()) {
val widget = remoteViewCache.get(0)
return widget as TextureView
}
return null
}
companion object {
const val TAG = "LiblinphoneFlutterPlugin"
}
}

View file

@ -0,0 +1,357 @@
package xyz.nuark.liblinphone_flutter
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.util.Log
import android.view.TextureView
import androidx.core.content.ContextCompat
import org.linphone.core.Account
import org.linphone.core.Call
import org.linphone.core.Core
import org.linphone.core.CoreListenerStub
import org.linphone.core.Factory
import org.linphone.core.MediaDirection
import org.linphone.core.PayloadType
import org.linphone.core.ProxyConfig
import org.linphone.core.RegistrationState
import org.linphone.mediastream.video.capture.CaptureTextureView
import kotlin.properties.Delegates
class LinphoneBridge(
private val activity: Activity,
private val remoteViewAcquisitor: () -> TextureView?,
private val localViewAcquisitor: () -> CaptureTextureView?,
private val onRegistrationStateChanged: (Int) -> Unit,
private val onCallStateChanged: (Int) -> Unit
) {
private lateinit var core: Core
private var isRegistered = false
private var currentCall: Call? = null
private var registrationState: RegistrationState by Delegates.observable(RegistrationState.None) { _, oldValue, newValue ->
Log.i(
LiblinphoneFlutterPlugin.TAG,
"registrationState delegate: oldValue: $oldValue, newValue: $newValue"
)
onRegistrationStateChanged(newValue.ordinal)
}
private var callState: Call.State by Delegates.observable(Call.State.Idle) { _, oldValue, newValue ->
Log.i(
LiblinphoneFlutterPlugin.TAG,
"callState delegate: oldValue: $oldValue, newValue: $newValue"
)
onCallStateChanged(newValue.ordinal)
}
private val coreListener = object : CoreListenerStub() {
// override fun onRegistrationStateChanged(
// core: Core,
// proxyConfig: ProxyConfig,
// state: RegistrationState,
// message: String
// ) {
// registrationState = state
// when (state) {
// RegistrationState.Ok -> {
// isRegistered = true
// }
//
// RegistrationState.Failed -> {
// isRegistered = false
// }
//
// RegistrationState.None -> {
// isRegistered = false
// }
//
// RegistrationState.Progress -> {
// isRegistered = false
// }
//
// else -> {}
// }
// }
override fun onAccountRegistrationStateChanged(
core: Core,
account: Account,
state: RegistrationState?,
message: String
) {
registrationState = state ?: RegistrationState.None
when (state) {
RegistrationState.Ok -> {
isRegistered = true
}
RegistrationState.Failed -> {
isRegistered = false
}
RegistrationState.None -> {
isRegistered = false
}
RegistrationState.Progress -> {
isRegistered = false
}
else -> {}
}
}
override fun onCallStateChanged(
core: Core,
call: Call,
state: Call.State,
message: String
) {
callState = state
when (state) {
Call.State.IncomingReceived -> {
currentCall = call
Log.i(
LiblinphoneFlutterPlugin.TAG,
"onCallStateChanged: INCOMING_RECEIVED $call"
)
}
Call.State.Connected -> {
Log.i(LiblinphoneFlutterPlugin.TAG, "onCallStateChanged: CONNECTED $call")
remoteViewAcquisitor()?.let {
call.nativeVideoWindowId = it
} ?: run {
Log.i(
LiblinphoneFlutterPlugin.TAG,
"onCallStateChanged: CONNECTED remoteViewAcquisitor found nothing"
)
}
localViewAcquisitor()?.let {
core.nativePreviewWindowId = it
} ?: run {
Log.i(
LiblinphoneFlutterPlugin.TAG,
"onCallStateChanged: CONNECTED localViewAcquisitor found nothing"
)
}
}
Call.State.StreamsRunning -> {
Log.i(LiblinphoneFlutterPlugin.TAG, "onCallStateChanged: STREAMS_RUNNING $call")
remoteViewAcquisitor()?.let {
call.nativeVideoWindowId = it
} ?: run {
Log.i(
LiblinphoneFlutterPlugin.TAG,
"onCallStateChanged: STREAMS_RUNNING remoteViewAcquisitor found nothing"
)
}
localViewAcquisitor()?.let {
core.nativePreviewWindowId = it
} ?: run {
Log.i(
LiblinphoneFlutterPlugin.TAG,
"onCallStateChanged: STREAMS_RUNNING localViewAcquisitor found nothing"
)
}
}
Call.State.End -> {
currentCall = null
}
Call.State.Error -> {
currentCall = null
}
Call.State.Released -> {
currentCall = null
}
else -> {}
}
}
}
private val neededPermissions = arrayOf(
Manifest.permission.RECORD_AUDIO,
Manifest.permission.CAMERA,
Manifest.permission.MODIFY_AUDIO_SETTINGS
)
fun checkPermissions(): Boolean {
val missingPermissions = neededPermissions.filter { permission ->
ContextCompat.checkSelfPermission(
activity,
permission
) != PackageManager.PERMISSION_GRANTED
}.toTypedArray()
if (missingPermissions.isNotEmpty()) {
activity.requestPermissions(missingPermissions, 1)
return false
}
return true
}
fun initializeLinphone() {
val factory = Factory.instance()
core = factory.createCore(null, null, activity.baseContext)
core.addListener(coreListener)
// Enable video
core.isVideoCaptureEnabled = true
core.isVideoDisplayEnabled = true
core.videoActivationPolicy.automaticallyInitiate = true
core.videoActivationPolicy.automaticallyAccept = true
// https://www.cisco.com/c/en/us/td/docs/switches/datacenter/nexus1000/sw/4_0/qos/configuration/guide/nexus1000v_qos/qos_6dscp_val.pdf
core.sipDscp = 26 // AF31
core.audioDscp = 46 // EF
core.videoDscp = 36 // AF42
core.isVideoAdaptiveJittcompEnabled = true
// core.isEchoCancellationEnabled = true // eh results, bad for audio
// good enough default
core.uploadBandwidth = 512
core.downloadBandwidth = 1500
val preferredAudio = listOf("opus", "pcmu", "pcma") // in order of preference
val preferredVideo = listOf("h264", "vp8")
core.audioPayloadTypes.forEach { pt: PayloadType ->
val mime = pt.mimeType.lowercase()
val enable = preferredAudio.contains(mime)
pt.enable(enable)
}
core.videoPayloadTypes.forEach { pt: PayloadType ->
val mime = pt.mimeType.lowercase()
val enable = preferredVideo.contains(mime)
pt.enable(enable)
}
core.getPayloadType("opus", -1, 0)?.let { it.normalBitrate = 32 } // 32 kbps for Opus
core.getPayloadType("h264", -1, 0)?.let { it.normalBitrate = 600 } // 600 kbps for H264
core.getPayloadType("vp8", -1, 0)?.let { it.normalBitrate = 600 } // 600 kbps for VP8
val preferredVidDef = factory.createVideoDefinition(720, 1280)
core.preferredVideoDefinition = preferredVidDef
core.preferredFramerate = 30f
core.start()
}
fun register(username: String, password: String, serverIp: String, serverPort: Int) {
val factory = Factory.instance()
val authInfo = factory.createAuthInfo(username, null, password, null, null, serverIp)
core.addAuthInfo(authInfo)
val identity = factory.createAddress("sip:$username@$serverIp")
val server = factory.createAddress("sip:$serverIp:$serverPort")
val accountParams = core.createAccountParams().let {
it.identityAddress = identity
it.serverAddress = server
it.isRegisterEnabled = true
it
}
val account = core.createAccount(accountParams)
core.addAccount(account)
core.defaultAccount = account
}
fun unregister() {
core.clearAccounts()
core.clearAllAuthInfo()
}
fun makeCall(callTo: String, isVideoEnabled: Boolean) {
if (!isRegistered) {
throw IllegalStateException("Not registered")
}
val factory = Factory.instance()
val remoteAddress = factory.createAddress(callTo)
val callParams = core.createCallParams(null)
if (remoteAddress == null) {
throw IllegalStateException("Failed to create remote address")
}
if (callParams == null) {
throw IllegalStateException("Failed to create call params")
}
callParams.let {
it.isVideoEnabled = isVideoEnabled
it.videoDirection =
if (isVideoEnabled) MediaDirection.SendRecv else MediaDirection.Inactive
}
currentCall = core.inviteAddressWithParams(remoteAddress, callParams)
}
fun answerCall(): Boolean {
val res = currentCall?.let { call ->
val callParams = core.createCallParams(call)
callParams?.let {
it.isVideoEnabled = true
it.videoDirection = MediaDirection.SendRecv
}
call.acceptWithParams(callParams)
return@let true
}
return res ?: false
}
fun hangupCall(): Boolean {
return currentCall?.terminate() == 0
}
fun toggleVideo(): Boolean {
currentCall?.let { call ->
val params = core.createCallParams(call)
params?.isVideoEnabled = !call.currentParams.isVideoEnabled
call.update(params)
}
return currentCall?.currentParams?.isVideoEnabled == true
}
fun toggleMicrophone(): Boolean {
core.isMicEnabled = !core.isMicEnabled
return core.isMicEnabled
}
fun stop() {
core.stop()
}
fun inCall(): Boolean {
return currentCall != null
}
enum class CallType {
Audio,
Video,
Unknown
}
fun callType(): CallType {
if (currentCall?.currentParams != null) {
if (currentCall?.currentParams?.isVideoEnabled == true) {
return CallType.Video
} else {
return CallType.Audio
}
}
if (currentCall?.currentParams?.isVideoEnabled == true) {
return CallType.Video
}
return CallType.Unknown
}
}

View file

@ -0,0 +1,25 @@
package xyz.nuark.liblinphone_flutter.views
import android.content.Context
import android.graphics.Color
import android.view.TextureView
import android.view.View
import io.flutter.plugin.platform.PlatformView
import org.linphone.mediastream.video.capture.CaptureTextureView
internal class LocalView(context: Context, id: Int, creationParams: Map<String?, Any?>?) : PlatformView {
private val textureView: CaptureTextureView
override fun getView(): View {
return textureView
}
override fun dispose() {
textureView.surfaceTexture?.release()
}
init {
textureView = CaptureTextureView(context)
textureView.id = id
}
}

View file

@ -0,0 +1,16 @@
package xyz.nuark.liblinphone_flutter.views
import android.content.Context
import android.view.View
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
class LocalViewFactory(internal val cacher: (View) -> Unit) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
val creationParams = args as Map<String?, Any?>?
return LocalView(context, viewId, creationParams).also {
cacher(it.view)
}
}
}

View file

@ -0,0 +1,24 @@
package xyz.nuark.liblinphone_flutter.views
import android.content.Context
import android.graphics.Color
import android.view.TextureView
import android.view.View
import io.flutter.plugin.platform.PlatformView
internal class RemoteView(context: Context, id: Int, creationParams: Map<String?, Any?>?) : PlatformView {
private val textureView: TextureView
override fun getView(): View {
return textureView
}
override fun dispose() {
textureView.surfaceTexture?.release()
}
init {
textureView = TextureView(context)
textureView.id = id
}
}

View file

@ -0,0 +1,16 @@
package xyz.nuark.liblinphone_flutter.views
import android.content.Context
import android.view.View
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
class RemoteViewFactory(internal val cacher: (View) -> Unit) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
val creationParams = args as Map<String?, Any?>?
return RemoteView(context, viewId, creationParams).also {
cacher(it.view)
}
}
}

View file

@ -0,0 +1,27 @@
package com.example.liblinphone_flutter
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import org.mockito.Mockito
import kotlin.test.Test
/*
* This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation.
*
* Once you have built the plugin's example app, you can run these tests from the command
* line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or
* you can run them directly from IDEs that support JUnit such as Android Studio.
*/
internal class LiblinphoneFlutterPluginTest {
@Test
fun onMethodCall_getPlatformVersion_returnsExpectedValue() {
val plugin = LiblinphoneFlutterPlugin()
val call = MethodCall("getPlatformVersion", null)
val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java)
plugin.onMethodCall(call, mockResult)
Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE)
}
}