Tuesday, March 9, 2021

Kamailio and delayed CANCEL on iOS

One who is working with SIP aware of a problem with mobile devices. Long story short, the only reliable and proper way to deliver SIP calls to mobile devices is push notification.

A usual algo here, the server sends push notification to a device (via Apple or Google PNS respectively) and after mobile device woke up an application, it registers to SIP server and only after this SIP server sends INVITE to application. And last part here - device starts ringing.

So, SIP server have to store info on this call and wait for a device to register. To achieve this, Kamailio offers tsilo module, which is working great. If you haven't seen it or having problems with it, there is a great presentation which is a good starting point.

But there is one problem. After the call is canceled or answered, no more info is stored in this module. So, if push arrives later, than the call was answered, the app will not receive any info after REGISTER. Usually, it's ok, but with iOS > 13, there is a new mechanism of push notifications using for VoIP calls. CallKit (iOS developers may correct me here, cause I'm not). Idea is the following. When iOS device receives push notification for the VoIP call, it first will show a screen with info, that call has arrived. Later, after the app is already wakened up and running, the app is taking control over the calling screen to update caller info, show Accept/Reject buttons, etc. Sounds good. But if the call was canceled (or answered elsewhere) at the moment when Call Screen is shown already, but the app is not ready yet? The app is woken up, sends REGISTER to SIP server, but no INVITE is following. The call is already not this device business. 

Usually, this problem is solved on an application level. Like if an app is not receiving INVITE after push within X seconds, it asks for a remote server for info and shows (or not) corresponding missed call info.

But that's not my case, unfortunately. I'm using slightly rebranded Linphone and not an expert in iOS programming. So, need to resolve everything fully on a server side, where Kamailio is my SIP server.

The idea of the solution is following - if iOS device registers within X seconds after the call start and the call is already canceled or answered - a fake call is simulated to this endpoint. X could be any, but we come to 15 seconds. For simulating call the famous sipp is used.

Here following some parts of the code

kamailio.conf

# Flag to define that the call was locally generated via SIPP

#!define FLT_LOCALGEN 4

...

# Save info iOS CallKit workaround
modparam("htable", "htable", "ios_reg=>size=5;autoexpire=2;")
modparam("htable", "htable", "ios_state=>size=5;autoexpire=15")

...

request_route {

...

    if (is_method("CANCEL")) {
        # inbound CANCEL

        xlog("L_INFO", "[REQUEST_ROUTE] Detected CANCEL, saving state\n");

        $sht(ios_state=>$rU) = "state=canceled;callid=" + $ci + ";fromnumber=" + $fU;
    }

    # Save info in a case if call was answered to preserve callerID/destination

    if (is_method("INVITE") && !has_totag()) {

        $avp(orig_fU) = $fU;
        $avp(orig_rU) = $rU;

    }

...

onreply_route {

     if ($rs == 200) {
        # Save call state fr
        xlog("L_INFO", "[MANAGE_REPLY] Call $avp(
orig_fU) -> $avp(orig_rU) was answered\n");

        $sht(ios_state=>$avp(orig_rU)) = "state=answered;callid=" + $ci + ";fromnumber=" + $avp(
orig_fU);
    }

...

route[AUTH]

...

   if ($si == 'MY_IP_ADDR') {
        xlog("L_NOTICE", "[AUTH] Packet from ourselves\n");

        setflag(FLT_LOCALGEN);
        return;
    }

...

# We don't want  this INVITE to be processed via tsilo routine.

route[INVITE_STORE] {

    if (isflagset(FLT_LOCALGEN)) {
        return;
    }

...

 route[PUSH_NOTIFICATION_ON_INVITE] {
    if (!is_method("INVITE") || isflagset(FLT_LOCALGEN)) {
        return;
    }

...

# Small extra route to be called after succesful save("location")

route[REGISTER_IOS] {
    if (!($ua =~ "LinphoneiOS*")) {
        xlog("L_INFO", "[REGISTER_IOS] Not IPhone, not interesting...\n");

        return;
    }

    xlog("L_INFO", "[REGISTER_IOS] IPhone registered, saving contact for 2 sec\n");

#  Saving location info for later
    $var(ios_contact) = "sip:" + $fU + "@" + $si + ":" + $sp + ";transport=" + $pr;

# Adding some random to support several iOS devices per same account.
    $var(ios_reg_hash_id) = $fU + "_" + $RANDOM;

# information on contact is stored in hash memrory, and the hash key would return to us via X-IOS-Contact-Hash header provided by SIPP.
    $sht(ios_reg=>$var(ios_reg_hash_id)) = $var(ios_contact);

    $var(ios_callstate) = $sht(ios_state=>$fU);

    if (not_empty("$var(ios_callstate)")) {
        $var(call_state) = $(var(ios_callstate){param.value,state});
        $var(call_callid) = $(var(ios_callstate){param.value,callid});
        $var(call_from) = $(var(ios_callstate){param.value,fromnumber});

        xlog("L_INFO", "[REGISTER_IOS] Registration from iOS is late. Call state is $var(call_state)\n");

        # Here we actually start call via SIPP

        exec_msg("/usr/bin/nohup launch_sipp.sh $var(call_callid) $fU $var(ios_reg_hash_id) $var(call_state) $var(call_from) >/dev/null 2>& 1 &");
    }
}

...

# Own lookup("location"), but with info saved on [REGISTER_IOS]

route[LOCATION_INTERNAL] {
    if (!isflagset(FLT_LOCALGEN) || !is_present_hf("X-IOS-Contact-Hash")) {
        return;
    }

    $var(ios_reg_hash_id) = $hdr(X-IOS-Contact-Hash);
    $var(ios_contact) = $sht(ios_reg=>$var(ios_reg_hash_id));

    remove_hf("X-IOS-Contact-Hash");

    if (!not_empty("$var(ios_contact)")) {
        xlog("L_WARN", "[LOCATION_INTERNAL] Locally generated packet, but no contact was saved\n");

        send_reply("404", "Saved contact not found");
        exit;
    }

    $ru = $var(ios_contact);
    $du = $ru;

    route(RELAY);
}

launch_sipp.sh

#!/bin/bash

if [ -z "${1}" ]; then
    echo "CallID is not specified"
    exit 1
fi
CALL_ID=${1}

if [ -z "${2}" ]; then
    echo "To number is not specified"
    exit 1
fi
TO_NUMBER=${2}

if [ -z "${3}" ]; then
    echo "HASH ID is not specified"
    exit 1
fi
HASH_ID=${3}

REASON=${4:-answered}
FROM_NUMBER=${5:-MY_PBX}
FROM_NAME=${6:-${FROM_NUMBER}}

# Create CSV file for SIPP first
/usr/bin/echo -e "SEQUENTIAL\n${TO_NUMBER};${FROM_NUMBER};'${FROM_NAME}';${HASH_ID};${REASON};" >> /tmp/invite_cancel_$$.csv

# Run SIPP with provided CallID, as it should be same as declared in Push Notification
/usr/bin/sipp localhost -sf invite_cancel.xml -inf /tmp/invite_cancel_$$.csv -m 1 -cid_str ${CALL_ID}
rm /tmp/invite_cancel_$$.csv

invite_cancel.xml

 <?xml version="1.0" encoding="UTF-8" ?>

<!-- This sipp scenario combines 2 types of CANCEL with different reasons
Reason "Call Completed Elsewhere" will not get into a missed calls list
CANCEL without a reason will get there
Which CANCEL would be sent, controlled by field 4 in CSV line
If this field == 'answered', call is considered answered and will not get into missed call list
In all other cases call considered missed -->

<scenario name="iOS CallKit canceler">
 <send>
  <![CDATA[

    INVITE sip:[field0 line=0]@[remote_ip]:[remote_port] SIP/2.0
    Via: SIP/2.0/[transport] [local_ip]:[local_port]
    From: <sip:[field1 line=0]@[local_ip]:[local_port]>;tag=[call_number]_sipp_call_canceler_[call_number]
    To: <sip:[field0 line=0]@[remote_ip]:[remote_port]>
    Call-ID: [call_id]
    Cseq: [cseq] INVITE
    Allow: OPTIONS, SUBSCRIBE, NOTIFY, PUBLISH, INVITE, ACK, BYE, CANCEL, UPDATE, PRACK, REGISTER, MESSAGE, REFER
    Supported: 100rel, timer, replaces, norefersub
    Session-Expires: 1800
    Min-SE: 90
    Contact: sip:asterisk@[local_ip]:[local_port]
    Max-Forwards: 70
    X-IOS-Contact-Hash: [field3 line=0]
    Content-Type: application/sdp
    Content-Length: [len]

    v=0
    o=- 558046395 558046395 IN IP[media_ip_type] [media_ip]
    s=Asterisk
    c=IN IP[media_ip_type] [media_ip]
    t=0 0
    m=audio 16170 RTP/AVP 107 8 0 101
    a=rtpmap:107 opus/48000/2
    a=fmtp:107 useinbandfec=1
    a=rtpmap:8 PCMA/8000
    a=rtpmap:0 PCMU/8000
    a=rtpmap:101 telephone-event/8000
    a=fmtp:101 0-16
    a=ptime:20
    a=maxptime:20
    a=sendrecv
    m=video 14074 RTP/AVP 100
    a=rtpmap:100 VP8/90000
    a=sendrecv

  ]]>
 </send>

 <!-- Wait for provisional responces and if something wrong (404 or timeout) - end scenario -->
 <recv response="100" optional="true" timeout="5000" ontimeout="12"/>
 <recv response="404" optional="true" next="12"/>
 <recv response="180" timeout="5000" ontimeout="12" />

<!-- Check if field4 == 'answered' and assign bool result to $3 -->

 <nop>
  <action>
    <assignstr assign_to="1" value="[field4 line=0]" />
    <strcmp assign_to="2" variable="1" value="answered" />
    <test assign_to="3" variable="2" compare="equal" value="0" />
  </action>
 </nop>

<!-- If $3 == true, go to label 10  -->
 <nop next="10" test="3" />

 <send>
  <![CDATA[

    CANCEL sip:[field0 line=0]@[remote_ip]:[remote_port] SIP/2.0
    Via: SIP/2.0/[transport] [local_ip]:[local_port]
    From: <sip:[field1 line=0]@[local_ip]:[local_port]>;tag=[call_number]_sipp_call_canceler_[call_number]
    To: <sip:[field0 line=0]@[remote_ip]:[remote_port]>
    Call-ID: [call_id]
    Cseq: [cseq] CANCEL
    Max-Forwards: 70
    Content-Length: 0

  ]]>
 </send>

 <!-- Simple goto "11" after receiving 200 -->
 <recv response="200" next="11"/>

 <label id="10"/>

 <send>
  <![CDATA[

    CANCEL sip:[field0 line=0]@[remote_ip]:[remote_port] SIP/2.0
    Via: SIP/2.0/[transport] [local_ip]:[local_port]
    From: <sip:[field1 line=0]@[local_ip]:[local_port]>;tag=[call_number]_sipp_call_canceler_[call_number]
    To: <sip:[field0 line=0]@[remote_ip]:[remote_port]>
    Call-ID: [call_id]
    Cseq: [cseq] CANCEL
    Max-Forwards: 70
    Reason: SIP;cause=200;text="Call completed elsewhere"
    Content-Length: 0

  ]]>
 </send>

 <recv response="200" />

 <label id="11"/>

 <recv response="487" />

 <send>
  <![CDATA[

    ACK sip:[field0 line=0]@[remote_ip]:[remote_port] SIP/2.0
    Via: SIP/2.0/[transport] [local_ip]:[local_port]
    From: <sip:[field1 line=0]@[local_ip]:[local_port]>;tag=[call_number]_sipp_call_canceler_[call_number]
    [last_To:]
    Call-ID: [call_id]
    Cseq: [cseq] ACK
    Contact: sip:asterisk@[local_ip]:[local_port]
    Content-Length: 0

  ]]>
 </send>

 <label id="12" />

</scenario>


A bit dirty solution, but it's working.

3 comments:

  1. Excellent post :)
    I came across this problem last year as well and so instead of creating a fake INVITE we took a slightly different approach. Sharing here just for exchanging ideas.

    1 - the IOS app devs put a timer on the ringer or something where once a push is received it waited for N seconds before it'll absolutely end the ringer.
    2 - From the Kamailio/SIP server end we generated a SIP MESSAGE using uac module for any incoming registration by IOS app expecting to connect with a cancelled/already-connected call. So instead of getting an INVITE the app got a MESSAGE and from the PJSIP stack developer could take this as a signal that the call is no longer valid and hence ended the App side call screen.

    Again your approach vs what we did is pretty much same except using different method to tell the IOS 13 to stop expecting a call.

    This is such a weird thing from Apple that they do not have any sort of Push Notification to tell the callkit that the caller no longer wants to connect !!

    ReplyDelete
    Replies
    1. Gohar,

      Nice to hear from you. In my case, I'd also wish to control it on the app level, but unfortunately, we can't control much on this as we're using a branded version of Linphone and trying to be as much aligned as possible with the main project. And Linphone aimed at own FlexiSIP server, which I haven't chance to test how it's working in this case. But Belldone saying, that it's saving the transaction to deliver after REGISTER. So, not "faking" INVITE, but storing it in some cache.
      But can't confirm without digging in source code and extensive testing.

      Delete
  2. hello igor, we are currently looking for a developer who can do a 2nd number voip phone app. if you are aware of someone, please let them or us know. thank you, dave at copycall dot com dave@copycall.com

    ReplyDelete