diff --git a/android/src/main/kotlin/xyz/nuark/liblinphone_flutter/LinphoneBridge.kt b/android/src/main/kotlin/xyz/nuark/liblinphone_flutter/LinphoneBridge.kt index 331fcb7..1319400 100644 --- a/android/src/main/kotlin/xyz/nuark/liblinphone_flutter/LinphoneBridge.kt +++ b/android/src/main/kotlin/xyz/nuark/liblinphone_flutter/LinphoneBridge.kt @@ -223,7 +223,7 @@ class LinphoneBridge( core.uploadBandwidth = 512 core.downloadBandwidth = 1500 - val preferredAudio = listOf("g729"/*, "opus", "speex", "pcmu", "pcma"*/) // in order of preference + val preferredAudio = listOf("g729") // in order of preference val preferredVideo = listOf("h264", "vp8") core.audioPayloadTypes.forEach { pt: PayloadType -> diff --git a/ios/Classes/LiblinphoneFlutterPlugin.swift b/ios/Classes/LiblinphoneFlutterPlugin.swift index bb549b1..bc4db2c 100644 --- a/ios/Classes/LiblinphoneFlutterPlugin.swift +++ b/ios/Classes/LiblinphoneFlutterPlugin.swift @@ -80,35 +80,21 @@ public class LiblinphoneFlutterPlugin: NSObject, FlutterPlugin { public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "checkPermissions": - let hasPermissions = linphoneBridge.checkPermissions() - result(hasPermissions) + result(linphoneBridge.checkPermissions()) case "initialize": - do { - linphoneBridge = LinphoneBridge( - remoteViewAcquisitor: { [weak self] in - return self?.acquireRemoteView() - }, - localViewAcquisitor: { [weak self] in - return self?.acquireLocalView() - }, - onRegistrationStateChanged: { [weak self] state in - self?.registrationEventsChannel?(state) - }, - onCallStateChanged: { [weak self] state in - self?.callEventsChannel?(state) - } - ) - linphoneBridge.initializeLinphone() - result(true) - } catch { - print("[\(LiblinphoneFlutterPlugin.TAG)] initialize error: \(error.localizedDescription)") - result(FlutterError( - code: "ERROR", - message: error.localizedDescription, - details: nil - )) - } + linphoneBridge = LinphoneBridge( + remoteViewAcquisitor: { [weak self] in self?.acquireRemoteView() }, + localViewAcquisitor: { [weak self] in self?.acquireLocalView() }, + onRegistrationStateChanged: { [weak self] state in + self?.registrationEventsChannel?(state) + }, + onCallStateChanged: { [weak self] state in + self?.callEventsChannel?(state) + } + ) + linphoneBridge.initializeLinphone() + result(true) case "register": guard let args = call.arguments as? [String: Any], @@ -116,30 +102,11 @@ public class LiblinphoneFlutterPlugin: NSObject, FlutterPlugin { let password = args["password"] as? String, let serverIp = args["serverIp"] as? String, let serverPort = args["serverPort"] as? Int else { - result(FlutterError( - code: "INVALID_ARGUMENTS", - message: "Missing required arguments", - details: nil - )) + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Missing required arguments", details: nil)) return } - - do { - linphoneBridge.register( - username: username, - password: password, - serverIp: serverIp, - serverPort: serverPort - ) - result(true) - } catch { - print("[\(LiblinphoneFlutterPlugin.TAG)] register error: \(error.localizedDescription)") - result(FlutterError( - code: "ERROR", - message: error.localizedDescription, - details: nil - )) - } + linphoneBridge.register(username: username, password: password, serverIp: serverIp, serverPort: serverPort) + result(true) case "unregister": linphoneBridge.unregister() @@ -149,118 +116,114 @@ public class LiblinphoneFlutterPlugin: NSObject, FlutterPlugin { guard let args = call.arguments as? [String: Any], let callTo = args["callTo"] as? String, let isVideoEnabled = args["isVideoEnabled"] as? Bool else { - result(FlutterError( - code: "INVALID_ARGUMENTS", - message: "Missing required arguments", - details: nil - )) + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Missing required arguments", details: nil)) return } - do { try linphoneBridge.makeCall(callTo: callTo, isVideoEnabled: isVideoEnabled) result(true) } catch { - print("[\(LiblinphoneFlutterPlugin.TAG)] makeCall error: \(error.localizedDescription)") - result(FlutterError( - code: "ERROR", - message: error.localizedDescription, - details: nil - )) + result(FlutterError(code: "ERROR", message: error.localizedDescription, details: nil)) } case "answerCall": - let success = linphoneBridge.answerCall() - result(success) + result(linphoneBridge.answerCall()) case "hangupCall": - let success = linphoneBridge.hangupCall() - result(success) + result(linphoneBridge.hangupCall()) case "inCall": - let inCall = linphoneBridge.inCall() - result(inCall) + result(linphoneBridge.inCall()) case "callType": - let callType = linphoneBridge.callType() - let ordinal: Int - switch callType { - case .audio: - ordinal = 0 - case .video: - ordinal = 1 - case .unknown: - ordinal = 2 + switch linphoneBridge.callType() { + case .audio: result(0) + case .video: result(1) + case .unknown: result(2) } - result(ordinal) case "toggleVideo": - let enabled = linphoneBridge.toggleVideo() - result(enabled) + result(linphoneBridge.toggleVideo()) case "toggleMicrophone": - let enabled = linphoneBridge.toggleMicrophone() - result(enabled) + result(linphoneBridge.toggleMicrophone()) case "stop": linphoneBridge.stop() result(true) - case "syncCurrentState" : + case "syncCurrentState": linphoneBridge.syncCurrentState() result(true) case "sendDtmf": guard let args = call.arguments as? [String: Any], let tone = args["tone"] as? String else { - result(FlutterError( - code: "INVALID_ARGUMENTS", - message: "Missing required arguments", - details: nil - )) + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Missing required arguments", details: nil)) return } - - let success = linphoneBridge.sendDtmf(tone: tone) - result(success) + result(linphoneBridge.sendDtmf(tone: tone)) case "stopCallService": + // Android-only; no-op on iOS result(true) case "setMicGain": guard let args = call.arguments as? [String: Any], - let level = args["level"] as? String else { - result(FlutterError( - code: "INVALID_ARGUMENTS", - message: "Missing required arguments", - details: nil - )) + let levelStr = args["level"] as? String, + let level = Float(levelStr) else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Missing or invalid 'level'", details: nil)) return } - linphoneBridge.setMicGain(level: level) - result(success) + result(true) case "getMicGain": - result(linphoneBridge.getMicGain()) + result(String(linphoneBridge.getMicGain())) case "setPlaybackGain": guard let args = call.arguments as? [String: Any], - let level = args["level"] as? String else { - result(FlutterError( - code: "INVALID_ARGUMENTS", - message: "Missing required arguments", - details: nil - )) + let levelStr = args["level"] as? String, + let level = Float(levelStr) else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Missing or invalid 'level'", details: nil)) return } - linphoneBridge.setPlaybackGain(level: level) - result(success) + result(true) case "getPlaybackGain": result(linphoneBridge.getPlaybackGain()) + case "setDscp": + guard let args = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Missing arguments", details: nil)) + return + } + // All three are optional — only present keys are applied + let sipDscp = args["sipDscp"] as? Int + let audioDscp = args["audioDscp"] as? Int + let videoDscp = args["videoDscp"] as? Int + linphoneBridge.setDscp(sipDscp: sipDscp, audioDscp: audioDscp, videoDscp: videoDscp) + result(true) + + case "getDscp": + result(linphoneBridge.getDscp()) + + case "getCurrentCallStats": + result(linphoneBridge.getCurrentCallStats()) + + case "getAvailableAudioCodecs": + result(linphoneBridge.getAvailableAudioCodecs()) + + case "setAudioCodec": + guard let args = call.arguments as? [String: Any], + let mime = args["mime"] as? String, + let clockRate = args["clockRate"] as? Int else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Missing required arguments", details: nil)) + return + } + result(linphoneBridge.setAudioCodec(mime: mime, clockRate: clockRate)) + default: result(FlutterMethodNotImplemented) } diff --git a/ios/Classes/LinphoneBridge.swift b/ios/Classes/LinphoneBridge.swift index f1d61a4..157ca19 100644 --- a/ios/Classes/LinphoneBridge.swift +++ b/ios/Classes/LinphoneBridge.swift @@ -45,7 +45,6 @@ class LinphoneBridge { 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 @@ -58,14 +57,11 @@ class LinphoneBridge { do { let factory = Factory.Instance core = try factory.createCore(configPath: nil, factoryConfigPath: nil, systemContext: nil) + core.addDelegate(delegate: self) core.ipv6Enabled = false - core.config?.setInt(section: "net", key: "ipv6", value: 0) - try? core.config?.sync() + core.agcEnabled = false - // Add core listener - core.addDelegate(delegate: self) - // Enable video core.videoCaptureEnabled = true core.videoDisplayEnabled = true @@ -84,7 +80,7 @@ class LinphoneBridge { core.downloadBandwidth = 1500 // Configure audio codecs - let preferredAudio = ["opus", "pcmu", "pcma"] + let preferredAudio = ["g729"] for pt in core.audioPayloadTypes { let mime = pt.mimeType.lowercased() let enabled = preferredAudio.contains(mime) @@ -102,9 +98,9 @@ class LinphoneBridge { } // 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 + // 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) @@ -156,29 +152,23 @@ class LinphoneBridge { 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 + 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) } func answerCall() -> Bool { - guard let call = currentCall else { - return false - } + guard let call = currentCall else { return false } do { let callParams = try core.createCallParams(call: call) @@ -203,9 +193,7 @@ class LinphoneBridge { } func toggleVideo() -> Bool { - guard let call = currentCall else { - return false - } + guard let call = currentCall else { return false } do { let params = try core.createCallParams(call: call) @@ -238,25 +226,12 @@ class LinphoneBridge { } func callType() -> CallType { - guard let params = currentCall?.currentParams else { - return .unknown - } - - if params.videoEnabled { - return .video - } else { - return .audio - } + guard let params = currentCall?.currentParams else { return .unknown } + return params.videoEnabled ? .video : .audio } func sendDtmf(tone: String) -> Bool { - guard let call = currentCall else { - return false - } - - guard !tone.isEmpty else { - return false - } + guard let call = currentCall, !tone.isEmpty else { return false } let dtmfChar = tone.first! do { @@ -273,6 +248,8 @@ class LinphoneBridge { onCallStateChanged(callState.rawValue) } + // MARK: - Gain + func setMicGain(level: Float) { core.micGainDb = level } @@ -288,6 +265,111 @@ class LinphoneBridge { func getPlaybackGain() -> Float { return core.playbackGainDb } + + // MARK: - DSCP + + func setDscp(sipDscp: Int?, audioDscp: Int?, videoDscp: Int?) { + if let sip = sipDscp { core.sipDscp = sip } + if let audio = audioDscp { core.audioDscp = audio } + if let video = videoDscp { core.videoDscp = video } + } + + func getDscp() -> [String: Any] { + return [ + "sipDscp": core.sipDscp, + "audioDscp": core.audioDscp, + "videoDscp": core.videoDscp, + ] + } + + // MARK: - Call Stats + + func getCurrentCallStats() -> [String: Any]? { + guard let call = core.currentCall, + let stats = call.getStats(type: .Audio) else { + return nil + } + + return [ + "recordTs": Int64(Date().timeIntervalSince1970 * 1000), + "remoteAddress": call.remoteAddress?.asString() ?? "", + "currentQuality": call.currentQuality, + "downloadBandwidth": stats.downloadBandwidth, + "estimatedDownloadBandwidth": stats.estimatedDownloadBandwidth, + "fecCumulativeLostPacketsNumber": stats.fecCumulativeLostPacketsNumber, + "fecDownloadBandwidth": stats.fecDownloadBandwidth, + "fecRepairedPacketsNumber": stats.fecRepairedPacketsNumber, + "fecUploadBandwidth": stats.fecUploadBandwidth, + "iceState": stats.iceState.rawValue, + "jitterBufferSizeMs": stats.jitterBufferSizeMs, + "latePacketsCumulativeNumber": stats.latePacketsCumulativeNumber, + "localLateRate": stats.localLateRate, + "localLossRate": stats.localLossRate, + "receiverInterarrivalJitter": stats.receiverInterarrivalJitter, + "receiverLossRate": stats.receiverLossRate, + "roundTripDelay": stats.roundTripDelay, + "rtcpDownloadBandwidth": stats.rtcpDownloadBandwidth, + "rtcpUploadBandwidth": stats.rtcpUploadBandwidth, + "rtpCumPacketLoss": stats.rtpCumPacketLoss, + "rtpDiscarded": stats.rtpDiscarded, + "rtpHwRecv": stats.rtpHwRecv, + "rtpPacketRecv": stats.rtpPacketRecv, + "rtpPacketSent": stats.rtpPacketSent, + "rtpRecv": stats.rtpRecv, + "rtpSent": stats.rtpSent, + "senderInterarrivalJitter": stats.senderInterarrivalJitter, + "senderLossRate": stats.senderLossRate, + "srtpSource": stats.srtpSource.rawValue, + "srtpSuite": stats.srtpSuite.rawValue, + "uploadBandwidth": stats.uploadBandwidth, + "upnpState": stats.upnpState.rawValue, + "zrtpAuthTagAlgo": stats.zrtpAuthTagAlgo, + "zrtpCipherAlgo": stats.zrtpCipherAlgo, + "zrtpHashAlgo": stats.zrtpHashAlgo, + "zrtpKeyAgreementAlgo": stats.zrtpKeyAgreementAlgo, + "zrtpSasAlgo": stats.zrtpSasAlgo, + "isZrtpKeyAgreementAlgoPostQuantum": stats.isZrtpKeyAgreementAlgoPostQuantum, + ] + } + + // MARK: - Codec management + + func getAvailableAudioCodecs() -> [[String: Any]] { + return core.audioPayloadTypes.map { pt in + [ + "mimeType": pt.mimeType.lowercased(), + "clockRate": pt.clockRate, + "enabled": pt.enabled(), + ] + } + } + + /// Enables the codec matching (mime, clockRate) and disables all others. + /// Returns true if the target codec was found and enabled successfully. + func setAudioCodec(mime: String, clockRate: Int) -> Bool { + var found = false + var ok = false + + for pt in core.audioPayloadTypes { + let ptMime = pt.mimeType.lowercased() + let ptClockRate = pt.clockRate + let enable = ptMime == mime && ptClockRate == clockRate + if enable { + ok = pt.enable(enabled: true) == 0 + found = true + print("\(LinphoneBridge.TAG) setAudioCodec: preferred codec found (\(ptMime) @ \(ptClockRate)) switch: \(ok)") + } else { + let switchedOk = pt.enable(enabled: false) == 0 + print("\(LinphoneBridge.TAG) setAudioCodec: \(ptMime) @ \(ptClockRate) switch: \(switchedOk)") + } + } + + if !found { + print("\(LinphoneBridge.TAG) setAudioCodec: could not find codec with params \(mime) @ \(clockRate)") + } + + return ok + } } // MARK: - CoreDelegate