# **Chapter 16: Building a SIP Client in SartajPHP Mobile App** ## Building a SIP Client in SartajPHP Mobile App In this chapter, we will develop a **fully working SIP VoIP client** inside a SartajPHP Mobile Application using: * PJSIP (pjsua2 Android binding) * SartajPHP KotlinGate Engine * WebView UI (Onsen UI + Bootstrap + FontAwesome) * Event-driven communication between Web UI and Kotlin By the end of this chapter, you will be able to: ✅ Register SIP account ✅ Make outgoing calls ✅ Receive incoming calls ✅ Answer / Reject calls ✅ Disconnect active calls ✅ Connect SIP engine with Web UI --- # 📌 16.1 SIP Architecture in SartajPHP Unlike traditional mobile SIP apps, SartajPHP separates logic into: ``` Frontend (HTML / JS / Onsen UI) ↓ SartajPHP Router ↓ KotlinGate Controller ↓ SipEngine (Native PJSIP Core) ``` --- # 📌 16.2 Required Components We will create: | File | Purpose | | ------------- | ---------------------------- | | Index.kt | Home controller | | SipApp.kt | SIP call controller | | SipEngine.kt | Core SIP handler | | SipAccount.kt | Registration + Incoming call | | SipCall.kt | Call media + audio routing | | sip.front | Dial pad UI | | sip.js | UI event handling | --- # 📌 16.3 Installing PJSIP Library Download and compile PJSIP library: Copy Compiled library into: ``` app/libs ``` Load library in engine: ```kotlin System.loadLibrary("pjsua2") ``` ```kotlin // File apps/kotlin_apps/IndexGate.kt package com.sartajphp.gate import android.util.Log import com.sartajphp.webviewlib.KotlinGate import com.sartajphp.webviewlib.JSServerC import org.json.JSONObject import java.time.LocalDateTime import java.time.format.DateTimeFormatter import kotlin.concurrent.thread import org.pjsip.pjsua2.* import com.sartajphp.apps.engine.SipEngine class Index: KotlinGate(){ /** on start Life Cycle Event of KotlinGate Set All Permissions of MobileApp and then create SipEngine Object */ override suspend fun onstart(){ if (!getAuthenticate("ADMIN")) { JSServer.addJSONHTMLBlock("spnstatus", "Permission required") JSServer.flush() //KotlinApi.println("Permission Denied"); //KotlinApi.setMsg("index","Permission Denied") forward("signin","","",data) return } JSServer.addJSONHTMLBlock("spnstatus", "Permission Passed") JSServer.flush() // if already registered if(KotlinApi.getProp("sip_register") == true){ JSServer.addJSONJSBlock("onsen.loadPage(\"sippage.html\");") JSServer.flush() exitMe() }else{ // make sure that property available KotlinApi.addProp("sip_register",false) } } override suspend fun onrun(){ // make sure create Sip Engine Object before further processing var eng1 = getSipEngine() eng1.setSphpApi(KotlinApi) JSServer.addJSONHTMLBlock("spnstatus", "SIP Engine Ready") JSServer.flush() KotlinApi.println("Permission Passed3") } /** When Form Submit with user,pass then regsiter Sip Server and open Page sip dialer */ suspend fun page_event_register(evtp:String){ // stop change user if already regsitered if(KotlinApi.getProp("sip_register") == false){ val user = data["user"].toString() val pass = data["pass"].toString() val domain = data["domain"].toString() val engine = getSipEngine() engine.start(user,pass,domain) // save status,User is regsitered KotlinApi.addProp("sip_register",true); } JSServer.addJSONHTMLBlock("spnstatus", "SIP Engine Registered") JSServer.flush() // load sip dialer page //JSServer.addJSONJSBlock("onsen.loadPage(\"sippage.html\");") //JSServer.flush() } private suspend fun getSipEngine(): SipEngine { if (!KotlinApi.isProp("sip_engine")) { val eng = SipEngine() KotlinApi.addProp("sip_engine", eng) } return KotlinApi.getProp("sip_engine") as SipEngine } } ``` ```kotlin // File apps/kotlin_apps/SipGate.kt package com.sartajphp.gate import com.sartajphp.webviewlib.KotlinGate import org.pjsip.pjsua2.* import com.sartajphp.gate.engine.SipEngine class SipGate : KotlinGate() { override suspend fun onstart(){ if(KotlinApi.getAuthenticateType() != "ADMIN"){ // stop further processing forward("index","","",data) } } suspend fun isRegistered(): Boolean{ if(KotlinApi.getProp("sip_register") == false){ // load Home page JSServer.addJSONJSBlock("onsen.loadPage(\"home.html\");") return false; } return true; } suspend fun page_event_call(evtp:String){ val engine = KotlinApi.getProp("sip_engine") as SipEngine engine.makeCall(data["number"].toString()) JSServer.addJSONHTMLBlock("spnsip", "Call Start") JSServer.flush() } suspend fun page_event_disconnect(evtp:String){ val engine = KotlinApi.getProp("sip_engine") as SipEngine engine.disconnectCall() JSServer.addJSONHTMLBlock("spnsip", "Call Ended") JSServer.flush() } suspend fun page_event_answer(evtp:String){ val engine = KotlinApi.getProp("sip_engine") as SipEngine engine.answerCall() JSServer.addJSONHTMLBlock("spnsip", "Call Start") JSServer.flush() } suspend fun page_event_rejectCall(evtp:String){ val engine = KotlinApi.getProp("sip_engine") as SipEngine engine.rejectCall() JSServer.addJSONHTMLBlock("spnsip", "Call Ended") JSServer.flush() } suspend fun page_event_hold(evtp:String){ val engine = KotlinApi.getProp("sip_engine") as SipEngine engine.holdCall() JSServer.addJSONHTMLBlock("spnsip", "Call Hold") JSServer.flush() } suspend fun page_event_resume(evtp:String){ val engine = KotlinApi.getProp("sip_engine") as SipEngine engine.resumeCall() JSServer.addJSONHTMLBlock("spnsip", "Call Resume") JSServer.flush() } suspend fun page_event_mute(evtp:String){ val engine = KotlinApi.getProp("sip_engine") as SipEngine engine.muteCall() JSServer.addJSONHTMLBlock("spnsip", "Call Mute") JSServer.flush() } suspend fun page_event_unmute(evtp:String){ val engine = KotlinApi.getProp("sip_engine") as SipEngine engine.unmuteCall() JSServer.addJSONHTMLBlock("spnsip", "Call Unmute") JSServer.flush() } suspend fun page_event_transfer(evtp:String){ val engine = KotlinApi.getProp("sip_engine") as SipEngine engine.transferCall(data["number"].toString()) JSServer.addJSONHTMLBlock("spnsip", "Call Transfered") JSServer.flush() } } ``` --- # 📌 16.4 Creating SIP Engine Core Create: ```kotlin // File apps/kotlin_apps/engine/SipEngine.kt package com.sartajphp.gate.engine import android.util.Log import com.sartajphp.webviewlib.SphpKotlinApi import org.pjsip.pjsua2.* class SipEngine { var KotlinApi: SphpKotlinApi? = null var endpoint: Endpoint? = null var account: SipAccount? = null var currentCall: SipCall? = null var domain:String = "sip2sip.info" var isMuted:Boolean = false var isHold:Boolean = false fun setSphpApi(sapi:SphpKotlinApi){ KotlinApi = sapi } // ---------------------------- // Initialize SIP Stack // ---------------------------- fun start(username: String, password: String, sipdomain:String) { try { Log.d("SIP_ENGINE", "Start Registration") domain = sipdomain System.loadLibrary("pjsua2") endpoint = Endpoint() endpoint!!.libCreate() val epConfig = EpConfig() epConfig.logConfig.level = 6 epConfig.logConfig.consoleLevel = 6 endpoint!!.libInit(epConfig) val sipTpConfig = TransportConfig() sipTpConfig.port = 5060 endpoint!!.transportCreate( pjsip_transport_type_e.PJSIP_TRANSPORT_UDP, sipTpConfig ) endpoint!!.libStart() Log.d("SIP_ENGINE", "Endpoint started") registerAccount(username, password) } catch (e: Exception) { Log.e("SIP_ENGINE", "Start failed: ${e.message}") } } // ---------------------------- // Registration // ---------------------------- private fun registerAccount(username: String, password: String) { val accCfg = AccountConfig() accCfg.videoConfig.autoTransmitOutgoing = false accCfg.videoConfig.autoShowIncoming = false accCfg.idUri = "sip:$username@$domain" accCfg.regConfig.registrarUri = "sip:$domain" val cred = AuthCredInfo( "digest", "*", username, 0, password ) accCfg.sipConfig.authCreds.add(cred) accCfg.natConfig.iceEnabled = true accCfg.natConfig.iceAggressiveNomination = true accCfg.natConfig.sipOutboundUse = 1 accCfg.natConfig.contactRewriteUse = 1 account = SipAccount(this) account!!.create(accCfg) Log.d("SIP_ENGINE", "Registering account") } // ---------------------------- // Make Call // ---------------------------- fun makeCall(dest: String) { if (account == null) return if (currentCall != null) { Log.d("SIP_ENGINE", "Call already active") return } currentCall = SipCall(this,account!!) val prm = CallOpParam(true) prm.opt.audioCount = 1 prm.opt.videoCount = 0 val uri = "sip:$dest@$domain" Log.d("SIP_ENGINE", "Dialing -> $uri") currentCall!!.makeCall(uri, prm) } // ---------------------------- // Disconnect Call // ---------------------------- fun disconnectCall() { try { if (currentCall == null) return val prm = CallOpParam() prm.statusCode = pjsip_status_code.PJSIP_SC_DECLINE //prm.statusCode = pjsip_status_code.PJSIP_SC_OK currentCall!!.hangup(prm) Log.d("SIP_ENGINE", "Call disconnected") } catch (e: Exception) { Log.e("SIP_ENGINE", "Disconnect failed: ${e.message}") } } fun onIncomingCall(call: SipCall) { Log.d("SIP_ENGINE", "Incoming call...") KotlinApi!!.JSServer.addJSONHTMLBlock("spnsip", "Call RX") KotlinApi!!.JSServer.callJsFunction("ringcall", "Number") KotlinApi!!.JSServer.flush() // Here you can: // 1. Ring UI // 2. Notify WebView // 3. Auto answer (optional) } fun answerCall() { currentCall?.let { val prm = CallOpParam() prm.statusCode = pjsip_status_code.PJSIP_SC_OK it.answer(prm) Log.d("SIP_ENGINE", "Call answered") } } fun rejectCall() { currentCall?.let { val prm = CallOpParam() prm.statusCode = pjsip_status_code.PJSIP_SC_DECLINE it.hangup(prm) Log.d("SIP_ENGINE", "Call rejected") } } fun holdCall() { try { currentCall?.let { val prm = CallOpParam() it.setHold(prm) isHold = true Log.d("SIP_ENGINE", "Call on HOLD") } } catch (e: Exception) { Log.e("SIP_ENGINE", "Hold failed: ${e.message}") } } fun resumeCall() { try { currentCall?.let { val prm = CallOpParam(true) prm.opt.audioCount = 1 prm.opt.videoCount = 0 it.reinvite(prm) isHold = false Log.d("SIP_ENGINE", "Call RESUMED") } } catch (e: Exception) { Log.e("SIP_ENGINE", "Resume failed: ${e.message}") } } fun muteCall() { try { currentCall?.let { val audDevManager = Endpoint.instance().audDevManager() val callInfo = it.info for (i in 0 until callInfo.media.size) { val media = it.getMedia(i.toLong()) ?: continue if (callInfo.media[i].type == pjmedia_type.PJMEDIA_TYPE_AUDIO) { val audioMedia = AudioMedia.typecastFromMedia(media) audDevManager.captureDevMedia.stopTransmit(audioMedia) isMuted = true Log.d("SIP_ENGINE", "Mic MUTED") } } } } catch (e: Exception) { Log.e("SIP_ENGINE", "Mute failed: ${e.message}") } } fun unmuteCall() { try { currentCall?.let { val audDevManager = Endpoint.instance().audDevManager() val callInfo = it.info for (i in 0 until callInfo.media.size) { val media = it.getMedia(i.toLong()) ?: continue if (callInfo.media[i].type == pjmedia_type.PJMEDIA_TYPE_AUDIO) { val audioMedia = AudioMedia.typecastFromMedia(media) audioMedia.startTransmit(audDevManager.captureDevMedia) isMuted = false Log.d("SIP_ENGINE", "Mic UNMUTED") } } } } catch (e: Exception) { Log.e("SIP_ENGINE", "Unmute failed: ${e.message}") } } fun transferCall(dest: String) { try { if (currentCall == null) return val uri = "sip:$dest@$domain" val prm = CallOpParam(true) prm.opt.audioCount = 1 prm.opt.videoCount = 0 currentCall!!.xfer(uri,prm) Log.d("SIP_ENGINE", "Call transferred to $uri") } catch (e: Exception) { Log.e("SIP_ENGINE", "Transfer failed: ${e.message}") } } fun attendedTransfer(otherCall: SipCall) { try { val prm = CallOpParam(true) prm.opt.audioCount = 1 prm.opt.videoCount = 0 currentCall?.xferReplaces(otherCall,prm) Log.d("SIP_ENGINE", "Attended transfer done") } catch (e: Exception) { Log.e("SIP_ENGINE", "Attended transfer failed: ${e.message}") } } // ---------------------------- // Stop Engine // ---------------------------- fun stop() { endpoint?.libDestroy() endpoint = null } } ``` ```kotlin // File apps/kotlin_apps/engine/SipAccount.kt package com.sartajphp.gate.engine import android.util.Log import org.pjsip.pjsua2.* class SipAccount(val engine: SipEngine) : Account() { override fun onRegState(prm: OnRegStateParam?) { val info = this.info Log.d( "SIP_ACCOUNT", "Registration: ${info.regIsActive} code=${info.regStatus}" ) } override fun onIncomingCall(prm: OnIncomingCallParam?) { Log.d("SIP_ACCOUNT", "Incoming call") val call = SipCall(engine,this, prm!!.callId) engine.currentCall = call engine.onIncomingCall(call) } } ``` ```kotlin // File apps/kotlin_apps/engine/SipCall.kt package com.sartajphp.gate.engine import android.util.Log import org.pjsip.pjsua2.* class SipCall( private val engine: SipEngine, acc: Account, callId: Int = -1 ) : Call(acc, callId) { override fun onCallState(prm: OnCallStateParam?) { val info = this.info Log.d("SIP_CALL", "State: ${info.stateText}") if (info.state == pjsip_inv_state.PJSIP_INV_STATE_DISCONNECTED) { engine.isMuted = false engine.isHold = false engine.currentCall = null delete() } } override fun onCallMediaState(prm: OnCallMediaStateParam?) { val callInfo = info val ep = Endpoint.instance() val audDevManager = ep.audDevManager() for (i in 0 until callInfo.media.size) { val media = getMedia(i.toLong()) ?: continue if (callInfo.media[i].type == pjmedia_type.PJMEDIA_TYPE_AUDIO && callInfo.media[i].status == pjsua_call_media_status.PJSUA_CALL_MEDIA_ACTIVE ) { val audioMedia = AudioMedia.typecastFromMedia(media) audioMedia.startTransmit(audDevManager.captureDevMedia) audDevManager.playbackDevMedia.startTransmit(audioMedia) Log.d("SIP_CALL", "Audio connected") } } } } ``` These classes manages: * Endpoint initialization * Account registration * Call handling * NAT configuration --- ## ⭐ SIP Engine Initialization ```kotlin fun start(username: String, password: String, sipdomain:String) ``` This function: 1. Creates SIP endpoint 2. Configures STUN 3. Starts SIP transport 4. Registers SIP account --- ## ⭐ STUN Configuration ```kotlin epConfig.uaConfig.stunServer.add("stun.l.google.com:19302") ``` This enables NAT traversal. --- ## ⭐ Transport Creation ```kotlin endpoint!!.transportCreate( pjsip_transport_type_e.PJSIP_TRANSPORT_UDP, sipTpConfig ) ``` UDP transport is commonly used in SIP VoIP. --- # 📌 16.5 SIP Account Registration Registration is handled using: ```kotlin AccountConfig() ``` --- ## ⭐ Registration Setup ```kotlin accCfg.idUri = "sip:$username@$domain" accCfg.regConfig.registrarUri = "sip:$domain" ``` --- ## ⭐ Authentication Credentials ```kotlin val cred = AuthCredInfo( "digest", "*", username, 0, password ) ``` --- ## ⭐ NAT Optimization ```kotlin accCfg.natConfig.iceEnabled = true accCfg.natConfig.contactRewriteUse = 1 ``` These improve connectivity across networks. --- # 📌 16.6 Creating SIP Account Listener Create: ``` SipAccount.kt ``` This listens to: * Registration events * Incoming call events --- ## ⭐ Incoming Call Listener ```kotlin override fun onIncomingCall(prm: OnIncomingCallParam?) ``` This method: 1. Creates new SipCall object 2. Stores active call 3. Notifies UI --- # 📌 16.7 Creating Call Handler Create: ``` SipCall.kt ``` This class manages: * Call states * Media connection * Audio routing --- ## ⭐ Audio Connection ```kotlin audioMedia.startTransmit(audDevManager.captureDevMedia) audDevManager.playbackDevMedia.startTransmit(audioMedia) ``` This connects: * Microphone → Remote * Remote → Speaker --- # 📌 16.8 Making Outgoing Calls Inside SipEngine: ```kotlin fun makeCall(dest: String) ``` --- ## ⭐ Call Example ```kotlin val uri = "sip:$dest@$domain" currentCall!!.makeCall(uri, prm) ``` --- # 📌 16.9 Disconnecting Calls ```kotlin fun disconnectCall() ``` ```kotlin currentCall!!.hangup(prm) ``` --- # 📌 16.10 Answering Incoming Calls ```kotlin fun answerCall() ``` ```kotlin prm.statusCode = PJSIP_SC_OK ``` --- # 📌 16.11 Rejecting Calls ```kotlin fun rejectCall() ``` ```kotlin prm.statusCode = PJSIP_SC_DECLINE ``` --- # 📌 16.12 Creating SIP Controller (SipApp.kt) SipGate acts as WebView bridge. --- ## ⭐ Outgoing Call Event ```kotlin suspend fun page_event_call(evtp:String) ``` --- ## ⭐ Disconnect Event ```kotlin suspend fun page_event_disconnect(evtp:String) ``` --- ## ⭐ Answer Event ```kotlin suspend fun page_event_answer(evtp:String) ``` --- ## ⭐ Reject Event ```kotlin suspend fun page_event_rejectCall(evtp:String) ``` --- # 📌 16.13 Designing Dial Pad UI Create: ``` sip.front ``` --- ## ⭐ Dial Pad Layout ```html <input id="number"/> <button id="btnrx">Call</button> <button id="btncancel">Disconnect</button> ``` --- # 📌 16.14 Handling UI Events Create: ``` sip.js ``` --- ## ⭐ Initialize SIP Page ```javascript function comp_sippage_init(eventer){ callKotlinApp("sipapp",{}); } ``` --- ## ⭐ Call Button Logic ```javascript callKotlinAppEvent('sipapp','call','',{number:$('#number').val()}) ``` --- ## ⭐ Disconnect Logic ```javascript callKotlinAppEvent('sipapp','disconnect') ``` --- ## ⭐ Incoming Call Ring UI ```javascript function ringcall(number1){ alert("Call Rx from " + number1); } ``` --- # 📌 16.15 Connecting Kotlin and Web UI SipEngine notifies UI using: ```kotlin KotlinApi!!.JSServer.callJsFunction("ringcall", "Number") ``` --- # 📌 16.16 Application Flow --- ## ⭐ Registration Flow ``` User Login ↓ Index.kt ↓ SipEngine.start() ↓ Account Registered ``` --- ## ⭐ Outgoing Call Flow ``` Dial Pad → JS ↓ SipApp.kt ↓ SipEngine.makeCall() ``` --- ## ⭐ Incoming Call Flow ``` SIP INVITE Received ↓ SipAccount.onIncomingCall() ↓ SipEngine.onIncomingCall() ↓ JS Ring UI ``` --- # 📌 16.17 NAT Handling Tips VoIP often fails across networks. Use: * STUN servers * ICE support * Contact rewrite * SIP outbound --- # 📌 16.18 Debugging SIP Calls Increase logging: ```kotlin epConfig.logConfig.level = 6 ``` Monitor: ``` Registration Status Call State RTP Media Connection ``` --- # 📌 16.19 Testing Setup Recommended testing: | Device | Tool | | ------- | ------------------- | | PC | X-Lite | | Android | SartajPHP Gate | | Server | Asterisk / ViciDial | --- # 📌 16.20 Chapter Summary In this chapter you learned: ✔ How to integrate PJSIP with SartajPHP ✔ How to build SIP Engine ✔ How to connect Web UI with Native SIP ✔ How to handle incoming and outgoing calls ✔ How to route audio streams ✔ How to manage NAT VoIP problems ---