Flutter WebRTC Softphone App Using the sip_ua Package
8 min read
================================================
Application : Softphone Mobile Client
Module : SIP_UA Flutter Integration
Document : Developer Guide
——————————————————————-
Version : v1.0.0
Author : Solusipbx (Developer Team)
Created : July 4, 2026
Last Updated : July 4, 2026
===============================================
Below is a Developer Guide for building a WebRTC softphone app using the sip_ua package in Flutter, with 3CX as the Core PBX. This guide is written so developers can copy, follow, and integrate it directly with a server infrastructure already running on port 443 (Nginx & CoTURN).
🛠️ Step 1: Dependencies & Device Permissions
Open your Flutter project’s pubspec.yaml file, then add the sip_ua package along with the supporting audio library.
YAML
dependencies:
flutter:
sdk: flutter
sip_ua: ^0.7.10 # Use the latest stable version
permission_handler: ^11.3.0 # For handling microphone permission
OS Permission Configuration (Microphone & Internet)
Android (android/app/src/main/AndroidManifest.xml)
Add the following lines inside the <manifest> tag:
XML
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
iOS (ios/Runner/Info.plist)
Add the following key to officially request microphone access on the Apple ecosystem:
XML
<key>NSMicrophoneUsageDescription</key>
<string>This app needs microphone access to make VoIP phone calls.</string>
Step 2: Controller Class Architecture (sip_helper.dart)
Create a new file named sip_helper.dart. This class wraps all the connection, registration, and call-state handling logic.
Dart
import 'package:sip_ua/sip_ua.dart';
import 'package:permission_handler/permission_handler.dart';
class MySipController implements SipUaHelperListener {
final SIPUAHelper _helper = SIPUAHelper();
// Callbacks passed through to the UI
Function(RegistrationState state)? onRegistrationStateChanged;
Function(Call call, CallState state)? onCallStateChanged;
MySipController() {
// Register the main listener
_helper.addSipUaHelperListener(this);
}
/// Note: 'required String authId' is added to the function parameters
Future<void> connectAndRegister({
required String extension,
required String authId,
required String password,
required String domain,
required String wssUrl,
}) async {
// 1. Microphone permission must be granted first
var status = await Permission.microphone.request();
if (!status.isGranted) {
print("Microphone permission denied by user.");
return;
}
// 2. Set up the SIP credential configuration
UASettings settings = UASettings();
settings.wsUri = wssUrl;
settings.uri = 'sip:$extension@$domain';
settings.authId = authId; // Note: this variable is now valid
settings.password = password;
settings.displayName = extension;
settings.transportType = TransportType.WS;
// 3. Add your CoTURN configuration on port 443
settings.pcConfig = {
'iceServers': [
{'url': 'stun:$domain:443'},
{
'url': 'turn:$domain:443',
'username': 'admin_coturn',
'credential': 'passwordcoturn' // password set in turnserver.conf
}
],
'iceTransportPolicy': 'all'
};
// 4. Start the registration process in the background
_helper.start(settings);
}
/// Makes an outbound call
void makeVoiceCall(String targetExtension) {
_helper.call(targetExtension, voiceonly: true);
}
/// Hangs up or rejects a call
void hangupOrReject(Call call) {
call.hangup();
}
/// Answers an inbound call
void answerCall(Call call) {
call.answer(_helper.buildConstraints());
}
// ===========================================================================
// SIPUAHELPERLISTENER IMPLEMENTATION (EVENT HANDLERS)
// ===========================================================================
@override
void registrationStateChanged(RegistrationState state) {
print('SIP registration status: ${state.state}');
if (onRegistrationStateChanged != null) {
onRegistrationStateChanged!(state);
}
}
@override
void callStateChanged(Call call, CallState state) {
print('Call status: ${state.state}');
if (onCallStateChanged != null) {
onCallStateChanged!(call, state);
}
}
@override
void transportStateChanged(TransportState state) {
print('WebSocket connection status: ${state.state}');
}
@override
void onNewMessage(SIPMessageRequest request) {
print('Received a new SIP text message');
}
@override
void onNewNotify(Notify notify) {
print('Received a new notification from the PBX');
}
}
Step 3: Integrating into the App UI (main.dart)
Here’s a simple UI implementation example for registering an extension, placing calls, and receiving inbound calls in real time.
import 'package:flutter/material.dart';
import 'package:sip_ua/sip_ua.dart';
import 'sip_helper.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: const SoftphoneScreen(),
);
}
}
class SoftphoneScreen extends StatefulWidget {
const SoftphoneScreen({super.key});
@override
State<SoftphoneScreen> createState() => _SoftphoneScreenState();
}
class _SoftphoneScreenState extends State<SoftphoneScreen> {
final MySipController _sipController = MySipController();
final TextEditingController _targetController = TextEditingController();
String _regStatus = "DISCONNECTED";
Call? _activeCall;
String _callStatus = "IDLE";
@override
void initState() {
super.initState();
// Wire the controller's event listeners to the UI state
_sipController.onRegistrationStateChanged = (RegistrationState state) {
setState(() {
_regStatus = state.state == RegistrationStateEnum.REGISTERED
? "CONNECTED ✔️ "
: state.state.toString().split('.').last;
});
};
_sipController.onCallStateChanged = (Call call, CallState state) {
setState(() {
_activeCall = call;
_callStatus = state.state.toString().split('.').last;
});
};
// Auto-connect when the app opens
_sipController.connectAndRegister(
extension: "101",
authId: "passwordAuthid",
password: "passwordExtension",
domain: "pbx.yourdomain.com",
wssUrl: "wss://rtc.yourdomain.com/yourappname",
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Alana Hotel Softphone")),
body: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text("Extension Status: $_regStatus",
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 20),
Text("Call Status: $_callStatus",
style: TextStyle(fontSize: 16, color: Colors.blue.shade700)),
const Divider(height: 40),
if (_activeCall == null || _callStatus == "CALL_INITIATION" || _callStatus == "IDLE") ...[
TextField(
controller: _targetController,
decoration: const InputDecoration(
labelText: "Enter the destination extension number",
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 10),
ElevatedButton.icon(
onPressed: () => _sipController.makeVoiceCall(_targetController.text),
icon: const Icon(Icons.call),
label: const Text("Call"),
style: ElevatedButton.styleFrom(backgroundColor: Colors.green, foregroundColor: Colors.white),
),
],
if (_activeCall != null && _callStatus == "INCOMING") ...[
Text("Incoming call from: ${_activeCall!.remote_identity}",
style: const TextStyle(fontSize: 16, color: Colors.orange)),
const SizedBox(height: 15),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: () => _sipController.answerCall(_activeCall!),
style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
child: const Text("Answer"),
),
ElevatedButton(
onPressed: () => _sipController.hangupOrReject(_activeCall!),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text("Reject"),
),
],
)
],
if (_activeCall != null && _callStatus == "CONFIRMED") ...[
const Icon(Icons.phone_in_talk, size: 60, color: Colors.green),
const SizedBox(height: 15),
ElevatedButton.icon(
onPressed: () => _sipController.hangupOrReject(_activeCall!),
icon: const Icon(Icons.call_end),
label: const Text("End Call"),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white),
)
],
],
),
),
);
}
}
Important Additional Notes for Developers
- Extension URI Format: The settings.uri string must use the full SIP URI format: ‘sip:USERNAME@DOMAIN’. A typo or a missing sip: prefix will cause the handshake to fail.
- Audio Handling: The sip_ua package coordinates directly with the phone’s native WebRTC framework. As long as a call is in the CONFIRMED state, audio streaming between microphone and speaker is handled automatically in real time.
- Next Step (Push Notifications): If you want this app to keep working while the phone is asleep/off, developers simply need to integrate the MySipController class above with the firebase_messaging (FCM) package and flutter_callkit_incoming, so an incoming call event can instantly wake up the Android/iOS operating system.
- When the phone’s operating system goes into power-saving/sleep mode, the WebSocket connection (wss://) that sip_ua maintains to the WebRTC gateway will automatically be dropped by the OS. This add-on section walks developers through integrating Firebase Cloud Messaging (FCM) and Flutter CallKit Incoming to wake the app remotely whenever there’s an incoming call on the 3CX PBX.
What do you need to prepare? Add the following packages below the sip_ua dependency:
dependencies:
# ... existing sip_ua and permission_handler dependencies ...
# ADD-ON DEPENDENCIES FOR GOOGLE PUSH
firebase_core: ^3.0.0
firebase_messaging: ^15.0.0
flutter_callkit_incoming: ^1.0.0 # Enables the full-screen call UI while asleep
Additional OS Permission Configuration
Android (android/app/src/main/AndroidManifest.xml)
Add these permissions inside the <manifest> tag to support push notifications and unlocking the lock screen:
XML
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
iOS (ios/Runner/Info.plist)
Add the following background capabilities (Background Modes):
XML
<key>UIBackgroundModes</key>
<array>
<string>voip</string>
<string>remote-notification</string>
</array>
Step 2: Background Handler Logic (main.dart)
Developers must register the background message handler function in main.dart before runApp is called. This function is invoked instantly by Google Play Services even while your app is fully closed.
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
/// Handles incoming push packets while the app is fully closed/asleep
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
// Configure the native OS incoming-call screen (CallKit) parameters
var params = <String, dynamic>{
'id': message.data['call_id'] ?? '123',
'nameCaller': message.data['caller_name'] ?? 'Hotel Call',
'handle': message.data['caller_extension'] ?? '000',
'type': 0, // 0 = Voice call (Audio VoIP)
'duration': 30000, // Ring timeout (30 seconds)
'android': {
'isCustomNotification': true,
'isShowLogo': false,
'ringtone': 'ringtone_default',
},
};
// Show the full-screen incoming call interface
await FlutterCallkitIncoming.showCallkitIncoming(params);
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Firebase Core
await Firebase.initializeApp();
// Register Google FCM's official background handler
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
runApp(const MyApp());
Step 3: Retrieving the Device’s FCM Token
Inside your main UI component, or as soon as the app detects a successful login/registration, the device token must be fetched and sent to your backend webhook server (Creomate).
import 'package:firebase_messaging/firebase_messaging.dart';
void getAndSaveFcmToken(String extensionNumber) async {
FirebaseMessaging messaging = FirebaseMessaging.instance;
// 1. Interactively ask the user for push notification permission
NotificationSettings settings = await messaging.requestPermission(
alert: true, badge: true, sound: true,
);
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
// 2. Get the unique token from Google's server
String? token = await messaging.getToken();
print("FCM Token for extension $extensionNumber: $token");
// 3. TODO: Developer sends an HTTP POST to the Creomate API / internal database
// to map: Extension 101 = Token XYZ
// _apiService.saveTokenToServer(extensionNumber, token);
}
}
Step 4: Syncing the “Answer Call” Action with sip_ua
When the CallKit screen appears and the user taps the green “Accept” button, the app needs to automatically re-initialize the sip_ua WebSocket connection and answer the call. Add this listener inside your main page’s initState:
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
void listenToCallKitActions() {
FlutterCallkitIncoming.onEvent.listen((CallEvent? event) {
if (event == null) return;
switch (event.event) {
case Event.actionCallAccept:
print("User tapped Answer!");
// 1. Quickly re-run the WebSocket registration to Janus/3CX
_sipController.connectAndRegister(
extension: "101",
authId: "passwordAuthid",
password: "passwordExtension",
domain: "pbx.yourdomain.com",
wssUrl: "wss://rtc.yourdomain.com/yourappname",
).then((_) {
// 2. Add a short delay/wait for the INCOMING event to fire in sip_ua,
// then call answerCall() automatically.
});
break;
case Event.actionCallDecline:
print("User declined the call from the lock screen.");
// Send a reject signal to the controller if needed
break;
default:
break;
}
});
}
[3CX PBX System]
│
▼ (Event: Incoming call to a sleeping extension)
[Webhook Manager / Creomate]
│
▼ (HTTP POST looking up the extension’s FCM token)
[Google FCM Cloud Services]
│
▼ (High-priority push signal)
[Android / iOS Phone (Sleep State)]
│
├─► Wakes up ‘_firebaseMessagingBackgroundHandler’
├─► Triggers ‘FlutterCallkitIncoming.showCallkitIncoming’
└─► User taps Answer ──► WebSocket reconnects ──► WebRTC audio flows
