sip_voip_plugin 1.0.5
sip_voip_plugin: ^1.0.5 copied to clipboard
A simple Flutter plugin for SIP VoIP calling with CallKit support. Supports single and multi-account SIP registrations.
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:sip_ua/sip_ua.dart';
import 'package:sip_voip_plugin/voip_service.dart';
import 'package:sip_voip_plugin/sip_service.dart';
import 'package:sip_voip_plugin/voip_plugin.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Set up global error handler
FlutterError.onError = (FlutterErrorDetails details) {
FlutterError.presentError(details);
debugPrint('═══════════════════════════════════════');
debugPrint('❌ FLUTTER ERROR');
debugPrint('═══════════════════════════════════════');
debugPrint('Exception: ${details.exception}');
debugPrint('Library: ${details.library}');
debugPrint('Context: ${details.context}');
debugPrint('═══════════════════════════════════════');
debugPrint('Stack Trace:');
debugPrint(details.stack.toString());
debugPrint('═══════════════════════════════════════');
};
// Handle async errors
PlatformDispatcher.instance.onError = (error, stack) {
debugPrint('═══════════════════════════════════════');
debugPrint('❌ PLATFORM DISPATCHER ERROR');
debugPrint('═══════════════════════════════════════');
debugPrint('Error: $error');
debugPrint('═══════════════════════════════════════');
debugPrint('Stack Trace:');
debugPrint(stack.toString());
debugPrint('═══════════════════════════════════════');
return true;
};
try {
// Initialize VoIP service
await VoipService.initialize();
// Request necessary permissions
await _requestPermissions();
runApp(const MyApp());
} catch (e, stackTrace) {
debugPrint('═══════════════════════════════════════');
debugPrint('❌ EXCEPTION: Main Function');
debugPrint('═══════════════════════════════════════');
debugPrint('Error Type: ${e.runtimeType}');
debugPrint('Error Message: $e');
debugPrint('═══════════════════════════════════════');
debugPrint('Stack Trace:');
debugPrint(stackTrace.toString());
debugPrint('═══════════════════════════════════════');
rethrow;
}
}
Future<void> _requestPermissions() async {
try {
// Request microphone permission for VoIP calls
await Permission.microphone.request();
// Request phone permission for call management (Android)
await Permission.phone.request();
// Request notification permission for Android 13+
if (await Permission.notification.isDenied) {
await Permission.notification.request();
}
} catch (e, stackTrace) {
debugPrint('═══════════════════════════════════════');
debugPrint('❌ EXCEPTION: Request Permissions');
debugPrint('═══════════════════════════════════════');
debugPrint('Error Type: ${e.runtimeType}');
debugPrint('Error Message: $e');
debugPrint('═══════════════════════════════════════');
debugPrint('Stack Trace:');
debugPrint(stackTrace.toString());
debugPrint('═══════════════════════════════════════');
}
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'SIP VoIP Call',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const SipCallPage(),
);
}
}
class SipCallPage extends StatefulWidget {
const SipCallPage({super.key});
@override
State<SipCallPage> createState() => _SipCallPageState();
}
class _SipCallPageState extends State<SipCallPage> {
// SIP controllers - Pre-filled with production server credentials
final TextEditingController _sipServerController = TextEditingController(text: '686e4c6534db7.voip.nextelco.io');
final TextEditingController _sipUsernameController = TextEditingController(text: '2502');
final TextEditingController _sipPasswordController = TextEditingController(text: '12345678');
final TextEditingController _sipRealmController = TextEditingController(text: 'wss://ws.nxtlinks.io');
final TextEditingController _sipDisplayNameController = TextEditingController(text: '(201)-908-9308');
final TextEditingController _sipTargetController = TextEditingController();
String _callStatus = 'Ready';
bool _isCallActive = false;
// SIP state
bool _sipRegistered = false;
String _sipRegistrationStatus = 'Not registered';
String _transportStatus = 'Unknown';
bool _isTransportConnected = false;
@override
void initState() {
super.initState();
try {
// Set up callback to receive call status updates
VoipService.onCallStatusChanged = (status, data) {
try {
if (mounted) {
setState(() {
switch (status) {
case 'accepted':
_callStatus = 'Call accepted';
_isCallActive = true;
break;
case 'declined':
_callStatus = 'Call declined';
_isCallActive = false;
break;
case 'ended':
_callStatus = 'Call ended';
_isCallActive = false;
break;
case 'timeout':
_callStatus = 'Call missed (timeout)';
_isCallActive = false;
break;
}
});
// Show snackbar with caller info
if (data != null) {
final callerName = data['callerName'] ?? 'Unknown';
_showSnackBar('Call $status: $callerName');
}
}
} catch (e, stackTrace) {
_printException('Call Status Callback', e, stackTrace);
}
};
// Initialize SIP service
_initializeSip();
// Set up SIP callbacks
SipService.onCallStateChanged = (state) {
if (mounted) {
final stateStr = state.state.toString();
setState(() {
if (stateStr.contains('CONNECTED') || stateStr.contains('connected')) {
_isCallActive = true;
_callStatus = 'Call active';
} else if (stateStr.contains('ENDED') || stateStr.contains('ended') ||
stateStr.contains('FAILED') || stateStr.contains('failed')) {
_isCallActive = false;
_callStatus = 'Call ended';
}
});
}
};
SipService.onRegistrationStateChanged = (state) {
if (mounted) {
setState(() {
_sipRegistered = state.state == RegistrationStateEnum.REGISTERED;
_sipRegistrationStatus = _getRegistrationStatusText(state.state);
});
}
};
SipService.onTransportStateChanged = (state) {
if (mounted) {
final stateStr = state.state.toString();
setState(() {
_isTransportConnected = stateStr.contains('CONNECTED') ||
stateStr.contains('connected');
_transportStatus = _getTransportStatusText(state.state);
// Show notification for disconnection
if (stateStr.contains('DISCONNECTED') || stateStr.contains('disconnected')) {
_showSnackBar('⚠️ SIP transport disconnected. Reconnecting...');
} else if (stateStr.contains('CONNECTED') || stateStr.contains('connected')) {
// Only show connected message if we were previously disconnected
if (!_isTransportConnected && _sipRegistered) {
_showSnackBar('✅ SIP transport connected');
}
}
});
}
};
} catch (e, stackTrace) {
_printException('InitState', e, stackTrace);
}
}
Future<void> _initializeSip() async {
try {
await SipService.initialize();
} catch (e, stackTrace) {
_printException('Initialize SIP', e, stackTrace);
}
}
String _getRegistrationStatusText(RegistrationStateEnum? state) {
if (state == null) return 'Unknown';
switch (state) {
case RegistrationStateEnum.REGISTERED:
return '✅ Registered';
case RegistrationStateEnum.UNREGISTERED:
return '❌ Unregistered';
case RegistrationStateEnum.REGISTRATION_FAILED:
return '❌ Registration failed';
default:
return 'Unknown';
}
}
String _getTransportStatusText(dynamic state) {
if (state == null) return 'Unknown';
final stateStr = state.toString();
if (stateStr.contains('CONNECTED') || stateStr.contains('connected')) {
return '✅ Connected';
} else if (stateStr.contains('CONNECTING') || stateStr.contains('connecting')) {
return '⏳ Connecting...';
} else if (stateStr.contains('DISCONNECTED') || stateStr.contains('disconnected')) {
return '❌ Disconnected';
} else {
return stateStr;
}
}
/// Print exception with full details
void _printException(String context, dynamic error, StackTrace stackTrace) {
debugPrint('═══════════════════════════════════════');
debugPrint('❌ EXCEPTION: Main - $context');
debugPrint('═══════════════════════════════════════');
debugPrint('Error Type: ${error.runtimeType}');
debugPrint('Error Message: $error');
debugPrint('═══════════════════════════════════════');
debugPrint('Stack Trace:');
debugPrint(stackTrace.toString());
debugPrint('═══════════════════════════════════════');
}
@override
void dispose() {
VoipService.onCallStatusChanged = null;
SipService.onCallStateChanged = null;
SipService.onRegistrationStateChanged = null;
SipService.onTransportStateChanged = null;
SipService.dispose();
_sipServerController.dispose();
_sipUsernameController.dispose();
_sipPasswordController.dispose();
_sipRealmController.dispose();
_sipDisplayNameController.dispose();
_sipTargetController.dispose();
super.dispose();
}
Future<void> _registerSip() async {
if (_sipServerController.text.isEmpty ||
_sipUsernameController.text.isEmpty ||
_sipPasswordController.text.isEmpty) {
_showSnackBar('Please enter SIP server, username, and password');
return;
}
setState(() {
_sipRegistrationStatus = 'Registering...';
});
try {
// Check if realm is provided (WebSocket connection)
final realm = _sipRealmController.text.trim();
final useWebSocket = realm.isNotEmpty && (realm.startsWith('ws://') || realm.startsWith('wss://'));
final success = await SipService.register(
username: _sipUsernameController.text.trim(),
password: _sipPasswordController.text.trim(),
domain: _sipServerController.text.trim(),
realm: realm.isNotEmpty ? realm : null,
displayName: _sipDisplayNameController.text.isEmpty
? _sipUsernameController.text.trim()
: _sipDisplayNameController.text.trim(),
callerId: _sipDisplayNameController.text.isNotEmpty
? _sipDisplayNameController.text.trim()
: null,
sendAs: _sipDisplayNameController.text.isNotEmpty
? _sipDisplayNameController.text.trim()
: null,
useWebSocket: useWebSocket,
wsUrl: useWebSocket ? realm : null,
);
if (success) {
_showSnackBar('SIP registration initiated');
} else {
setState(() {
_sipRegistrationStatus = 'Registration failed';
});
_showSnackBar('Failed to register SIP');
}
} catch (e, stackTrace) {
_printException('Register SIP', e, stackTrace);
setState(() {
_sipRegistrationStatus = 'Error: $e';
});
_showSnackBar('Error: $e');
}
}
Future<void> _unregisterSip() async {
try {
await SipService.unregister();
setState(() {
_sipRegistered = false;
_sipRegistrationStatus = 'Unregistered';
});
_showSnackBar('SIP unregistered');
} catch (e, stackTrace) {
_printException('Unregister SIP', e, stackTrace);
_showSnackBar('Error: $e');
}
}
Future<void> _makeSipCall() async {
if (!_sipRegistered) {
_showSnackBar('Please register SIP first');
return;
}
if (_sipTargetController.text.isEmpty) {
_showSnackBar('Please enter target SIP URI or extension');
return;
}
setState(() {
_callStatus = 'Calling...';
_isCallActive = true;
});
try {
await SipService.makeCall(_sipTargetController.text.trim());
setState(() {
_callStatus = 'Call initiated';
});
} catch (e, stackTrace) {
_printException('Make SIP Call', e, stackTrace);
setState(() {
_callStatus = 'Error: $e';
_isCallActive = false;
});
_showSnackBar('Error: $e');
}
}
Future<void> _endCall() async {
try {
// End both CallKit and SIP calls
await VoipService.endAllCalls();
if (SipService.isInCall) {
await SipService.hangup();
}
setState(() {
_callStatus = 'Call ended';
_isCallActive = false;
});
} catch (e, stackTrace) {
_printException('End Call', e, stackTrace);
_showSnackBar('Error ending call: $e');
}
}
void _showSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('SIP VoIP Call'),
centerTitle: true,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Status Card
Card(
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Icon(
_sipRegistered ? Icons.phone_enabled : Icons.phone_disabled,
size: 48,
color: _sipRegistered ? Colors.green : Colors.grey,
),
const SizedBox(height: 8),
Text(
_callStatus,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
_sipRegistrationStatus,
style: TextStyle(
color: _sipRegistered ? Colors.green : Colors.grey,
fontSize: 14,
),
),
if (_transportStatus != 'Unknown') ...[
const SizedBox(height: 4),
Text(
_transportStatus,
style: TextStyle(
color: _isTransportConnected ? Colors.green : Colors.orange,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
],
),
),
),
const SizedBox(height: 32),
// SIP Registration Section
Card(
color: Colors.teal.shade50,
child: ExpansionTile(
initiallyExpanded: true,
leading: Icon(
_sipRegistered ? Icons.check_circle : Icons.radio_button_unchecked,
color: _sipRegistered ? Colors.green : Colors.grey,
),
title: Text(
'SIP Registration',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.teal.shade700,
),
),
subtitle: Text(_sipRegistrationStatus),
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: _sipServerController,
decoration: const InputDecoration(
labelText: 'Domain / SIP Server',
hintText: 'e.g., domain.voip.example.com',
prefixIcon: Icon(Icons.dns),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _sipRealmController,
decoration: const InputDecoration(
labelText: 'Realm / WebSocket URL',
hintText: 'e.g., wss://ws.example.com',
prefixIcon: Icon(Icons.cloud),
border: OutlineInputBorder(),
helperText: 'Required for WebSocket (wss:// or ws://)',
),
),
const SizedBox(height: 12),
TextField(
controller: _sipUsernameController,
decoration: const InputDecoration(
labelText: 'Username',
hintText: 'e.g., 2502',
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _sipPasswordController,
decoration: const InputDecoration(
labelText: 'Password',
hintText: 'Enter SIP password',
prefixIcon: Icon(Icons.lock),
border: OutlineInputBorder(),
),
obscureText: false,
),
const SizedBox(height: 12),
TextField(
controller: _sipDisplayNameController,
decoration: const InputDecoration(
labelText: 'Caller ID / Display Name',
hintText: 'e.g., (201)-908-9308',
prefixIcon: Icon(Icons.badge),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _sipRegistered ? null : _registerSip,
icon: const Icon(Icons.login),
label: const Text('Register'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
backgroundColor: Colors.teal,
foregroundColor: Colors.white,
),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _sipRegistered ? _unregisterSip : null,
icon: const Icon(Icons.logout),
label: const Text('Unregister'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
],
),
),
],
),
),
const SizedBox(height: 16),
// SIP Call Section
Card(
color: Colors.blue.shade50,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.call, color: Colors.blue.shade700, size: 20),
const SizedBox(width: 8),
Text(
'Make SIP Call',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.blue.shade700,
fontSize: 16,
),
),
],
),
const SizedBox(height: 16),
TextField(
controller: _sipTargetController,
decoration: const InputDecoration(
labelText: 'SIP Target (Extension or URI)',
hintText: 'e.g., 1002 or sip:1002@sip.example.com',
prefixIcon: Icon(Icons.phone),
border: OutlineInputBorder(),
helperText: 'Enter extension number or full SIP URI',
),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _sipRegistered && !_isCallActive ? _makeSipCall : null,
icon: const Icon(Icons.call),
label: const Text('Make SIP Call'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: _isCallActive ? _endCall : null,
icon: const Icon(Icons.call_end),
label: const Text('End Call'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
),
],
),
),
),
const SizedBox(height: 16),
// Info Card
Card(
color: Colors.blue.shade50,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline, color: Colors.blue.shade700),
const SizedBox(width: 8),
Text(
'How to use:',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.blue.shade700,
),
),
],
),
const SizedBox(height: 8),
const Text(
'1. Enter your SIP server details\n'
'2. Enter your extension/username and password\n'
'3. Tap "Register" to connect to SIP server\n'
'4. Wait for "✅ Registered" status\n'
'5. Enter target extension or SIP URI\n'
'6. Tap "Make SIP Call" to initiate call\n'
'7. Use "End Call" to hang up\n\n'
'Note: Requires a SIP server (Asterisk, FreeSWITCH, etc.)',
style: TextStyle(fontSize: 12),
),
],
),
),
),
],
),
),
);
}
}