liblinphone_flutter/ios/Classes/LinphoneBridge.swift
Andrew G 0375fe4d1a feat: implement Linphone SDK integration with video call support
Complete rewrite from stub plugin to (probably) functional liblinphone integration featuring:
- Core SDK bridge with registration, calls, and media controls
- Platform views for remote and local video rendering
- Event channels for registration and call state updates
2026-01-22 16:15:28 +07:00

312 lines
11 KiB
Swift

import Foundation
import UIKit
import linphonesw
import AVFoundation
class LinphoneBridge {
private var core: Core!
private var isRegistered = false
private var currentCall: Call?
private let activity: UIViewController
private let remoteViewAcquisitor: () -> UIView?
private let localViewAcquisitor: () -> UIView?
private let onRegistrationStateChanged: (Int) -> Void
private let onCallStateChanged: (Int) -> Void
private var registrationState: RegistrationState = .None {
didSet {
print("registrationState delegate: oldValue: \(oldValue), newValue: \(registrationState)")
onRegistrationStateChanged(registrationState.rawValue)
}
}
private var callState: Call.State = .Idle {
didSet {
print("callState delegate: oldValue: \(oldValue), newValue: \(callState)")
onCallStateChanged(callState.rawValue)
}
}
private static let TAG = "LinphoneBridge"
init(
activity: UIViewController,
remoteViewAcquisitor: @escaping () -> UIView?,
localViewAcquisitor: @escaping () -> UIView?,
onRegistrationStateChanged: @escaping (Int) -> Void,
onCallStateChanged: @escaping (Int) -> Void
) {
self.activity = activity
self.remoteViewAcquisitor = remoteViewAcquisitor
self.localViewAcquisitor = localViewAcquisitor
self.onRegistrationStateChanged = onRegistrationStateChanged
self.onCallStateChanged = onCallStateChanged
}
func checkPermissions() -> Bool {
let audioStatus = AVCaptureDevice.authorizationStatus(for: .audio)
let videoStatus = AVCaptureDevice.authorizationStatus(for: .video)
if audioStatus != .authorized || videoStatus != .authorized {
// Request permissions
AVCaptureDevice.requestAccess(for: .audio) { _ in }
AVCaptureDevice.requestAccess(for: .video) { _ in }
return false
}
return true
}
func initializeLinphone() {
do {
let factory = Factory.Instance
core = try factory.createCore(configPath: nil, factoryConfigPath: nil, systemContext: nil)
// Add core listener
core.addDelegate(delegate: self)
// Enable video
core.videoCaptureEnabled = true
core.videoDisplayEnabled = true
core.videoActivationPolicy?.automaticallyInitiate = true
core.videoActivationPolicy?.automaticallyAccept = true
// DSCP settings
core.sipDscp = 26 // AF31
core.audioDscp = 46 // EF
core.videoDscp = 36 // AF42
core.videoAdaptiveJittcompEnabled = true
// Bandwidth settings
core.uploadBandwidth = 512
core.downloadBandwidth = 1500
// Configure audio codecs
let preferredAudio = ["opus", "pcmu", "pcma"]
for pt in core.audioPayloadTypes {
let mime = pt.mimeType.lowercased()
let enabled = preferredAudio.contains(mime)
let ok = pt.enable(enabled: enabled) == 0
print("\(LinphoneBridge.TAG) Change state of \(mime) to \(enabled) = \(ok)")
}
// Configure video codecs
let preferredVideo = ["h264", "vp8"]
for pt in core.videoPayloadTypes {
let mime = pt.mimeType.lowercased()
let enabled = preferredVideo.contains(mime)
let ok = pt.enable(enabled: enabled) == 0
print("\(LinphoneBridge.TAG) Change state of \(mime) to \(enabled) = \(ok)")
}
// Set bitrates
core.getPayloadType(type: "opus", rate: -1, channels: 0)?.normalBitrate = 32
core.getPayloadType(type: "h264", rate: -1, channels: 0)?.normalBitrate = 600
core.getPayloadType(type: "vp8", rate: -1, channels: 0)?.normalBitrate = 600
// Video settings
let preferredVidDef = try factory.createVideoDefinition(width: 720, height: 1280)
core.preferredVideoDefinition = preferredVidDef
core.preferredFramerate = 30
try core.start()
} catch {
print("Error initializing Linphone: \(error)")
}
}
func register(username: String, password: String, serverIp: String, serverPort: Int) {
do {
let factory = Factory.Instance
let authInfo = try factory.createAuthInfo(
username: username,
userid: nil,
passwd: password,
ha1: nil,
realm: nil,
domain: serverIp
)
core.addAuthInfo(info: authInfo)
let identity = try factory.createAddress(addr: "sip:\(username)@\(serverIp)")
let server = try factory.createAddress(addr: "sip:\(serverIp):\(serverPort)")
let accountParams = try core.createAccountParams()
try accountParams.setIdentityaddress(newValue: identity)
try accountParams.setServeraddress(newValue: server)
accountParams.registerEnabled = true
let account = try core.createAccount(params: accountParams)
try core.addAccount(account: account)
core.defaultAccount = account
} catch {
print("Error registering: \(error)")
}
}
func unregister() {
core.clearAccounts()
core.clearAllAuthInfo()
}
func makeCall(callTo: String, isVideoEnabled: Bool) throws {
guard isRegistered else {
throw NSError(domain: "LinphoneBridge", code: 1, userInfo: [NSLocalizedDescriptionKey: "Not registered"])
}
do {
let factory = Factory.Instance
guard let remoteAddress = try? factory.createAddress(addr: callTo) else {
throw NSError(domain: "LinphoneBridge", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to create remote address"])
}
guard let callParams = try? core.createCallParams(call: nil) else {
throw NSError(domain: "LinphoneBridge", code: 3, userInfo: [NSLocalizedDescriptionKey: "Failed to create call params"])
}
callParams.videoEnabled = isVideoEnabled
callParams.videoDirection = isVideoEnabled ? .SendRecv : .Inactive
currentCall = try core.inviteAddressWithParams(addr: remoteAddress, params: callParams)
} catch {
throw error
}
}
func answerCall() -> Bool {
guard let call = currentCall else {
return false
}
do {
let callParams = try core.createCallParams(call: call)
callParams.videoEnabled = true
callParams.videoDirection = .SendRecv
try call.acceptWithParams(params: callParams)
return true
} catch {
print("Error answering call: \(error)")
return false
}
}
func hangupCall() -> Bool {
do {
try currentCall?.terminate()
return true
} catch {
print("Error hanging up: \(error)")
return false
}
}
func toggleVideo() -> Bool {
guard let call = currentCall else {
return false
}
do {
let params = try core.createCallParams(call: call)
params.videoEnabled = !(call.currentParams?.videoEnabled ?? false)
try call.update(params: params)
return call.currentParams?.videoEnabled ?? false
} catch {
print("Error toggling video: \(error)")
return false
}
}
func toggleMicrophone() -> Bool {
core.micEnabled = !core.micEnabled
return core.micEnabled
}
func stop() {
core.stop()
}
func inCall() -> Bool {
return currentCall != nil
}
enum CallType {
case audio
case video
case unknown
}
func callType() -> CallType {
guard let params = currentCall?.currentParams else {
return .unknown
}
if params.videoEnabled {
return .video
} else {
return .audio
}
}
}
// MARK: - CoreDelegate
extension LinphoneBridge: CoreDelegate {
func onAccountRegistrationStateChanged(core: Core, account: Account, state: RegistrationState, message: String) {
registrationState = state
switch state {
case .Ok:
isRegistered = true
case .Failed, .None, .Progress:
isRegistered = false
default:
break
}
}
func onCallStateChanged(core: Core, call: Call, state: Call.State, message: String) {
callState = state
switch state {
case .IncomingReceived:
currentCall = call
print("onCallStateChanged: INCOMING_RECEIVED \(call)")
case .Connected:
print("onCallStateChanged: CONNECTED \(call)")
if let remoteView = remoteViewAcquisitor() {
call.nativeVideoWindowId = UnsafeMutableRawPointer(Unmanaged.passUnretained(remoteView).toOpaque())
} else {
print("onCallStateChanged: CONNECTED remoteViewAcquisitor found nothing")
}
if let localView = localViewAcquisitor() {
core.nativePreviewWindowId = UnsafeMutableRawPointer(Unmanaged.passUnretained(localView).toOpaque())
} else {
print("onCallStateChanged: CONNECTED localViewAcquisitor found nothing")
}
case .StreamsRunning:
print("onCallStateChanged: STREAMS_RUNNING \(call)")
if let remoteView = remoteViewAcquisitor() {
call.nativeVideoWindowId = UnsafeMutableRawPointer(Unmanaged.passUnretained(remoteView).toOpaque())
} else {
print("onCallStateChanged: STREAMS_RUNNING remoteViewAcquisitor found nothing")
}
if let localView = localViewAcquisitor() {
core.nativePreviewWindowId = UnsafeMutableRawPointer(Unmanaged.passUnretained(localView).toOpaque())
} else {
print("onCallStateChanged: STREAMS_RUNNING localViewAcquisitor found nothing")
}
case .End, .Error, .Released:
currentCall = nil
default:
break
}
}
}