================================================
Aplikasi : Softphone Mobile Client
Modul : Integrasi SIP_UA Flutter
Dokumen : Panduan Pengembangan Aplikasi (Developer Guide)
——————————————————————-
Versi : v1.0.0
Penulis/Pembuat : Solusipbx (Developer Team)
Tanggal Dibuat : 04 Juli 2026
Terakhir Diubah : 04 Juli 2026
===============================================
Berikut adalah Developer Guide untuk membangun aplikasi Softphone WebRTC menggunakan package sip_ua di Flutter dan 3CX sebagai Core PBX. Panduan ini dirancang agar developer bisa langsung menyalin, mengikuti, dan mengintegrasikannya dengan infrastruktur server yang sudah berjalan di port 443 (Nginx & CoTURN). P
🛠️ Langkah 1: Persiapan Dependensi & Izin Perangkat
Buka file pubspec.yaml proyek Flutter Anda, lalu tambahkan package sip_ua dan library pendukung audio.
YAML
dependencies:
flutter:
sdk: flutter
sip_ua: ^0.7.10 # Gunakan versi stabil terbaru
permission_handler: ^11.3.0 # Untuk mengurus izin Mikrofon
Konfigurasi Izin OS (Izin Mikrofon & Internet)
Android (android/app/src/main/AndroidManifest.xml)
Tambahkan baris berikut di dalam tag <manifest>:
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)
Tambahkan key berikut untuk meminta izin mikrofon secara resmi pada ekosistem Apple:
XML
<key>NSMicrophoneUsageDescription</key>
<string>Aplikasi ini memerlukan akses mikrofon untuk melakukan panggilan telepon VoIP.</string>
Langkah 2: Arsitektur Kelas Controller (sip_helper.dart)
Buat sebuah file baru bernama sip_helper.dart. Kelas ini bertugas membungkus (wrapper) semua logika koneksi, registrasi, hingga penanganan pemicu panggilan (call states).
Dart
import 'package:sip_ua/sip_ua.dart';
import 'package:permission_handler/permission_handler.dart';
class MySipController implements SipUaHelperListener {
final SIPUAHelper _helper = SIPUAHelper();
// Callback untuk dioper ke UI
Function(RegistrationState state)? onRegistrationStateChanged;
Function(Call call, CallState state)? onCallStateChanged;
MySipController() {
// Daftarkan listener utama
_helper.addSipUaHelperListener(this);
}
/// REVISI: Menambahkan 'required String authId' ke dalam parameter fungsi
Future<void> connectAndRegister({
required String extension,
required String authId,
required String password,
required String domain,
required String wssUrl,
}) async {
// 1. Wajib pastikan izin mikrofon disetujui
var status = await Permission.microphone.request();
if (!status.isGranted) {
print("Izin mikrofon ditolak oleh user.");
return;
}
// 2. Setup Konfigurasi Kredensial SIP
UASettings settings = UASettings();
settings.wsUri = wssUrl;
settings.uri = 'sip:$extension@$domain';
settings.authId = authId; // REVISI: Sekarang variabel ini sudah valid
settings.password = password;
settings.displayName = extension;
settings.transportType = TransportType.WS;
// 3. Masukkan Konfigurasi CoTURN Port 443 Anda
settings.pcConfig = {
'iceServers': [
{'url': 'stun:$domain:443'},
{
'url': 'turn:$domain:443',
'username': 'admin_coturn',
'credential': 'passwordcoturn' // password di turnserver.conf
}
],
'iceTransportPolicy': 'all'
};
// 4. Mulai proses registrasi di latar belakang
_helper.start(settings);
}
/// Fungsi Melakukan Panggilan Keluar (Outbound Call)
void makeVoiceCall(String targetExtension) {
_helper.call(targetExtension, voiceonly: true);
}
/// Fungsi Menutup atau Menolak Panggilan
void hangupOrReject(Call call) {
call.hangup();
}
/// Fungsi Menerima Panggilan Masuk (Inbound Call)
void answerCall(Call call) {
call.answer(_helper.buildConstraints());
}
// ===========================================================================
// IMPLEMENTASI SIPUAHELPERLISTENER (EVENT HANDLERS)
// ===========================================================================
@override
void registrationStateChanged(RegistrationState state) {
print('Status Registrasi SIP: ${state.state}');
if (onRegistrationStateChanged != null) {
onRegistrationStateChanged!(state);
}
}
@override
void callStateChanged(Call call, CallState state) {
print('Status Panggilan: ${state.state}');
if (onCallStateChanged != null) {
onCallStateChanged!(call, state);
}
}
@override
void transportStateChanged(TransportState state) {
print('Status Koneksi WebSocket: ${state.state}');
}
@override
void onNewMessage(SIPMessageRequest request) {
print('Menerima pesan teks SIP baru');
}
@override
void onNewNotify(Notify notify) {
print('Menerima notifikasi baru dari PBX');
}
}
Langkah 3: Integrasi ke Tampilan Aplikasi (main.dart)
Berikut adalah contoh implementasi UI sederhana untuk melakukan registrasi ekstensi, melakukan panggilan, dan menerima panggilan masuk secara realtime.
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() {
// REVISI: Mengubah super.override manually menjadi standar Flutter
super.initState();
// Sambungkan event listener controller ke state UI
_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 saat aplikasi terbuka
_sipController.connectAndRegister(
extension: "101",
authId: "passwordAuthid",
password: "passwordExtension",
domain: "pbx.domainanda.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("Status Ekstensi: $_regStatus",
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 20),
Text("Status Call: $_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: "Masukkan Nomor Ekstensi Tujuan",
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 10),
ElevatedButton.icon(
onPressed: () => _sipController.makeVoiceCall(_targetController.text),
icon: const Icon(Icons.call),
label: const Text("Panggil"),
style: ElevatedButton.styleFrom(backgroundColor: Colors.green, foregroundColor: Colors.white),
),
],
if (_activeCall != null && _callStatus == "INCOMING") ...[
Text("Panggilan Masuk dari: ${_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("Terima"),
),
ElevatedButton(
onPressed: () => _sipController.hangupOrReject(_activeCall!),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text("Tolak"),
),
],
)
],
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("Putuskan Panggilan"),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white),
)
],
],
),
),
);
}
}
Dokumen Tambahan Penting bagi Developer
- Format Ekstensi URL: Penulisan string settings.uri harus menggunakan format lengkap URI SIP: ‘sip:USERNAME@DOMAIN’. Kesalahan ketik atau hilangnya prefiks sip: akan menggagalkan handshake.
- Penanganan Audio: Package sip_ua secara internal langsung berkoordinasi dengan WebRTC native framework smartphone. Selama panggilan berada pada state CONFIRMED, aliran suara dari mikrofon ke speaker sudah dikelola otomatis secara realtime.
- Tahap Lanjutan (Push Notification): Jika aplikasi ini ingin diuji saat ponsel mati (standby), developer tinggal mengintegrasikan kelas MySipController di atas dengan package firebase_messaging (FCM) dan flutter_callkit_incoming agar event panggilan masuk bisa langsung membangunkan sistem operasi Android/iOS secara instan.
- Saat sistem operasi smartphone masuk ke mode hemat daya (sleep/standby), koneksi WebSocket (wss://) yang diatur oleh sip_ua ke webrtc gateway akan otomatis diputus oleh sistem operasi. Dokumen add-on ini memberikan panduan kepada developer untuk mengintegrasikan Firebase Cloud Messaging (FCM) dan Flutter CallKit Incoming guna membangunkan aplikasi dari jarak jauh ketika ada panggilan masuk di PBX 3CX.
apa yang harus di persiapkan? tambahkan paket berikut di bawah dependensi sip_ua:
dependencies:
# ... dependensi sip_ua dan permission_handler yang sudah ada ...
# DEPENDENSI ADD-ON UNTUK GOOGLE PUSH
firebase_core: ^3.0.0
firebase_messaging: ^15.0.0
flutter_callkit_incoming: ^1.0.0 # Mengaktifkan UI Panggilan penuh di layar saat sleep
Konfigurasi Hak Akses Tambahan OS
Android (android/app/src/main/AndroidManifest.xml)
Tambahkan izin ini di dalam tag <manifest> untuk mendukung push dan pembongkaran status lockscreen:
XML
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
iOS (ios/Runner/Info.plist)
Tambahkan kapabilitas latar belakang (Background Modes) berikut:
XML
<key>UIBackgroundModes</key>
<array>
<string>voip</string>
<string>remote-notification</string>
</array>
Langkah 2: Logika Background Handler (main.dart)
Developer harus mendaftarkan fungsi penanganan pesan latar belakang (Background Handler) di file main.dart sebelum fungsi runApp dijalankan. Fungsi ini akan dipanggil oleh sistem operasi Google Play Services secara instan meskipun aplikasi Anda sedang ditutup total.
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';
/// Fungsi penanganan paket push yang masuk saat aplikasi MATI/SLEEP total
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
// Konfigurasi Parameter UI Layar Panggil Bawaan Sistem HP (CallKit)
var params = <String, dynamic>{
'id': message.data['call_id'] ?? '123',
'nameCaller': message.data['caller_name'] ?? 'Panggilan Hotel',
'handle': message.data['caller_extension'] ?? '000',
'type': 0, // 0 = Panggilan Suara (Audio VoIP)
'duration': 30000, // Waktu tunggu dering (30 detik)
'android': {
'isCustomNotification': true,
'isShowLogo': false,
'ringtone': 'ringtone_default',
},
};
// Tampilkan antarmuka panggilan masuk di layar penuh
await FlutterCallkitIncoming.showCallkitIncoming(params);
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Inisialisasi Firebase Core
await Firebase.initializeApp();
// Daftarkan background handler resmi Google FCM
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
runApp(const MyApp());
Langkah 3: Mengambil Token FCM Perangkat
Di dalam komponen UI utama atau saat aplikasi pertama kali mendeteksi user berhasil login/registrasi, token perangkat wajib diambil dan dikirimkan ke server webhook backend (Creomate) Anda.
import 'package:firebase_messaging/firebase_messaging.dart';
void dapatkanDanSimpanTokenFCM(String nomorEkstensi) async {
FirebaseMessaging messaging = FirebaseMessaging.instance;
// 1. Minta izin Push Notification secara interaktif ke User
NotificationSettings settings = await messaging.requestPermission(
alert: true, badge: true, sound: true,
);
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
// 2. Ambil token unik dari server Google
String? token = await messaging.getToken();
print("FCM Token untuk Ekstensi $nomorEkstensi: $token");
// 3. TODO: Developer melakukan HTTP POST ke API Creomate / Database internal
// untuk memetakan bahwa: Ekstensi 101 = Token XYZ
// _apiService.simpanTokenKeServer(nomorEkstensi, token);
}
}
Langkah 4: Sinkronisasi Aksi “Terima Panggilan” dengan sip_ua
Ketika layar CallKit muncul dan user menekan tombol hijau (“Accept / Terima”), aplikasi harus otomatis melalukan inisialisasi ulang WebSocket sip_ua dan mengangkat telepon. Tambahkan listener ini di dalam initState halaman utama Anda:
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
void ikutiAksiCallKit() {
FlutterCallkitIncoming.onEvent.listen((CallEvent? event) {
if (event == null) return;
switch (event.event) {
case Event.actionCallAccept:
print("User menekan tombol Terima Telepon!");
// 1. Jalankan ulang pendaftaran WebSocket ke Janus/3CX secara cepat
_sipController.connectAndRegister(
extension: "101",
authId: "passwordAuthid",
password: "passwordExtension",
domain: "pbx.domainanda.com",
wssUrl: "wss://rtc.yourdomain.com/yourappname",
).then((_) {
// 2. Tambahkan delay kecil/tunggu event INCOMING terpicu di sip_ua,
// kemudian panggil fungsi answerCall() otomatis.
});
break;
case Event.actionCallDecline:
print("User menolak panggilan dari layar kunci.");
// Kirim sinyal reject ke controller jika diperlukan
break;
default:
break;
}
});
}
[3CX PBX System]
│
▼ (Event: Ada Panggilan Masuk ke Ekstensi Mati)
[Webhook Manager / Creomate]
│
▼ (HTTP POST mencari Token FCM Ekstensi terkait)
[Google FCM Cloud Services]
│
▼ (Sinyal Jalur Prioritas Tinggi / High Priority Push)
[Ponsel Android / iOS (Kondisi Sleep)]
│
├─► Membangunkan ‘_firebaseMessagingBackgroundHandler’
├─► Memicu ‘FlutterCallkitIncoming.showCallkitIncoming’
└─► User Klik Terima ──► WebSocket Terhubung Kembali ──► Audio WebRTC Berjalan
