diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index d29d01c463..ebc4529321 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -727,6 +727,11 @@ jobs: python3 run.py --exe ../../pjsip-apps/src/3rdparty_media_sample/alt_pjsua mod_sipp.py scripts-sipp/alt-pjsua-uas-custom-sdp.xml python3 run.py --exe ../../pjsip-apps/src/3rdparty_media_sample/alt_pjsua mod_sipp.py scripts-sipp/alt-pjsua-uas-amr-sdp.xml python3 run.py --exe ../../pjsip-apps/src/3rdparty_media_sample/alt_pjsua mod_sipp.py scripts-sipp/alt-pjsua-uas-static-pt-no-rtpmap.xml + python3 run.py --exe ../../pjsip-apps/src/3rdparty_media_sample/alt_pjsua mod_sipp.py scripts-sipp/alt-pjsua-uac-sdp-passthrough.xml + python3 run.py --exe ../../pjsip-apps/src/3rdparty_media_sample/alt_pjsua mod_sipp.py scripts-sipp/alt-pjsua-uas-sdp-passthrough.xml + python3 run.py --exe ../../pjsip-apps/src/3rdparty_media_sample/alt_pjsua mod_sipp.py scripts-sipp/alt-pjsua-uas-multiple-amr.xml + python3 run.py --exe ../../pjsip-apps/src/3rdparty_media_sample/alt_pjsua mod_sipp.py scripts-sipp/alt-pjsua-uas-media-app-managed.xml + python3 run.py --exe ../../pjsip-apps/src/3rdparty_media_sample/alt_pjsua mod_sipp.py scripts-sipp/alt-pjsua-uas-early-update-app-managed.xml - name: upload artifacts on failure if: ${{ failure() }} uses: actions/upload-artifact@v4 diff --git a/pjmedia/include/pjmedia/sdp_neg.h b/pjmedia/include/pjmedia/sdp_neg.h index 1249fb6394..e51e4ef80f 100644 --- a/pjmedia/include/pjmedia/sdp_neg.h +++ b/pjmedia/include/pjmedia/sdp_neg.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008-2011 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2008-2026 Teluu Inc. (http://www.teluu.com) * Copyright (C) 2003-2008 Benny Prijono * * This program is free software; you can redistribute it and/or modify @@ -677,6 +677,19 @@ pjmedia_sdp_neg_set_local_answer( pj_pool_t *pool, PJ_DECL(pj_bool_t) pjmedia_sdp_neg_has_local_answer(pjmedia_sdp_neg *neg); +/** + * Cancel the pending local answer and return to remote-offer state. + * The negotiator must be in PJMEDIA_SDP_NEG_STATE_WAIT_NEGO state with + * a local answer. + * + * @param neg The negotiator. + * + * @return PJ_SUCCESS or the appropriate error code. + */ +PJ_DECL(pj_status_t) +pjmedia_sdp_neg_cancel_local_answer(pjmedia_sdp_neg *neg); + + /** * Cancel any pending offer, whether the offer is initiated by local or * remote, and move negotiator state back to previous stable state @@ -719,6 +732,30 @@ PJ_DECL(pj_status_t) pjmedia_sdp_neg_negotiate( pj_pool_t *pool, pj_bool_t allow_asym); +/** + * Enable SDP passthrough mode for a signalling-plane application. + * + * Codec/PT negotiation and media-line rewriting are skipped, while the + * negotiator retains its normal state handling and stack-managed "o=" line. + * Answers must have the same media-line count as their offers; negotiation + * fails with PJMEDIA_SDPNEG_EMISMEDIA otherwise. + * Local re-offers must retain at least the active SDP's media-line count; + * shortened re-offers fail with PJMEDIA_SDPNEG_EMISMEDIA. + * Local answers must be supplied explicitly; set_local_answer() with a + * NULL local SDP returns PJ_EINVAL instead of reusing previous SDP. + * Set this once for the negotiator lifetime. + * + * @param neg The SDP negotiator instance. + * @param passthrough PJ_TRUE to enable passthrough mode, PJ_FALSE to + * disable it (default is disabled). + * + * @return PJ_SUCCESS on success, or PJ_EINVAL if \a neg is + * NULL. + */ +PJ_DECL(pj_status_t) pjmedia_sdp_neg_set_passthrough(pjmedia_sdp_neg *neg, + pj_bool_t passthrough); + + /** * Enumeration of customized SDP format matching option flags. See * #pjmedia_sdp_neg_register_fmt_match_cb() for more info. @@ -819,4 +856,3 @@ PJ_END_DECL #endif /* __PJMEDIA_SDP_NEG_H__ */ - diff --git a/pjmedia/src/pjmedia/sdp_neg.c b/pjmedia/src/pjmedia/sdp_neg.c index eb5f0f4db6..b818a5ce70 100644 --- a/pjmedia/src/pjmedia/sdp_neg.c +++ b/pjmedia/src/pjmedia/sdp_neg.c @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008-2011 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2008-2026 Teluu Inc. (http://www.teluu.com) * Copyright (C) 2003-2008 Benny Prijono * * This program is free software; you can redistribute it and/or modify @@ -62,6 +62,10 @@ struct pjmedia_sdp_neg pj_bool_t answer_with_multiple_codecs; pj_bool_t has_remote_answer; pj_bool_t answer_was_remote; + pj_bool_t passthrough; /**< See + #pjmedia_sdp_neg_set_passthrough() + */ + pj_bool_t initial_sdp_tmp_valid; pt_to_codec_map pt_to_codec[PJMEDIA_MAX_SDP_MEDIA]; codec_to_pt_map codec_to_pt[PJMEDIA_MAX_SDP_MEDIA]; @@ -290,6 +294,18 @@ PJ_DEF(pj_status_t) pjmedia_sdp_neg_set_answer_multiple_codecs( } +/* + * Enable/disable passthrough mode. + */ +PJ_DEF(pj_status_t) pjmedia_sdp_neg_set_passthrough(pjmedia_sdp_neg *neg, + pj_bool_t passthrough) +{ + PJ_ASSERT_RETURN(neg, PJ_EINVAL); + neg->passthrough = passthrough; + return PJ_SUCCESS; +} + + /* * Get SDP negotiator state. */ @@ -387,6 +403,17 @@ PJ_DEF(pj_status_t) pjmedia_sdp_neg_modify_local_offer( pj_pool_t *pool, return pjmedia_sdp_neg_modify_local_offer2(pool, neg, 0, local); } +static void force_origin_identity(pj_pool_t *pool, + pjmedia_sdp_session *dst, + const pjmedia_sdp_session *src) +{ + pj_strdup(pool, &dst->origin.user, &src->origin.user); + dst->origin.id = src->origin.id; + pj_strdup(pool, &dst->origin.net_type, &src->origin.net_type); + pj_strdup(pool, &dst->origin.addr_type, &src->origin.addr_type); + pj_strdup(pool, &dst->origin.addr, &src->origin.addr); +} + PJ_DEF(pj_status_t) pjmedia_sdp_neg_modify_local_offer2( pj_pool_t *pool, pjmedia_sdp_neg *neg, @@ -410,6 +437,12 @@ PJ_DEF(pj_status_t) pjmedia_sdp_neg_modify_local_offer2( if (status != PJ_SUCCESS) return status; + if (neg->passthrough && neg->active_local_sdp && + local->media_count < neg->active_local_sdp->media_count) + { + return PJMEDIA_SDPNEG_EMISMEDIA; + } + /* Change state to STATE_LOCAL_OFFER */ neg->state = PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER; @@ -419,11 +452,14 @@ PJ_DEF(pj_status_t) pjmedia_sdp_neg_modify_local_offer2( */ if (!neg->active_local_sdp) { neg->initial_sdp_tmp = NULL; + neg->initial_sdp_tmp_valid = PJ_TRUE; neg->initial_sdp = pjmedia_sdp_session_clone(pool, local); - /* Assign PT numbers for our offer and update the mapping. */ - assign_pt_and_update_map(pool, neg, neg->initial_sdp, - PJ_TRUE, PJ_FALSE); + if (!neg->passthrough) { + /* Assign PT numbers for our offer and update the mapping. */ + assign_pt_and_update_map(pool, neg, neg->initial_sdp, + PJ_TRUE, PJ_FALSE); + } neg->neg_local_sdp = pjmedia_sdp_session_clone(pool, neg->initial_sdp); if (pjmedia_sdp_session_cmp(neg->last_sent, neg->neg_local_sdp, 0) != @@ -445,14 +481,10 @@ PJ_DEF(pj_status_t) pjmedia_sdp_neg_modify_local_offer2( * previous SDP, except that the version in the origin field MUST * increment by one from the previous SDP. */ - pj_strdup(pool, &new_offer->origin.user, &old_offer->origin.user); - new_offer->origin.id = old_offer->origin.id; + force_origin_identity(pool, new_offer, old_offer); - pj_strdup(pool, &new_offer->origin.net_type, &old_offer->origin.net_type); - pj_strdup(pool, &new_offer->origin.addr_type,&old_offer->origin.addr_type); - pj_strdup(pool, &new_offer->origin.addr, &old_offer->origin.addr); - - if ((flags & PJMEDIA_SDP_NEG_ALLOW_MEDIA_CHANGE) == 0) { + if (!neg->passthrough && + (flags & PJMEDIA_SDP_NEG_ALLOW_MEDIA_CHANGE) == 0) { /* Generating the new offer, in the case media lines doesn't match the * active SDP (e.g. current/active SDP's have m=audio and m=video lines, * and the new offer only has m=audio line), the negotiator will fix @@ -501,7 +533,7 @@ PJ_DEF(pj_status_t) pjmedia_sdp_neg_modify_local_offer2( new_offer->media_count++, oi, &m); } } - } else { + } else if (!neg->passthrough) { /* If media type change is allowed, the negotiator only needs to fix * the new offer by adding the missing media line(s) with port number * set to zero. @@ -528,13 +560,14 @@ PJ_DEF(pj_status_t) pjmedia_sdp_neg_modify_local_offer2( /* New_offer fixed */ new_offer->origin.version = old_offer->origin.version; - /* Assign PT numbers for our offer and update the mapping. */ - assign_pt_and_update_map(pool, neg, new_offer, PJ_TRUE, PJ_FALSE); + if (!neg->passthrough) + assign_pt_and_update_map(pool, neg, new_offer, PJ_TRUE, PJ_FALSE); if (pjmedia_sdp_session_cmp(neg->last_sent, new_offer, 0) != PJ_SUCCESS) { ++new_offer->origin.version; } neg->initial_sdp_tmp = neg->initial_sdp; + neg->initial_sdp_tmp_valid = PJ_TRUE; neg->initial_sdp = new_offer; neg->neg_local_sdp = pjmedia_sdp_session_clone(pool, new_offer); neg->last_sent = neg->neg_local_sdp; @@ -567,6 +600,7 @@ PJ_DEF(pj_status_t) pjmedia_sdp_neg_send_local_offer( pj_pool_t *pool, /* Retain initial SDP */ if (neg->initial_sdp) { neg->initial_sdp_tmp = neg->initial_sdp; + neg->initial_sdp_tmp_valid = PJ_TRUE; neg->initial_sdp = pjmedia_sdp_session_clone(pool, neg->initial_sdp); } @@ -646,7 +680,7 @@ PJ_DEF(pj_status_t) pjmedia_sdp_neg_set_local_answer( pj_pool_t *pool, const pjmedia_sdp_session *local) { /* Check arguments are valid. */ - PJ_ASSERT_RETURN(pool && neg && local, PJ_EINVAL); + PJ_ASSERT_RETURN(pool && neg, PJ_EINVAL); /* Can only do this in STATE_REMOTE_OFFER or WAIT_NEGO. * If we already provide local offer, then set_remote_answer() should @@ -655,14 +689,19 @@ PJ_DEF(pj_status_t) pjmedia_sdp_neg_set_local_answer( pj_pool_t *pool, PJ_ASSERT_RETURN(neg->state == PJMEDIA_SDP_NEG_STATE_REMOTE_OFFER || neg->state == PJMEDIA_SDP_NEG_STATE_WAIT_NEGO, PJMEDIA_SDPNEG_EINSTATE); + if (!local && neg->passthrough) + return PJ_EINVAL; + if (!local && !neg->initial_sdp) + return PJMEDIA_SDPNEG_ENOINITIAL; /* State now is STATE_WAIT_NEGO. */ neg->state = PJMEDIA_SDP_NEG_STATE_WAIT_NEGO; if (local) { neg->neg_local_sdp = pjmedia_sdp_session_clone(pool, local); + neg->initial_sdp_tmp = neg->initial_sdp; + neg->initial_sdp_tmp_valid = PJ_TRUE; if (neg->initial_sdp) { /* Retain initial_sdp value. */ - neg->initial_sdp_tmp = neg->initial_sdp; neg->initial_sdp = pjmedia_sdp_session_clone(pool, neg->initial_sdp); @@ -672,13 +711,18 @@ PJ_DEF(pj_status_t) pjmedia_sdp_neg_set_local_answer( pj_pool_t *pool, * Note that the version will be incremented in * pjmedia_sdp_neg_negotiate() */ - neg->neg_local_sdp->origin.id = neg->initial_sdp->origin.id; + if (!neg->passthrough) { + neg->neg_local_sdp->origin.id = neg->initial_sdp->origin.id; + } else { + force_origin_identity(pool, neg->neg_local_sdp, + neg->initial_sdp); + } } else { neg->initial_sdp = pjmedia_sdp_session_clone(pool, local); } } else { - PJ_ASSERT_RETURN(neg->initial_sdp, PJMEDIA_SDPNEG_ENOINITIAL); neg->initial_sdp_tmp = neg->initial_sdp; + neg->initial_sdp_tmp_valid = PJ_TRUE; neg->initial_sdp = pjmedia_sdp_session_clone(pool, neg->initial_sdp); neg->neg_local_sdp = pjmedia_sdp_session_clone(pool, neg->initial_sdp); } @@ -692,6 +736,24 @@ PJ_DEF(pj_bool_t) pjmedia_sdp_neg_has_local_answer(pjmedia_sdp_neg *neg) return !neg->has_remote_answer; } +PJ_DEF(pj_status_t) +pjmedia_sdp_neg_cancel_local_answer(pjmedia_sdp_neg *neg) +{ + PJ_ASSERT_RETURN(neg, PJ_EINVAL); + PJ_ASSERT_RETURN(neg->state == PJMEDIA_SDP_NEG_STATE_WAIT_NEGO && + !neg->has_remote_answer, + PJMEDIA_SDPNEG_EINSTATE); + + if (neg->initial_sdp_tmp_valid) + neg->initial_sdp = neg->initial_sdp_tmp; + neg->initial_sdp_tmp = NULL; + neg->initial_sdp_tmp_valid = PJ_FALSE; + neg->neg_local_sdp = NULL; + neg->state = PJMEDIA_SDP_NEG_STATE_REMOTE_OFFER; + + return PJ_SUCCESS; +} + /* Swap string. */ static void str_swap(pj_str_t *str1, pj_str_t *str2) @@ -2225,11 +2287,15 @@ PJ_DEF(pj_status_t) pjmedia_sdp_neg_cancel_offer(pjmedia_sdp_neg *neg) } /* Revert back initial SDP */ - if (neg->state == PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER) + if (neg->state == PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER && + neg->initial_sdp_tmp_valid) + { neg->initial_sdp = neg->initial_sdp_tmp; + } /* Clear temporary SDP */ neg->initial_sdp_tmp = NULL; + neg->initial_sdp_tmp_valid = PJ_FALSE; neg->neg_local_sdp = neg->neg_remote_sdp = NULL; neg->has_remote_answer = PJ_FALSE; @@ -2240,6 +2306,18 @@ PJ_DEF(pj_status_t) pjmedia_sdp_neg_cancel_offer(pjmedia_sdp_neg *neg) } +static pj_status_t passthrough_answer(pj_pool_t *pool, + const pjmedia_sdp_neg *neg, + pjmedia_sdp_session **local) +{ + if (neg->neg_local_sdp->media_count != neg->neg_remote_sdp->media_count) + return PJMEDIA_SDPNEG_EMISMEDIA; + + *local = pjmedia_sdp_session_clone(pool, neg->neg_local_sdp); + return PJ_SUCCESS; +} + + /* The best bit: SDP negotiation function! */ PJ_DEF(pj_status_t) pjmedia_sdp_neg_negotiate( pj_pool_t *pool, pjmedia_sdp_neg *neg, @@ -2259,18 +2337,27 @@ PJ_DEF(pj_status_t) pjmedia_sdp_neg_negotiate( pj_pool_t *pool, #if PJMEDIA_SDP_NEG_MAINTAIN_REMOTE_PT_MAP /* Update PT mapping based on remote SDP as well. */ - assign_pt_and_update_map(pool, neg, neg->neg_remote_sdp, - !neg->has_remote_answer, PJ_TRUE); + if (!neg->passthrough) { + assign_pt_and_update_map(pool, neg, neg->neg_remote_sdp, + !neg->has_remote_answer, PJ_TRUE); + } #endif if (neg->has_remote_answer) { pjmedia_sdp_session *active; - status = process_answer(pool, neg->neg_local_sdp, neg->neg_remote_sdp, - allow_asym, &active); + + if (neg->passthrough) { + status = passthrough_answer(pool, neg, &active); + } else { + status = process_answer(pool, neg->neg_local_sdp, + neg->neg_remote_sdp, allow_asym, &active); + } if (status == PJ_SUCCESS) { /* Only update active SDPs when negotiation is successfull */ neg->active_local_sdp = active; - neg->active_remote_sdp = neg->neg_remote_sdp; + neg->active_remote_sdp = neg->passthrough ? + pjmedia_sdp_session_clone(pool, neg->neg_remote_sdp) : + neg->neg_remote_sdp; /* Keep the pool used for allocating the active SDPs */ neg->pool_active = pool; @@ -2282,13 +2369,19 @@ PJ_DEF(pj_status_t) pjmedia_sdp_neg_negotiate( pj_pool_t *pool, } else { pjmedia_sdp_session *answer = NULL; - status = create_answer(pool, neg->prefer_remote_codec_order, - neg->answer_with_multiple_codecs, - neg->neg_local_sdp, neg->neg_remote_sdp, - &answer); + if (neg->passthrough) { + status = passthrough_answer(pool, neg, &answer); + } else { + status = create_answer(pool, neg->prefer_remote_codec_order, + neg->answer_with_multiple_codecs, + neg->neg_local_sdp, neg->neg_remote_sdp, + &answer); + } if (status == PJ_SUCCESS) { /* Assign PT numbers for our answer and update the mapping. */ - assign_pt_and_update_map(pool, neg, answer, PJ_FALSE, PJ_FALSE); + if (!neg->passthrough) + assign_pt_and_update_map(pool, neg, answer, PJ_FALSE, + PJ_FALSE); if (neg->last_sent) answer->origin.version = neg->last_sent->origin.version; @@ -2302,7 +2395,9 @@ PJ_DEF(pj_status_t) pjmedia_sdp_neg_negotiate( pj_pool_t *pool, /* Only update active SDPs when negotiation is successfull */ neg->active_local_sdp = answer; - neg->active_remote_sdp = neg->neg_remote_sdp; + neg->active_remote_sdp = neg->passthrough ? + pjmedia_sdp_session_clone(pool, neg->neg_remote_sdp) : + neg->neg_remote_sdp; /* This answer will be sent, so update the last sent SDP */ neg->last_sent = answer; @@ -2319,11 +2414,12 @@ PJ_DEF(pj_status_t) pjmedia_sdp_neg_negotiate( pj_pool_t *pool, neg->answer_was_remote = neg->has_remote_answer; /* Revert back initial SDP if nego fails */ - if (status != PJ_SUCCESS) + if (status != PJ_SUCCESS && neg->initial_sdp_tmp_valid) neg->initial_sdp = neg->initial_sdp_tmp; /* Clear temporary SDP */ neg->initial_sdp_tmp = NULL; + neg->initial_sdp_tmp_valid = PJ_FALSE; neg->neg_local_sdp = neg->neg_remote_sdp = NULL; neg->has_remote_answer = PJ_FALSE; @@ -2331,6 +2427,7 @@ PJ_DEF(pj_status_t) pjmedia_sdp_neg_negotiate( pj_pool_t *pool, } + static pj_status_t custom_fmt_match(pj_pool_t *pool, const pj_str_t *fmt_name, pjmedia_sdp_media *offer, @@ -2459,4 +2556,3 @@ PJ_DEF(pj_status_t) pjmedia_sdp_neg_fmt_match(pj_pool_t *pool, return custom_fmt_match(pool, &o_rtpmap.enc_name, offer, o_fmt_idx, answer, a_fmt_idx, option); } - diff --git a/pjmedia/src/test/sdp_neg_test.c b/pjmedia/src/test/sdp_neg_test.c index 7c9c41852f..623b2abd27 100644 --- a/pjmedia/src/test/sdp_neg_test.c +++ b/pjmedia/src/test/sdp_neg_test.c @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008-2011 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2008-2026 Teluu Inc. (http://www.teluu.com) * Copyright (C) 2003-2008 Benny Prijono * * This program is free software; you can redistribute it and/or modify @@ -1896,6 +1896,647 @@ static int sdp_neg_static_pt_repr_test(pj_pool_t *pool) return 0; } +/* Real-world SDP offer/answer pair used to verify that + * pjmedia_sdp_neg_negotiate_passthrough() promotes the fed-in SDP verbatim, + * without the fmtp reconciliation / payload-type bookkeeping that a normal + * pjmedia_sdp_neg_negotiate() would apply. The offer carries 6 payload + * types (AMR-WB/AMR/telephone-event), while the answer narrows this down + * to 2 with different fmtp parameters than the offer's — exactly the kind + * of content a normal negotiation would rewrite. + */ +static char pt_offer_str[] = + "v=0\r\n" + "o=- 893867431174198 4062473431 IN IP4 10.162.173.125\r\n" + "s=SS VOIP\r\n" + "c=IN IP4 10.186.99.36\r\n" + "t=0 0\r\n" + "m=audio 56832 RTP/AVP 116 107 118 96 111 110\r\n" + "b=AS:41\r\n" + "b=RS:512\r\n" + "b=RR:1537\r\n" + "a=rtpmap:116 AMR-WB/16000/1\r\n" + "a=fmtp:116 mode-change-capability=2;max-red=220\r\n" + "a=rtpmap:107 AMR-WB/16000/1\r\n" + "a=fmtp:107 octet-align=1;mode-change-capability=2;max-red=220\r\n" + "a=rtpmap:118 AMR/8000/1\r\n" + "a=fmtp:118 mode-change-capability=2;max-red=220\r\n" + "a=rtpmap:96 AMR/8000/1\r\n" + "a=fmtp:96 octet-align=1;mode-change-capability=2;max-red=220\r\n" + "a=rtpmap:111 telephone-event/16000\r\n" + "a=fmtp:111 0-15\r\n" + "a=rtpmap:110 telephone-event/8000\r\n" + "a=fmtp:110 0-15\r\n" + "a=curr:qos local none\r\n" + "a=curr:qos remote none\r\n" + "a=des:qos mandatory local sendrecv\r\n" + "a=des:qos optional remote sendrecv\r\n" + "a=sendrecv\r\n" + "a=ptime:20\r\n" + "a=maxptime:240\r\n"; + +static char pt_answer_str[] = + "v=0\r\n" + "o=- 893867432491909 4062736973 IN IP4 10.162.173.125\r\n" + "s=-\r\n" + "t=0 0\r\n" + "a=msid-semantic: WMS\r\n" + "m=audio 56384 RTP/AVP 116 111\r\n" + "c=IN IP4 10.186.99.38\r\n" + "b=AS:41\r\n" + "a=rtpmap:116 AMR-WB/16000\r\n" + "a=fmtp:116 max-red=0; mode-change-capability=2; mode-change-neighbor=1;" + " mode-change-period=2\r\n" + "a=rtpmap:111 telephone-event/16000\r\n" + "a=fmtp:111 0-15\r\n" + "a=ptime:20\r\n" + "a=maxptime:40\r\n" + "a=msid:- 415221c9-141a-4cfa-905a-024af91d0e7c\r\n" + "a=ssrc:18411299 cname:NNI4WBv5F7m8JUWO\r\n" + "a=sendrecv\r\n"; + +/* Second offer/answer pair for a passthrough re-offer/re-answer round + * (Case 3 of sdp_neg_passthrough_test()). The offer's "o=" user/id/ + * net_type/addr are all deliberately different from pt_offer_str's, to + * verify modify_local_offer2() keeps the local SDP origin identity under + * passthrough mode. + */ +static char pt_offer2_str[] = + "v=0\r\n" + "o=testuser2 111222333 1 IN IP4 192.0.2.1\r\n" + "s=SS VOIP\r\n" + "c=IN IP4 192.0.2.1\r\n" + "t=0 0\r\n" + "m=audio 56832 RTP/AVP 116 107 118 96 111 110\r\n" + "b=AS:41\r\n" + "a=rtpmap:116 AMR-WB/16000/1\r\n" + "a=fmtp:116 mode-change-capability=2;max-red=220\r\n" + "a=rtpmap:107 AMR-WB/16000/1\r\n" + "a=fmtp:107 octet-align=1;mode-change-capability=2;max-red=220\r\n" + "a=rtpmap:118 AMR/8000/1\r\n" + "a=fmtp:118 mode-change-capability=2;max-red=220\r\n" + "a=rtpmap:96 AMR/8000/1\r\n" + "a=fmtp:96 octet-align=1;mode-change-capability=2;max-red=220\r\n" + "a=rtpmap:111 telephone-event/16000\r\n" + "a=fmtp:111 0-15\r\n" + "a=rtpmap:110 telephone-event/8000\r\n" + "a=fmtp:110 0-15\r\n" + "a=sendrecv\r\n" + "a=ptime:20\r\n" + "a=maxptime:240\r\n"; + +static char pt_answer2_str[] = + "v=0\r\n" + "o=- 999888777 1 IN IP4 10.162.173.125\r\n" + "s=-\r\n" + "t=0 0\r\n" + "m=audio 56390 RTP/AVP 118 110\r\n" + "c=IN IP4 10.186.99.38\r\n" + "b=AS:41\r\n" + "a=rtpmap:118 AMR/8000\r\n" + "a=fmtp:118 octet-align=1;mode-change-capability=2;max-red=0\r\n" + "a=rtpmap:110 telephone-event/8000\r\n" + "a=fmtp:110 0-15\r\n" + "a=ptime:20\r\n" + "a=maxptime:40\r\n" + "a=sendrecv\r\n"; + +static int passthrough_mismatched_answer_test(pj_pool_t *pool, + pj_bool_t remote_answer, + pj_bool_t extra_media) +{ + pjmedia_sdp_session *offer, *answer; + pjmedia_sdp_neg *neg; + const pjmedia_sdp_session *old_local, *old_remote; + const pjmedia_sdp_session *active_local, *active_remote; + pj_status_t status; + char b1[sizeof(pt_offer_str)], b2[sizeof(pt_answer_str)]; + + pj_memcpy(b1, pt_offer_str, sizeof(pt_offer_str)); + pj_memcpy(b2, pt_answer_str, sizeof(pt_answer_str)); + if (pjmedia_sdp_parse(pool, b1, pj_ansi_strlen(b1), &offer) != PJ_SUCCESS || + pjmedia_sdp_parse(pool, b2, pj_ansi_strlen(b2), &answer) != PJ_SUCCESS || + pjmedia_sdp_neg_create_w_local_offer(pool, offer, &neg) != PJ_SUCCESS) + { + return -3680; + } + pjmedia_sdp_neg_set_passthrough(neg, PJ_TRUE); + if (pjmedia_sdp_neg_set_remote_answer(pool, neg, answer) != PJ_SUCCESS || + pjmedia_sdp_neg_negotiate(pool, neg, 0) != PJ_SUCCESS || + pjmedia_sdp_neg_get_active_local(neg, &old_local) != PJ_SUCCESS || + pjmedia_sdp_neg_get_active_remote(neg, &old_remote) != PJ_SUCCESS) + { + return -3690; + } + + if (extra_media) { + answer->media[answer->media_count] = + pjmedia_sdp_media_clone(pool, answer->media[0]); + ++answer->media_count; + } else { + --answer->media_count; + } + if (remote_answer) { + status = pjmedia_sdp_neg_modify_local_offer2(pool, neg, 0, offer); + if (status == PJ_SUCCESS) + status = pjmedia_sdp_neg_set_remote_answer(pool, neg, answer); + } else { + status = pjmedia_sdp_neg_set_remote_offer(pool, neg, offer); + if (status == PJ_SUCCESS) { + if (pjmedia_sdp_neg_set_local_answer(pool, neg, NULL) != PJ_EINVAL || + pjmedia_sdp_neg_get_state(neg) != + PJMEDIA_SDP_NEG_STATE_REMOTE_OFFER) + { + return -3695; + } + status = pjmedia_sdp_neg_set_local_answer(pool, neg, answer); + if (status == PJ_SUCCESS && + (pjmedia_sdp_neg_set_local_answer(pool, neg, NULL) != + PJ_EINVAL || + pjmedia_sdp_neg_get_state(neg) != + PJMEDIA_SDP_NEG_STATE_WAIT_NEGO)) + { + return -3696; + } + } + } + if (status != PJ_SUCCESS || + pjmedia_sdp_neg_negotiate(pool, neg, 0) != PJMEDIA_SDPNEG_EMISMEDIA || + pjmedia_sdp_neg_get_state(neg) != PJMEDIA_SDP_NEG_STATE_DONE || + pjmedia_sdp_neg_get_active_local(neg, &active_local) != PJ_SUCCESS || + pjmedia_sdp_neg_get_active_remote(neg, &active_remote) != PJ_SUCCESS || + active_local != old_local || active_remote != old_remote) + { + return -3700; + } + answer->media_count = offer->media_count; + answer->media[0]->desc.port = 0; + if (remote_answer) { + status = pjmedia_sdp_neg_modify_local_offer2(pool, neg, 0, offer); + if (status == PJ_SUCCESS) + status = pjmedia_sdp_neg_set_remote_answer(pool, neg, answer); + } else { + status = pjmedia_sdp_neg_set_remote_offer(pool, neg, offer); + if (status == PJ_SUCCESS) + status = pjmedia_sdp_neg_set_local_answer(pool, neg, answer); + } + if (status != PJ_SUCCESS || + pjmedia_sdp_neg_negotiate(pool, neg, 0) != PJ_SUCCESS || + pjmedia_sdp_neg_get_active_local(neg, &active_local) != PJ_SUCCESS || + pjmedia_sdp_neg_get_active_remote(neg, &active_remote) != PJ_SUCCESS || + active_local->media_count != active_remote->media_count || + (remote_answer ? active_remote : active_local)->media[0]->desc.port != 0) + { + return -3710; + } + return 0; +} + +/* Verify passthrough mode (#pjmedia_sdp_neg_set_passthrough()) for both + * negotiation directions: we answer a remote offer, and remote answers our + * offer. In both cases, the active local/remote SDP after negotiation must + * be content-identical to what was fed into the negotiator, unlike a + * normal #pjmedia_sdp_neg_negotiate() which would reconcile/filter the + * answer's fmtp against the offer's and reassign payload types. A third + * case then verifies a re-offer/re-answer round (#pjmedia_sdp_neg_modify_local_offer2()) + * on top of an established passthrough negotiator is covered too: the + * "o=" remains stack-managed while content below it passes through. + */ +static int sdp_neg_passthrough_test(pj_pool_t *pool) +{ + pjmedia_sdp_session *offer, *answer; + pjmedia_sdp_neg *neg; + const pjmedia_sdp_session *active_local, *active_remote; + pj_status_t status; + char b1[sizeof(pt_offer_str)], b2[sizeof(pt_answer_str)]; + + /* Case 1: we answer a remote offer (has_remote_answer == PJ_FALSE). */ + pj_memcpy(b1, pt_offer_str, sizeof(pt_offer_str)); + pj_memcpy(b2, pt_answer_str, sizeof(pt_answer_str)); + if (pjmedia_sdp_parse(pool, b1, pj_ansi_strlen(b1), &offer) != PJ_SUCCESS) + return -3000; + if (pjmedia_sdp_parse(pool, b2, pj_ansi_strlen(b2), &answer) != PJ_SUCCESS) + return -3010; + + if (pjmedia_sdp_neg_create_w_remote_offer(pool, NULL, offer, &neg) != + PJ_SUCCESS) + { + return -3020; + } + pjmedia_sdp_neg_set_passthrough(neg, PJ_TRUE); + if (pjmedia_sdp_neg_set_local_answer(pool, neg, answer) != PJ_SUCCESS) + return -3030; + + status = pjmedia_sdp_neg_negotiate(pool, neg, 0); + if (status != PJ_SUCCESS) { + app_perror(status, " sdp_neg_passthrough_test: negotiate failed"); + return -3040; + } + + if (pjmedia_sdp_neg_get_state(neg) != PJMEDIA_SDP_NEG_STATE_DONE) + return -3050; + + if (pjmedia_sdp_neg_get_active_local(neg, &active_local) != PJ_SUCCESS) + return -3060; + if (pjmedia_sdp_neg_get_active_remote(neg, &active_remote) != PJ_SUCCESS) + return -3070; + + /* Content must be identical to what was fed in, but origin.version is + * still managed by the negotiator: since there was no previous + * last_sent, negotiate() bumps it by one on top of the fed-in + * answer's own version. + */ + { + pjmedia_sdp_session *expected_answer; + char b6[sizeof(pt_answer_str)]; + + pj_memcpy(b6, pt_answer_str, sizeof(pt_answer_str)); + if (pjmedia_sdp_parse(pool, b6, pj_ansi_strlen(b6), + &expected_answer) != PJ_SUCCESS) + { + return -3075; + } + expected_answer->origin.version = answer->origin.version + 1; + + if (compare_sdp_string("passthrough answer", "expected answer", + expected_answer, "active local", active_local, + PJ_SUCCESS) != 0) + { + return -3080; + } + } + if (compare_sdp_string("passthrough offer", "fed-in offer", offer, + "active remote", active_remote, PJ_SUCCESS) != 0) + { + return -3090; + } + + /* Case 2: remote answers our offer (has_remote_answer == PJ_TRUE). */ + pj_memcpy(b1, pt_offer_str, sizeof(pt_offer_str)); + pj_memcpy(b2, pt_answer_str, sizeof(pt_answer_str)); + if (pjmedia_sdp_parse(pool, b1, pj_ansi_strlen(b1), &offer) != PJ_SUCCESS) + return -3100; + if (pjmedia_sdp_parse(pool, b2, pj_ansi_strlen(b2), &answer) != PJ_SUCCESS) + return -3110; + + if (pjmedia_sdp_neg_create_w_local_offer(pool, offer, &neg) != PJ_SUCCESS) + return -3120; + pjmedia_sdp_neg_set_passthrough(neg, PJ_TRUE); + if (pjmedia_sdp_neg_set_remote_answer(pool, neg, answer) != PJ_SUCCESS) + return -3130; + + status = pjmedia_sdp_neg_negotiate(pool, neg, 0); + if (status != PJ_SUCCESS) { + app_perror(status, " sdp_neg_passthrough_test: negotiate failed"); + return -3140; + } + + if (pjmedia_sdp_neg_get_state(neg) != PJMEDIA_SDP_NEG_STATE_DONE) + return -3150; + + if (pjmedia_sdp_neg_get_active_local(neg, &active_local) != PJ_SUCCESS) + return -3160; + if (pjmedia_sdp_neg_get_active_remote(neg, &active_remote) != PJ_SUCCESS) + return -3170; + + if (compare_sdp_string("passthrough offer", "fed-in offer", offer, + "active local", active_local, PJ_SUCCESS) != 0) + { + return -3180; + } + if (compare_sdp_string("passthrough answer", "fed-in answer", answer, + "active remote", active_remote, PJ_SUCCESS) != 0) + { + return -3190; + } + + /* Case 3: a re-offer/re-answer round on top of Case 2's negotiator + * (still in passthrough mode), to verify modify_local_offer2()/ + * negotiate() cover re-INVITE/UPDATE offers too, not just the + * initial offer/answer. + */ + { + pjmedia_sdp_session *offer2, *answer2, *expected_offer2, *short_offer; + const pjmedia_sdp_session *old_active_local, *neg_local; + unsigned i; + char b3[sizeof(pt_offer2_str)], b4[sizeof(pt_answer2_str)]; + char b5[sizeof(pt_offer2_str)]; + + if (pjmedia_sdp_neg_get_active_local(neg, &old_active_local) != + PJ_SUCCESS) + { + return -3200; + } + + pj_memcpy(b3, pt_offer2_str, sizeof(pt_offer2_str)); + if (pjmedia_sdp_parse(pool, b3, pj_ansi_strlen(b3), &offer2) != + PJ_SUCCESS) + { + return -3210; + } + + short_offer = pjmedia_sdp_session_clone(pool, offer2); + short_offer->media_count = old_active_local->media_count - 1; + for (i = 0; i < 2; ++i) { + status = pjmedia_sdp_neg_modify_local_offer2( + pool, neg, + i ? PJMEDIA_SDP_NEG_ALLOW_MEDIA_CHANGE : 0, + short_offer); + if (status != PJMEDIA_SDPNEG_EMISMEDIA || + pjmedia_sdp_neg_get_state(neg) != PJMEDIA_SDP_NEG_STATE_DONE || + pjmedia_sdp_neg_get_active_local(neg, &neg_local) != + PJ_SUCCESS || + neg_local != old_active_local) + { + return -3215; + } + } + + status = pjmedia_sdp_neg_modify_local_offer2(pool, neg, 0, offer2); + if (status != PJ_SUCCESS) { + app_perror(status, " sdp_neg_passthrough_test: " + "modify_local_offer2 failed"); + return -3220; + } + + if (pjmedia_sdp_neg_get_state(neg) != + PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER) + { + return -3230; + } + + /* SDP content passes through, but "o=" identity stays local. */ + pj_memcpy(b5, pt_offer2_str, sizeof(pt_offer2_str)); + if (pjmedia_sdp_parse(pool, b5, pj_ansi_strlen(b5), + &expected_offer2) != PJ_SUCCESS) + { + return -3240; + } + expected_offer2->origin.user = old_active_local->origin.user; + expected_offer2->origin.id = old_active_local->origin.id; + expected_offer2->origin.net_type = old_active_local->origin.net_type; + expected_offer2->origin.addr_type = old_active_local->origin.addr_type; + expected_offer2->origin.addr = old_active_local->origin.addr; + expected_offer2->origin.version = old_active_local->origin.version+1; + + if (pjmedia_sdp_neg_get_neg_local(neg, &neg_local) != PJ_SUCCESS) + return -3250; + if (compare_sdp_string("re-offer", "expected re-offer", + expected_offer2, "neg local", neg_local, + PJ_SUCCESS) != 0) + { + return -3260; + } + + pj_memcpy(b4, pt_answer2_str, sizeof(pt_answer2_str)); + if (pjmedia_sdp_parse(pool, b4, pj_ansi_strlen(b4), &answer2) != + PJ_SUCCESS) + { + return -3270; + } + + if (pjmedia_sdp_neg_set_remote_answer(pool, neg, answer2) != + PJ_SUCCESS) + { + return -3280; + } + + status = pjmedia_sdp_neg_negotiate(pool, neg, 0); + if (status != PJ_SUCCESS) { + app_perror(status, " sdp_neg_passthrough_test: " + "re-negotiate failed"); + return -3290; + } + + if (pjmedia_sdp_neg_get_state(neg) != PJMEDIA_SDP_NEG_STATE_DONE) + return -3300; + + if (pjmedia_sdp_neg_get_active_local(neg, &active_local) != + PJ_SUCCESS) + { + return -3310; + } + if (pjmedia_sdp_neg_get_active_remote(neg, &active_remote) != + PJ_SUCCESS) + { + return -3320; + } + + if (compare_sdp_string("re-offer", "expected re-offer", + expected_offer2, "active local", active_local, + PJ_SUCCESS) != 0) + { + return -3330; + } + if (compare_sdp_string("re-answer", "fed-in re-answer", answer2, + "active remote", active_remote, + PJ_SUCCESS) != 0) + { + return -3340; + } + } + + /* Case 4: a re-answer keeps the original local "o=" identity. */ + { + pjmedia_sdp_session *offer2, *answer2, *expected_answer2; + const pjmedia_sdp_session *base_local; + char b3[sizeof(pt_offer2_str)], b4[sizeof(pt_answer2_str)]; + char b5[sizeof(pt_answer2_str)]; + + pj_memcpy(b1, pt_offer_str, sizeof(pt_offer_str)); + pj_memcpy(b2, pt_answer_str, sizeof(pt_answer_str)); + if (pjmedia_sdp_parse(pool, b1, pj_ansi_strlen(b1), &offer) != + PJ_SUCCESS) + { + return -3350; + } + if (pjmedia_sdp_parse(pool, b2, pj_ansi_strlen(b2), &answer) != + PJ_SUCCESS) + { + return -3360; + } + if (pjmedia_sdp_neg_create_w_remote_offer(pool, NULL, offer, &neg) != + PJ_SUCCESS) + { + return -3370; + } + pjmedia_sdp_neg_set_passthrough(neg, PJ_TRUE); + if (pjmedia_sdp_neg_set_local_answer(pool, neg, answer) != PJ_SUCCESS || + pjmedia_sdp_neg_negotiate(pool, neg, 0) != PJ_SUCCESS) + { + return -3380; + } + if (pjmedia_sdp_neg_get_active_local(neg, &base_local) != PJ_SUCCESS) + return -3390; + + pj_memcpy(b3, pt_offer2_str, sizeof(pt_offer2_str)); + if (pjmedia_sdp_parse(pool, b3, pj_ansi_strlen(b3), &offer2) != + PJ_SUCCESS) + { + return -3400; + } + if (pjmedia_sdp_neg_set_remote_offer(pool, neg, offer2) != PJ_SUCCESS) + return -3410; + + pj_memcpy(b4, pt_answer2_str, sizeof(pt_answer2_str)); + if (pjmedia_sdp_parse(pool, b4, pj_ansi_strlen(b4), &answer2) != + PJ_SUCCESS) + { + return -3420; + } + if (pjmedia_sdp_neg_set_local_answer(pool, neg, answer2) != PJ_SUCCESS || + pjmedia_sdp_neg_negotiate(pool, neg, 0) != PJ_SUCCESS) + { + return -3430; + } + if (pjmedia_sdp_neg_get_active_local(neg, &active_local) != PJ_SUCCESS) + return -3440; + + pj_memcpy(b5, pt_answer2_str, sizeof(pt_answer2_str)); + if (pjmedia_sdp_parse(pool, b5, pj_ansi_strlen(b5), + &expected_answer2) != PJ_SUCCESS) + { + return -3450; + } + expected_answer2->origin.user = base_local->origin.user; + expected_answer2->origin.id = base_local->origin.id; + expected_answer2->origin.net_type = base_local->origin.net_type; + expected_answer2->origin.addr_type = base_local->origin.addr_type; + expected_answer2->origin.addr = base_local->origin.addr; + expected_answer2->origin.version = base_local->origin.version + 1; + + if (compare_sdp_string("re-answer", "expected re-answer", + expected_answer2, "active local", active_local, + PJ_SUCCESS) != 0) + { + return -3460; + } + } + + /* Case 5: a pending local answer can be discarded safely. */ + { + pjmedia_sdp_session *offer2, *answer2; + const pjmedia_sdp_session *neg_remote; + char b3[sizeof(pt_offer2_str)], b4[sizeof(pt_answer2_str)]; + + pj_memcpy(b3, pt_offer2_str, sizeof(pt_offer2_str)); + if (pjmedia_sdp_parse(pool, b3, pj_ansi_strlen(b3), &offer2) != + PJ_SUCCESS) + { + return -3470; + } + if (pjmedia_sdp_neg_set_remote_offer(pool, neg, offer2) != PJ_SUCCESS) + return -3480; + + pj_memcpy(b4, pt_answer2_str, sizeof(pt_answer2_str)); + if (pjmedia_sdp_parse(pool, b4, pj_ansi_strlen(b4), &answer2) != + PJ_SUCCESS) + { + return -3490; + } + if (pjmedia_sdp_neg_set_local_answer(pool, neg, answer2) != PJ_SUCCESS) + return -3500; + if (pjmedia_sdp_neg_cancel_local_answer(neg) != PJ_SUCCESS) + return -3510; + if (pjmedia_sdp_neg_get_state(neg) != + PJMEDIA_SDP_NEG_STATE_REMOTE_OFFER) + { + return -3520; + } + if (pjmedia_sdp_neg_get_neg_remote(neg, &neg_remote) != PJ_SUCCESS) + return -3530; + if (compare_sdp_string("cancel answer", "remote offer", offer2, + "neg remote", neg_remote, PJ_SUCCESS) != 0) + { + return -3540; + } + if (pjmedia_sdp_neg_set_local_answer(pool, neg, answer2) != PJ_SUCCESS || + pjmedia_sdp_neg_negotiate(pool, neg, 0) != PJ_SUCCESS) + { + return -3550; + } + } + + /* Case 6: cancellation preserves an initial answer supplied at + * negotiator creation. + */ + { + pjmedia_sdp_session *initial; + + pj_memcpy(b1, pt_offer_str, sizeof(pt_offer_str)); + pj_memcpy(b2, pt_answer_str, sizeof(pt_answer_str)); + if (pjmedia_sdp_parse(pool, b1, pj_ansi_strlen(b1), &offer) != + PJ_SUCCESS) + { + return -3560; + } + if (pjmedia_sdp_parse(pool, b2, pj_ansi_strlen(b2), &initial) != + PJ_SUCCESS) + { + return -3570; + } + if (pjmedia_sdp_neg_create_w_remote_offer(pool, initial, offer, + &neg) != PJ_SUCCESS) + { + return -3580; + } + if (pjmedia_sdp_neg_cancel_local_answer(neg) != PJ_SUCCESS) + return -3590; + if (pjmedia_sdp_neg_set_local_answer(pool, neg, NULL) != PJ_SUCCESS) + return -3600; + } + + /* Case 7: cancellation restores the absence of an initial answer. */ + { + pj_memcpy(b1, pt_offer_str, sizeof(pt_offer_str)); + pj_memcpy(b2, pt_answer_str, sizeof(pt_answer_str)); + if (pjmedia_sdp_parse(pool, b1, pj_ansi_strlen(b1), &offer) != + PJ_SUCCESS) + { + return -3610; + } + if (pjmedia_sdp_parse(pool, b2, pj_ansi_strlen(b2), &answer) != + PJ_SUCCESS) + { + return -3620; + } + if (pjmedia_sdp_neg_create_w_remote_offer(pool, NULL, offer, &neg) != + PJ_SUCCESS) + { + return -3630; + } + if (pjmedia_sdp_neg_set_local_answer(pool, neg, answer) != PJ_SUCCESS) + return -3640; + if (pjmedia_sdp_neg_cancel_local_answer(neg) != PJ_SUCCESS) + return -3650; + if (pjmedia_sdp_neg_set_local_answer(pool, neg, NULL) != + PJMEDIA_SDPNEG_ENOINITIAL) + { + return -3660; + } + if (pjmedia_sdp_neg_get_state(neg) != + PJMEDIA_SDP_NEG_STATE_REMOTE_OFFER) + { + return -3670; + } + } + + /* Mismatched answers must preserve the previously negotiated SDPs. */ + status = passthrough_mismatched_answer_test(pool, PJ_FALSE, PJ_FALSE); + if (status != 0) + return status; + status = passthrough_mismatched_answer_test(pool, PJ_FALSE, PJ_TRUE); + if (status != 0) + return status; + status = passthrough_mismatched_answer_test(pool, PJ_TRUE, PJ_FALSE); + if (status != 0) + return status; + status = passthrough_mismatched_answer_test(pool, PJ_TRUE, PJ_TRUE); + if (status != 0) + return status; + + return 0; +} + /* Regression: offer without c= on a port=0 media (passes lenient validation, * fails strict) must leave the negotiator in DONE state, not stuck in WAIT_NEGO. */ static int sdp_neg_strict_validate_test(pj_pool_t *pool) @@ -2247,6 +2888,21 @@ int sdp_neg_test() return status; } + { + pj_pool_t *pool; + + pool = pj_pool_create(mem, "sdp_neg_passthrough", 4000, 4000, NULL); + if (!pool) + return PJ_ENOMEM; + + PJ_LOG(3,(THIS_FILE, " sdp_neg_passthrough_test")); + status = sdp_neg_passthrough_test(pool); + pj_pool_release(pool); + + if (status != 0) + return status; + } + { pj_pool_t *pool; diff --git a/pjsip-apps/src/pjsua/pjsua_app.c b/pjsip-apps/src/pjsua/pjsua_app.c index 20d59307c7..04976ac6fb 100644 --- a/pjsip-apps/src/pjsua/pjsua_app.c +++ b/pjsip-apps/src/pjsua/pjsua_app.c @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008-2011 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2008-2026 Teluu Inc. (http://www.teluu.com) * Copyright (C) 2003-2008 Benny Prijono * * This program is free software; you can redistribute it and/or modify @@ -365,11 +365,16 @@ static void on_incoming_call(pjsua_acc_id acc_id, pjsua_call_id call_id, pjsip_rx_data *rdata) { pjsua_call_info call_info; + pj_status_t status; PJ_UNUSED_ARG(acc_id); PJ_UNUSED_ARG(rdata); - pjsua_call_get_info(call_id, &call_info); + status = pjsua_call_get_info(call_id, &call_info); + if (status != PJ_SUCCESS) { + pjsua_perror(THIS_FILE, "Unable to get incoming call info", status); + return; + } if (current_call==PJSUA_INVALID_ID) current_call = call_id; @@ -387,8 +392,9 @@ static void on_incoming_call(pjsua_acc_id acc_id, pjsua_call_id call_id, app_config_init_call_setting(&opt); - pjsua_call_answer2(call_id, &opt, app_config.auto_answer, NULL, - NULL); + status = app_answer_call(call_id, &opt, app_config.auto_answer, NULL); + if (status != PJ_SUCCESS) + pjsua_perror(THIS_FILE, "Unable to auto-answer call", status); } if (app_config.auto_answer < 200) { @@ -1299,7 +1305,6 @@ static void on_call_sdp_created_cb(pjsua_call_id call_id, const pjmedia_sdp_session *rem_sdp) { pjmedia_sdp_session *custom = NULL; - pj_str_t sdp_str; pj_status_t status; PJ_UNUSED_ARG(call_id); @@ -1308,16 +1313,7 @@ static void on_call_sdp_created_cb(pjsua_call_id call_id, if (app_config.custom_sdp.slen == 0) return; - /* Make a writable copy since pjmedia_sdp_parse modifies the buffer */ - sdp_str.ptr = (char*)pj_pool_alloc(pool, - (pj_size_t)(app_config.custom_sdp.slen + 1)); - pj_memcpy(sdp_str.ptr, app_config.custom_sdp.ptr, - (pj_size_t)app_config.custom_sdp.slen); - sdp_str.ptr[app_config.custom_sdp.slen] = '\0'; - sdp_str.slen = app_config.custom_sdp.slen; - - status = pjmedia_sdp_parse(pool, sdp_str.ptr, (pj_size_t)sdp_str.slen, - &custom); + status = app_parse_custom_sdp(pool, &custom); if (status != PJ_SUCCESS) { PJ_PERROR(1,(THIS_FILE, status, "Failed to parse custom SDP, using generated SDP")); @@ -1970,6 +1966,7 @@ static pj_status_t app_init(void) pjsua_acc_get_config(aid, tmp_pool, &acc_cfg); app_config_init_video(&acc_cfg); + app_config_apply_acc_setting(&acc_cfg); acc_cfg.txt_red_level = app_config.txt_red_level; acc_cfg.rtp_cfg = app_config.rtp_cfg; acc_cfg.enable_rtcp_mux = app_config.enable_rtcp_mux; @@ -2017,6 +2014,7 @@ static pj_status_t app_init(void) pjsua_acc_get_config(aid, tmp_pool, &acc_cfg); app_config_init_video(&acc_cfg); + app_config_apply_acc_setting(&acc_cfg); acc_cfg.txt_red_level = app_config.txt_red_level; acc_cfg.rtp_cfg = app_config.rtp_cfg; acc_cfg.enable_rtcp_mux = app_config.enable_rtcp_mux; @@ -2055,6 +2053,7 @@ static pj_status_t app_init(void) pjsua_acc_get_config(aid, tmp_pool, &acc_cfg); app_config_init_video(&acc_cfg); + app_config_apply_acc_setting(&acc_cfg); acc_cfg.txt_red_level = app_config.txt_red_level; acc_cfg.rtp_cfg = app_config.rtp_cfg; acc_cfg.enable_rtcp_mux = app_config.enable_rtcp_mux; @@ -2088,6 +2087,7 @@ static pj_status_t app_init(void) pjsua_acc_get_config(aid, tmp_pool, &acc_cfg); app_config_init_video(&acc_cfg); + app_config_apply_acc_setting(&acc_cfg); acc_cfg.txt_red_level = app_config.txt_red_level; acc_cfg.rtp_cfg = app_config.rtp_cfg; acc_cfg.enable_rtcp_mux = app_config.enable_rtcp_mux; @@ -2130,6 +2130,7 @@ static pj_status_t app_init(void) pjsua_acc_get_config(acc_id, tmp_pool, &acc_cfg); app_config_init_video(&acc_cfg); + app_config_apply_acc_setting(&acc_cfg); acc_cfg.txt_red_level = app_config.txt_red_level; acc_cfg.rtp_cfg = app_config.rtp_cfg; acc_cfg.enable_rtcp_mux = app_config.enable_rtcp_mux; @@ -2162,6 +2163,7 @@ static pj_status_t app_init(void) pjsua_acc_get_config(aid, tmp_pool, &acc_cfg); app_config_init_video(&acc_cfg); + app_config_apply_acc_setting(&acc_cfg); acc_cfg.txt_red_level = app_config.txt_red_level; acc_cfg.rtp_cfg = app_config.rtp_cfg; acc_cfg.enable_rtcp_mux = app_config.enable_rtcp_mux; @@ -2190,6 +2192,7 @@ static pj_status_t app_init(void) app_config.acc_cfg[i].reg_first_retry_interval = 60; app_config_init_video(&app_config.acc_cfg[i]); + app_config_apply_acc_setting(&app_config.acc_cfg[i]); app_config.acc_cfg[i].txt_red_level = app_config.txt_red_level; status = pjsua_acc_add(&app_config.acc_cfg[i], PJ_TRUE, NULL); @@ -2320,7 +2323,7 @@ pj_status_t pjsua_app_run(pj_bool_t wait_telnet_cli) if (uri_arg.slen) { app_config_init_call_setting(&call_opt); - status = pjsua_call_make_call(current_acc, &uri_arg, &call_opt, NULL, + status = app_make_call(current_acc, &uri_arg, &call_opt, NULL, NULL); if (status != PJ_SUCCESS) pjsua_perror(THIS_FILE, "Unable to make call", status); diff --git a/pjsip-apps/src/pjsua/pjsua_app_cli.c b/pjsip-apps/src/pjsua/pjsua_app_cli.c index 9d7fbaddc8..d7b12d43b1 100644 --- a/pjsip-apps/src/pjsua/pjsua_app_cli.c +++ b/pjsip-apps/src/pjsua/pjsua_app_cli.c @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008-2011 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2008-2026 Teluu Inc. (http://www.teluu.com) * Copyright (C) 2003-2008 Benny Prijono * * This program is free software; you can redistribute it and/or modify @@ -844,6 +844,7 @@ static pj_status_t cmd_add_account(pj_cli_cmd_val *cval) acc_cfg.rtp_cfg = app_config.rtp_cfg; acc_cfg.txt_red_level = app_config.txt_red_level; app_config_init_video(&acc_cfg); + app_config_apply_acc_setting(&acc_cfg); status = pjsua_acc_add(&acc_cfg, PJ_TRUE, NULL); if (status != PJ_SUCCESS) { @@ -1979,8 +1980,8 @@ static pj_status_t cmd_make_single_call(pj_cli_cmd_val *cval) pjsua_msg_data_init(&msg_data); TEST_MULTIPART(&msg_data); - status = pjsua_call_make_call(current_acc, &tmp, &call_opt, NULL, - &msg_data, ¤t_call); + status = app_make_call(current_acc, &tmp, &call_opt, &msg_data, + ¤t_call); if (status != PJ_SUCCESS) pjsua_perror(THIS_FILE, "Unable to make call", status); @@ -2029,10 +2030,12 @@ static pj_status_t cmd_make_multi_call(pj_cli_cmd_val *cval) for (i=0; iargv[1]); if ((st_code < 100) || (st_code > 699)) @@ -2091,9 +2095,13 @@ static pj_status_t cmd_answer_call(pj_cli_cmd_val *cval) if (current_call == PJSUA_INVALID_ID) { const pj_str_t err_msg = pj_str("Call has been disconnected\n"); pj_cli_sess_write_msg(cval->sess, err_msg.ptr, err_msg.slen); + return PJ_SUCCESS; } - pjsua_call_answer2(current_call, &call_opt, st_code, NULL, &msg_data); + status = app_answer_call(current_call, &call_opt, st_code, &msg_data); + if (status != PJ_SUCCESS) + pjsua_perror(THIS_FILE, "Unable to answer call", status); + return status; } return PJ_SUCCESS; } diff --git a/pjsip-apps/src/pjsua/pjsua_app_common.c b/pjsip-apps/src/pjsua/pjsua_app_common.c index a4c1ccb0bd..f4fb3866c8 100644 --- a/pjsip-apps/src/pjsua/pjsua_app_common.c +++ b/pjsip-apps/src/pjsua/pjsua_app_common.c @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008-2011 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2008-2026 Teluu Inc. (http://www.teluu.com) * Copyright (C) 2003-2008 Benny Prijono * * This program is free software; you can redistribute it and/or modify @@ -18,6 +18,9 @@ */ #include "pjsua_app_common.h" +#if !PJSUA_MEDIA_HAS_PJMEDIA +#include +#endif #define THIS_FILE "pjsua_app_common.c" @@ -228,6 +231,131 @@ void app_config_init_video(pjsua_acc_config *acc_cfg) } #endif +void app_config_apply_acc_setting(pjsua_acc_config *acc_cfg) +{ + if (app_config.sdp_passthrough) + acc_cfg->media_app_managed = PJ_TRUE; +} + +#if !PJSUA_MEDIA_HAS_PJMEDIA +pj_status_t app_parse_custom_sdp(pj_pool_t *pool, pjmedia_sdp_session **sdp) +{ + pj_str_t sdp_str; + + PJ_ASSERT_RETURN(app_config.custom_sdp.slen != 0, PJ_ENOTFOUND); + + sdp_str.ptr = (char*)pj_pool_alloc(pool, + (pj_size_t)(app_config.custom_sdp.slen + 1)); + pj_memcpy(sdp_str.ptr, app_config.custom_sdp.ptr, + (pj_size_t)app_config.custom_sdp.slen); + sdp_str.ptr[app_config.custom_sdp.slen] = '\0'; + sdp_str.slen = app_config.custom_sdp.slen; + + return pjmedia_sdp_parse(pool, sdp_str.ptr, (pj_size_t)sdp_str.slen, sdp); +} +#endif + +pj_status_t app_make_call(pjsua_acc_id acc_id, const pj_str_t *uri, + const pjsua_call_setting *opt, + const pjsua_msg_data *msg_data, + pjsua_call_id *call_id) +{ +#if !PJSUA_MEDIA_HAS_PJMEDIA + if (app_config.custom_sdp.slen && + !(opt->flag & PJSUA_CALL_NO_SDP_OFFER)) + { + pj_pool_t *pool; + pjsua_acc_config acc_cfg; + pjmedia_sdp_session *sdp; + pjsua_call_op_param param; + pj_status_t status; + + pool = pjsua_pool_create("custom sdp", 1024, 1024); + if (!pool) + return PJ_ENOMEM; + + status = pjsua_acc_get_config(acc_id, pool, &acc_cfg); + if (status == PJ_SUCCESS && + (acc_cfg.media_app_managed || + (opt->flag & PJSUA_CALL_MEDIA_APP_MANAGED))) + { + status = app_parse_custom_sdp(pool, &sdp); + if (status == PJ_SUCCESS) { + pjsua_call_op_param_default(¶m); + param.opt = opt; + param.sdp = sdp; + param.msg_data = msg_data; + status = pjsua_call_make_call2(acc_id, uri, ¶m, call_id); + } + pj_pool_release(pool); + return status; + } + pj_pool_release(pool); + if (status != PJ_SUCCESS) + return status; + } +#endif + return pjsua_call_make_call(acc_id, uri, opt, NULL, msg_data, call_id); +} + +pj_status_t app_answer_call(pjsua_call_id call_id, + const pjsua_call_setting *opt, + unsigned code, const pjsua_msg_data *msg_data) +{ +#if !PJSUA_MEDIA_HAS_PJMEDIA + if ((code == PJSIP_SC_PROGRESS || code/100 == 2) && + app_config.custom_sdp.slen) + { + pjsua_call_info info; + pj_status_t status; + + status = pjsua_call_get_info(call_id, &info); + if (status != PJ_SUCCESS) + return status; + + if ((info.setting.flag | (opt ? opt->flag : 0)) & + PJSUA_CALL_MEDIA_APP_MANAGED) + { + pj_pool_t *pool; + pjmedia_sdp_session *sdp; + pjsip_inv_session *inv; + pj_bool_t needs_sdp; + + PJSUA_LOCK(); + inv = pjsua_var.calls[call_id].inv; + if (!inv) { + PJSUA_UNLOCK(); + return PJ_EINVALIDOP; + } + pjsip_dlg_inc_lock(inv->dlg); + needs_sdp = !inv->neg || + pjmedia_sdp_neg_get_state(inv->neg) == + PJMEDIA_SDP_NEG_STATE_REMOTE_OFFER || + (pjmedia_sdp_neg_get_state(inv->neg) == + PJMEDIA_SDP_NEG_STATE_DONE && + pjsip_inv_has_pending_offerless_invite(inv)); + pjsip_dlg_dec_lock(inv->dlg); + PJSUA_UNLOCK(); + if (!needs_sdp) + return pjsua_call_answer2(call_id, opt, code, NULL, msg_data); + + pool = pjsua_pool_create("custom sdp", 1024, 1024); + if (!pool) + return PJ_ENOMEM; + + status = app_parse_custom_sdp(pool, &sdp); + if (status == PJ_SUCCESS) { + status = pjsua_call_answer_with_sdp(call_id, sdp, opt, code, + NULL, msg_data); + } + pj_pool_release(pool); + return status; + } + } +#endif + return pjsua_call_answer2(call_id, opt, code, NULL, msg_data); +} + /* Indexed by pjmedia_dir */ static const char *media_dir_names[] = { "inactive", "sendonly", "recvonly", "sendrecv" }; @@ -258,6 +386,8 @@ void app_config_init_call_setting(pjsua_call_setting *opt) opt->aud_cnt = app_config.aud_cnt; opt->vid_cnt = app_config.vid.vid_cnt; opt->txt_cnt = app_config.txt_cnt; + if (app_config.sdp_passthrough) + opt->flag |= PJSUA_CALL_MEDIA_APP_MANAGED; if (app_config.media_dir_cnt) { opt->flag |= PJSUA_CALL_SET_MEDIA_DIR; diff --git a/pjsip-apps/src/pjsua/pjsua_app_common.h b/pjsip-apps/src/pjsua/pjsua_app_common.h index f0b8b2682b..eac8c5cb79 100644 --- a/pjsip-apps/src/pjsua/pjsua_app_common.h +++ b/pjsip-apps/src/pjsua/pjsua_app_common.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008-2011 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2008-2026 Teluu Inc. (http://www.teluu.com) * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -204,6 +204,10 @@ typedef struct pjsua_app_config * Only available when PJSUA_MEDIA_HAS_PJMEDIA=0 (alt media backend). */ pj_str_t custom_sdp; #endif + + /* Set PJSUA_CALL_MEDIA_APP_MANAGED on the default call setting used for + * outgoing and auto-answered incoming calls (--sdp-passthrough). */ + pj_bool_t sdp_passthrough; } pjsua_app_config; /** Extern variable declaration **/ @@ -230,7 +234,19 @@ int write_settings(pjsua_app_config *cfg, char *buf, pj_size_t max); char *alloc_settings(pjsua_app_config *cfg, pj_pool_t **p_pool, int *p_len); pj_status_t dump_settings(pjsua_app_config *cfg); void app_config_init_video(pjsua_acc_config *acc_cfg); +void app_config_apply_acc_setting(pjsua_acc_config *acc_cfg); +#if !PJSUA_MEDIA_HAS_PJMEDIA +pj_status_t app_parse_custom_sdp(pj_pool_t *pool, + pjmedia_sdp_session **sdp); +#endif void app_config_init_call_setting(pjsua_call_setting *opt); +pj_status_t app_make_call(pjsua_acc_id acc_id, const pj_str_t *uri, + const pjsua_call_setting *opt, + const pjsua_msg_data *msg_data, + pjsua_call_id *call_id); +pj_status_t app_answer_call(pjsua_call_id call_id, + const pjsua_call_setting *opt, + unsigned code, const pjsua_msg_data *msg_data); pj_status_t app_parse_media_dir(const pj_str_t *name, pjmedia_dir *dir); const char *app_media_dir_name(pjmedia_dir dir); void arrange_window(pjsua_vid_win_id wid); @@ -280,4 +296,3 @@ void app_config_show_video(int acc_id, const pjsua_acc_config *acc_cfg); PJ_END_DECL #endif /* __PJSUA_APP_COMMON_H__ */ - diff --git a/pjsip-apps/src/pjsua/pjsua_app_config.c b/pjsip-apps/src/pjsua/pjsua_app_config.c index a96985b3c7..ede43e09b1 100644 --- a/pjsip-apps/src/pjsua/pjsua_app_config.c +++ b/pjsip-apps/src/pjsua/pjsua_app_config.c @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008-2011 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2008-2026 Teluu Inc. (http://www.teluu.com) * Copyright (C) 2003-2008 Benny Prijono * * This program is free software; you can redistribute it and/or modify @@ -188,6 +188,12 @@ static void usage(void) #if !PJSUA_MEDIA_HAS_PJMEDIA puts (" --custom-sdp=STR Replace generated SDP with this string."); puts (" Use \\r\\n or \\n as line separators. The full SDP is replaced as-is."); + puts (" --sdp-passthrough Keep and forward the local/remote SDP as-is during"); + puts (" negotiation for outgoing and incoming calls."); + puts (" --acc-media-app-managed Treat all media on incoming and outgoing calls"); + puts (" for this account as application-managed from the start,"); + puts (" so pjsua never creates a media transport for them (even"); + puts (" the initial incoming offer). See pjsua_acc_config."); #endif #if PJSUA_HAS_VIDEO @@ -446,7 +452,7 @@ static pj_status_t parse_args(int argc, char *argv[], OPT_USE_CLI, OPT_CLI_TELNET_PORT, OPT_DISABLE_CLI_CONSOLE, OPT_SERVER_AFFINITY, OPT_SERVER_FAILOVER #if !PJSUA_MEDIA_HAS_PJMEDIA - , OPT_CUSTOM_SDP + , OPT_CUSTOM_SDP, OPT_SDP_PASSTHROUGH, OPT_ACC_MEDIA_APP_MANAGED #endif }; struct pj_getopt_option long_options[] = { @@ -612,7 +618,9 @@ static pj_status_t parse_args(int argc, char *argv[], { "no-cli-console", 0, 0, OPT_DISABLE_CLI_CONSOLE}, { "server-affinity", 2, 0, OPT_SERVER_AFFINITY}, #if !PJSUA_MEDIA_HAS_PJMEDIA + { "acc-media-app-managed", 0, 0, OPT_ACC_MEDIA_APP_MANAGED}, { "custom-sdp", 1, 0, OPT_CUSTOM_SDP}, + { "sdp-passthrough", 0, 0, OPT_SDP_PASSTHROUGH}, #endif { NULL, 0, 0, 0} }; @@ -1718,6 +1726,14 @@ static pj_status_t parse_args(int argc, char *argv[], break; #if !PJSUA_MEDIA_HAS_PJMEDIA + case OPT_ACC_MEDIA_APP_MANAGED: + cur_acc->media_app_managed = PJ_TRUE; + break; + + case OPT_SDP_PASSTHROUGH: + cfg->sdp_passthrough = PJ_TRUE; + break; + case OPT_CUSTOM_SDP: { /* Unescape \r\n and \n in the option value so the user can @@ -2226,6 +2242,11 @@ static void write_account_settings(int acc_index, pj_str_t *result, } /* Media Transport*/ +#if !PJSUA_MEDIA_HAS_PJMEDIA + if (acc_cfg->media_app_managed) + cfg_add(result, max, "--acc-media-app-managed\n"); +#endif + if (acc_cfg->ice_cfg.enable_ice) cfg_add(result, max, "--use-ice\n"); @@ -2797,6 +2818,9 @@ int write_settings(pjsua_app_config *config, char *buf, pj_size_t max) } #if !PJSUA_MEDIA_HAS_PJMEDIA + if (config->sdp_passthrough) + cfg_add(&cfg, max, "--sdp-passthrough\n"); + if (config->custom_sdp.slen) { /* Escape actual CRLF/LF back to \r\n/\n when saving */ const char *src = config->custom_sdp.ptr; diff --git a/pjsip-apps/src/pjsua/pjsua_app_legacy.c b/pjsip-apps/src/pjsua/pjsua_app_legacy.c index ed67c95e4e..374a39283a 100644 --- a/pjsip-apps/src/pjsua/pjsua_app_legacy.c +++ b/pjsip-apps/src/pjsua/pjsua_app_legacy.c @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008-2011 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2008-2026 Teluu Inc. (http://www.teluu.com) * Copyright (C) 2003-2008 Benny Prijono * * This program is free software; you can redistribute it and/or modify @@ -741,7 +741,7 @@ static void ui_make_new_call() if (app_config.enable_loam) { call_opt.flag |= PJSUA_CALL_NO_SDP_OFFER; } - status = pjsua_call_make_call(current_acc, &tmp, &call_opt, NULL, + status = app_make_call(current_acc, &tmp, &call_opt, &msg_data_, ¤t_call); if (status != PJ_SUCCESS) pjsua_perror(THIS_FILE, "Unable to make call", status); @@ -789,10 +789,12 @@ static void ui_make_multi_call() for (i=0; i * * This program is free software; you can redistribute it and/or modify diff --git a/pjsip/include/pjsip-ua/sip_inv.h b/pjsip/include/pjsip-ua/sip_inv.h index c17a9c4b4c..91a0648bc4 100644 --- a/pjsip/include/pjsip-ua/sip_inv.h +++ b/pjsip/include/pjsip-ua/sip_inv.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008-2011 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2008-2026 Teluu Inc. (http://www.teluu.com) * Copyright (C) 2003-2008 Benny Prijono * * This program is free software; you can redistribute it and/or modify @@ -559,6 +559,12 @@ enum pjsip_inv_option * Indicate support for siprec */ PJSIP_INV_SUPPORT_SIPREC = 2048, + + /** + * Keep local and remote SDP content application-managed for this + * invite session. This is a local creation-time mode. + */ + PJSIP_INV_SDP_PASSTHROUGH = 4096, }; @@ -638,6 +644,10 @@ struct pjsip_inv_session pj_str_t siprec_metadata; /**< SIPREC metadata update pending notification, internal. */ + pjsip_transaction *update_tsx; /**< Pending outgoing + UPDATE tsx carrying + an unanswered + offer, if any. */ }; @@ -896,6 +906,18 @@ PJ_DECL(pj_status_t) pjsip_inv_add_ref( pjsip_inv_session *inv ); PJ_DECL(pj_status_t) pjsip_inv_dec_ref( pjsip_inv_session *inv ); +/** + * Check whether a pending incoming INVITE without an SDP offer still needs + * offer/answer negotiation. The caller must hold the dialog lock. + * + * @param inv The invite session. + * @return PJ_TRUE if an unanswered offerless UAS INVITE has + * not completed offer/answer negotiation. + */ +PJ_DECL(pj_bool_t) pjsip_inv_has_pending_offerless_invite( + const pjsip_inv_session *inv); + + /** * Forcefully terminate and destroy INVITE session, regardless of * the state of the session. Note that this function should only be used @@ -1089,10 +1111,15 @@ PJ_DECL(pj_status_t) pjsip_inv_answer( pjsip_inv_session *inv, /** * Set local offer or answer depending on negotiator state (it may also * create a negotiator if it doesn't exist yet). + * A new local offer is refused with PJ_EINVALIDOP while an outgoing + * INVITE's offer/answer is incomplete, or an incoming INVITE's offer + * remains unanswered. Answers to pending remote offers are allowed. + * A NULL SDP cancels a staged local offer only if it has not been sent. * * @param inv The invite session. * @param sdp The SDP description which will be set as - * an offer/answer to remote. + * an offer/answer to remote, or NULL to cancel an + * unsent local offer. * * @return PJ_SUCCESS if local offer/answer can be accepted by * SDP negotiator. @@ -1209,7 +1236,10 @@ PJ_DECL(pj_status_t) pjsip_inv_reinvite(pjsip_inv_session *inv, * contact, it can specify the new contact in this * argument; otherwise this argument must be NULL. * @param offer Offer to be sent to remote. This argument is - * mandatory. + * mandatory. In passthrough mode, if a local offer is + * already staged, this must match the pending offer + * except for the stack-managed origin line; + * otherwise PJMEDIA_SDPNEG_EINSTATE is returned. * @param p_tdata Pointer to receive the UPDATE request message to * be created. * diff --git a/pjsip/include/pjsua-lib/pjsua.h b/pjsip/include/pjsua-lib/pjsua.h index 38653fd17c..2375f89d5d 100644 --- a/pjsip/include/pjsua-lib/pjsua.h +++ b/pjsip/include/pjsua-lib/pjsua.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008-2011 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2008-2026 Teluu Inc. (http://www.teluu.com) * Copyright (C) 2003-2008 Benny Prijono * * This program is free software; you can redistribute it and/or modify @@ -1632,6 +1632,8 @@ typedef struct pjsua_callback * is not defined, the default behavior is to accept the * transfer. See also on_call_transfer_request2() callback for * the version with \a pjsua_call_setting in the argument list. + * Automatic transfers requiring application-managed SDP are rejected + * with 488 before this callback. * * @param call_id The call index. * @param dst The destination where the call will be @@ -1649,6 +1651,8 @@ typedef struct pjsua_callback * by setting the code (default is 202). When this callback * is not defined, the default behavior is to accept the * transfer. + * Automatic transfers requiring application-managed SDP are rejected + * with 488 before this callback. * * @param call_id The call index. * @param dst The destination where the call will be @@ -1769,16 +1773,22 @@ typedef struct pjsua_callback /** - * Notify application when call has received a re-INVITE with offer - * from the peer. It allows more fine-grained control over the response - * to a re-INVITE. If application sets async to PJ_TRUE, it can send + * Notify application when call has received a re-INVITE from the peer. + * It allows more fine-grained control over the response to a re-INVITE. + * If application sets async to PJ_TRUE, it can send * the reply manually using the function #pjsua_call_answer_with_sdp(). * Otherwise, by default the re-INVITE will be answered automatically * after the callback returns. * - * Currently, this callback is only called for re-INVITE with - * SDP, but app should be prepared to handle the case of re-INVITE - * without SDP. + * For an application-managed call receiving a re-INVITE without SDP, + * this callback is also called with a NULL offer. By default, the active + * local SDP is re-offered. Application may set \a async to PJ_TRUE and + * later send the response with #pjsua_call_answer_with_sdp(), supplying + * its own local SDP offer. On the asynchronous path, a successful final + * response requires an application-supplied offer; the active local SDP + * is not automatically re-offered. + * On the synchronous offerless path, any 2xx callback code is treated + * as 200. Codes outside 200-699 are rejected with 500. * * Remarks: If manually answering at a later timing, application may * need to monitor on_call_tsx_state() callback to check whether @@ -1786,7 +1796,8 @@ typedef struct pjsua_callback * being cancelled. * * Note: on_call_rx_offer() will still be called after this callback, - * but only if async is PJ_FALSE and code is 200. + * but only if the re-INVITE contains SDP, async is PJ_FALSE and code + * is 200. * * Answering manually also allows application to accept media that * pjsua does not manage (e.g. T.38 image/udptl), by answering with its @@ -1797,7 +1808,8 @@ typedef struct pjsua_callback * as pjsua deactivates it there, see on_call_sdp_created(). * * @param call_id The call index. - * @param offer Remote offer. + * @param offer Remote offer, or NULL for an application-managed + * call receiving a re-INVITE without SDP. * @param rdata The received re-INVITE request. * @param reserved Reserved param, currently not used. * @param async On input, it is PJ_FALSE. Set to PJ_TRUE if @@ -5320,6 +5332,11 @@ typedef struct pjsua_acc_config */ pj_bool_t auto_repond_sip_message; + /** + * Make new calls on this account application-managed from creation. + * Default: PJ_FALSE. + */ + pj_bool_t media_app_managed; } pjsua_acc_config; @@ -6360,7 +6377,15 @@ typedef enum pjsua_call_flag /** * Disable inter-media synchronization. */ - PJSUA_CALL_NO_MEDIA_SYNC = 256 + PJSUA_CALL_NO_MEDIA_SYNC = 256, + + /** + * Let the application manage all SDP media on this call. Set before + * creating the call; it cannot be enabled or disabled later. + * An SDP with no media lines or all media ports set to zero does not + * cause the call to be rejected for lack of active media. + */ + PJSUA_CALL_MEDIA_APP_MANAGED = 512 } pjsua_call_flag; @@ -6533,6 +6558,26 @@ typedef struct pjsua_call_send_text_param } pjsua_call_send_text_param; +/** + * Parameters for call operations with optional application SDP. Application + * should use #pjsua_call_op_param_default() to initialize this structure. + */ +typedef struct pjsua_call_op_param +{ + /** Optional call setting for all operations. */ + const pjsua_call_setting *opt; + + /** Optional local SDP offer for all operations. */ + const pjmedia_sdp_session *sdp; + + /** User data for #pjsua_call_make_call2(). */ + void *user_data; + + /** Optional SIP message data for all operations. */ + const pjsua_msg_data *msg_data; + +} pjsua_call_op_param; + /** * Initialize call settings. @@ -6541,6 +6586,13 @@ typedef struct pjsua_call_send_text_param */ PJ_DECL(void) pjsua_call_setting_default(pjsua_call_setting *opt); +/** + * Initialize call operation parameters. + * + * @param param The call operation parameters to be initialized. + */ +PJ_DECL(void) pjsua_call_op_param_default(pjsua_call_op_param *param); + /** * Initialize video stream operation param with default values. @@ -6620,6 +6672,25 @@ PJ_DECL(pj_status_t) pjsua_call_make_call(pjsua_acc_id acc_id, const pjsua_msg_data *msg_data, pjsua_call_id *p_call_id); +/** + * Make an outgoing call with an optional application-supplied SDP offer. + * Supplying \a sdp requires PJSUA_CALL_MEDIA_APP_MANAGED in \a opt or the + * account's media_app_managed default. + * + * @param acc_id Account ID. + * @param dst_uri Destination URI. + * @param param Optional call operation parameters. When specified, + * initialize with #pjsua_call_op_param_default(). + * @param p_call_id Pointer to receive call ID. + * + * @return PJ_SUCCESS on success, or the appropriate error code. + */ +PJ_DECL(pj_status_t) pjsua_call_make_call2( + pjsua_acc_id acc_id, + const pj_str_t *dst_uri, + const pjsua_call_op_param *param, + pjsua_call_id *p_call_id); + /** * Check if the specified call has active INVITE session and the INVITE @@ -6824,11 +6895,18 @@ PJ_DECL(pj_status_t) pjsua_call_answer2(pjsua_call_id call_id, /** - * Same as #pjsua_call_answer2() but this function will set the SDP - * answer first before sending the response. + * Same as #pjsua_call_answer2() but this function will set the local SDP + * first before sending the response. For application-managed calls + * (#PJSUA_CALL_MEDIA_APP_MANAGED), the SDP is used as an offer when answering + * an offerless INVITE or re-INVITE, and as an answer otherwise. For ordinary + * calls, the SDP is used only as an answer to a pending remote offer. + * An application-managed answer must have the same media-line count as the + * remote offer; otherwise PJMEDIA_SDPNEG_EMISMEDIA is returned. * * @param call_id Incoming call identification. - * @param sdp SDP answer. + * @param sdp Local SDP answer, or a local SDP offer for an + * application-managed call answering an offerless + * INVITE or re-INVITE. * @param opt Optional call setting. * @param code Status code, (100-699). * @param reason Optional reason phrase. If NULL, default text @@ -6990,6 +7068,59 @@ PJ_DECL(pj_status_t) pjsua_call_reinvite2(pjsua_call_id call_id, const pjsua_call_setting *opt, const pjsua_msg_data *msg_data); +/** + * Send re-INVITE request or release hold, optionally using an + * application-supplied SDP offer. The SDP is applied atomically with request + * creation, after the call state and call setting have been validated. + * Supplying \a sdp requires the call to use + * PJSUA_CALL_MEDIA_APP_MANAGED. + * + * @param call_id Call identification. + * @param param Optional call operation parameters. When specified, + * initialize with #pjsua_call_op_param_default(). + * + * @return PJ_SUCCESS on success, or the appropriate error code. + */ +PJ_DECL(pj_status_t) pjsua_call_reinvite3( + pjsua_call_id call_id, + const pjsua_call_op_param *param); + + +/** + * Set/replace the call's local SDP offer or answer directly, bypassing + * pjsua's own SDP generation (the on_call_sdp_created() callback). This is + * intended for calls where the application owns the media plane, e.g. a + * signalling-plane B2BUA relaying a body it received from elsewhere, or + * that it wants to fully control (see PJSUA_CALL_MEDIA_APP_MANAGED). If + * called while there is a pending remote offer, \a sdp is set as the local + * answer; otherwise it becomes a new/modified local offer (creating the + * negotiator first if the call doesn't have one yet). + * A local answer must have the same media-line count as the remote offer; + * otherwise PJMEDIA_SDPNEG_EMISMEDIA is returned without changing the + * negotiator. + * + * This function only updates the SDP negotiator state; it does not by + * itself send any SIP message. Follow up with the usual call operation + * (e.g. #pjsua_call_answer(), #pjsua_call_reinvite(), + * #pjsua_call_update()) to actually send it. + * A staged local offer blocks incoming offers until sent and negotiated, + * or cancelled by calling this function with a NULL SDP. Cancellation + * returns PJ_EINVALIDOP if there is no staged local offer or the offer + * is already outstanding in a SIP transaction. + * The call must use PJSUA_CALL_MEDIA_APP_MANAGED. + * + * @param call_id Call identification. + * @param sdp The local SDP offer or answer to use verbatim, + * or NULL to cancel a staged, unsent local offer. + * + * @return PJ_SUCCESS on success, or the appropriate error + * code, e.g. if the call has no invite session yet, + * or the negotiator is in a state that cannot accept + * a local SDP right now. + */ +PJ_DECL(pj_status_t) pjsua_call_set_sdp(pjsua_call_id call_id, + const pjmedia_sdp_session *sdp); + /** * Send UPDATE request. @@ -7025,6 +7156,22 @@ PJ_DECL(pj_status_t) pjsua_call_update2(pjsua_call_id call_id, const pjsua_call_setting *opt, const pjsua_msg_data *msg_data); +/** + * Send UPDATE request, optionally using an application-supplied SDP offer. + * The SDP is applied atomically with request creation, after the call state + * and call setting have been validated. Supplying \a sdp requires the call + * to use PJSUA_CALL_MEDIA_APP_MANAGED. + * + * @param call_id Call identification. + * @param param Optional call operation parameters. When specified, + * initialize with #pjsua_call_op_param_default(). + * + * @return PJ_SUCCESS on success, or the appropriate error code. + */ +PJ_DECL(pj_status_t) pjsua_call_update3( + pjsua_call_id call_id, + const pjsua_call_op_param *param); + /** * Initiate call transfer to the specified address. This function will send @@ -7270,6 +7417,9 @@ PJ_DECL(pj_bool_t) pjsua_call_vid_stream_is_running(pjsua_call_id call_id, * specified call. This may trigger a re-INVITE or UPDATE to be sent * for the call. * + * App-managed calls do not have pjsua-managed video streams. This function + * returns PJ_EINVALIDOP for these calls. + * * @param call_id Call identification. * @param op The video stream operation to be performed, * possible values are #pjsua_call_vid_strm_op. diff --git a/pjsip/include/pjsua-lib/pjsua_internal.h b/pjsip/include/pjsua-lib/pjsua_internal.h index 08ae30c95f..87dbd2e5db 100644 --- a/pjsip/include/pjsua-lib/pjsua_internal.h +++ b/pjsip/include/pjsua-lib/pjsua_internal.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008-2011 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2008-2026 Teluu Inc. (http://www.teluu.com) * Copyright (C) 2003-2008 Benny Prijono * * This program is free software; you can redistribute it and/or modify @@ -244,6 +244,7 @@ struct pjsua_call union { struct { pjsua_msg_data *msg_data;/**< Headers for outgoing INVITE. */ + pjmedia_sdp_session *local_sdp; /**< Local SDP offer. */ pj_bool_t hangup; /**< Call is hangup? */ } out_call; struct { @@ -264,14 +265,7 @@ struct pjsua_call offer. */ pj_bool_t rx_reinv_async;/**< on_call_rx_reinvite() async. */ - pj_bool_t offer_app_managed; - /**< App is answering the current - remote offer itself, so media it - may accept but pjsua does not - manage (e.g. T.38) must not be - rejected as "no media". Transient, - set around the media re-init in - on_rx_offer. */ + pj_bool_t offer_app_managed; /**< Async offer 488 exception. */ pj_timer_entry reinv_timer; /**< Reinvite retry timer. */ pj_bool_t reinv_pending;/**< Pending until CONFIRMED state. */ pj_bool_t reinv_ice_sent;/**< Has reinvite for ICE upd sent? */ @@ -305,6 +299,12 @@ struct pjsua_call pjmedia_av_sync *av_sync; /**< Media stream synchronizer */ }; +PJ_INLINE(pj_bool_t) pjsua_call_media_is_app_managed( + const pjsua_call *call) +{ + return (call->opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED) != 0; +} + /** * Server presence subscription list head. @@ -1211,4 +1211,3 @@ pj_bool_t pjsua_auth_on_challenge( PJ_END_DECL #endif /* __PJSUA_INTERNAL_H__ */ - diff --git a/pjsip/include/pjsua2/account.hpp b/pjsip/include/pjsua2/account.hpp index acf974679b..36264f2ceb 100644 --- a/pjsip/include/pjsua2/account.hpp +++ b/pjsip/include/pjsua2/account.hpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2013 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2013-2026 Teluu Inc. (http://www.teluu.com) * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -431,6 +431,12 @@ struct AccountCallConfig : public PersistentObject */ unsigned timerSessExpiresSec; + /** + * Make new calls on this account application-managed from creation. + * Default: false. + */ + bool mediaAppManaged; + public: /** * Default constructor @@ -442,7 +448,8 @@ struct AccountCallConfig : public PersistentObject siprecRequireLabel(false), siprecRequireMetadata(false), timerMinSESec(90), - timerSessExpiresSec(PJSIP_SESS_TIMER_DEF_SE) + timerSessExpiresSec(PJSIP_SESS_TIMER_DEF_SE), + mediaAppManaged(false) {} /** @@ -2702,4 +2709,3 @@ class Account } // namespace pj #endif /* __PJSUA2_ACCOUNT_HPP__ */ - diff --git a/pjsip/include/pjsua2/call.hpp b/pjsip/include/pjsua2/call.hpp index f8b08f3d51..9e422bac0f 100644 --- a/pjsip/include/pjsua2/call.hpp +++ b/pjsip/include/pjsua2/call.hpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2012-2013 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2012-2026 Teluu Inc. (http://www.teluu.com) * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -1153,6 +1153,15 @@ struct OnCallRxOfferParam * The new offer received. */ SdpSession offer; + + /** + * SDP answer for application-managed calls only + * (PJSUA_CALL_MEDIA_APP_MANAGED). Set #SdpSession::wholeSdp to answer + * the offer synchronously. Leave empty for ordinary calls, which use + * pjsua's generated answer. On ordinary calls this field is ignored + * with a warning. + */ + SdpSession answer; /** * Status code to be returned for answering the offer. On input, @@ -1174,7 +1183,9 @@ struct OnCallRxOfferParam struct OnCallRxReinviteParam { /** - * The new offer received. + * The new offer received. For an application-managed call receiving + * a re-INVITE without SDP, + * #SdpSession::wholeSdp is empty. */ SdpSession offer; @@ -1340,9 +1351,9 @@ struct OnCreateMediaTransportSrtpParam */ /** - * This structure contains parameters for Call::answer(), Call::hangup(), - * Call::reinvite(), Call::update(), Call::xfer(), Call::xferReplaces(), - * Call::setHold(). + * This structure contains parameters for Call::makeCall(), Call::answer(), + * Call::hangup(), Call::reinvite(), Call::update(), Call::xfer(), + * Call::xferReplaces(), Call::setHold(). */ struct CallOpParam { @@ -1374,7 +1385,17 @@ struct CallOpParam SipTxOption txOption; /** - * SDP answer. Currently only used for Call::answer(). + * Local SDP offer or answer to use verbatim instead of pjsua2's own + * generated SDP, bypassing the onCallSdpCreated() callback for this + * operation. Used by Call::makeCall() and Call::reinvite()/update() as + * the SDP offer, and by Call::answer() as the SDP answer or, for an + * application-managed call answering an offerless INVITE or re-INVITE, + * as the local SDP offer. + * Supplying an offer requires mediaAppManaged to be enabled for the + * account or call. + * For ordinary calls, leave empty to let pjsua2 generate the SDP as + * usual. Application-managed initial outgoing calls require an SDP + * offer unless PJSUA_CALL_NO_SDP_OFFER is set. */ SdpSession sdp; @@ -2265,9 +2286,9 @@ class Call * (i.e. re-INVITE/UPDATE with SDP is received). Application can * decide to accept/reject the offer by setting the code (default * is PJSIP_SC_OK (200)). If the offer is accepted, application can update - * the call setting to be applied in the answer. When this callback is - * not implemented, the default behavior is to accept the offer using - * current call setting. + * the call setting and supply the SDP answer to be applied. When this + * callback is not implemented, the default behavior is to accept the + * offer using current call setting. * * @param prm Callback parameter. */ @@ -2275,16 +2296,20 @@ class Call { PJ_UNUSED_ARG(prm); } /** - * Notify application when call has received a re-INVITE offer from - * the peer. It allows more fine-grained control over the response to - * a re-INVITE. If application sets prm.isAsync to true, it can send + * Notify application when call has received a re-INVITE from the peer. + * It allows more fine-grained control over the response to a re-INVITE. + * If application sets prm.isAsync to true, it can send * the reply manually using the function #pj::Call::answer() and setting * the SDP answer. Otherwise, by default the re-INVITE will be * answered automatically after the callback returns. * - * Currently, this callback is only called for re-INVITE with - * SDP, but app should be prepared to handle the case of re-INVITE - * without SDP. + * For an application-managed call receiving a re-INVITE without SDP, + * this callback is also called with an empty prm.offer. By default, + * the active local SDP is re-offered. Application may set prm.isAsync + * to true and later call #pj::Call::answer() with its own local SDP offer. + * On the asynchronous path, a successful final response requires an + * application-supplied offer; the active local SDP is not automatically + * re-offered. * * Remarks: If manually answering at a later timing, application may * need to monitor onCallTsxState() callback to check whether @@ -2292,8 +2317,8 @@ class Call * PJSIP_SC_REQUEST_TERMINATED (487) due to being cancelled. * * Note: onCallRxOffer() will still be called after this callback, - * but only if prm.isAsync is false and prm.statusCode is PJSIP_SC_OK - * (200). + * but only if the re-INVITE contains SDP, prm.isAsync is false and + * prm.statusCode is PJSIP_SC_OK (200). * * Answering manually also allows application to accept media that * the library does not manage (e.g. T.38 image/udptl), by answering @@ -2474,4 +2499,3 @@ class Call } // namespace pj #endif /* __PJSUA2_CALL_HPP__ */ - diff --git a/pjsip/src/pjsip-ua/sip_100rel.c b/pjsip/src/pjsip-ua/sip_100rel.c index f9a02b107a..4d1ebbc6b1 100644 --- a/pjsip/src/pjsip-ua/sip_100rel.c +++ b/pjsip/src/pjsip-ua/sip_100rel.c @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008-2011 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2008-2026 Teluu Inc. (http://www.teluu.com) * Copyright (C) 2003-2008 Benny Prijono * * This program is free software; you can redistribute it and/or modify @@ -668,6 +668,34 @@ static pj_bool_t has_sdp(dlg_data *dd) } +/* Internal query; the caller holds the dialog lock. */ +pj_bool_t +pjsip_100rel_has_pending_sdp(const pjsip_inv_session *inv) +{ + dlg_data *dd; + tx_data_list_t *tl; + + PJ_ASSERT_RETURN(inv, PJ_FALSE); + if (mod_100rel.mod.id < 0) + return PJ_FALSE; + dd = (dlg_data*)inv->dlg->mod_data[mod_100rel.mod.id]; + if (!dd || !dd->uas_state) + return PJ_FALSE; + + for (tl = dd->uas_state->tx_data_list.next; + tl != &dd->uas_state->tx_data_list; tl = tl->next) + { + pjsip_sdp_info *info = pjsip_tdata_get_sdp_info(tl->tdata); + + if (info->sdp || info->body.ptr) + { + return PJ_TRUE; + } + } + return PJ_FALSE; +} + + /* Send response reliably */ PJ_DEF(pj_status_t) pjsip_100rel_tx_response(pjsip_inv_session *inv, pjsip_tx_data *tdata) @@ -889,5 +917,3 @@ PJ_DEF(pj_status_t) pjsip_100rel_tx_response(pjsip_inv_session *inv, return status; } - - diff --git a/pjsip/src/pjsip-ua/sip_inv.c b/pjsip/src/pjsip-ua/sip_inv.c index 65262e6217..ff99139847 100644 --- a/pjsip/src/pjsip-ua/sip_inv.c +++ b/pjsip/src/pjsip-ua/sip_inv.c @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008-2011 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2008-2026 Teluu Inc. (http://www.teluu.com) * Copyright (C) 2003-2008 Benny Prijono * * This program is free software; you can redistribute it and/or modify @@ -100,11 +100,15 @@ void (*pjsip_inv_process_hparam_ptr)(pjsip_inv_session *sess, /* * Static prototypes. */ +/* Internal 100rel query; the caller holds the dialog lock. */ +pj_bool_t pjsip_100rel_has_pending_sdp(const pjsip_inv_session *inv); + static pj_status_t mod_inv_load(pjsip_endpoint *endpt); static pj_status_t mod_inv_unload(void); static pj_bool_t mod_inv_on_rx_request(pjsip_rx_data *rdata); static pj_bool_t mod_inv_on_rx_response(pjsip_rx_data *rdata); static void mod_inv_on_tsx_state(pjsip_transaction*, pjsip_event*); +static pj_bool_t tx_data_has_sdp(pjsip_tx_data *tdata); static void inv_on_state_null( pjsip_inv_session *inv, pjsip_event *e); static void inv_on_state_calling( pjsip_inv_session *inv, pjsip_event *e); @@ -936,6 +940,21 @@ static void mod_inv_on_tsx_state(pjsip_transaction *tsx, pjsip_event *e) if (inv == NULL) return; + /* Track only outgoing UPDATEs carrying an unanswered SDP offer. */ + if (tsx->role == PJSIP_ROLE_UAC && + pjsip_method_cmp(&tsx->method, &pjsip_update_method)==0) + { + if (tsx->state == PJSIP_TSX_STATE_CALLING && + tsx->last_tx && tx_data_has_sdp(tsx->last_tx)) + { + inv->update_tsx = tsx; + } else if (tsx->state >= PJSIP_TSX_STATE_COMPLETED && + tsx == inv->update_tsx) + { + inv->update_tsx = NULL; + } + } + /* Call on_tsx_state_changed() upon receipt of request (tsx state is * PJSIP_TSX_STATE_TRYING). We need to do this before calling the state * handler since the handler will send response and change the tsx state. @@ -983,40 +1002,91 @@ static void mod_inv_on_tsx_state(pjsip_transaction *tsx, pjsip_event *e) } } +/* + * Check if the local offer awaits a response, ACK, or PRACK. + */ +static pj_bool_t inv_offer_tsx_pending(const pjsip_inv_session *inv) +{ + pjsip_transaction *tsx = inv->invite_tsx; + struct tsx_inv_data *tsx_inv_data; + + if (inv->update_tsx) + return PJ_TRUE; + if (!tsx) + return PJ_FALSE; + tsx_inv_data = (struct tsx_inv_data*)tsx->mod_data[mod_inv.mod.id]; + if (tsx->role == PJSIP_ROLE_UAC) + return !tsx_inv_data || !tsx_inv_data->sdp_done; + if (pjsip_100rel_has_pending_sdp(inv)) + return PJ_TRUE; + + return tsx->last_tx && tx_data_has_sdp(tsx->last_tx) && + (!tsx_inv_data || !tsx_inv_data->sdp_done) && + (tsx->status_code/100 == 2 || + (tsx->status_code/100 == 1 && + (inv->options & PJSIP_INV_REQUIRE_100REL))); +} + +PJ_DEF(pj_bool_t) pjsip_inv_has_pending_offerless_invite( + const pjsip_inv_session *inv) +{ + const pjsip_transaction *tsx; + const struct tsx_inv_data *data; + + PJ_ASSERT_RETURN(inv, PJ_FALSE); + + tsx = inv->invite_tsx; + if (!tsx || tsx->role != PJSIP_ROLE_UAS || + tsx->state >= PJSIP_TSX_STATE_COMPLETED) + { + return PJ_FALSE; + } + data = (const struct tsx_inv_data*)tsx->mod_data[mod_inv.mod.id]; + return !data || (!data->has_sdp && !data->sdp_done); +} + /* * Check if tx_data has sdp. */ -static pj_bool_t tx_data_has_sdp(const pjsip_tx_data *tdata) +static pj_bool_t tx_data_has_sdp(pjsip_tx_data *tdata) { - pjsip_msg_body *body = tdata->msg->body; - pjsip_media_type app_sdp; + pjsip_sdp_info *info; PJ_ASSERT_RETURN(tdata, PJ_FALSE); - pjsip_media_type_init2(&app_sdp, "application", "sdp"); - - if (body && - pj_stricmp(&body->content_type.type, &app_sdp.type)==0 && - pj_stricmp(&body->content_type.subtype, &app_sdp.subtype)==0) - { - return PJ_TRUE; + info = pjsip_tdata_get_sdp_info(tdata); + return info->sdp || info->body.ptr; +} - } else if (body && - pj_stricmp2(&body->content_type.type, "multipart") && - (pj_stricmp2(&body->content_type.subtype, "mixed")==0 || - pj_stricmp2(&body->content_type.subtype, "alternative")==0)) - { - pjsip_multipart_part *part; - part = pjsip_multipart_find_part(body, &app_sdp, NULL); - if (part) { - return PJ_TRUE; - } - } +static pj_status_t inv_create_neg_local_offer(pjsip_inv_session *inv, + const pjmedia_sdp_session *sdp) +{ + pj_status_t status; - return PJ_FALSE; + status = pjmedia_sdp_neg_create_w_local_offer(inv->pool, sdp, &inv->neg); + if (status == PJ_SUCCESS) + status = pjmedia_sdp_neg_set_passthrough( + inv->neg, + (inv->options & PJSIP_INV_SDP_PASSTHROUGH) != 0); + return status; } +static pj_status_t inv_create_neg_remote_offer( + pjsip_inv_session *inv, + const pjmedia_sdp_session *local, + const pjmedia_sdp_session *remote) +{ + pj_status_t status; + + status = pjmedia_sdp_neg_create_w_remote_offer(inv->pool, local, remote, + &inv->neg); + if (status == PJ_SUCCESS) + status = pjmedia_sdp_neg_set_passthrough( + inv->neg, + (inv->options & PJSIP_INV_SDP_PASSTHROUGH) != 0); + return status; +} /* * Initialize the invite module. @@ -1135,8 +1205,7 @@ PJ_DEF(pj_status_t) pjsip_inv_create_uac( pjsip_dialog *dlg, /* Create negotiator if local_sdp is specified. */ if (local_sdp) { - status = pjmedia_sdp_neg_create_w_local_offer(inv->pool, - local_sdp, &inv->neg); + status = inv_create_neg_local_offer(inv, local_sdp); if (status != PJ_SUCCESS) { pjsip_dlg_dec_lock(dlg); return status; @@ -1925,13 +1994,10 @@ PJ_DEF(pj_status_t) pjsip_inv_create_uas( pjsip_dialog *dlg, /* Create negotiator. */ if (sdp_info->sdp) { - status = pjmedia_sdp_neg_create_w_remote_offer(inv->pool, local_sdp, - sdp_info->sdp, - &inv->neg); + status = inv_create_neg_remote_offer(inv, local_sdp, sdp_info->sdp); } else if (local_sdp) { - status = pjmedia_sdp_neg_create_w_local_offer(inv->pool, - local_sdp, &inv->neg); + status = inv_create_neg_local_offer(inv, local_sdp); } else { status = PJ_SUCCESS; } @@ -2779,9 +2845,7 @@ static pj_status_t inv_check_sdp_in_incoming_msg( pjsip_inv_session *inv, pjsip_rx_data_get_info(rdata))); if (inv->neg == NULL) { - status=pjmedia_sdp_neg_create_w_remote_offer(inv->pool, NULL, - sdp_info->sdp, - &inv->neg); + status = inv_create_neg_remote_offer(inv, NULL, sdp_info->sdp); } else { status=pjmedia_sdp_neg_set_remote_offer(inv->pool_prov, inv->neg, sdp_info->sdp); @@ -2899,9 +2963,7 @@ static pj_status_t process_answer( pjsip_inv_session *inv, if (local_sdp && (st_code/100==1 || st_code/100==2)) { if (inv->neg == NULL) { - status = pjmedia_sdp_neg_create_w_local_offer(inv->pool, - local_sdp, - &inv->neg); + status = inv_create_neg_local_offer(inv, local_sdp); } else if (pjmedia_sdp_neg_get_state(inv->neg)== PJMEDIA_SDP_NEG_STATE_REMOTE_OFFER) { @@ -3160,10 +3222,24 @@ PJ_DEF(pj_status_t) pjsip_inv_set_local_sdp(pjsip_inv_session *inv, const pjmedia_sdp_session *offer; pj_status_t status; - PJ_ASSERT_RETURN(inv && sdp, PJ_EINVAL); + PJ_ASSERT_RETURN(inv, PJ_EINVAL); pjsip_inv_add_ref(inv); + if (!sdp) { + if (!inv->neg || + pjmedia_sdp_neg_get_state(inv->neg) != + PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER || + inv_offer_tsx_pending(inv)) + { + PJ_LOG(3,(inv->obj_name, "No unsent local SDP offer to cancel")); + status = PJ_EINVALIDOP; + } else { + status = pjmedia_sdp_neg_cancel_offer(inv->neg); + } + goto on_return; + } + /* If we have remote SDP offer, set local answer to respond to the offer, * otherwise we set/modify our local offer (and create an SDP negotiator * if we don't have one yet). @@ -3177,20 +3253,43 @@ PJ_DEF(pj_status_t) pjsip_inv_set_local_sdp(pjsip_inv_session *inv, { status = pjsip_inv_set_sdp_answer(inv, sdp); } else if (neg_state == PJMEDIA_SDP_NEG_STATE_DONE) { - status = pjmedia_sdp_neg_modify_local_offer2(inv->pool, inv->neg, + pjsip_transaction *tsx = inv->invite_tsx; + struct tsx_inv_data *data = tsx ? + (struct tsx_inv_data*)tsx->mod_data[mod_inv.mod.id] : NULL; + + if (tsx && + ((tsx->role == PJSIP_ROLE_UAC && + (!data || !data->sdp_done)) || + (tsx->role == PJSIP_ROLE_UAS && data && data->has_sdp && + !data->sdp_done))) + { + PJ_LOG(3,(inv->obj_name, "Cannot stage a local offer " + "during the pending INVITE")); + status = PJ_EINVALIDOP; + } else { + status = pjmedia_sdp_neg_modify_local_offer2(inv->pool_prov, + inv->neg, inv->sdp_neg_flags, sdp); + } } else { - pjsip_inv_dec_ref(inv); - return PJMEDIA_SDPNEG_EINSTATE; + status = PJMEDIA_SDPNEG_EINSTATE; + goto on_return; } } else { - status = pjmedia_sdp_neg_create_w_local_offer(inv->pool, - sdp, &inv->neg); + if (inv->invite_tsx && + inv->invite_tsx->role == PJSIP_ROLE_UAC) + { + PJ_LOG(3,(inv->obj_name, "Cannot stage a local offer " + "during the pending INVITE")); + status = PJ_EINVALIDOP; + goto on_return; + } + status = inv_create_neg_local_offer(inv, sdp); } +on_return: pjsip_inv_dec_ref(inv); - return status; } @@ -3841,8 +3940,8 @@ PJ_DEF(pj_status_t) pjsip_inv_reinvite( pjsip_inv_session *inv, /* Check arguments. */ PJ_ASSERT_RETURN(inv && p_tdata, PJ_EINVAL); - /* Must NOT have a pending INVITE transaction */ - if (inv->invite_tsx!=NULL) + /* Do not overlap INVITEs or an outstanding UPDATE offer. */ + if (inv->invite_tsx != NULL || inv->update_tsx != NULL) return PJ_EINVALIDOP; pj_log_push_indent(); @@ -3866,9 +3965,7 @@ PJ_DEF(pj_status_t) pjsip_inv_reinvite( pjsip_inv_session *inv, if (new_offer) { if (!inv->neg) { - status = pjmedia_sdp_neg_create_w_local_offer(inv->pool, - new_offer, - &inv->neg); + status = inv_create_neg_local_offer(inv, new_offer); if (status != PJ_SUCCESS) goto on_return; @@ -3931,6 +4028,8 @@ PJ_DEF(pj_status_t) pjsip_inv_update ( pjsip_inv_session *inv, pjsip_contact_hdr *contact_hdr = NULL; pjsip_tx_data *tdata = NULL; pjmedia_sdp_session *sdp_copy; + const pjmedia_sdp_session *pending_offer; + pjmedia_sdp_session comparable_offer; const pjsip_hdr *hdr; pjsip_supported_hdr *sup_hdr = NULL; pj_status_t status = PJ_SUCCESS; @@ -3963,23 +4062,55 @@ PJ_DEF(pj_status_t) pjsip_inv_update ( pjsip_inv_session *inv, #endif } - if (pjmedia_sdp_neg_get_state(inv->neg)!=PJMEDIA_SDP_NEG_STATE_DONE) { + switch (pjmedia_sdp_neg_get_state(inv->neg)) { + case PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER: + if ((inv->options & PJSIP_INV_SDP_PASSTHROUGH) == 0) { + PJ_LOG(4,(inv->dlg->obj_name, + "Invalid SDP offer/answer state for UPDATE")); + status = PJ_EINVALIDOP; + goto on_error; + } + /* Do not resend an offer awaiting a response, ACK, or PRACK. */ + if (inv_offer_tsx_pending(inv)) { + PJ_LOG(4,(inv->dlg->obj_name, + "Unable to send UPDATE, a pending offer is still " + "outstanding in another transaction")); + status = PJ_EINVALIDOP; + goto on_error; + } + status = pjmedia_sdp_neg_get_neg_local(inv->neg, &pending_offer); + if (status != PJ_SUCCESS) + goto on_error; + comparable_offer = *offer; + comparable_offer.origin = pending_offer->origin; + if (pjmedia_sdp_session_cmp(&comparable_offer, pending_offer, 0) != + PJ_SUCCESS) + { + PJ_LOG(3,(inv->obj_name, "UPDATE offer differs from " + "pending local offer")); + status = PJMEDIA_SDPNEG_EINSTATE; + goto on_error; + } + offer = pending_offer; + break; + + case PJMEDIA_SDP_NEG_STATE_DONE: + /* Fix the new offer with the correct SDP origin. */ + status = pjmedia_sdp_neg_modify_local_offer2( + inv->pool_prov, inv->neg, + inv->sdp_neg_flags, offer); + if (status != PJ_SUCCESS) + goto on_error; + + pjmedia_sdp_neg_get_neg_local(inv->neg, &offer); + break; + + default: PJ_LOG(4,(inv->dlg->obj_name, "Invalid SDP offer/answer state for UPDATE")); status = PJ_EINVALIDOP; goto on_error; } - - /* Notify negotiator about the new offer. This will fix the offer - * with correct SDP origin. - */ - status = pjmedia_sdp_neg_modify_local_offer2(inv->pool_prov, inv->neg, - inv->sdp_neg_flags, offer); - if (status != PJ_SUCCESS) - goto on_error; - - /* Retrieve the "fixed" offer from negotiator */ - pjmedia_sdp_neg_get_neg_local(inv->neg, &offer); } /* Update Contact if required */ @@ -4874,6 +5005,13 @@ static void inv_respond_incoming_prack(pjsip_inv_session *inv, /* No SDP in the PRACK request, respond with 200/OK without SDP */ if (rdata->msg_info.msg->body == NULL) { + if (inv->neg && + pjmedia_sdp_neg_get_state(inv->neg) == + PJMEDIA_SDP_NEG_STATE_DONE && + !pjsip_100rel_has_pending_sdp(inv)) + { + inv->sdp_done_early_rel = PJ_TRUE; + } status = pjsip_dlg_create_response(inv->dlg, rdata, PJSIP_SC_OK, NULL, &tdata); goto on_return; @@ -6705,4 +6843,3 @@ static void inv_on_state_disconnected( pjsip_inv_session *inv, pjsip_event *e) handle_uac_tsx_response(inv, e); } } - diff --git a/pjsip/src/pjsua-lib/pjsua_acc.c b/pjsip/src/pjsua-lib/pjsua_acc.c index 6d6e8f4552..f793787f12 100644 --- a/pjsip/src/pjsua-lib/pjsua_acc.c +++ b/pjsip/src/pjsua-lib/pjsua_acc.c @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008-2011 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2008-2026 Teluu Inc. (http://www.teluu.com) * Copyright (C) 2003-2008 Benny Prijono * * This program is free software; you can redistribute it and/or modify @@ -1479,6 +1479,7 @@ PJ_DEF(pj_status_t) pjsua_acc_modify( pjsua_acc_id acc_id, /* User data */ acc->cfg.user_data = cfg->user_data; + acc->cfg.media_app_managed = cfg->media_app_managed; /* Priority */ if (acc->cfg.priority != cfg->priority) { diff --git a/pjsip/src/pjsua-lib/pjsua_call.c b/pjsip/src/pjsua-lib/pjsua_call.c index 0bb50bb4f8..3e978dff17 100644 --- a/pjsip/src/pjsua-lib/pjsua_call.c +++ b/pjsip/src/pjsua-lib/pjsua_call.c @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008-2011 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2008-2026 Teluu Inc. (http://www.teluu.com) * Copyright (C) 2003-2008 Benny Prijono * * This program is free software; you can redistribute it and/or modify @@ -202,6 +202,7 @@ static void reset_call(pjsua_call_id id) call_med->idx = i; call_med->tp_auto_del = PJ_TRUE; } + pj_memcpy(call->media_prov, call->media, sizeof(call->media)); pjsua_call_setting_default(&call->opt); pj_timer_entry_init(&call->reinv_timer, PJ_FALSE, (void*)(pj_size_t)id, &reinv_timer_cb); @@ -267,9 +268,7 @@ pj_status_t pjsua_call_subsys_init(const pjsua_config *cfg) inv_cb.on_create_offer = &pjsua_call_on_create_offer; inv_cb.on_tsx_state_changed = &pjsua_call_on_tsx_state_changed; inv_cb.on_redirected = &pjsua_call_on_redirected; - if (pjsua_var.ua_cfg.cb.on_call_rx_reinvite) { - inv_cb.on_rx_reinvite = &pjsua_call_on_rx_reinvite; - } + inv_cb.on_rx_reinvite = &pjsua_call_on_rx_reinvite; if (pjsua_var.ua_cfg.cb.on_call_tsx_terminate_session) { inv_cb.on_uac_tsx_terminate_session = &pjsua_call_on_uac_tsx_terminate_session; @@ -528,7 +527,8 @@ on_make_call_med_tp_complete(pjsua_call_id call_id, } /* Create offer */ - if ((call->opt.flag & PJSUA_CALL_NO_SDP_OFFER) == 0) { + offer = call->async_call.call_var.out_call.local_sdp; + if (!offer && (call->opt.flag & PJSUA_CALL_NO_SDP_OFFER) == 0) { status = pjsua_media_channel_create_sdp(call->index, dlg->pool, NULL, &offer, NULL); if (status != PJ_SUCCESS) { @@ -553,6 +553,8 @@ on_make_call_med_tp_complete(pjsua_call_id call_id, { options |= PJSIP_INV_SUPPORT_TRICKLE_ICE; } + if (call->opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED) + options |= PJSIP_INV_SDP_PASSTHROUGH; status = pjsip_inv_create_uac( dlg, offer, options, &inv); if (status != PJ_SUCCESS) { @@ -710,6 +712,11 @@ PJ_DEF(void) pjsua_call_setting_default(pjsua_call_setting *opt) } } +PJ_DEF(void) pjsua_call_op_param_default(pjsua_call_op_param *param) +{ + pj_bzero(param, sizeof(*param)); +} + /* * Initialize pjsua_call_send_dtmf_param default values. */ @@ -730,12 +737,81 @@ PJ_DEF(void) pjsua_call_send_text_param_default( param->med_idx = -1; } +/* True if the given account's configuration defaults new calls to + * app-managed media. + */ +static pj_bool_t acc_media_app_managed_default(pjsua_acc_id acc_id) +{ + return acc_id != PJSUA_INVALID_ID && + pjsua_var.acc[acc_id].cfg.media_app_managed; +} + +/* Apply the account media-app-managed default. */ +static void apply_acc_media_app_managed_default(pjsua_call *call) +{ + if (acc_media_app_managed_default(call->acc_id)) + call->opt.flag |= PJSUA_CALL_MEDIA_APP_MANAGED; +} + +static pj_status_t validate_app_sdp(const pjmedia_sdp_session *sdp, + const pjsip_inv_session *inv) +{ + pj_status_t status; + const pjmedia_sdp_session *offer; + + if (sdp && sdp->media_count > PJSUA_MAX_CALL_MEDIA) { + PJ_LOG(1,(THIS_FILE, "Application SDP media count %u exceeds " + "maximum %u", sdp->media_count, PJSUA_MAX_CALL_MEDIA)); + return PJ_ETOOMANY; + } + + if (sdp) { + status = pjmedia_sdp_validate(sdp); + if (status != PJ_SUCCESS) { + PJ_PERROR(1,(THIS_FILE, status, "Invalid application SDP")); + return status; + } + if (inv && inv->neg && + (pjmedia_sdp_neg_get_state(inv->neg) == + PJMEDIA_SDP_NEG_STATE_REMOTE_OFFER || + pjmedia_sdp_neg_get_state(inv->neg) == + PJMEDIA_SDP_NEG_STATE_WAIT_NEGO)) + { + status = pjmedia_sdp_neg_get_neg_remote(inv->neg, &offer); + if (status != PJ_SUCCESS) { + PJ_PERROR(1,(THIS_FILE, status, "Unable to get remote offer")); + return status; + } + if (sdp->media_count != offer->media_count) { + PJ_LOG(1,(THIS_FILE, "Application SDP answer media count %u " + "does not match offer count %u", + sdp->media_count, offer->media_count)); + return PJMEDIA_SDPNEG_EMISMEDIA; + } + } + } + + return PJ_SUCCESS; +} + +static pj_status_t validate_managed_sdp(pj_bool_t media_app_managed, + const pjmedia_sdp_session *sdp, + const pjsip_inv_session *inv) +{ + if (sdp && !media_app_managed) { + PJ_LOG(3,(THIS_FILE, "Application SDP requires app-managed media")); + return PJ_EINVALIDOP; + } + + return validate_app_sdp(sdp, inv); +} static pj_status_t apply_call_setting(pjsua_call *call, const pjsua_call_setting *opt, const pjmedia_sdp_session *rem_sdp) { pjsua_call_setting prev_opt; + pj_bool_t media_app_managed; pj_assert(call); @@ -745,12 +821,17 @@ static pj_status_t apply_call_setting(pjsua_call *call, * the next re-offer). */ prev_opt = call->opt; + media_app_managed = + (prev_opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED) != 0; - /* Reject media counts that would overflow the call's fixed-size media - * arrays (pjsua_call.media[PJSUA_MAX_CALL_MEDIA]). This is application - * input, so fail gracefully rather than assert or overflow. The - * per-field checks precede the sum so the addition cannot overflow. - */ + if (call->inv && opt && + (opt->flag & PJSUA_CALL_MEDIA_APP_MANAGED) && + !(prev_opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED)) + { + return PJ_EINVALIDOP; + } + + /* Reject media counts exceeding the fixed call-media array. */ if (opt && (opt->aud_cnt > PJSUA_MAX_CALL_MEDIA || opt->vid_cnt > PJSUA_MAX_CALL_MEDIA || @@ -769,6 +850,8 @@ static pj_status_t apply_call_setting(pjsua_call *call, } else { call->opt = *opt; } + if (media_app_managed) + call->opt.flag |= PJSUA_CALL_MEDIA_APP_MANAGED; #if !PJMEDIA_HAS_VIDEO pj_assert(call->opt.vid_cnt == 0); @@ -799,7 +882,6 @@ static pj_status_t apply_call_setting(pjsua_call *call, if (status != PJ_SUCCESS) { pjsua_perror(THIS_FILE, "Error re-initializing media channel", status); - /* Restore the previous setting; this re-offer was rejected. */ call->opt = prev_opt; return status; } @@ -921,12 +1003,32 @@ PJ_DEF(pj_status_t) pjsua_call_make_call(pjsua_acc_id acc_id, const pjsua_msg_data *msg_data, pjsua_call_id *p_call_id) { + pjsua_call_op_param param; + + pjsua_call_op_param_default(¶m); + param.opt = opt; + param.user_data = user_data; + param.msg_data = msg_data; + return pjsua_call_make_call2(acc_id, dest_uri, ¶m, p_call_id); +} + +PJ_DEF(pj_status_t) pjsua_call_make_call2( + pjsua_acc_id acc_id, + const pj_str_t *dest_uri, + const pjsua_call_op_param *param, + pjsua_call_id *p_call_id) +{ + const pjsua_call_setting *opt = param ? param->opt : NULL; + const pjmedia_sdp_session *sdp = param ? param->sdp : NULL; + void *user_data = param ? param->user_data : NULL; + const pjsua_msg_data *msg_data = param ? param->msg_data : NULL; pj_pool_t *tmp_pool = NULL; pjsip_dialog *dlg = NULL; pjsua_acc *acc; pjsua_call *call = NULL; int call_id = -1; pj_str_t contact; + pj_bool_t media_app_managed; pj_status_t status; /* Check that account is valid */ @@ -951,6 +1053,22 @@ PJ_DEF(pj_status_t) pjsua_call_make_call(pjsua_acc_id acc_id, goto on_error; } + media_app_managed = + acc->cfg.media_app_managed || + (opt && (opt->flag & PJSUA_CALL_MEDIA_APP_MANAGED)); + status = validate_managed_sdp(media_app_managed, sdp, NULL); + if (status != PJ_SUCCESS) + goto on_error; + + if (media_app_managed && !sdp && + (!opt || (opt->flag & PJSUA_CALL_NO_SDP_OFFER) == 0)) + { + PJ_LOG(3,(THIS_FILE, "Application SDP offer required for " + "app-managed call")); + status = PJ_EINVALIDOP; + goto on_error; + } + /* Find free call slot. */ call_id = alloc_call_id(); @@ -968,6 +1086,7 @@ PJ_DEF(pj_status_t) pjsua_call_make_call(pjsua_acc_id acc_id, /* Associate session with account */ call->acc_id = acc_id; call->call_hold_type = acc->cfg.call_hold_type; + apply_acc_media_app_managed_default(call); /* Generate per-session RTCP CNAME, according to RFC 7022. */ pj_create_random_string(call->cname_buf, call->cname.slen); @@ -980,13 +1099,18 @@ PJ_DEF(pj_status_t) pjsua_call_make_call(pjsua_acc_id acc_id, } else if (!opt) { opt = &call->opt; } + if (sdp && (call->opt.flag & PJSUA_CALL_NO_SDP_OFFER)) { + status = PJ_EINVAL; + goto on_error; + } /* Create sound port if none is instantiated, to check if sound device * can be used. But only do this with the conference bridge, as with * audio switchboard (i.e. APS-Direct), we can only open the sound * device once the correct format has been known */ - if (!pjsua_var.is_mswitch && pjsua_var.snd_port==NULL && + if (!(call->opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED) && + !pjsua_var.is_mswitch && pjsua_var.snd_port==NULL && pjsua_var.null_snd==NULL && !pjsua_var.no_snd && call->opt.aud_cnt > 0) { status = pjsua_set_snd_dev(pjsua_var.cap_dev, pjsua_var.play_dev); @@ -1122,6 +1246,10 @@ PJ_DEF(pj_status_t) pjsua_call_make_call(pjsua_acc_id acc_id, call->async_call.call_var.out_call.msg_data = pjsua_msg_data_clone( dlg->pool, msg_data); } + if (sdp) { + call->async_call.call_var.out_call.local_sdp = + pjmedia_sdp_session_clone(dlg->pool, sdp); + } call->async_call.dlg = dlg; /* Temporarily increment dialog session. Without this, dialog will be @@ -1130,7 +1258,9 @@ PJ_DEF(pj_status_t) pjsua_call_make_call(pjsua_acc_id acc_id, */ pjsip_dlg_inc_session(dlg, &pjsua_var.mod); - if ((call->opt.flag & PJSUA_CALL_NO_SDP_OFFER) == 0) { + if ((call->opt.flag & PJSUA_CALL_NO_SDP_OFFER) == 0 && + !(sdp && (call->opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED))) + { /* Init media channel */ status = pjsua_media_channel_init(call->index, PJSIP_ROLE_UAC, call->secure_level, dlg->pool, @@ -1504,7 +1634,7 @@ static pj_status_t verify_request(const pjsua_call *call, pjsip_tx_data **response) { const pjmedia_sdp_session *offer = NULL; - pjmedia_sdp_session *answer; + pjmedia_sdp_session *answer = NULL; int err_code = 0; pj_status_t status; @@ -1514,7 +1644,9 @@ static pj_status_t verify_request(const pjsua_call *call, pjmedia_sdp_neg_get_neg_remote(call->inv->neg, &offer); } - if (use_tmp_sdp) { + if (pjsua_call_media_is_app_managed(call)) { + status = PJ_SUCCESS; + } else if (use_tmp_sdp) { if (offer == NULL) return PJ_SUCCESS; @@ -1840,6 +1972,26 @@ pj_bool_t pjsua_call_on_incoming(pjsip_rx_data *rdata) goto on_return; } + /* Reject Replaces for app-managed calls, as no app SDP is available. */ + if (replaced_dlg) { + const pjsua_call *replaced_call = (const pjsua_call*) + replaced_dlg->mod_data[pjsua_var.mod.id]; + + if ((replaced_call->opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED) || + acc_media_app_managed_default(replaced_call->acc_id)) + { + int st_code = PJSIP_SC_NOT_ACCEPTABLE_HERE; + pj_str_t st_text = *pjsip_get_status_text(st_code); + + PJ_LOG(3,(THIS_FILE, "Rejecting Replaces for app-managed " + "call %d", replaced_call->index)); + pjsip_endpt_respond(pjsua_var.endpt, NULL, rdata, + st_code, &st_text, NULL, NULL, NULL); + ret_st_code = st_code; + goto on_return; + } + } + /* If this INVITE request contains Replaces header, notify application * about the request so that application can do subsequent checking * if it wants to. @@ -1880,6 +2032,15 @@ pj_bool_t pjsua_call_on_incoming(pjsip_rx_data *rdata) /* Must specify final response */ PJ_ASSERT_ON_FAIL(st_code >= 200, st_code = 200); + if (st_code < 300 && + (call->opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED)) + { + PJ_LOG(3,(THIS_FILE, "Rejecting Replaces with application-managed " + "callback settings")); + st_code = PJSIP_SC_NOT_ACCEPTABLE_HERE; + st_text = *pjsip_get_status_text(st_code); + } + /* Check if application rejects this request. */ if (st_code >= 300) { @@ -1940,6 +2101,9 @@ pj_bool_t pjsua_call_on_incoming(pjsip_rx_data *rdata) } call->call_hold_type = pjsua_var.acc[acc_id].cfg.call_hold_type; + /* Apply the account default before creating the invite session. */ + apply_acc_media_app_managed_default(call); + /* Get call's secure level */ if (PJSIP_URI_SCHEME_IS_SIPS(rdata->msg_info.msg->line.req.uri)) call->secure_level = 2; @@ -2011,7 +2175,9 @@ pj_bool_t pjsua_call_on_incoming(pjsip_rx_data *rdata) /* Do quick checks on SDP before passing it to transports. More elabore * checks will be done in pjsip_inv_verify_request2() below. */ - if ((offer) && (offer->media_count==0)) { + if (offer && offer->media_count == 0 && + !pjsua_call_media_is_app_managed(call)) + { st_reason = pj_str("Missing media in SDP"); ret_st_code = PJSIP_SC_BAD_REQUEST; @@ -2216,6 +2382,8 @@ pj_bool_t pjsua_call_on_incoming(pjsip_rx_data *rdata) if (cap_status == PJSIP_DIALOG_CAP_SUPPORTED) options |= PJSIP_INV_REQUIRE_100REL; } + if (call->opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED) + options |= PJSIP_INV_SDP_PASSTHROUGH; /* Create invite session: */ status = pjsip_inv_create_uas( dlg, rdata, NULL, options, &inv); @@ -3072,6 +3240,26 @@ on_answer_call_med_tp_complete(pjsua_call_id call_id, return status; } +static pj_bool_t has_pending_local_answer(const pjsip_inv_session *inv) +{ + return inv && inv->neg && + pjmedia_sdp_neg_get_state(inv->neg) == + PJMEDIA_SDP_NEG_STATE_WAIT_NEGO && + pjmedia_sdp_neg_has_local_answer(inv->neg); +} + +static void cancel_pending_local_answer(pjsip_inv_session *inv) +{ + pj_status_t status; + + if (!has_pending_local_answer(inv)) + return; + + status = pjmedia_sdp_neg_cancel_local_answer(inv->neg); + if (status != PJ_SUCCESS) + pjsua_perror(THIS_FILE, "Unable to cancel SDP answer", status); +} + /* * Send response to incoming INVITE request. @@ -3119,6 +3307,25 @@ PJ_DEF(pj_status_t) pjsua_call_answer2(pjsua_call_id call_id, goto on_return; } + if ((code/100 == 2 || + (code == PJSIP_SC_PROGRESS && + (call->inv->options & PJSIP_INV_REQUIRE_100REL))) && + (call->opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED) && + (!call->inv->neg || + pjmedia_sdp_neg_get_state(call->inv->neg) == + PJMEDIA_SDP_NEG_STATE_NULL || + pjmedia_sdp_neg_get_state(call->inv->neg) == + PJMEDIA_SDP_NEG_STATE_REMOTE_OFFER || + (pjsip_inv_has_pending_offerless_invite(call->inv) && + pjmedia_sdp_neg_get_state(call->inv->neg) != + PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER))) + { + PJ_LOG(3,(THIS_FILE, "Application SDP required to answer " + "app-managed call %d with %d", call_id, code)); + status = PJ_EINVALIDOP; + goto on_return; + } + /* Apply call setting, only if status code is 1xx or 2xx. */ if (opt && code < 300) { /* Check if it has not been set previously or it is different to @@ -3148,7 +3355,8 @@ PJ_DEF(pj_status_t) pjsua_call_answer2(pjsua_call_id call_id, * - call setting has just been set, or SDP offer needs to be sent, i.e: * answer code 183 or 2xx is issued */ - if (!call->med_ch_cb && + if (!(call->opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED) && + !call->med_ch_cb && (call->opt_inited || (code==183 || code/100==2)) && (!call->inv->neg || pjmedia_sdp_neg_get_state(call->inv->neg) == @@ -3263,7 +3471,10 @@ pjsua_call_answer_with_sdp(pjsua_call_id call_id, { pjsua_call *call; pjsip_dialog *dlg = NULL; - pj_status_t status; + pjsip_inv_session *inv; + pjmedia_sdp_neg *old_neg; + pj_bool_t app_managed; + pj_status_t status, cancel_status; PJ_ASSERT_RETURN(call_id>=0 && call_id<(int)pjsua_var.ua_cfg.max_calls, PJ_EINVAL); @@ -3273,14 +3484,66 @@ pjsua_call_answer_with_sdp(pjsua_call_id call_id, if (status != PJ_SUCCESS) return status; - status = pjsip_inv_set_sdp_answer(call->inv, sdp); + inv = call->inv; + old_neg = inv->neg; + app_managed = (call->opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED) != 0; + if (app_managed) { + if (inv->neg && + pjmedia_sdp_neg_get_state(inv->neg) == + PJMEDIA_SDP_NEG_STATE_DONE && + inv->invite_tsx && inv->invite_tsx->role == PJSIP_ROLE_UAS && + !pjsip_inv_has_pending_offerless_invite(inv)) + { + PJ_LOG(3,(THIS_FILE, "INVITE offer/answer already completed; " + "answer without supplying new SDP")); + pjsip_dlg_dec_lock(dlg); + return PJ_EINVALIDOP; + } + status = validate_app_sdp(sdp, inv); + if (status == PJ_SUCCESS) + status = pjsip_inv_set_local_sdp(inv, sdp); + } else { + status = pjsip_inv_set_sdp_answer(inv, sdp); + } pjsip_dlg_dec_lock(dlg); if (status != PJ_SUCCESS) return status; - - return pjsua_call_answer2(call_id, opt, code, reason, msg_data); + + status = pjsua_call_answer2(call_id, opt, code, reason, msg_data); + if (status == PJ_SUCCESS) + return status; + + cancel_status = acquire_call("pjsua_call_answer_with_sdp() rollback", + call_id, &call, &dlg); + if (cancel_status == PJ_SUCCESS) { + if (call->inv != inv) { + pjsip_dlg_dec_lock(dlg); + return status; + } + if (!old_neg) { + /* pjsip_inv_set_local_sdp() created this negotiator for our + * offer. It never completed a round, so cancel_offer() would + * leave it DONE with no active SDP; drop it instead. + */ + inv->neg = NULL; + } else if (has_pending_local_answer(call->inv)) { + cancel_pending_local_answer(call->inv); + } else if (call->inv && call->inv->neg && + pjmedia_sdp_neg_get_state(call->inv->neg) == + PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER) + { + cancel_status = pjmedia_sdp_neg_cancel_offer(call->inv->neg); + if (cancel_status != PJ_SUCCESS) { + pjsua_perror(THIS_FILE, "Unable to cancel SDP offer", + cancel_status); + } + } + pjsip_dlg_dec_lock(dlg); + } + + return status; } @@ -3591,6 +3854,12 @@ PJ_DEF(pj_status_t) pjsua_call_set_hold2(pjsua_call_id call_id, goto on_return; } + if (pjsua_call_media_is_app_managed(call)) { + PJ_LOG(3,(THIS_FILE, "Application SDP is required to hold call")); + status = PJ_EINVALIDOP; + goto on_return; + } + /* We may need to re-initialize media before creating SDP */ if (call->med_prov_cnt == 0) { status = apply_call_setting(call, &call->opt, NULL); @@ -3666,6 +3935,7 @@ PJ_DEF(pj_status_t) pjsua_call_reinvite( pjsua_call_id call_id, const pjsua_msg_data *msg_data) { pjsua_call *call; + pjsua_call_setting opt; pjsip_dialog *dlg = NULL; pj_status_t status; @@ -3681,10 +3951,9 @@ PJ_DEF(pj_status_t) pjsua_call_reinvite( pjsua_call_id call_id, goto on_return; } - if (options != call->opt.flag) - call->opt.flag = options; - - status = pjsua_call_reinvite2(call_id, &call->opt, msg_data); + opt = call->opt; + opt.flag = options; + status = pjsua_call_reinvite2(call_id, &opt, msg_data); on_return: if (dlg) pjsip_dlg_dec_lock(dlg); @@ -3699,11 +3968,148 @@ PJ_DEF(pj_status_t) pjsua_call_reinvite2(pjsua_call_id call_id, const pjsua_call_setting *opt, const pjsua_msg_data *msg_data) { - pjmedia_sdp_session *sdp = NULL; + pjsua_call_op_param param; + + pjsua_call_op_param_default(¶m); + param.opt = opt; + param.msg_data = msg_data; + return pjsua_call_reinvite3(call_id, ¶m); +} + + +static void cancel_created_local_offer(pjsua_call *call, + pjmedia_sdp_neg_state old_state) +{ + pj_status_t status; + + if (!call->inv || !call->inv->neg || + old_state == PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER || + pjmedia_sdp_neg_get_state(call->inv->neg) != + PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER) + { + return; + } + + status = pjmedia_sdp_neg_cancel_offer(call->inv->neg); + if (status != PJ_SUCCESS) { + PJ_PERROR(3,(THIS_FILE, status, + "Unable to cancel failed local SDP offer")); + } +} + +static void rollback_failed_call_operation(pjsua_call *call, + pjmedia_sdp_neg_state old_state, + const pjsua_call_setting *old_opt) +{ + cancel_created_local_offer(call, old_state); + call->opt = *old_opt; +} + +static pj_status_t create_unsupplied_offer( + pjsua_call *call, + const pjmedia_sdp_session **offer, + pjmedia_sdp_session **generated_offer) +{ + pj_status_t status; + + *offer = NULL; + *generated_offer = NULL; + + if (pjsua_call_media_is_app_managed(call)) { + if (call->opt.flag & PJSUA_CALL_NO_SDP_OFFER) + return PJ_SUCCESS; + if (!call->inv->neg) + return PJ_EINVALIDOP; + if (pjmedia_sdp_neg_get_state(call->inv->neg) == + PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER) + { + return pjmedia_sdp_neg_get_neg_local(call->inv->neg, offer); + } + return pjmedia_sdp_neg_get_active_local(call->inv->neg, offer); + } + + if (call->local_hold && + (call->opt.flag & PJSUA_CALL_UNHOLD) == 0) + { + status = create_sdp_of_call_hold(call, generated_offer); + } else if ((call->opt.flag & PJSUA_CALL_NO_SDP_OFFER) == 0) { + status = pjsua_media_channel_create_sdp(call->index, + call->inv->pool_prov, + NULL, generated_offer, NULL); + } else { + return PJ_SUCCESS; + } + + if (status == PJ_SUCCESS) + *offer = *generated_offer; + return status; +} + + +static pj_status_t prepare_call_offer(pjsua_call *call, + const pjsua_call_setting *opt, + const pjmedia_sdp_session **sdp, + pjsua_call_setting *old_opt, + pjmedia_sdp_neg_state *old_neg_state) +{ + pjmedia_sdp_session *generated_sdp = NULL; + pj_status_t status; + + if (*sdp && !call->inv->neg) { + PJ_LOG(3,(THIS_FILE, "Can not set a new SDP offer before the initial " + "offer/answer negotiation")); + return PJ_EINVALIDOP; + } + if (*sdp && call->inv->neg && + pjmedia_sdp_neg_get_state(call->inv->neg) != + PJMEDIA_SDP_NEG_STATE_DONE) + { + PJ_LOG(3,(THIS_FILE, "Can not set a new SDP offer while another " + "offer is pending")); + return PJ_EINVALIDOP; + } + if (*sdp && opt && (opt->flag & PJSUA_CALL_NO_SDP_OFFER)) + return PJ_EINVAL; + status = validate_managed_sdp( + (call->opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED) != 0, + *sdp, NULL); + if (status != PJ_SUCCESS) + return status; + + *old_opt = call->opt; + status = apply_call_setting(call, opt, NULL); + if (status != PJ_SUCCESS) { + pjsua_perror(THIS_FILE, "Failed to apply call setting", status); + return status; + } + *old_neg_state = call->inv->neg ? + pjmedia_sdp_neg_get_state(call->inv->neg) : PJMEDIA_SDP_NEG_STATE_NULL; + if (!*sdp) + status = create_unsupplied_offer(call, sdp, &generated_sdp); + if (status != PJ_SUCCESS) { + pjsua_perror(THIS_FILE, "Unable to create SDP offer", status); + rollback_failed_call_operation(call, *old_neg_state, old_opt); + } + return status; +} + +/* + * Send re-INVITE with an optional application-supplied SDP offer. + */ +PJ_DEF(pj_status_t) pjsua_call_reinvite3( + pjsua_call_id call_id, + const pjsua_call_op_param *param) +{ + const pjsua_call_setting *opt = param ? param->opt : NULL; + const pjmedia_sdp_session *offer = param ? param->sdp : NULL; + const pjsua_msg_data *msg_data = param ? param->msg_data : NULL; + const pjmedia_sdp_session *sdp = offer; pj_str_t *new_contact = NULL; pjsip_tx_data *tdata; pjsua_call *call; + pjsua_call_setting old_opt; pjsip_dialog *dlg = NULL; + pjmedia_sdp_neg_state old_neg_state = PJMEDIA_SDP_NEG_STATE_NULL; pj_status_t status; @@ -3713,7 +4119,7 @@ PJ_DEF(pj_status_t) pjsua_call_reinvite2(pjsua_call_id call_id, PJ_LOG(4,(THIS_FILE, "Sending re-INVITE on call %d", call_id)); pj_log_push_indent(); - status = acquire_call("pjsua_call_reinvite2()", call_id, &call, &dlg); + status = acquire_call("pjsua_call_reinvite3()", call_id, &call, &dlg); if (status != PJ_SUCCESS) goto on_return; @@ -3737,25 +4143,9 @@ PJ_DEF(pj_status_t) pjsua_call_reinvite2(pjsua_call_id call_id, goto on_return; } - status = apply_call_setting(call, opt, NULL); - if (status != PJ_SUCCESS) { - pjsua_perror(THIS_FILE, "Failed to apply call setting", status); + status = prepare_call_offer(call, opt, &sdp, &old_opt, &old_neg_state); + if (status != PJ_SUCCESS) goto on_return; - } - - /* Create SDP */ - if (call->local_hold && (call->opt.flag & PJSUA_CALL_UNHOLD)==0) { - status = create_sdp_of_call_hold(call, &sdp); - } else if ((call->opt.flag & PJSUA_CALL_NO_SDP_OFFER) == 0) { - status = pjsua_media_channel_create_sdp(call->index, - call->inv->pool_prov, - NULL, &sdp, NULL); - } - if (status != PJ_SUCCESS) { - pjsua_perror(THIS_FILE, "Unable to get SDP from media endpoint", - status); - goto on_return; - } if ((call->opt.flag & PJSUA_CALL_UPDATE_CONTACT) && pjsua_acc_is_valid(call->acc_id)) @@ -3775,7 +4165,7 @@ PJ_DEF(pj_status_t) pjsua_call_reinvite2(pjsua_call_id call_id, status = dlg_set_target(dlg, &msg_data->target_uri); if (status != PJ_SUCCESS) { pjsua_perror(THIS_FILE, "Unable to set new target", status); - goto on_return; + goto on_error; } } @@ -3783,7 +4173,7 @@ PJ_DEF(pj_status_t) pjsua_call_reinvite2(pjsua_call_id call_id, status = pjsip_inv_reinvite( call->inv, new_contact, sdp, &tdata); if (status != PJ_SUCCESS) { pjsua_perror(THIS_FILE, "Unable to create re-INVITE", status); - goto on_return; + goto on_error; } /* Add additional headers etc */ @@ -3799,6 +4189,54 @@ PJ_DEF(pj_status_t) pjsua_call_reinvite2(pjsua_call_id call_id, call->local_hold = PJ_FALSE; } else if (status != PJ_SUCCESS) { pjsua_perror(THIS_FILE, "Unable to send re-INVITE", status); + goto on_error; + } + goto on_return; + +on_error: + rollback_failed_call_operation(call, old_neg_state, &old_opt); +on_return: + if (dlg) pjsip_dlg_dec_lock(dlg); + pj_log_pop_indent(); + return status; +} + + +/* + * Set/replace/clear the call's local SDP directly, bypassing pjsua's own SDP + * generation (on_call_sdp_created()). + */ +PJ_DEF(pj_status_t) pjsua_call_set_sdp(pjsua_call_id call_id, + const pjmedia_sdp_session *sdp) +{ + pjsua_call *call; + pjsip_dialog *dlg = NULL; + pj_status_t status; + + PJ_ASSERT_RETURN(call_id>=0 && call_id<(int)pjsua_var.ua_cfg.max_calls, + PJ_EINVAL); + PJ_LOG(4,(THIS_FILE, "%s local SDP on call %d", + sdp ? "Setting" : "Cancelling", call_id)); + pj_log_push_indent(); + + status = acquire_call("pjsua_call_set_sdp()", call_id, &call, &dlg); + if (status != PJ_SUCCESS) + goto on_return; + + if (!call->inv) { + status = PJ_EINVALIDOP; + goto on_return; + } + + status = validate_managed_sdp( + (call->opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED) != 0, + sdp, call->inv); + if (status != PJ_SUCCESS) + goto on_return; + + status = pjsip_inv_set_local_sdp(call->inv, sdp); + if (status != PJ_SUCCESS) { + pjsua_perror(THIS_FILE, "Unable to set local SDP", status); goto on_return; } @@ -3817,6 +4255,7 @@ PJ_DEF(pj_status_t) pjsua_call_update( pjsua_call_id call_id, const pjsua_msg_data *msg_data) { pjsua_call *call; + pjsua_call_setting opt; pjsip_dialog *dlg = NULL; pj_status_t status; @@ -3832,10 +4271,9 @@ PJ_DEF(pj_status_t) pjsua_call_update( pjsua_call_id call_id, goto on_return; } - if (options != call->opt.flag) - call->opt.flag = options; - - status = pjsua_call_update2(call_id, &call->opt, msg_data); + opt = call->opt; + opt.flag = options; + status = pjsua_call_update2(call_id, &opt, msg_data); on_return: if (dlg) pjsip_dlg_dec_lock(dlg); @@ -3850,11 +4288,32 @@ PJ_DEF(pj_status_t) pjsua_call_update2(pjsua_call_id call_id, const pjsua_call_setting *opt, const pjsua_msg_data *msg_data) { - pjmedia_sdp_session *sdp = NULL; + pjsua_call_op_param param; + + pjsua_call_op_param_default(¶m); + param.opt = opt; + param.msg_data = msg_data; + return pjsua_call_update3(call_id, ¶m); +} + + +/* + * Send UPDATE with an optional application-supplied SDP offer. + */ +PJ_DEF(pj_status_t) pjsua_call_update3( + pjsua_call_id call_id, + const pjsua_call_op_param *param) +{ + const pjsua_call_setting *opt = param ? param->opt : NULL; + const pjmedia_sdp_session *offer = param ? param->sdp : NULL; + const pjsua_msg_data *msg_data = param ? param->msg_data : NULL; + const pjmedia_sdp_session *sdp = offer; pj_str_t *new_contact = NULL; pjsip_tx_data *tdata; pjsua_call *call; + pjsua_call_setting old_opt; pjsip_dialog *dlg = NULL; + pjmedia_sdp_neg_state old_neg_state = PJMEDIA_SDP_NEG_STATE_NULL; pj_status_t status; PJ_ASSERT_RETURN(call_id>=0 && call_id<(int)pjsua_var.ua_cfg.max_calls, @@ -3863,7 +4322,7 @@ PJ_DEF(pj_status_t) pjsua_call_update2(pjsua_call_id call_id, PJ_LOG(4,(THIS_FILE, "Sending UPDATE on call %d", call_id)); pj_log_push_indent(); - status = acquire_call("pjsua_call_update2()", call_id, &call, &dlg); + status = acquire_call("pjsua_call_update3()", call_id, &call, &dlg); if (status != PJ_SUCCESS) goto on_return; @@ -3884,26 +4343,9 @@ PJ_DEF(pj_status_t) pjsua_call_update2(pjsua_call_id call_id, goto on_return; } - status = apply_call_setting(call, opt, NULL); - if (status != PJ_SUCCESS) { - pjsua_perror(THIS_FILE, "Failed to apply call setting", status); - goto on_return; - } - - /* Create SDP */ - if (call->local_hold && (call->opt.flag & PJSUA_CALL_UNHOLD)==0) { - status = create_sdp_of_call_hold(call, &sdp); - } else if ((call->opt.flag & PJSUA_CALL_NO_SDP_OFFER) == 0) { - status = pjsua_media_channel_create_sdp(call->index, - call->inv->pool_prov, - NULL, &sdp, NULL); - } - - if (status != PJ_SUCCESS) { - pjsua_perror(THIS_FILE, "Unable to get SDP from media endpoint", - status); + status = prepare_call_offer(call, opt, &sdp, &old_opt, &old_neg_state); + if (status != PJ_SUCCESS) goto on_return; - } if ((call->opt.flag & PJSUA_CALL_UPDATE_CONTACT) && pjsua_acc_is_valid(call->acc_id)) @@ -3923,7 +4365,7 @@ PJ_DEF(pj_status_t) pjsua_call_update2(pjsua_call_id call_id, status = dlg_set_target(dlg, &msg_data->target_uri); if (status != PJ_SUCCESS) { pjsua_perror(THIS_FILE, "Unable to set new target", status); - goto on_return; + goto on_error; } } @@ -3931,7 +4373,7 @@ PJ_DEF(pj_status_t) pjsua_call_update2(pjsua_call_id call_id, status = pjsip_inv_update(call->inv, new_contact, sdp, &tdata); if (status != PJ_SUCCESS) { pjsua_perror(THIS_FILE, "Unable to create UPDATE request", status); - goto on_return; + goto on_error; } /* Add additional headers etc */ @@ -3947,9 +4389,12 @@ PJ_DEF(pj_status_t) pjsua_call_update2(pjsua_call_id call_id, call->local_hold = PJ_FALSE; } else if (status != PJ_SUCCESS) { pjsua_perror(THIS_FILE, "Unable to send UPDATE request", status); - goto on_return; + goto on_error; } + goto on_return; +on_error: + rollback_failed_call_operation(call, old_neg_state, &old_opt); on_return: if (dlg) pjsip_dlg_dec_lock(dlg); pj_log_pop_indent(); @@ -4549,8 +4994,8 @@ static pj_bool_t check_lock_codec(pjsua_call *call) continue; } - /* Remote may answer with less media lines. */ - if (i >= remote_sdp->media_count) + /* App-managed local and remote SDP counts may differ. */ + if (i >= remote_sdp->media_count || i >= local_sdp->media_count) continue; rem_m = remote_sdp->media[i]; @@ -5970,6 +6415,7 @@ static void pjsua_call_on_rx_offer(pjsip_inv_session *inv, const pjmedia_sdp_session *offer = param->offer; pjsua_call_setting opt; pj_bool_t async = PJ_FALSE; + pj_bool_t app_answer = PJ_FALSE; call = (pjsua_call*) inv->dlg->mod_data[pjsua_var.mod.id]; if (call->hanging_up) @@ -6033,7 +6479,6 @@ static void pjsua_call_on_rx_offer(pjsip_inv_session *inv, goto on_return; } - call->opt = opt; } if (pjsua_var.ua_cfg.cb.on_call_rx_offer && !async) { @@ -6043,25 +6488,42 @@ static void pjsua_call_on_rx_offer(pjsip_inv_session *inv, &code, &opt); if (code != PJSIP_SC_OK) { + cancel_pending_local_answer(call->inv); PJ_LOG(4,(THIS_FILE, "Rejecting updated media offer on call %d", call->index)); goto on_return; } - call->opt = opt; } + app_answer = has_pending_local_answer(call->inv); + /* Re-init media for the new remote offer before creating SDP. When the * app answers the re-INVITE itself (async), it may accept media that * pjsua does not manage (e.g. T.38), so such offer must not be rejected * here as "no media". */ call->offer_app_managed = async; - status = apply_call_setting(call, &call->opt, offer); + status = apply_call_setting(call, &opt, offer); call->offer_app_managed = PJ_FALSE; - if (status != PJ_SUCCESS) + if (status != PJ_SUCCESS) { + if (app_answer) + cancel_pending_local_answer(call->inv); + goto on_return; + } + + if (async && (call->opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED)) { + call->rx_reinv_async = async; + goto on_return; + } + + if (app_answer) goto on_return; + if (call->opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED) { + PJ_LOG(3,(THIS_FILE, "Application SDP answer is required")); + goto on_return; + } status = pjsua_media_channel_create_sdp(call->index, call->inv->pool_prov, offer, &answer, NULL); @@ -6133,10 +6595,76 @@ static pj_status_t pjsua_call_on_rx_reinvite(pjsip_inv_session *inv, pjsua_call *call; pj_bool_t async; - PJ_UNUSED_ARG(offer); - PJ_UNUSED_ARG(rdata); - call = (pjsua_call*) inv->dlg->mod_data[pjsua_var.mod.id]; + + if (!offer && pjsua_call_media_is_app_managed(call)) { + pjsua_call_setting opt; + pjsip_status_code code = PJSIP_SC_OK; + pjsip_tx_data *response = NULL; + pj_status_t status; + + /* Clean up transient flags on a local copy, not on call->opt + * directly: call->opt must stay untouched until the application + * (via the callback below) and this function have both decided + * to actually accept the re-INVITE. + */ + opt = call->opt; + pjsua_call_cleanup_flag(&opt); + async = PJ_FALSE; + + if (pjsua_var.ua_cfg.cb.on_call_rx_reinvite) { + (*pjsua_var.ua_cfg.cb.on_call_rx_reinvite)( + call->index, NULL, rdata, + NULL, &async, &code, &opt); + } + + if (code/100 == 2) { + code = PJSIP_SC_OK; + } else if (code < 300 || code > 699) { + PJ_LOG(2,(THIS_FILE, "Invalid synchronous offerless re-INVITE " + "response %d on call %d", code, call->index)); + code = PJSIP_SC_INTERNAL_SERVER_ERROR; + } + if (code != PJSIP_SC_OK) + async = PJ_FALSE; + + /* Apply callback settings only when accepting the re-INVITE. */ + if (code == PJSIP_SC_OK) { + status = apply_call_setting(call, &opt, NULL); + if (status != PJ_SUCCESS) { + PJ_PERROR(3,(THIS_FILE, status, + "Unable to apply call setting for offerless " + "re-INVITE on call %d", call->index)); + code = PJSIP_ERRNO_TO_SIP_STATUS(status); + if (code == 599) + code = (status == PJ_ETOOMANY || status == PJ_EINVAL || + status == PJ_EINVALIDOP) ? + PJSIP_SC_NOT_ACCEPTABLE_HERE : + PJSIP_SC_INTERNAL_SERVER_ERROR; + async = PJ_FALSE; + } + } + + if (!async && code == PJSIP_SC_OK) { + return !PJ_SUCCESS; + } + + status = pjsip_inv_initial_answer(inv, rdata, + async ? PJSIP_SC_TRYING : code, + NULL, NULL, &response); + if (status == PJ_SUCCESS) + status = pjsip_inv_send_msg(inv, response); + else if (response) + pjsip_tx_data_dec_ref(response); + + if (status != PJ_SUCCESS) { + PJ_PERROR(3,(THIS_FILE, status, + "Unable to respond to offerless re-INVITE")); + } + + return status; + } + async = call->rx_reinv_async; call->rx_reinv_async = PJ_FALSE; @@ -6292,6 +6820,11 @@ static void pjsua_call_on_create_offer(pjsip_inv_session *inv, ERR_MEDIA_CHANGING)); goto on_return; } + + if (pjsua_call_media_is_app_managed(call)) { + *offer = NULL; + goto on_return; + } #if RESTART_ICE_ON_REINVITE @@ -6631,6 +7164,17 @@ static void on_call_transferred( pjsip_inv_session *inv, goto on_return; } + /* Automatic transfers cannot supply application-managed SDP. */ + if (pjsua_call_media_is_app_managed(existing_call) || + acc_media_app_managed_default(existing_call->acc_id)) + { + PJ_LOG(3,(THIS_FILE, "Rejecting REFER for app-managed call %d", + existing_call->index)); + pjsip_dlg_respond(inv->dlg, rdata, PJSIP_SC_NOT_ACCEPTABLE_HERE, + NULL, NULL, NULL); + goto on_return; + } + /* Find optional Refer-Sub header */ refer_sub = (pjsip_generic_string_hdr*) pjsip_msg_find_hdr_by_name(rdata->msg_info.msg, &str_refer_sub, NULL); @@ -6666,6 +7210,8 @@ static void on_call_transferred( pjsip_inv_session *inv, if (code < 200) code = PJSIP_SC_ACCEPTED; + if (code < 300 && (call_opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED)) + code = PJSIP_SC_NOT_ACCEPTABLE_HERE; if (code >= 300) { /* Application rejects call transfer request */ pjsip_dlg_respond( inv->dlg, rdata, code, NULL, NULL, NULL); diff --git a/pjsip/src/pjsua-lib/pjsua_media.c b/pjsip/src/pjsua-lib/pjsua_media.c index 7916681eee..2c659e7a80 100644 --- a/pjsip/src/pjsua-lib/pjsua_media.c +++ b/pjsip/src/pjsua-lib/pjsua_media.c @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008-2011 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2008-2026 Teluu Inc. (http://www.teluu.com) * Copyright (C) 2003-2008 Benny Prijono * * This program is free software; you can redistribute it and/or modify @@ -2476,10 +2476,15 @@ static pj_bool_t sdp_has_active_media(const pjmedia_sdp_session *sdp) * build without video keeps reporting an offered video line as unsupported * media instead of silently treating it as media the application owns. */ -static pj_bool_t is_stream_media(const pjmedia_sdp_media *m) +static pj_bool_t is_stream_media(const pjsua_call *call, + const pjmedia_sdp_media *m) { - pjmedia_type type = pjmedia_get_type(&m->desc.media); + pjmedia_type type; + if (pjsua_call_media_is_app_managed(call)) + return PJ_FALSE; + + type = pjmedia_get_type(&m->desc.media); return (type == PJMEDIA_TYPE_AUDIO || type == PJMEDIA_TYPE_VIDEO || type == PJMEDIA_TYPE_TEXT); } @@ -2597,11 +2602,14 @@ pj_status_t pjsua_media_channel_init(pjsua_call_id call_id, mtxtidx, &mtxtcnt, &mtottxtcnt); if (maudcnt + mvidcnt + mtxtcnt == 0 && + !(call->opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED) && !(call->offer_app_managed && sdp_has_active_media(rem_sdp))) { - /* Expecting media in the offer, unless the app is answering the - * offer itself and may accept active media that pjsua does not - * manage (e.g. T.38), see pjsua_media_channel_update(). + /* Expecting media in the offer, unless the call is fully + * app-managed (the application owns all media unconditionally), + * or this is an async re-offer the app is answering itself and + * the offer carries active media that pjsua does not manage + * (e.g. T.38), see pjsua_media_channel_update(). */ if (sip_err_code) *sip_err_code = PJSIP_SC_NOT_ACCEPTABLE_HERE; @@ -2622,6 +2630,9 @@ pj_status_t pjsua_media_channel_init(pjsua_call_id call_id, call->rem_vid_cnt = mvidcnt; call->rem_txt_cnt = mtxtcnt; + } else if (pjsua_call_media_is_app_managed(call)) { + call->rem_offerer = PJ_FALSE; + } else { /* If call is already established, adjust the existing call media list @@ -2796,7 +2807,7 @@ pj_status_t pjsua_media_channel_init(pjsua_call_id call_id, call->rem_offerer = PJ_FALSE; } - if (call->med_prov_cnt == 0) { + if (call->med_prov_cnt == 0 && !pjsua_call_media_is_app_managed(call)) { /* Expecting at least one media */ if (sip_err_code) *sip_err_code = PJSIP_SC_NOT_ACCEPTABLE_HERE; @@ -2846,6 +2857,11 @@ pj_status_t pjsua_media_channel_init(pjsua_call_id call_id, } } + if (call->opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED) { + /* The application owns all media transports. */ + enabled = PJ_FALSE; + } + if (call->opt.flag & PJSUA_CALL_SET_MEDIA_DIR) { call_med->def_dir = call->opt.media_dir[mi]; PJ_LOG(4,(THIS_FILE, "Call %d: setting media direction " @@ -4659,6 +4675,7 @@ pj_status_t pjsua_media_channel_update(pjsua_call_id call_id, unsigned mtxtcnt = PJ_ARRAY_SIZE(mtxtidx); unsigned mtottxtcnt = PJ_ARRAY_SIZE(mtxtidx); pj_bool_t need_renego_sdp = PJ_FALSE; + pj_bool_t app_managed; if (pjsua_get_state() != PJSUA_STATE_RUNNING) return PJ_EBUSY; @@ -4669,6 +4686,25 @@ pj_status_t pjsua_media_channel_update(pjsua_call_id call_id, /* Destroy existing media session, if any. */ //stop_media_session(call->index); + app_managed = pjsua_call_media_is_app_managed(call); + got_media = app_managed; + + if (local_sdp->media_count > PJSUA_MAX_CALL_MEDIA) { + PJ_LOG(1,(THIS_FILE, "Call %d: SDP media count %u exceeds maximum " + "%u", call_id, local_sdp->media_count, + PJSUA_MAX_CALL_MEDIA)); + status = PJ_ETOOMANY; + goto on_error; + } + + if (app_managed && call->med_prov_cnt < local_sdp->media_count) { + mi = call->med_prov_cnt; + pj_memcpy(&call->media_prov[mi], &call->media[mi], + sizeof(call->media[0]) * + (local_sdp->media_count - call->med_prov_cnt)); + call->med_prov_cnt = local_sdp->media_count; + } + /* Call media count must be at least equal to SDP media. Note that * it may not be equal when remote removed any SDP media line. */ @@ -4677,6 +4713,13 @@ pj_status_t pjsua_media_channel_update(pjsua_call_id call_id, /* Reset audio_idx first */ call->audio_idx = -1; + if (app_managed) { + maudcnt = mtotaudcnt = 0; + mvidcnt = mtotvidcnt = 0; + mtxtcnt = mtottxtcnt = 0; + goto media_sorted; + } + /* Sort audio/video based on "quality" */ sort_media(local_sdp, &STR_AUDIO, acc->cfg.use_srtp, maudidx, &maudcnt, &mtotaudcnt); @@ -4743,13 +4786,14 @@ pj_status_t pjsua_media_channel_update(pjsua_call_id call_id, { continue; } - + /* Deactivate this excess media */ pjmedia_sdp_media_deactivate(tmp_pool, m); } } } +media_sorted: /* Update call media from provisional media */ call->med_cnt = call->med_prov_cnt; pj_memcpy(call->media, call->media_prov, @@ -4822,7 +4866,7 @@ pj_status_t pjsua_media_channel_update(pjsua_call_id call_id, * (app-supplied) answer keeps the line active, count it as media so the * call is not dropped for having no media. */ - if (!is_stream_media(remote_sdp->media[mi])) { + if (!is_stream_media(call, remote_sdp->media[mi])) { stop_media_stream(call, mi, PJ_FALSE); close_call_med_tp(call_med); call_med->type = PJMEDIA_TYPE_UNKNOWN; @@ -5076,4 +5120,3 @@ pj_status_t pjsua_media_apply_xml_control(pjsua_call_id call_id, return PJ_ENOTSUP; } - diff --git a/pjsip/src/pjsua-lib/pjsua_vid.c b/pjsip/src/pjsua-lib/pjsua_vid.c index 65d37dacde..fe5a460e0e 100644 --- a/pjsip/src/pjsua-lib/pjsua_vid.c +++ b/pjsip/src/pjsua-lib/pjsua_vid.c @@ -2834,6 +2834,13 @@ PJ_DEF(pj_status_t) pjsua_call_set_vid_strm ( if (status != PJ_SUCCESS) goto on_return; + if (pjsua_call_media_is_app_managed(call)) { + PJ_LOG(3,(THIS_FILE, "Can not change video stream on " + "app-managed call %d", call_id)); + status = PJ_EINVALIDOP; + goto on_return; + } + if (call->hanging_up) { PJ_LOG(3,(THIS_FILE, "Can not change video stream on call %d while it is " diff --git a/pjsip/src/pjsua2/account.cpp b/pjsip/src/pjsua2/account.cpp index 76e597abd1..42db6f740e 100644 --- a/pjsip/src/pjsua2/account.cpp +++ b/pjsip/src/pjsua2/account.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2013 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2013-2026 Teluu Inc. (http://www.teluu.com) * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -399,6 +399,7 @@ void AccountCallConfig::readObject(const ContainerNode &node) NODE_READ_UNSIGNED( this_node, timerMinSESec); NODE_READ_UNSIGNED( this_node, timerSessExpiresSec); NODE_READ_BOOL_OPT( this_node, siprecRequireMetadata); + NODE_READ_BOOL_OPT( this_node, mediaAppManaged); } void AccountCallConfig::writeObject(ContainerNode &node) const @@ -414,6 +415,7 @@ void AccountCallConfig::writeObject(ContainerNode &node) const NODE_WRITE_UNSIGNED( this_node, timerMinSESec); NODE_WRITE_UNSIGNED( this_node, timerSessExpiresSec); NODE_WRITE_BOOL ( this_node, siprecRequireMetadata); + NODE_WRITE_BOOL ( this_node, mediaAppManaged); } /////////////////////////////////////////////////////////////////////////////// @@ -752,6 +754,7 @@ void AccountConfig::toPj(pjsua_acc_config &ret) const ret.siprec_require_metadata = callConfig.siprecRequireMetadata; ret.timer_setting.min_se = callConfig.timerMinSESec; ret.timer_setting.sess_expires = callConfig.timerSessExpiresSec; + ret.media_app_managed = callConfig.mediaAppManaged; // AccountPresConfig for (i=0; igetId(), &pj_dst_uri, - param.p_opt, this, - param.p_msg_data, &id) ); + PoolGuard pool_guard(prm.sdp.wholeSdp.empty()? NULL: "tmp-make-call", + "Call::makeCall()"); + + call_param param(prm.txOption, prm.opt, prm.reason, + pool_guard.get(), prm.sdp.wholeSdp); + pjsua_call_op_param op_param; + PJSUA2_CHECK_RAISE_ERROR2(param.sdp_status, "Call::makeCall()"); + + pjsua_call_op_param_default(&op_param); + op_param.opt = param.p_opt; + op_param.sdp = param.sdp; + op_param.user_data = this; + op_param.msg_data = param.p_msg_data; + PJSUA2_CHECK_EXPR( pjsua_call_make_call2(acc->getId(), &pj_dst_uri, + &op_param, &id) ); } void Call::answer(const CallOpParam &prm) PJSUA2_THROW(Error) { - pj_pool_t *tmp_pool = NULL; - - /* Create temporary pool for SDP operations if SDP is provided */ - if (!prm.sdp.wholeSdp.empty()) { - tmp_pool = pjsua_pool_create("tmp-answer", 2048, 512); - if (!tmp_pool) { - PJSUA2_RAISE_ERROR2(PJ_ENOMEM, "Call::answer()"); - } - } - + PoolGuard pool_guard(prm.sdp.wholeSdp.empty()? NULL: "tmp-answer", + "Call::answer()"); + call_param param(prm.txOption, prm.opt, prm.reason, - tmp_pool, prm.sdp.wholeSdp); + pool_guard.get(), prm.sdp.wholeSdp); + PJSUA2_CHECK_RAISE_ERROR2(param.sdp_status, "Call::answer()"); if (param.sdp) { PJSUA2_CHECK_EXPR( pjsua_call_answer_with_sdp(id, param.sdp, @@ -786,10 +796,6 @@ void Call::answer(const CallOpParam &prm) PJSUA2_THROW(Error) param.p_msg_data) ); } - /* Release temporary pool */ - if (tmp_pool) { - pj_pool_release(tmp_pool); - } } void Call::hangup(const CallOpParam &prm) PJSUA2_THROW(Error) @@ -810,18 +816,36 @@ void Call::setHold(const CallOpParam &prm) PJSUA2_THROW(Error) void Call::reinvite(const CallOpParam &prm) PJSUA2_THROW(Error) { - call_param param(prm.txOption, prm.opt, prm.reason); + PoolGuard pool_guard(prm.sdp.wholeSdp.empty()? NULL: "tmp-reinvite", + "Call::reinvite()"); - PJSUA2_CHECK_EXPR( pjsua_call_reinvite2(id, param.p_opt, - param.p_msg_data) ); + call_param param(prm.txOption, prm.opt, prm.reason, + pool_guard.get(), prm.sdp.wholeSdp); + pjsua_call_op_param op_param; + PJSUA2_CHECK_RAISE_ERROR2(param.sdp_status, "Call::reinvite()"); + + pjsua_call_op_param_default(&op_param); + op_param.opt = param.p_opt; + op_param.sdp = param.sdp; + op_param.msg_data = param.p_msg_data; + PJSUA2_CHECK_EXPR( pjsua_call_reinvite3(id, &op_param) ); } void Call::update(const CallOpParam &prm) PJSUA2_THROW(Error) { - call_param param(prm.txOption, prm.opt, prm.reason); - - PJSUA2_CHECK_EXPR( pjsua_call_update2(id, param.p_opt, - param.p_msg_data) ); + PoolGuard pool_guard(prm.sdp.wholeSdp.empty()? NULL: "tmp-update", + "Call::update()"); + + call_param param(prm.txOption, prm.opt, prm.reason, + pool_guard.get(), prm.sdp.wholeSdp); + pjsua_call_op_param op_param; + PJSUA2_CHECK_RAISE_ERROR2(param.sdp_status, "Call::update()"); + + pjsua_call_op_param_default(&op_param); + op_param.opt = param.p_opt; + op_param.sdp = param.sdp; + op_param.msg_data = param.p_msg_data; + PJSUA2_CHECK_EXPR( pjsua_call_update3(id, &op_param) ); } void Call::xfer(const string &dest, const CallOpParam &prm) diff --git a/pjsip/src/pjsua2/endpoint.cpp b/pjsip/src/pjsua2/endpoint.cpp index f90267d4c7..bc7ae5dfac 100644 --- a/pjsip/src/pjsua2/endpoint.cpp +++ b/pjsip/src/pjsua2/endpoint.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2013 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2013-2026 Teluu Inc. (http://www.teluu.com) * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -1955,6 +1955,9 @@ void Endpoint::on_call_rx_offer(pjsua_call_id call_id, pjsip_status_code *code, pjsua_call_setting *opt) { + pjmedia_sdp_session *answer; + pj_status_t status; + PJ_UNUSED_ARG(reserved); Call *call = Call::lookup(call_id); @@ -1963,11 +1966,36 @@ void Endpoint::on_call_rx_offer(pjsua_call_id call_id, } OnCallRxOfferParam prm; + bool media_app_managed = + (opt->flag & PJSUA_CALL_MEDIA_APP_MANAGED) != 0; prm.offer.fromPj(*offer); prm.statusCode = *code; prm.opt.fromPj(*opt); call->onCallRxOffer(prm); + + if (!media_app_managed && !prm.answer.wholeSdp.empty()) { + PJ_LOG(2,(THIS_FILE, "Ignoring application SDP answer for ordinary " + "call %d", call_id)); + } else if (prm.statusCode == PJSIP_SC_OK && + !prm.answer.wholeSdp.empty()) + { + try { + PoolGuard pool_guard("rx-offer", "onCallRxOffer()"); + status = parseCallSdp(pool_guard.get(), prm.answer.wholeSdp, + &answer); + if (status == PJ_SUCCESS) + status = pjsua_call_set_sdp(call_id, answer); + } catch (Error &err) { + status = err.status; + } + + if (status != PJ_SUCCESS) { + PJ_PERROR(1,(THIS_FILE, status, + "Failed to set SDP answer for call %d", call_id)); + prm.statusCode = PJSIP_SC_NOT_ACCEPTABLE_HERE; + } + } *code = prm.statusCode; *opt = prm.opt.toPj(); @@ -1989,7 +2017,8 @@ void Endpoint::on_call_rx_reinvite(pjsua_call_id call_id, } OnCallRxReinviteParam prm; - prm.offer.fromPj(*offer); + if (offer) + prm.offer.fromPj(*offer); prm.rdata.fromPj(*rdata); prm.isAsync = PJ2BOOL(*async); prm.statusCode = *code; diff --git a/pjsip/src/pjsua2/util.hpp b/pjsip/src/pjsua2/util.hpp index b90de9a886..3161ce5ffd 100644 --- a/pjsip/src/pjsua2/util.hpp +++ b/pjsip/src/pjsua2/util.hpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2013 Teluu Inc. (http://www.teluu.com) + * Copyright (C) 2013-2026 Teluu Inc. (http://www.teluu.com) * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -41,6 +41,35 @@ inline string pj2Str(const pj_str_t &input_str) return string(); } +inline pj_status_t parseCallSdp(pj_pool_t *pool, const string &text, + pjmedia_sdp_session **sdp) +{ + pj_str_t source = str2Pj(text); + pj_str_t copy; + + pj_strdup(pool, ©, &source); + return pjmedia_sdp_parse(pool, copy.ptr, copy.slen, sdp); +} + +class PoolGuard +{ +public: + PoolGuard(const char *name, const char *operation); + + ~PoolGuard() + { + if (pool_) + pj_pool_release(pool_); + } + + pj_pool_t *get() const { return pool_; } + +private: + PoolGuard(const PoolGuard &); + PoolGuard &operator=(const PoolGuard &); + pj_pool_t *pool_; +}; + class AudioMediaHelper : public AudioMedia { public: diff --git a/pjsip/src/test/pjsua_call_test.c b/pjsip/src/test/pjsua_call_test.c index e4d96e16db..0afda2edbf 100644 --- a/pjsip/src/test/pjsua_call_test.c +++ b/pjsip/src/test/pjsua_call_test.c @@ -78,6 +78,36 @@ /* SIP user for the loopback account. */ #define TEST_USER "pjsua-call-test" +#define TEST_TIMEOUT_MSEC 8000 +#define TEST_POLL_MSEC 50 +#define MEDIA_SETTLE_MSEC 500 +#define IOQUEUE_CLEANUP_MARGIN_MSEC 200 + +static const char app_managed_audio_sdp[] = + "v=0\r\n" + "o=- 1 1 IN IP4 127.0.0.1\r\n" + "s=-\r\n" + "c=IN IP4 127.0.0.1\r\n" + "t=0 0\r\n" + "m=audio 4000 RTP/AVP 0\r\n"; + +static const char app_managed_audio_reoffer_sdp[] = + "v=0\r\n" + "o=- 1 2 IN IP4 127.0.0.1\r\n" + "s=-\r\n" + "c=IN IP4 127.0.0.1\r\n" + "t=0 0\r\n" + "m=audio 4002 RTP/AVP 0\r\n"; + +static const char app_managed_two_audio_sdp[] = + "v=0\r\n" + "o=- 1 1 IN IP4 127.0.0.1\r\n" + "s=-\r\n" + "c=IN IP4 127.0.0.1\r\n" + "t=0 0\r\n" + "m=audio 4000 RTP/AVP 0\r\n" + "m=audio 4002 RTP/AVP 0\r\n"; + /* pjmedia globals (defined in endpoint.c; config.h only doc-comments them). */ extern pj_bool_t pjmedia_add_rtpmap_for_static_pt; @@ -168,6 +198,37 @@ static struct pj_bool_t incoming_seen; /* Incoming (callee) leg of the most recent self-call. */ pjsua_call_id incoming_call_id; + pj_bool_t initial_app_managed_armed; + pj_bool_t initial_app_managed_seen; + pj_bool_t initial_app_managed_remote_offer; + pj_status_t initial_app_managed_plain_status; + pj_status_t initial_app_managed_progress_status; + pj_status_t initial_app_managed_invalid_status; + pj_status_t initial_app_managed_oversize_status; + pj_status_t initial_app_managed_answer_status; + int initial_app_managed_mismatch_result; + pj_bool_t first_class_offer_armed; + pjsua_call_id first_class_offer_caller; + pjsua_call_id first_class_offer_callee; + pj_status_t first_class_set_status; + unsigned first_class_offer_count; + int first_class_reinv_code; + int first_class_update_code; + unsigned first_class_expected_port; + pj_bool_t offerless_reinv_armed; + pj_bool_t offerless_reinv_seen; + pj_status_t offerless_reinv_set_status; + pjsip_status_code managed_reinv_response; + int managed_refer_code; + pj_bool_t async_sdp_armed; + pjsua_call_id async_sdp_callee; + pj_pool_t *async_sdp_pool; + pjmedia_sdp_session *async_sdp_answer; + unsigned async_sdp_count; + pj_bool_t update_reinvite_armed; + pj_bool_t update_reinvite_seen; + pjsua_call_id update_reinvite_caller; + pj_status_t update_reinvite_status; /* When set, on_call_sdp_created appends an m=application line to the * local SDP offer, so the peer establishes a call holding a media slot * whose type is neither audio, video nor text. */ @@ -227,10 +288,198 @@ static struct int app_med_reinv_code; pj_pool_t *app_med_pool; pjmedia_sdp_session *app_med_offer; + /* When set together with app_med_armed, on_call_sdp_created instead + * deactivates every m-line of the offer (port 0, no appended line), + * i.e. an offer with no active media at all, for + * test_async_reoffer_no_active_media(). + */ + pj_bool_t app_med_zero_media; + /* ---- app-managed offerless INVITE (3PCC) ---- */ + + /* When armed, on_incoming_call records that the offerless INVITE left the + * negotiator unset, that answering without application SDP is rejected, + * and that supplying it with pjsua_call_set_sdp() then succeeds. + * on_call_rx_offer answers the offer the caller receives in the 200. + */ + pj_bool_t offerless_armed; + pj_bool_t offerless_neg_null; + pj_bool_t offerless_neg_still_null; + pj_status_t offerless_progress_status; + pj_bool_t app_managed_bodyless_progress_seen; + pj_status_t offerless_ringing_status; + pj_status_t offerless_plain_status; + pj_status_t offerless_set_status; + pj_status_t offerless_caller_set_status; + pj_status_t offerless_answer_status; + pj_status_t offerless_rollback_status; + pj_bool_t offerless_rollback_neg_null; + pj_status_t offerless_retry_plain_status; + + pj_bool_t uas_offer_armed; + pj_status_t uas_offer_answer_status; + + pj_bool_t answer_rollback_armed; + pj_bool_t answer_rollback_inject; + pj_bool_t answer_rollback_reject; + pjsua_call_id answer_rollback_caller; + pjsua_call_id answer_rollback_callee; + pj_status_t answer_rollback_set_status; + int answer_rollback_reinv_code; /* Snapshot of med_state_cnt[], to wait for the next media update. */ unsigned med_state_base[PJSUA_MAX_CALLS]; + + /* ---- incoming INVITE/Replaces vs. account-default app-managed ---- */ + + /* When armed, on_call_transfer_request2 adds PJSUA_CALL_NO_SDP_OFFER + * to the new call's setting, so that the attended-transfer-triggered + * outgoing INVITE (with a Replaces header) is not itself blocked by + * the "app-managed call needs an application SDP offer" guard in + * pjsua_call_make_call2() -- what's under test is call-replace + * rejection, not that unrelated guard. + */ + pj_bool_t xfer_replaces_armed; + /* The four call legs already known to the test (the replaced call D's + * two legs and the transferor/transferee call T's two legs). Recorded + * so on_call_tsx_state can recognize the new, transferee-placed + * outgoing call as "none of these". + */ + pjsua_call_id xfer_d_caller; + pjsua_call_id xfer_d_callee; + pjsua_call_id xfer_t_caller; + pjsua_call_id xfer_t_callee; + /* Recorded by on_call_tsx_state: the transferee-placed call's id and + * the final status code of its (replace) INVITE transaction. */ + pjsua_call_id xfer_replaces_new_call; + int xfer_replaces_new_code; + + /* ---- offerless re-INVITE applies app opt changes ---- */ + + /* When armed, on_call_rx_reinvite records that it was entered for an + * offerless re-INVITE on offerless_opt_callee, sets a media direction + * change on "opt", and either accepts (default) or rejects (per + * offerless_opt_reject) the re-INVITE -- in both cases, call->opt + * must only reflect that change if the re-INVITE was accepted. + */ + pj_bool_t offerless_opt_armed; + pjsua_call_id offerless_opt_callee; + pj_bool_t offerless_opt_reject; + pj_bool_t offerless_opt_overlimit; + pj_bool_t offerless_opt_seen; } g_ctx; +static pj_status_t parse_sdp_text(pj_pool_t *pool, const char *text, + pjmedia_sdp_session **sdp) +{ + pj_size_t len; + char *buf; + + PJ_ASSERT_RETURN(pool && text && sdp, PJ_EINVAL); + + len = pj_ansi_strlen(text); + buf = (char*)pj_pool_alloc(pool, len + 1); + if (!buf) + return PJ_ENOMEM; + + pj_memcpy(buf, text, len + 1); + return pjmedia_sdp_parse(pool, buf, len, sdp); +} + +static pjmedia_sdp_session *create_oversized_sdp( + pj_pool_t *pool, + const pjmedia_sdp_session *sdp) +{ + pjmedia_sdp_session *oversized; + + oversized = pjmedia_sdp_session_clone(pool, sdp); + while (oversized->media_count <= PJSUA_MAX_CALL_MEDIA && + oversized->media_count < PJMEDIA_MAX_SDP_MEDIA) + { + oversized->media[oversized->media_count] = + pjmedia_sdp_media_clone(pool, oversized->media[0]); + ++oversized->media_count; + } + + if (oversized->media_count <= PJSUA_MAX_CALL_MEDIA) + oversized->media_count = PJSUA_MAX_CALL_MEDIA + 1; + + return oversized; +} + +static pjmedia_sdp_session *create_missing_conn_sdp( + pj_pool_t *pool, + const pjmedia_sdp_session *sdp) +{ + pjmedia_sdp_session *invalid; + unsigned i; + + invalid = pjmedia_sdp_session_clone(pool, sdp); + invalid->conn = NULL; + for (i = 0; i < invalid->media_count; ++i) + invalid->media[i]->conn = NULL; + + return invalid; +} + +static pjmedia_sdp_session *create_missing_rtpmap_sdp( + pj_pool_t *pool, + const pjmedia_sdp_session *sdp) +{ + pjmedia_sdp_session *invalid; + + invalid = pjmedia_sdp_session_clone(pool, sdp); + invalid->media[0]->desc.fmt[0] = pj_str("96"); + + return invalid; +} + + +static int check_app_answer_media_count(pjsua_call_id call_id, pj_pool_t *pool, + const pjmedia_sdp_session *sdp, + pj_bool_t extra_media) +{ + pjsip_inv_session *inv = pjsua_var.calls[call_id].inv; + pjmedia_sdp_neg *neg = inv->neg; + pjmedia_sdp_neg_state state = pjmedia_sdp_neg_get_state(neg); + pjmedia_sdp_session *answer = pjmedia_sdp_session_clone(pool, sdp); + const pjmedia_sdp_session *old_local = NULL, *local; + pjsip_transaction *tsx = inv->invite_tsx; + int code = tsx->status_code; + pj_status_t status; + + if (state == PJMEDIA_SDP_NEG_STATE_WAIT_NEGO && + pjmedia_sdp_neg_get_neg_local(neg, &old_local) != PJ_SUCCESS) + { + return -1970; + } + if (extra_media) { + answer->media[answer->media_count] = + pjmedia_sdp_media_clone(pool, answer->media[0]); + ++answer->media_count; + } else { + --answer->media_count; + } + status = pjsua_call_set_sdp(call_id, answer); + if (status != PJMEDIA_SDPNEG_EMISMEDIA || inv->neg != neg || + pjmedia_sdp_neg_get_state(neg) != state) + { + return -1971; + } + status = pjsua_call_answer_with_sdp(call_id, answer, NULL, PJSIP_SC_OK, + NULL, NULL); + if (status != PJMEDIA_SDPNEG_EMISMEDIA || inv->neg != neg || + pjmedia_sdp_neg_get_state(neg) != state || inv->invite_tsx != tsx || + tsx->status_code != code) + { + return -1972; + } + if (old_local && + (pjmedia_sdp_neg_get_neg_local(neg, &local) != PJ_SUCCESS || + local != old_local)) + { + return -1973; + } + return 0; +} /***************************************************************************** * Callbacks @@ -245,7 +494,19 @@ static struct static void on_incoming_call(pjsua_acc_id acc_id, pjsua_call_id call_id, pjsip_rx_data *rdata) { + static const char app_managed_sdp[] = + "v=0\r\n" + "o=- 1 1 IN IP4 127.0.0.1\r\n" + "s=-\r\n" + "c=IN IP4 127.0.0.1\r\n" + "t=0 0\r\n" + "m=audio 4002 RTP/AVP 0\r\n" + "m=audio 4004 RTP/AVP 0\r\n"; pjsua_call_setting opt; + pjmedia_sdp_session *sdp; + pjmedia_sdp_session *invalid; + pjmedia_sdp_session *oversized; + pj_pool_t *pool; pj_status_t status; PJ_UNUSED_ARG(acc_id); @@ -253,6 +514,109 @@ static void on_incoming_call(pjsua_acc_id acc_id, pjsua_call_id call_id, g_ctx.incoming_call_id = call_id; + if (g_ctx.uas_offer_armed) { + g_ctx.incoming_seen = PJ_TRUE; + return; + } + + if (g_ctx.offerless_armed) { + pjsua_call *call = &pjsua_var.calls[call_id]; + + g_ctx.offerless_neg_null = (call->inv && call->inv->neg == NULL); + g_ctx.offerless_progress_status = + pjsua_call_answer(call_id, PJSIP_SC_PROGRESS, NULL, NULL); + g_ctx.offerless_neg_still_null = + (call->inv && call->inv->neg == NULL); + g_ctx.offerless_plain_status = + pjsua_call_answer(call_id, 200, NULL, NULL); + + pool = pjsua_pool_create("offerless-app-managed", 4096, 4096); + if (!pool) { + g_ctx.offerless_answer_status = PJ_ENOMEM; + return; + } + + status = parse_sdp_text(pool, app_managed_sdp, &sdp); + if (status == PJ_SUCCESS) { + opt = call->opt; + opt.aud_cnt = PJSUA_MAX_CALL_MEDIA + 1; + g_ctx.offerless_rollback_status = + pjsua_call_answer_with_sdp(call_id, sdp, &opt, + PJSIP_SC_OK, NULL, NULL); + g_ctx.offerless_rollback_neg_null = (call->inv->neg == NULL); + g_ctx.offerless_retry_plain_status = + pjsua_call_answer(call_id, PJSIP_SC_OK, NULL, NULL); + opt = call->opt; + g_ctx.offerless_ringing_status = + pjsua_call_answer2(call_id, &opt, PJSIP_SC_RINGING, + NULL, NULL); + g_ctx.offerless_set_status = pjsua_call_set_sdp(call_id, sdp); + status = pjsua_call_answer(call_id, 200, NULL, NULL); + } + g_ctx.offerless_answer_status = status; + pj_pool_release(pool); + return; + } + + if (g_ctx.initial_app_managed_armed) { + pjsua_call *call = &pjsua_var.calls[call_id]; + + g_ctx.initial_app_managed_seen = PJ_TRUE; + g_ctx.initial_app_managed_remote_offer = + call->inv && call->inv->neg && + pjmedia_sdp_neg_get_state(call->inv->neg) == + PJMEDIA_SDP_NEG_STATE_REMOTE_OFFER; + g_ctx.initial_app_managed_progress_status = + pjsua_call_answer(call_id, PJSIP_SC_PROGRESS, NULL, NULL); + g_ctx.initial_app_managed_plain_status = + pjsua_call_answer2(call_id, NULL, 200, NULL, NULL); + + pool = pjsua_pool_create("initial-app-managed", 4096, 4096); + if (!pool) { + g_ctx.initial_app_managed_answer_status = PJ_ENOMEM; + return; + } + + status = parse_sdp_text(pool, app_managed_sdp, &sdp); + if (status == PJ_SUCCESS) { + invalid = create_missing_conn_sdp(pool, sdp); + g_ctx.initial_app_managed_invalid_status = + pjsua_call_answer_with_sdp(call_id, invalid, NULL, 200, + NULL, NULL); + oversized = create_oversized_sdp(pool, sdp); + if (oversized) { + g_ctx.initial_app_managed_oversize_status = + pjsua_call_answer_with_sdp(call_id, oversized, NULL, 200, + NULL, NULL); + } + g_ctx.initial_app_managed_mismatch_result = + check_app_answer_media_count(call_id, pool, sdp, PJ_FALSE); + if (g_ctx.initial_app_managed_mismatch_result == 0) { + g_ctx.initial_app_managed_mismatch_result = + check_app_answer_media_count(call_id, pool, sdp, PJ_TRUE); + } + if (g_ctx.initial_app_managed_mismatch_result == 0) { + status = pjsua_call_set_sdp(call_id, sdp); + if (status != PJ_SUCCESS) { + g_ctx.initial_app_managed_mismatch_result = -1974; + } else { + g_ctx.initial_app_managed_mismatch_result = + check_app_answer_media_count(call_id, pool, sdp, + PJ_FALSE); + } + } + if (g_ctx.initial_app_managed_mismatch_result == 0) { + g_ctx.initial_app_managed_mismatch_result = + check_app_answer_media_count(call_id, pool, sdp, PJ_TRUE); + } + status = pjsua_call_answer_with_sdp(call_id, sdp, NULL, 200, + NULL, NULL); + } + g_ctx.initial_app_managed_answer_status = status; + pj_pool_release(pool); + return; + } + /* Probe: over-limit audio count must be rejected. answer2() leaves * call->opt_inited FALSE on failure, so the valid answer below still * applies. @@ -289,6 +653,14 @@ static void on_call_sdp_created(pjsua_call_id call_id, { pjmedia_sdp_media *m; + if (g_ctx.async_sdp_armed && call_id == g_ctx.async_sdp_callee && + rem_sdp != NULL) + { + g_ctx.async_sdp_answer = + pjmedia_sdp_session_clone(g_ctx.async_sdp_pool, sdp); + ++g_ctx.async_sdp_count; + } + /* Turn the offer of the armed caller leg into a T.38 handover offer: the * audio line is deactivated and an image/udptl line appended, which is * what a fax gateway sends. PJSUA syncs med_prov_cnt up to the media @@ -298,6 +670,16 @@ static void on_call_sdp_created(pjsua_call_id call_id, call_id == g_ctx.app_med_caller && sdp->media_count > 0 && sdp->media_count < PJMEDIA_MAX_SDP_MEDIA) { + if (g_ctx.app_med_zero_media) { + /* Offer with no active media at all: deactivate every m-line, + * don't append any (unlike the T.38 handover below). + */ + unsigned i; + for (i = 0; i < sdp->media_count; ++i) + sdp->media[i]->desc.port = 0; + return; + } + sdp->media[0]->desc.port = 0; m = PJ_POOL_ZALLOC_T(pool, pjmedia_sdp_media); @@ -394,7 +776,51 @@ static void on_call_rx_offer(pjsua_call_id call_id, PJ_UNUSED_ARG(call_id); PJ_UNUSED_ARG(reserved); PJ_UNUSED_ARG(code); - PJ_UNUSED_ARG(opt); + + if (g_ctx.uas_offer_armed) { + g_ctx.uas_offer_answer_status = pjsua_call_set_sdp(call_id, offer); + return; + } + + if (g_ctx.offerless_reinv_armed && + call_id == g_ctx.first_class_offer_caller) + { + g_ctx.offerless_reinv_set_status = + pjsua_call_set_sdp(call_id, offer); + return; + } + + if (g_ctx.first_class_offer_armed && + call_id == g_ctx.first_class_offer_callee) + { + g_ctx.first_class_set_status = + pjsua_call_set_sdp(call_id, offer); + ++g_ctx.first_class_offer_count; + return; + } + + if (g_ctx.offerless_armed && call_id != g_ctx.incoming_call_id) { + g_ctx.offerless_caller_set_status = pjsua_call_set_sdp(call_id, offer); + return; + } + + if (g_ctx.answer_rollback_reject && + call_id == g_ctx.answer_rollback_callee) + { + g_ctx.answer_rollback_set_status = + pjsua_call_set_sdp(call_id, offer); + *code = PJSIP_SC_NOT_ACCEPTABLE_HERE; + return; + } + + if (g_ctx.answer_rollback_inject && + call_id == g_ctx.answer_rollback_callee) + { + g_ctx.answer_rollback_set_status = + pjsip_inv_set_sdp_answer(pjsua_var.calls[call_id].inv, offer); + opt->flag |= PJSUA_CALL_MEDIA_APP_MANAGED; + return; + } /* Confirm the peer really receives a=rtcp-mux in the offer, so the * rtcp-mux sub-test cannot silently degrade into a no-op if the @@ -484,6 +910,51 @@ static void on_call_rx_reinvite(pjsua_call_id call_id, PJ_UNUSED_ARG(rdata); PJ_UNUSED_ARG(reserved); + if (g_ctx.offerless_armed && !offer && g_ctx.managed_reinv_response) { + *code = g_ctx.managed_reinv_response; + *async = PJ_FALSE; + return; + } + + if (g_ctx.uas_offer_armed && call_id == g_ctx.incoming_call_id) { + *async = PJ_TRUE; + return; + } + + if (g_ctx.async_sdp_armed && call_id == g_ctx.async_sdp_callee) { + *async = PJ_TRUE; + return; + } + + /* Offerless re-INVITE on an app-managed callee: edit "opt" (media + * direction) and, per offerless_opt_reject, either accept or reject. + * Either way, this callback must be the only place the change is + * made -- pjsua_call_on_rx_reinvite() must not have touched call->opt + * before invoking it, and must only persist the change if accepted. + */ + if (g_ctx.offerless_opt_armed && call_id == g_ctx.offerless_opt_callee && + offer == NULL) + { + g_ctx.offerless_opt_seen = PJ_TRUE; + opt->flag |= PJSUA_CALL_SET_MEDIA_DIR; + opt->media_dir[0] = PJMEDIA_DIR_DECODING; + if (g_ctx.offerless_opt_overlimit) + opt->aud_cnt = PJSUA_MAX_CALL_MEDIA + 1; + if (g_ctx.offerless_opt_reject) + *code = PJSIP_SC_NOT_ACCEPTABLE_HERE; + else + *async = PJ_TRUE; + return; + } + + if (g_ctx.offerless_reinv_armed && + call_id == g_ctx.first_class_offer_callee && offer == NULL) + { + g_ctx.offerless_reinv_seen = PJ_TRUE; + *async = PJ_TRUE; + return; + } + /* T.38 offer on the armed callee leg: record it and, when taking over, * defer the answer to the test (see test_app_managed_media()). */ @@ -516,12 +987,55 @@ static void on_call_rx_reinvite(pjsua_call_id call_id, g_ctx.narrow_dir_reinvite_seen = PJ_TRUE; } +/* Add PJSUA_CALL_NO_SDP_OFFER to the new call's setting when armed; see + * g_ctx.xfer_replaces_armed. + */ +static void on_call_transfer_request2(pjsua_call_id call_id, + const pj_str_t *dst, + pjsip_status_code *code, + pjsua_call_setting *opt) +{ + PJ_UNUSED_ARG(call_id); + PJ_UNUSED_ARG(dst); + PJ_UNUSED_ARG(code); + + if (g_ctx.xfer_replaces_armed) + opt->flag |= PJSUA_CALL_NO_SDP_OFFER; +} + /* Record the final status of the re-INVITE sent on the armed caller leg. */ static void on_call_tsx_state(pjsua_call_id call_id, pjsip_transaction *tsx, pjsip_event *e) { - PJ_UNUSED_ARG(e); + if (g_ctx.app_med_armed && call_id == g_ctx.app_med_caller && + tsx->role == PJSIP_ROLE_UAC && tsx->status_code >= 200 && + pj_stricmp2(&tsx->method.name, "REFER") == 0) + { + g_ctx.managed_refer_code = tsx->status_code; + } + + if ((g_ctx.offerless_armed || g_ctx.initial_app_managed_armed) && + tsx->role == PJSIP_ROLE_UAC && + tsx->method.id == PJSIP_INVITE_METHOD && + tsx->status_code == PJSIP_SC_PROGRESS && + e->type == PJSIP_EVENT_TSX_STATE && + e->body.tsx_state.type == PJSIP_EVENT_RX_MSG && + e->body.tsx_state.src.rdata->msg_info.msg->body == NULL) + { + g_ctx.app_managed_bodyless_progress_seen = PJ_TRUE; + } + + if (g_ctx.update_reinvite_armed && + call_id == g_ctx.update_reinvite_caller && + tsx->role == PJSIP_ROLE_UAC && tsx->status_code == PJSIP_SC_OK && + pj_stricmp2(&tsx->method.name, "UPDATE") == 0) + { + g_ctx.update_reinvite_armed = PJ_FALSE; + g_ctx.update_reinvite_status = + pjsua_call_reinvite2(call_id, NULL, NULL); + g_ctx.update_reinvite_seen = PJ_TRUE; + } if (g_ctx.app_med_armed && call_id == g_ctx.app_med_caller && tsx->role == PJSIP_ROLE_UAC && @@ -530,6 +1044,39 @@ static void on_call_tsx_state(pjsua_call_id call_id, { g_ctx.app_med_reinv_code = tsx->status_code; } + + /* The transferee-placed outgoing call carrying the Replaces header is + * none of the four legs already known to the test; record its id and + * the final status of its INVITE transaction. + */ + if (g_ctx.xfer_replaces_armed && + tsx->role == PJSIP_ROLE_UAC && tsx->method.id == PJSIP_INVITE_METHOD && + tsx->status_code >= 200 && g_ctx.xfer_replaces_new_code == 0 && + call_id != g_ctx.xfer_d_caller && call_id != g_ctx.xfer_d_callee && + call_id != g_ctx.xfer_t_caller && call_id != g_ctx.xfer_t_callee) + { + g_ctx.xfer_replaces_new_call = call_id; + g_ctx.xfer_replaces_new_code = tsx->status_code; + } + + if (g_ctx.answer_rollback_armed && + call_id == g_ctx.answer_rollback_caller && + tsx->role == PJSIP_ROLE_UAC && + tsx->method.id == PJSIP_INVITE_METHOD && + tsx->status_code >= 200) + { + g_ctx.answer_rollback_reinv_code = tsx->status_code; + } + + if (g_ctx.first_class_offer_armed && + call_id == g_ctx.first_class_offer_caller && + tsx->role == PJSIP_ROLE_UAC && tsx->status_code >= 200) + { + if (tsx->method.id == PJSIP_INVITE_METHOD) + g_ctx.first_class_reinv_code = tsx->status_code; + else if (pj_stricmp2(&tsx->method.name, "UPDATE") == 0) + g_ctx.first_class_update_code = tsx->status_code; + } } @@ -557,7 +1104,7 @@ static pj_bool_t wait_until(pj_bool_t (*predicate)(pjsua_call_id), PJ_TIME_VAL_SUB(now, t0); if ((unsigned)PJ_TIME_VAL_MSEC(now) >= timeout_ms) break; - pjsua_handle_events(50); + pjsua_handle_events(TEST_POLL_MSEC); } return predicate ? predicate(call_id) : PJ_FALSE; } @@ -596,6 +1143,96 @@ static pj_bool_t app_med_reinvite_done(pjsua_call_id call_id) return g_ctx.app_med_reinv_code != 0; } +static pj_bool_t answer_rollback_done(pjsua_call_id call_id) +{ + PJ_UNUSED_ARG(call_id); + return g_ctx.answer_rollback_reinv_code != 0; +} + +/* True once the given leg's outstanding re-INVITE transaction (if any) + * has concluded, i.e. inv->invite_tsx has been cleared again and the + * negotiator is back in DONE state. + */ +static pj_bool_t reinvite_tsx_done(pjsua_call_id call_id) +{ + pjsip_inv_session *inv; + + if (call_id < 0 || call_id >= (int)pjsua_var.ua_cfg.max_calls) + return PJ_FALSE; + + inv = pjsua_var.calls[call_id].inv; + return inv && inv->invite_tsx == NULL && inv->neg && + pjmedia_sdp_neg_get_state(inv->neg) == PJMEDIA_SDP_NEG_STATE_DONE; +} + +/* Same as reinvite_tsx_done() above, but for an outstanding UPDATE + * transaction (inv->update_tsx) instead of a re-INVITE. + */ +static pj_bool_t update_tsx_done(pjsua_call_id call_id) +{ + pjsip_inv_session *inv; + + if (call_id < 0 || call_id >= (int)pjsua_var.ua_cfg.max_calls) + return PJ_FALSE; + + inv = pjsua_var.calls[call_id].inv; + return inv && inv->update_tsx == NULL && inv->neg && + pjmedia_sdp_neg_get_state(inv->neg) == PJMEDIA_SDP_NEG_STATE_DONE; +} + +static pj_bool_t offerless_reinvite_seen(pjsua_call_id call_id) +{ + PJ_UNUSED_ARG(call_id); + return g_ctx.offerless_reinv_seen; +} + +static pj_bool_t offerless_opt_callback_seen(pjsua_call_id call_id) +{ + PJ_UNUSED_ARG(call_id); + return g_ctx.offerless_opt_seen; +} + +static pj_bool_t async_sdp_created(pjsua_call_id call_id) +{ + PJ_UNUSED_ARG(call_id); + return g_ctx.async_sdp_answer != NULL; +} + +static pj_bool_t first_class_reinvite_done(pjsua_call_id call_id) +{ + PJ_UNUSED_ARG(call_id); + return g_ctx.first_class_reinv_code != 0; +} + +static pj_bool_t first_class_sdp_applied(pjsua_call_id call_id) +{ + const pjmedia_sdp_session *local; + const pjmedia_sdp_session *remote; + pjsip_inv_session *caller_inv; + pjsip_inv_session *callee_inv; + + PJ_UNUSED_ARG(call_id); + + caller_inv = pjsua_var.calls[g_ctx.first_class_offer_caller].inv; + callee_inv = pjsua_var.calls[g_ctx.first_class_offer_callee].inv; + if (!caller_inv || !caller_inv->neg || !callee_inv || !callee_inv->neg || + pjmedia_sdp_neg_get_state(caller_inv->neg) != + PJMEDIA_SDP_NEG_STATE_DONE || + pjmedia_sdp_neg_get_state(callee_inv->neg) != + PJMEDIA_SDP_NEG_STATE_DONE || + pjmedia_sdp_neg_get_active_local(caller_inv->neg, &local) != + PJ_SUCCESS || + pjmedia_sdp_neg_get_active_remote(callee_inv->neg, &remote) != + PJ_SUCCESS || + local->media_count < 1 || remote->media_count < 1) + { + return PJ_FALSE; + } + + return local->media[0]->desc.port == g_ctx.first_class_expected_port && + remote->media[0]->desc.port == g_ctx.first_class_expected_port; +} + /* True once the given leg has completed a media update since the snapshot * taken into med_state_base[]. */ @@ -606,6 +1243,26 @@ static pj_bool_t media_state_advanced(pjsua_call_id call_id) return g_ctx.med_state_cnt[call_id] > g_ctx.med_state_base[call_id]; } +static pj_bool_t xfer_replaces_new_call_done(pjsua_call_id call_id) +{ + PJ_UNUSED_ARG(call_id); + return g_ctx.xfer_replaces_new_code != 0; +} + +static void replaces_request_cb(void *token, pjsip_event *event) +{ + pjsip_transaction *tsx; + + PJ_UNUSED_ARG(token); + + if (event->type != PJSIP_EVENT_TSX_STATE) + return; + + tsx = event->body.tsx_state.tsx; + if (tsx->status_code >= 200) + g_ctx.xfer_replaces_new_code = tsx->status_code; +} + static pj_bool_t call_is_confirmed(pjsua_call_id call_id) { pjsua_call_info ci; @@ -615,6 +1272,98 @@ static pj_bool_t call_is_confirmed(pjsua_call_id call_id) return ci.state == PJSIP_INV_STATE_CONFIRMED; } +static pj_status_t make_call_with_sdp(pjsua_acc_id acc_id, + const pj_str_t *uri, + const pjsua_call_setting *opt, + const pjmedia_sdp_session *sdp, + pjsua_call_id *call_id) +{ + pjsua_call_op_param param; + + pjsua_call_op_param_default(¶m); + param.opt = opt; + param.sdp = sdp; + return pjsua_call_make_call2(acc_id, uri, ¶m, call_id); +} + +static pj_status_t reinvite_with_sdp(pjsua_call_id call_id, + const pjsua_call_setting *opt, + const pjmedia_sdp_session *sdp) +{ + pjsua_call_op_param param; + + pjsua_call_op_param_default(¶m); + param.opt = opt; + param.sdp = sdp; + return pjsua_call_reinvite3(call_id, ¶m); +} + +static pj_status_t update_with_sdp(pjsua_call_id call_id, + const pjsua_call_setting *opt, + const pjmedia_sdp_session *sdp) +{ + pjsua_call_op_param param; + + pjsua_call_op_param_default(¶m); + param.opt = opt; + param.sdp = sdp; + return pjsua_call_update3(call_id, ¶m); +} + +static pj_status_t establish_app_managed_self_call( + const pjmedia_sdp_session *sdp, + pjsua_call_id *caller, + pjsua_call_id *callee) +{ + pjsua_call_setting opt; + pj_str_t uri = pj_str(g_ctx.self_uri); + pj_status_t status; + + g_ctx.initial_app_managed_armed = PJ_TRUE; + g_ctx.initial_app_managed_seen = PJ_FALSE; + pjsua_call_setting_default(&opt); + status = make_call_with_sdp(g_ctx.acc_id, &uri, &opt, sdp, caller); + if (status == PJ_SUCCESS && + (!wait_until(&call_is_confirmed, *caller, TEST_TIMEOUT_MSEC) || + !g_ctx.initial_app_managed_seen)) + { + status = PJ_ETIMEDOUT; + } + if (status == PJ_SUCCESS) { + *callee = g_ctx.incoming_call_id; + if (!wait_until(&call_is_confirmed, *callee, TEST_TIMEOUT_MSEC)) + status = PJ_ETIMEDOUT; + } + g_ctx.initial_app_managed_armed = PJ_FALSE; + return status; +} + +static pj_bool_t call_media_slots_initialized(pjsua_call_id call_id, + unsigned count) +{ + pjsua_call *call = &pjsua_var.calls[call_id]; + unsigned i; + + if (call->med_cnt < count || call->med_prov_cnt < count) + return PJ_FALSE; + + for (i = 0; i < count; ++i) { + pjsua_call_media *media = &call->media[i]; + pjsua_call_media *prov = &call->media_prov[i]; + + if (media->call != call || media->idx != i || + media->strm.a.conf_slot != PJSUA_INVALID_ID || + !media->tp_auto_del || prov->call != call || prov->idx != i || + prov->strm.a.conf_slot != PJSUA_INVALID_ID || + !prov->tp_auto_del) + { + return PJ_FALSE; + } + } + + return PJ_TRUE; +} + /* Tear down every call and pump the event loop until none remain (or a * generous cap elapses). Re-issues hangup each round so a leg that only * materializes after the first hangup_all() (e.g. an auto-answered incoming @@ -626,7 +1375,7 @@ static void drain_all_calls(void) for (i = 0; i < 60 && pjsua_call_get_count() > 0; ++i) { pjsua_call_hangup_all(); - pjsua_handle_events(50); + pjsua_handle_events(TEST_POLL_MSEC); } /* Closed media sockets keep their ioqueue slot for @@ -636,7 +1385,8 @@ static void drain_all_calls(void) * Wait out the delay so the slots are * recycled before the next sub-test creates new media sockets. */ - wait_until(NULL, PJSUA_INVALID_ID, PJ_IOQUEUE_KEY_FREE_DELAY + 200); + wait_until(NULL, PJSUA_INVALID_ID, + PJ_IOQUEUE_KEY_FREE_DELAY + IOQUEUE_CLEANUP_MARGIN_MSEC); } #if PJSUA_HAS_SIPREC @@ -802,1945 +1552,4289 @@ static int test_reinvite_reinit_bounds(void) return 0; } -/* Same reinit/reoffer overflow guard, but with an established call that holds - * a media slot of non-audio/video/text type (an injected m=application line). - * That slot counts toward the call's media total but is invisible to the - * per-media-type totals, so a guard that only summed per-type counts would - * under-count the existing media and still overflow. Here we keep the 1 audio - * and request PJSUA_MAX_CALL_MEDIA-1 text m-lines: the per-type view is - * 1 + (max-1) = max (looks acceptable), but the retained application m-line - * makes the real count max+1, which must be rejected. - */ -static int test_reinit_bounds_untyped_mline(void) +static int test_app_managed_mode_immutability(void) { pjsua_call_setting opt; + pjsua_acc_config acc_cfg; pj_str_t uri = pj_str(g_ctx.self_uri); - pj_status_t status; - pjsua_call_id cid = PJSUA_INVALID_ID; - pjsua_call_info ci; + pj_pool_t *pool; + pj_status_t status, cancel_status; + pjsip_tx_data *tdata = NULL; + pjsua_call_id caller = PJSUA_INVALID_ID; + pjsua_call_id callee; + const pjmedia_sdp_session *active; + pj_bool_t acc_cfg_valid = PJ_FALSE; + int rc = 0; - PJ_LOG(3, (THIS_FILE, " reinit guard with untyped (application) m-line")); + PJ_LOG(3, (THIS_FILE, " app-managed mode immutability")); - g_ctx.inject_app_mline = PJ_TRUE; + pool = pjsua_pool_create("app-managed-test", 4096, 4096); + if (!pool) + return -1250; - /* Offer 1 audio + 1 application m-line to ourselves. */ + g_ctx.incoming_seen = PJ_FALSE; pjsua_call_setting_default(&opt); opt.aud_cnt = 1; opt.vid_cnt = 0; opt.txt_cnt = 0; - status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, &cid); + status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, + &caller); if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " make_call failed (%d)", status)); - g_ctx.inject_app_mline = PJ_FALSE; - drain_all_calls(); - return -1300; + rc = -1251; + goto on_return; } - - if (!wait_until(&call_is_confirmed, cid, 8000)) { - PJ_LOG(1, (THIS_FILE, " call did not reach CONFIRMED in time")); - g_ctx.inject_app_mline = PJ_FALSE; - drain_all_calls(); - return -1301; + if (!wait_until(&call_is_confirmed, caller, TEST_TIMEOUT_MSEC) || + !g_ctx.incoming_seen) + { + rc = -1252; + goto on_return; } + callee = g_ctx.incoming_call_id; - /* Stop injecting; the reinvite below must not add further m-lines. */ - g_ctx.inject_app_mline = PJ_FALSE; - - /* Sanity/diagnostic: the call should now carry the extra m-line. The - * assertion below is self-validating regardless (PJ_ETOOMANY is only - * returned when the retained untyped slot pushes the count over max). - */ - if (pjsua_call_get_info(cid, &ci) == PJ_SUCCESS) { - PJ_LOG(3, (THIS_FILE, " established with media_cnt=%u", - ci.media_cnt)); + status = pjmedia_sdp_neg_get_active_local( + pjsua_var.calls[caller].inv->neg, &active); + if (status != PJ_SUCCESS || + pjsua_call_set_sdp(caller, active) != PJ_EINVALIDOP || + reinvite_with_sdp(caller, NULL, active) != PJ_EINVALIDOP || + update_with_sdp(caller, NULL, active) != PJ_EINVALIDOP) + { + rc = -1261; + goto on_return; } - pjsua_call_setting_default(&opt); - opt.aud_cnt = 1; - opt.vid_cnt = 0; - opt.txt_cnt = PJSUA_MAX_CALL_MEDIA - 1; - status = pjsua_call_reinvite2(cid, &opt, NULL); - if (status != PJ_ETOOMANY) { - PJ_LOG(1, (THIS_FILE, " reinit overflow with untyped m-line " - "expected PJ_ETOOMANY, got %d", status)); - drain_all_calls(); - return -1302; + status = pjmedia_sdp_neg_modify_local_offer2( + pjsua_var.calls[caller].inv->pool_prov, + pjsua_var.calls[caller].inv->neg, + pjsua_var.calls[caller].inv->sdp_neg_flags, + active); + if (status != PJ_SUCCESS) { + rc = -1262; + goto on_return; } - if (pjsua_call_get_info(cid, &ci) != PJ_SUCCESS || - ci.state != PJSIP_INV_STATE_CONFIRMED) + status = pjsip_inv_update(pjsua_var.calls[caller].inv, NULL, active, + &tdata); + if (tdata) + pjsip_tx_data_dec_ref(tdata); + cancel_status = pjmedia_sdp_neg_cancel_offer( + pjsua_var.calls[caller].inv->neg); + if (status != PJ_EINVALIDOP || cancel_status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " call not alive after rejected reinvite")); - drain_all_calls(); - return -1303; + rc = -1262; + goto on_return; } - drain_all_calls(); - return 0; -} - -#if PJSUA_HAS_SIPREC -/* Helper to create SDP with label attribute. */ -static pjmedia_sdp_session *create_siprec_sdp(pj_pool_t *pool) -{ - pjmedia_sdp_session *sdp; - pjmedia_sdp_media *m; - pjmedia_sdp_attr *a; - pjmedia_sdp_conn *conn; + status = pjsua_acc_get_config(g_ctx.acc_id, pool, &acc_cfg); + if (status != PJ_SUCCESS) { + rc = -1253; + goto on_return; + } + acc_cfg_valid = PJ_TRUE; - sdp = PJ_POOL_ZALLOC_T(pool, pjmedia_sdp_session); - pj_strdup2(pool, &sdp->origin.user, "-"); - sdp->origin.version = 0; - sdp->origin.id = 0; - pj_strdup2(pool, &sdp->origin.net_type, "IN"); - pj_strdup2(pool, &sdp->origin.addr_type, "IP4"); - pj_strdup2(pool, &sdp->origin.addr, "127.0.0.1"); - sdp->name = pj_str("SIPREC Test"); + acc_cfg.media_app_managed = PJ_TRUE; + status = pjsua_acc_modify(g_ctx.acc_id, &acc_cfg); + if (status != PJ_SUCCESS) { + rc = -1254; + goto on_return; + } - /* Add connection line at session level. */ - conn = PJ_POOL_ZALLOC_T(pool, pjmedia_sdp_conn); - pj_strdup2(pool, &conn->net_type, "IN"); - pj_strdup2(pool, &conn->addr_type, "IP4"); - pj_strdup2(pool, &conn->addr, "127.0.0.1"); - sdp->conn = conn; + g_ctx.answer_rollback_armed = PJ_TRUE; + g_ctx.answer_rollback_inject = PJ_FALSE; + g_ctx.answer_rollback_caller = caller; + g_ctx.answer_rollback_callee = callee; + g_ctx.answer_rollback_reinv_code = 0; + status = pjsua_call_reinvite2(caller, NULL, NULL); - sdp->time.start = 0; - sdp->time.stop = 0; - sdp->media_count = 1; + acc_cfg.media_app_managed = PJ_FALSE; + pjsua_acc_modify(g_ctx.acc_id, &acc_cfg); - m = PJ_POOL_ZALLOC_T(pool, pjmedia_sdp_media); - pj_strdup2(pool, &m->desc.media, "audio"); - m->desc.port = 4000; - pj_strdup2(pool, &m->desc.transport, "RTP/AVP"); - m->desc.fmt_count = 1; - pj_strdup2(pool, &m->desc.fmt[0], "0"); + if (status != PJ_SUCCESS || + (pjsua_var.calls[caller].opt.flag & + PJSUA_CALL_MEDIA_APP_MANAGED) != 0) + { + rc = -1255; + goto on_return; + } + if (!wait_until(&answer_rollback_done, PJSUA_INVALID_ID, + TEST_TIMEOUT_MSEC) || + g_ctx.answer_rollback_reinv_code != PJSIP_SC_OK) + { + rc = -1256; + goto on_return; + } - /* Add connection line at media level. */ - conn = PJ_POOL_ZALLOC_T(pool, pjmedia_sdp_conn); - pj_strdup2(pool, &conn->net_type, "IN"); - pj_strdup2(pool, &conn->addr_type, "IP4"); - pj_strdup2(pool, &conn->addr, "127.0.0.1"); - m->conn = conn; + g_ctx.answer_rollback_inject = PJ_TRUE; + g_ctx.answer_rollback_set_status = PJ_EUNKNOWN; + g_ctx.answer_rollback_reinv_code = 0; + status = pjsua_call_reinvite2(caller, NULL, NULL); + if (status != PJ_SUCCESS || + !wait_until(&answer_rollback_done, PJSUA_INVALID_ID, TEST_TIMEOUT_MSEC)) + { + rc = -1257; + goto on_return; + } + if (g_ctx.answer_rollback_set_status != PJ_SUCCESS || + g_ctx.answer_rollback_reinv_code != + PJSIP_SC_NOT_ACCEPTABLE_HERE) + { + rc = -1258; + goto on_return; + } + if (!call_is_confirmed(caller) || !call_is_confirmed(callee)) { + rc = -1259; + goto on_return; + } - /* Add label attribute (required for SIPREC). */ + g_ctx.answer_rollback_inject = PJ_FALSE; + g_ctx.answer_rollback_reinv_code = 0; + status = pjsua_call_reinvite2(caller, NULL, NULL); + if (status != PJ_SUCCESS || + !wait_until(&answer_rollback_done, PJSUA_INVALID_ID, + TEST_TIMEOUT_MSEC) || + g_ctx.answer_rollback_reinv_code != PJSIP_SC_OK) { - pj_str_t label_str = pj_str("1"); - a = pjmedia_sdp_attr_create(pool, "label", &label_str); + rc = -1260; } - m->attr[m->attr_count++] = a; - sdp->media[0] = m; - return sdp; +on_return: + if (acc_cfg_valid) { + acc_cfg.media_app_managed = PJ_FALSE; + pjsua_acc_modify(g_ctx.acc_id, &acc_cfg); + } + g_ctx.answer_rollback_armed = PJ_FALSE; + g_ctx.answer_rollback_inject = PJ_FALSE; + drain_all_calls(); + pj_pool_release(pool); + return rc; } -/* State for SIPREC tests to track response code. */ -static struct { - pj_bool_t request_seen; - int response_code; - /* Initial/mid-dialog metadata notifications from pjsua layer. */ - unsigned metadata_notified_count; - int metadata_last_call; - pj_bool_t metadata_rdata_valid; - char metadata_last_text[256]; -} g_siprec_test_ctx; - -/* Capture SIPREC metadata notifications delivered to the application. */ -static void on_call_siprec_metadata_update(pjsua_call_id call_id, - const pj_str_t *old_metadata, - const pj_str_t *new_metadata, - pjsip_rx_data *rdata) +static int test_initial_app_managed_answer(void) { - unsigned n; + static const char sdp_str[] = + "v=0\r\n" + "o=- 1 1 IN IP4 127.0.0.1\r\n" + "s=-\r\n" + "c=IN IP4 127.0.0.1\r\n" + "t=0 0\r\n" + "m=audio 4000 RTP/AVP 0\r\n" + "m=audio 4002 RTP/AVP 0\r\n"; + pjsua_acc_config acc_cfg; + pjsua_call_setting opt; + pjmedia_sdp_session *sdp; + pjmedia_sdp_session *offer; + pjmedia_sdp_session *invalid; + pjmedia_sdp_session *oversized; + pj_str_t uri = pj_str(g_ctx.self_uri); + pj_pool_t *pool; + pj_status_t status; + pjsua_call_id caller = PJSUA_INVALID_ID; + pjsua_call_id callee = PJSUA_INVALID_ID; + pjsua_call_id invalid_call = PJSUA_INVALID_ID; + pj_bool_t acc_cfg_valid = PJ_FALSE; + int rc = 0; - PJ_UNUSED_ARG(old_metadata); + PJ_LOG(3, (THIS_FILE, " initial app-managed SDP answer")); - g_siprec_test_ctx.metadata_notified_count++; - g_siprec_test_ctx.metadata_last_call = call_id; - g_siprec_test_ctx.metadata_rdata_valid = (rdata != NULL); + pool = pjsua_pool_create("initial-app-answer", 4096, 4096); + if (!pool) + return -1279; - if (new_metadata && new_metadata->ptr && new_metadata->slen > 0) { - n = new_metadata->slen; - if (n > sizeof(g_siprec_test_ctx.metadata_last_text) - 1) - n = sizeof(g_siprec_test_ctx.metadata_last_text) - 1; - pj_memcpy(g_siprec_test_ctx.metadata_last_text, new_metadata->ptr, n); - g_siprec_test_ctx.metadata_last_text[n] = '\0'; - } else { - g_siprec_test_ctx.metadata_last_text[0] = '\0'; + status = parse_sdp_text(pool, sdp_str, &sdp); + if (status != PJ_SUCCESS) { + rc = -1280; + goto on_return; } - PJ_LOG(3,(THIS_FILE, " on_call_siprec_metadata_update call=%d len=%u", - call_id, (unsigned)(new_metadata ? new_metadata->slen : 0))); -} - -/* Predicate to check if SIPREC response has been received */ -static pj_bool_t siprec_response_seen(pjsua_call_id call_id) -{ - PJ_UNUSED_ARG(call_id); - return g_siprec_test_ctx.request_seen; -} + status = pjsua_acc_get_config(g_ctx.acc_id, pool, &acc_cfg); + if (status != PJ_SUCCESS) { + rc = -1281; + goto on_return; + } + acc_cfg_valid = PJ_TRUE; -/* Callback for SIPREC test stateful requests to capture response status */ -static void siprec_test_request_cb(void *token, pjsip_event *e) -{ - PJ_UNUSED_ARG(token); + pjsua_call_setting_default(&opt); + opt.aud_cnt = 1; + opt.vid_cnt = 0; + opt.txt_cnt = 0; + opt.flag |= PJSUA_CALL_MEDIA_APP_MANAGED; + status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, + &invalid_call); + if (status != PJ_EINVALIDOP || invalid_call != PJSUA_INVALID_ID) { + rc = -1278; + goto on_return; + } + opt.flag &= ~PJSUA_CALL_MEDIA_APP_MANAGED; - if (e->type == PJSIP_EVENT_TSX_STATE) { - pjsip_transaction *tsx = e->body.tsx_state.tsx; + status = make_call_with_sdp(g_ctx.acc_id, &uri, &opt, sdp, + &invalid_call); + if (status != PJ_EINVALIDOP || invalid_call != PJSUA_INVALID_ID) { + rc = -1282; + goto on_return; + } - if (tsx && tsx->status_code > 0) { - g_siprec_test_ctx.request_seen = PJ_TRUE; - g_siprec_test_ctx.response_code = tsx->status_code; - PJ_LOG(3,(THIS_FILE, " SIPREC test response received: %d", - tsx->status_code)); - } + acc_cfg.media_app_managed = PJ_TRUE; + status = pjsua_acc_modify(g_ctx.acc_id, &acc_cfg); + if (status != PJ_SUCCESS) { + rc = -1283; + goto on_return; } -} -/* Test 1: SIPREC INVITE with direct application/sdp body (non-multipart) - * using default (PJ_FALSE) config. Should be accepted. - */ -static int test_siprec_non_multipart_accepted(void) -{ - extern struct pjsua_data pjsua_var; /* From pjsua_internal.h */ - pjsip_tx_data *tdata; - pj_pool_t *pool; - pjsip_uri *target; - pj_str_t target_str; - pjsip_contact_hdr *contact_hdr; - pj_str_t str_src = pj_str("+sip.src"); - pjmedia_sdp_session *sdp; - pj_status_t status; - pj_str_t sdp_str; - char sdp_buf[2048]; - pjsip_msg_body *body; + status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, + &invalid_call); + if (status != PJ_EINVALIDOP || invalid_call != PJSUA_INVALID_ID) { + rc = -1277; + goto on_return; + } - PJ_LOG(3, (THIS_FILE, " SIPREC non-multipart (direct SDP) acceptance")); + oversized = create_oversized_sdp(pool, sdp); + if (!oversized) { + rc = -1284; + goto on_return; + } - /* Enable SIPREC support on the account. */ - pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_OPTIONAL; + opt.flag |= PJSUA_CALL_MEDIA_APP_MANAGED; + opt.aud_cnt = 0; + opt.vid_cnt = 0; + opt.txt_cnt = 0; + invalid = create_missing_conn_sdp(pool, sdp); + status = make_call_with_sdp(g_ctx.acc_id, &uri, &opt, invalid, + &invalid_call); + if (status != PJMEDIA_SDP_EMISSINGCONN || + invalid_call != PJSUA_INVALID_ID) + { + rc = -1285; + goto on_return; + } - g_siprec_test_ctx.request_seen = PJ_FALSE; - g_siprec_test_ctx.response_code = 0; + status = make_call_with_sdp(g_ctx.acc_id, &uri, &opt, oversized, + &invalid_call); + if (status != PJ_ETOOMANY || invalid_call != PJSUA_INVALID_ID) { + rc = -1286; + goto on_return; + } - pool = pjsua_pool_create("siprec-test1", 4096, 4096); - if (!pool) { - PJ_LOG(1, (THIS_FILE, " failed to create pool")); - return -1400; + g_ctx.initial_app_managed_armed = PJ_TRUE; + g_ctx.initial_app_managed_seen = PJ_FALSE; + g_ctx.initial_app_managed_remote_offer = PJ_FALSE; + g_ctx.initial_app_managed_plain_status = PJ_SUCCESS; + g_ctx.initial_app_managed_progress_status = PJ_EUNKNOWN; + g_ctx.app_managed_bodyless_progress_seen = PJ_FALSE; + g_ctx.initial_app_managed_invalid_status = PJ_SUCCESS; + g_ctx.initial_app_managed_oversize_status = PJ_SUCCESS; + g_ctx.initial_app_managed_answer_status = PJ_EUNKNOWN; + g_ctx.initial_app_managed_mismatch_result = 0; + + status = make_call_with_sdp(g_ctx.acc_id, &uri, &opt, sdp, &caller); + if (status != PJ_SUCCESS || + !wait_until(&call_is_confirmed, caller, TEST_TIMEOUT_MSEC)) + { + rc = -1287; + goto on_return; } - /* Create target URI. */ - target_str = pj_str(g_ctx.self_uri); - target = pjsip_parse_uri(pool, target_str.ptr, target_str.slen, 0); - if (!target) { - PJ_LOG(1, (THIS_FILE, " failed to parse target URI")); - pj_pool_release(pool); - return -1401; + /* The caller reaching CONFIRMED only means it has processed the 200 + * OK; the callee only reaches CONFIRMED once it has processed the + * ACK, which is a separate, independently-scheduled packet. Wait for + * it explicitly instead of assuming it is already done. + */ + if (!wait_until(&call_is_confirmed, g_ctx.incoming_call_id, + TEST_TIMEOUT_MSEC)) + { + rc = -1288; + goto on_return; } - /* Create INVITE request. */ - status = pjsip_endpt_create_request(pjsua_var.endpt, &pjsip_invite_method, - &target_str, &target_str, &target_str, - NULL, NULL, -1, NULL, &tdata); - if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " failed to create INVITE (%d)", status)); - pj_pool_release(pool); - return -1402; + if (!g_ctx.initial_app_managed_seen || + !g_ctx.initial_app_managed_remote_offer || + g_ctx.initial_app_managed_plain_status != PJ_EINVALIDOP || + g_ctx.initial_app_managed_progress_status != PJ_SUCCESS || + !g_ctx.app_managed_bodyless_progress_seen || + g_ctx.initial_app_managed_invalid_status != + PJMEDIA_SDP_EMISSINGCONN || + g_ctx.initial_app_managed_oversize_status != PJ_ETOOMANY || + g_ctx.initial_app_managed_answer_status != PJ_SUCCESS || + g_ctx.initial_app_managed_mismatch_result != 0 || + pjsua_var.calls[caller].med_cnt != 2 || + pjsua_var.calls[g_ctx.incoming_call_id].med_cnt != 2 || + pjsua_var.calls[caller].media[0].tp != NULL || + pjsua_var.calls[caller].media[1].tp != NULL || + pjsua_var.calls[g_ctx.incoming_call_id].media[0].tp != NULL || + pjsua_var.calls[g_ctx.incoming_call_id].media[1].tp != NULL || + !call_media_slots_initialized(caller, 2) || + !call_media_slots_initialized(g_ctx.incoming_call_id, 2)) + { + rc = -1289; + goto on_return; } + callee = g_ctx.incoming_call_id; - /* Add Require: siprec header. */ - { - pj_str_t hname = pj_str("Require"); - pj_str_t hvalue = pj_str("siprec"); - pjsip_generic_string_hdr *req_hdr = - pjsip_generic_string_hdr_create(tdata->pool, &hname, &hvalue); - pjsip_msg_add_hdr(tdata->msg, (pjsip_hdr*)req_hdr); + invalid = create_missing_rtpmap_sdp(pool, sdp); + status = pjsua_call_set_sdp(caller, invalid); + if (status != PJMEDIA_SDP_EMISSINGRTPMAP) { + rc = -1290; + goto on_return; } - /* Add Contact header with +sip.src parameter. */ - contact_hdr = pjsip_contact_hdr_create(tdata->pool); - contact_hdr->uri = pjsip_parse_uri(tdata->pool, g_ctx.self_uri, - pj_ansi_strlen(g_ctx.self_uri), 0); - if (contact_hdr->uri) { - pjsip_param *param = PJ_POOL_ALLOC_T(pool, pjsip_param); - param->name = str_src; - param->value.slen = 0; - pj_list_push_back(&contact_hdr->other_param, param); + status = reinvite_with_sdp(caller, NULL, invalid); + if (status != PJMEDIA_SDP_EMISSINGRTPMAP) { + rc = -1291; + goto on_return; } - pjsip_msg_add_hdr(tdata->msg, (pjsip_hdr*)contact_hdr); - /* Create SDP body with label attribute. */ - sdp = create_siprec_sdp(pool); - status = pjmedia_sdp_print(sdp, sdp_buf, sizeof(sdp_buf)); - if (status < 1) { - PJ_LOG(1, (THIS_FILE, " failed to print SDP")); - pjsip_tx_data_dec_ref(tdata); - pj_pool_release(pool); - return -1403; + status = update_with_sdp(caller, NULL, invalid); + if (status != PJMEDIA_SDP_EMISSINGRTPMAP) { + rc = -1292; + goto on_return; } - /* Set application/sdp content type (direct SDP, not multipart). */ - sdp_str.ptr = (char*)pj_pool_alloc(pool, status); - pj_memcpy(sdp_str.ptr, sdp_buf, status); - sdp_str.slen = status; + status = pjsua_call_set_sdp(caller, oversized); + if (status != PJ_ETOOMANY) { + rc = -1293; + goto on_return; + } - body = PJ_POOL_ZALLOC_T(tdata->pool, pjsip_msg_body); - pj_strdup2(tdata->pool, &body->content_type.type, "application"); - pj_strdup2(tdata->pool, &body->content_type.subtype, "sdp"); - body->data = sdp_str.ptr; - body->len = sdp_str.slen; - body->print_body = &pjsip_print_text_body; - tdata->msg->body = body; + status = reinvite_with_sdp(caller, NULL, oversized); + if (status != PJ_ETOOMANY) { + rc = -1294; + goto on_return; + } - /* Send the request. */ - status = pjsip_endpt_send_request(pjsua_var.endpt, tdata, -1, - NULL, &siprec_test_request_cb); - if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " failed to send INVITE (%d)", status)); - pj_pool_release(pool); - return -1404; + status = update_with_sdp(caller, NULL, oversized); + if (status != PJ_ETOOMANY) { + rc = -1295; + goto on_return; } - /* Wait for response. */ - PJ_LOG(3,(THIS_FILE, " Waiting for SIPREC response...")); - if (!wait_until(&siprec_response_seen, PJSUA_INVALID_ID, 8000)) { - PJ_LOG(1, (THIS_FILE, " timeout waiting for SIPREC response")); - pj_pool_release(pool); - pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; - drain_all_calls(); - return -1405; + g_ctx.first_class_offer_armed = PJ_TRUE; + g_ctx.first_class_offer_caller = caller; + g_ctx.first_class_offer_callee = callee; + g_ctx.first_class_set_status = PJ_EUNKNOWN; + g_ctx.first_class_offer_count = 0; + g_ctx.first_class_reinv_code = 0; + g_ctx.first_class_update_code = 0; + + offer = pjmedia_sdp_session_clone(pool, sdp); + offer->media[0]->desc.port = 4100; + offer->media[1]->desc.port = 4102; + g_ctx.first_class_expected_port = 4100; + status = reinvite_with_sdp(caller, NULL, offer); + if (status != PJ_SUCCESS || + !wait_until(&first_class_sdp_applied, PJSUA_INVALID_ID, + TEST_TIMEOUT_MSEC) || + g_ctx.first_class_set_status != PJ_SUCCESS || + g_ctx.first_class_offer_count != 1 || + g_ctx.first_class_reinv_code != PJSIP_SC_OK) + { + rc = -1296; + goto on_return; + } + PJ_LOG(3,(THIS_FILE, " reinvite3/set_sdp answer applied")); + + offer = pjmedia_sdp_session_clone(pool, sdp); + offer->media[0]->desc.port = 4200; + offer->media[1]->desc.port = 4202; + g_ctx.first_class_expected_port = 4200; + g_ctx.first_class_set_status = PJ_EUNKNOWN; + g_ctx.first_class_update_code = 0; + status = pjsua_call_set_sdp(caller, offer); + if (status == PJ_SUCCESS) + status = pjsua_call_update(caller, 0, NULL); + if (status != PJ_SUCCESS || + !wait_until(&first_class_sdp_applied, PJSUA_INVALID_ID, + TEST_TIMEOUT_MSEC) || + g_ctx.first_class_set_status != PJ_SUCCESS || + g_ctx.first_class_offer_count != 2 || + g_ctx.first_class_update_code != PJSIP_SC_OK) + { + rc = -1297; + goto on_return; + } + PJ_LOG(3,(THIS_FILE, " set_sdp/update answer applied")); + + offer = pjmedia_sdp_session_clone(pool, sdp); + offer->media[0]->desc.port = 4300; + offer->media[1]->desc.port = 4302; + g_ctx.first_class_expected_port = 4300; + g_ctx.first_class_set_status = PJ_EUNKNOWN; + g_ctx.first_class_update_code = 0; + status = update_with_sdp(caller, NULL, offer); + if (status != PJ_SUCCESS || + !wait_until(&first_class_sdp_applied, PJSUA_INVALID_ID, + TEST_TIMEOUT_MSEC) || + g_ctx.first_class_set_status != PJ_SUCCESS || + g_ctx.first_class_offer_count != 3 || + g_ctx.first_class_update_code != PJSIP_SC_OK) + { + rc = -1298; + goto on_return; + } + PJ_LOG(3,(THIS_FILE, " update3/set_sdp answer applied")); + + offer = pjmedia_sdp_session_clone(pool, sdp); + offer->media[0]->desc.port = 0; + offer->media[1]->desc.port = 0; + g_ctx.first_class_expected_port = 0; + g_ctx.first_class_set_status = PJ_EUNKNOWN; + g_ctx.first_class_reinv_code = 0; + status = reinvite_with_sdp(caller, NULL, offer); + if (status != PJ_SUCCESS || + !wait_until(&first_class_sdp_applied, PJSUA_INVALID_ID, + TEST_TIMEOUT_MSEC) || + g_ctx.first_class_set_status != PJ_SUCCESS || + g_ctx.first_class_offer_count != 4 || + g_ctx.first_class_reinv_code != PJSIP_SC_OK) + { + rc = -1299; + goto on_return; + } + PJ_LOG(3,(THIS_FILE, " all-disabled app SDP offer applied")); + + offer = pjmedia_sdp_session_clone(pool, sdp); + offer->media[0]->desc.port = 4400; + offer->media[1]->desc.port = 4402; + g_ctx.first_class_expected_port = 4400; + g_ctx.first_class_reinv_code = 0; + g_ctx.offerless_reinv_seen = PJ_FALSE; + g_ctx.offerless_reinv_set_status = PJ_EUNKNOWN; + g_ctx.offerless_reinv_armed = PJ_TRUE; + opt.flag |= PJSUA_CALL_NO_SDP_OFFER; + status = pjsua_call_reinvite2(caller, &opt, NULL); + if (status != PJ_SUCCESS || + !wait_until(&offerless_reinvite_seen, callee, TEST_TIMEOUT_MSEC)) + { + rc = -1300; + goto on_return; + } + status = pjsua_call_answer_with_sdp(callee, offer, NULL, + PJSIP_SC_OK, NULL, NULL); + if (status != PJ_SUCCESS || + !wait_until(&first_class_sdp_applied, PJSUA_INVALID_ID, + TEST_TIMEOUT_MSEC) || + g_ctx.offerless_reinv_set_status != PJ_SUCCESS || + g_ctx.first_class_reinv_code != PJSIP_SC_OK) + { + rc = -1301; + goto on_return; } + PJ_LOG(3,(THIS_FILE, " offerless re-INVITE app offer applied")); + g_ctx.offerless_reinv_armed = PJ_FALSE; - pj_pool_release(pool); + g_ctx.first_class_reinv_code = 0; + g_ctx.offerless_armed = PJ_TRUE; + g_ctx.offerless_caller_set_status = PJ_EUNKNOWN; + status = pjsua_call_reinvite2(caller, &opt, NULL); + if (status != PJ_SUCCESS || + !wait_until(&first_class_reinvite_done, caller, TEST_TIMEOUT_MSEC) || + g_ctx.first_class_reinv_code != PJSIP_SC_OK || + g_ctx.offerless_caller_set_status != PJ_SUCCESS || + /* The callee negotiates the answer when it receives the ACK. */ + !wait_until(&first_class_sdp_applied, PJSUA_INVALID_ID, + TEST_TIMEOUT_MSEC) || + !call_is_confirmed(caller) || !call_is_confirmed(callee)) + { + rc = -1302; + goto on_return; + } + PJ_LOG(3,(THIS_FILE, " active app SDP re-offered automatically")); - /* Restore SIPREC setting. */ - pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; + status = pjsua_call_set_hold(caller, NULL); + if (status != PJ_EINVALIDOP) + rc = -1303; - /* Cleanup any auto-answered call. */ - drain_all_calls(); +#if PJSUA_HAS_VIDEO + { + static const pjsua_call_vid_strm_op ops[] = { + PJSUA_CALL_VID_STRM_ADD, + PJSUA_CALL_VID_STRM_REMOVE, + PJSUA_CALL_VID_STRM_CHANGE_DIR, + PJSUA_CALL_VID_STRM_CHANGE_CAP_DEV, + PJSUA_CALL_VID_STRM_START_TRANSMIT, + PJSUA_CALL_VID_STRM_STOP_TRANSMIT, + PJSUA_CALL_VID_STRM_SEND_KEYFRAME + }; + unsigned i; - if (g_siprec_test_ctx.response_code < 200 || - g_siprec_test_ctx.response_code >= 300) { - PJ_LOG(1, (THIS_FILE, " expected 2xx, got %d", - g_siprec_test_ctx.response_code)); - return -1406; + for (i = 0; i < PJ_ARRAY_SIZE(ops); ++i) { + status = pjsua_call_set_vid_strm(caller, ops[i], NULL); + if (status != PJ_EINVALIDOP) { + rc = -1304; + goto on_return; + } + } } +#endif - return 0; +on_return: + g_ctx.initial_app_managed_armed = PJ_FALSE; + g_ctx.first_class_offer_armed = PJ_FALSE; + g_ctx.offerless_reinv_armed = PJ_FALSE; + g_ctx.offerless_armed = PJ_FALSE; + if (acc_cfg_valid) { + acc_cfg.media_app_managed = PJ_FALSE; + pjsua_acc_modify(g_ctx.acc_id, &acc_cfg); + } + drain_all_calls(); + pj_pool_release(pool); + return rc; } -/* Test 2: SIPREC INVITE without rs-metadata using PJ_TRUE config. - * Should be rejected with 400 Bad Request. - */ -static int test_siprec_no_metadata_rejected(void) +static int test_app_managed_offerless_invite(void) { - extern struct pjsua_data pjsua_var; - pjsip_tx_data *tdata; + pjsua_acc_config acc_cfg; + pjsua_call_setting opt; + pj_str_t uri = pj_str(g_ctx.self_uri); pj_pool_t *pool; - pjsip_uri *target; - pj_str_t target_str; - pj_str_t siprec_str = pj_str("siprec"); - pj_str_t src_str = pj_str("+sip.src"); - pjmedia_sdp_session *sdp; - pjsip_msg_body *sdp_body, *multipart_body; - pjsip_multipart_part *sdp_part; - pjsip_multipart_part *dummy_part; pj_status_t status; - pj_bool_t restore_setting = PJ_FALSE; - char sdp_buf[2048]; + pjsua_call_id caller = PJSUA_INVALID_ID; + pj_bool_t acc_cfg_valid = PJ_FALSE; + int rc = 0; - PJ_LOG(3, (THIS_FILE, " SIPREC without rs-metadata rejection (strict)")); + PJ_LOG(3, (THIS_FILE, " app-managed offerless INVITE (3PCC)")); - /* Enable SIPREC support on the account. */ - pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_OPTIONAL; + pool = pjsua_pool_create("offerless-invite", 4096, 4096); + if (!pool) + return -1310; - /* Save and enable strict metadata requirement. */ - if (!pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata) { - pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_TRUE; - restore_setting = PJ_TRUE; + status = pjsua_acc_get_config(g_ctx.acc_id, pool, &acc_cfg); + if (status != PJ_SUCCESS) { + rc = -1311; + goto on_return; } + acc_cfg_valid = PJ_TRUE; - g_siprec_test_ctx.request_seen = PJ_FALSE; - g_siprec_test_ctx.response_code = 0; - - pool = pjsua_pool_create("siprec-test2", 4096, 4096); - if (!pool) { - PJ_LOG(1, (THIS_FILE, " failed to create pool")); - if (restore_setting) - pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; - return -1500; + acc_cfg.media_app_managed = PJ_TRUE; + status = pjsua_acc_modify(g_ctx.acc_id, &acc_cfg); + if (status != PJ_SUCCESS) { + rc = -1312; + goto on_return; } - /* Create target URI. */ - target_str = pj_str(g_ctx.self_uri); - target = pjsip_parse_uri(pool, target_str.ptr, target_str.slen, 0); - if (!target) { - PJ_LOG(1, (THIS_FILE, " failed to parse target URI")); - pj_pool_release(pool); - if (restore_setting) - pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; - return -1501; - } + g_ctx.offerless_armed = PJ_TRUE; + g_ctx.offerless_neg_null = PJ_FALSE; + g_ctx.offerless_neg_still_null = PJ_FALSE; + g_ctx.offerless_progress_status = PJ_SUCCESS; + g_ctx.app_managed_bodyless_progress_seen = PJ_FALSE; + g_ctx.offerless_ringing_status = PJ_EUNKNOWN; + g_ctx.offerless_plain_status = PJ_SUCCESS; + g_ctx.offerless_set_status = PJ_EUNKNOWN; + g_ctx.offerless_caller_set_status = PJ_EUNKNOWN; + g_ctx.offerless_answer_status = PJ_EUNKNOWN; + g_ctx.offerless_rollback_status = PJ_EUNKNOWN; + g_ctx.offerless_rollback_neg_null = PJ_FALSE; + g_ctx.offerless_retry_plain_status = PJ_SUCCESS; + g_ctx.incoming_call_id = PJSUA_INVALID_ID; - /* Create INVITE request. */ - status = pjsip_endpt_create_request(pjsua_var.endpt, &pjsip_invite_method, - &target_str, &target_str, &target_str, - NULL, NULL, -1, NULL, &tdata); - if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " failed to create INVITE (%d)", status)); - pj_pool_release(pool); - if (restore_setting) - pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; - return -1502; + pjsua_call_setting_default(&opt); + opt.flag |= PJSUA_CALL_MEDIA_APP_MANAGED | PJSUA_CALL_NO_SDP_OFFER; + opt.aud_cnt = 0; + opt.vid_cnt = 0; + opt.txt_cnt = 0; + + status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, + &caller); + if (status != PJ_SUCCESS || + !wait_until(&call_is_confirmed, caller, TEST_TIMEOUT_MSEC)) + { + rc = -1313; + goto on_return; } - /* Add Require: siprec header. */ + if (!wait_until(&call_is_confirmed, g_ctx.incoming_call_id, + TEST_TIMEOUT_MSEC)) { - pj_str_t hname = pj_str("Require"); - pjsip_generic_string_hdr *req_hdr = - pjsip_generic_string_hdr_create(tdata->pool, &hname, &siprec_str); - pjsip_msg_add_hdr(tdata->msg, (pjsip_hdr*)req_hdr); + rc = -1314; + goto on_return; } - /* Add Contact header with +sip.src parameter. */ + /* The INVITE carried no offer, so the callee's negotiator did not exist + * yet. Answering with a code that carries SDP must then be rejected until + * the application supplies its own offer. + */ + if (!g_ctx.offerless_neg_null || + !g_ctx.offerless_neg_still_null || + g_ctx.offerless_progress_status != PJ_SUCCESS || + !g_ctx.app_managed_bodyless_progress_seen || + g_ctx.offerless_ringing_status != PJ_SUCCESS || + g_ctx.offerless_plain_status != PJ_EINVALIDOP || + g_ctx.offerless_rollback_status != PJ_ETOOMANY || + !g_ctx.offerless_rollback_neg_null || + g_ctx.offerless_retry_plain_status != PJ_EINVALIDOP || + g_ctx.offerless_set_status != PJ_SUCCESS || + g_ctx.offerless_answer_status != PJ_SUCCESS || + g_ctx.offerless_caller_set_status != PJ_SUCCESS) { - pjsip_contact_hdr *contact_hdr = pjsip_contact_hdr_create(tdata->pool); - contact_hdr->uri = pjsip_parse_uri(tdata->pool, g_ctx.self_uri, - pj_ansi_strlen(g_ctx.self_uri), 0); - if (contact_hdr->uri) { - pjsip_param *new_param = PJ_POOL_ALLOC_T(pool, pjsip_param); - new_param->name = src_str; - new_param->value.slen = 0; - pj_list_push_back(&contact_hdr->other_param, new_param); - } - pjsip_msg_add_hdr(tdata->msg, (pjsip_hdr*)contact_hdr); + rc = -1315; + goto on_return; } - /* Create multipart body with SDP but no rs-metadata. */ - multipart_body = pjsip_multipart_create(tdata->pool, NULL, NULL); + /* The established call must carry the application's media, and PJSUA must + * still own no media transport for it. + */ + if (pjsua_var.calls[caller].med_cnt != 2 || + pjsua_var.calls[g_ctx.incoming_call_id].med_cnt != 2 || + pjsua_var.calls[caller].media[0].tp != NULL || + pjsua_var.calls[caller].media[1].tp != NULL || + pjsua_var.calls[g_ctx.incoming_call_id].media[0].tp != NULL || + pjsua_var.calls[g_ctx.incoming_call_id].media[1].tp != NULL) + { + rc = -1316; + goto on_return; + } - /* Create SDP part. */ - sdp = create_siprec_sdp(pool); - status = pjmedia_sdp_print(sdp, sdp_buf, sizeof(sdp_buf)); - if (status < 1) { - PJ_LOG(1, (THIS_FILE, " failed to print SDP")); - pjsip_tx_data_dec_ref(tdata); - pj_pool_release(pool); - if (restore_setting) - pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; - return -1503; +on_return: + g_ctx.offerless_armed = PJ_FALSE; + if (acc_cfg_valid) { + acc_cfg.media_app_managed = PJ_FALSE; + pjsua_acc_modify(g_ctx.acc_id, &acc_cfg); } + drain_all_calls(); + pj_pool_release(pool); + return rc; +} - sdp_body = PJ_POOL_ZALLOC_T(tdata->pool, pjsip_msg_body); - pj_strdup2(tdata->pool, &sdp_body->content_type.type, "application"); - pj_strdup2(tdata->pool, &sdp_body->content_type.subtype, "sdp"); - sdp_body->data = pj_pool_alloc(tdata->pool, status); - pj_memcpy(sdp_body->data, sdp_buf, status); - sdp_body->len = status; - sdp_body->print_body = &pjsip_print_text_body; +static int test_outgoing_app_managed_reoffer_rejection(void) +{ + pjsua_call_setting opt; + pjmedia_sdp_session *sdp; + pj_str_t uri = pj_str(g_ctx.self_uri); + pj_pool_t *pool; + pj_status_t status; + pjsua_call_id caller = PJSUA_INVALID_ID; + pjsua_call_id callee; + int rc = 0; - sdp_part = pjsip_multipart_create_part(tdata->pool); - sdp_part->body = sdp_body; - pjsip_multipart_add_part(tdata->pool, multipart_body, sdp_part); + PJ_LOG(3, (THIS_FILE, " outgoing app-managed re-offer rejection")); - /* Add a dummy text part (not rs-metadata). */ - { - pjsip_msg_body *text_body = PJ_POOL_ZALLOC_T(tdata->pool, pjsip_msg_body); - pj_strdup2(tdata->pool, &text_body->content_type.type, "text"); - pj_strdup2(tdata->pool, &text_body->content_type.subtype, "plain"); - text_body->data = pj_pool_alloc(tdata->pool, 5); - pj_memcpy(text_body->data, "dummy", 5); - text_body->len = 5; - text_body->print_body = &pjsip_print_text_body; + pool = pjsua_pool_create("app-managed-uac", 4096, 4096); + if (!pool) + return -1270; - dummy_part = pjsip_multipart_create_part(tdata->pool); - dummy_part->body = text_body; - pjsip_multipart_add_part(tdata->pool, multipart_body, dummy_part); + status = parse_sdp_text(pool, app_managed_audio_sdp, &sdp); + if (status != PJ_SUCCESS) { + rc = -1271; + goto on_return; } - tdata->msg->body = multipart_body; - - /* Send the request. */ - status = pjsip_endpt_send_request(pjsua_var.endpt, tdata, -1, - NULL, &siprec_test_request_cb); - if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " failed to send INVITE (%d)", status)); - pj_pool_release(pool); - if (restore_setting) - pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; - return -1504; + g_ctx.incoming_seen = PJ_FALSE; + pjsua_call_setting_default(&opt); + opt.flag |= PJSUA_CALL_MEDIA_APP_MANAGED; + status = make_call_with_sdp(g_ctx.acc_id, &uri, &opt, sdp, &caller); + if (status != PJ_SUCCESS || + !wait_until(&call_is_confirmed, caller, TEST_TIMEOUT_MSEC) || + !g_ctx.incoming_seen) + { + rc = -1272; + goto on_return; } - - /* Wait for response. */ - PJ_LOG(3,(THIS_FILE, " Waiting for SIPREC response...")); - if (!wait_until(&siprec_response_seen, PJSUA_INVALID_ID, 8000)) { - PJ_LOG(1, (THIS_FILE, " timeout waiting for SIPREC response")); - pj_pool_release(pool); - pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; - if (restore_setting) - pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; - return -1505; + callee = g_ctx.incoming_call_id; + if (!wait_until(&call_is_confirmed, callee, TEST_TIMEOUT_MSEC)) { + rc = -1273; + goto on_return; } - pj_pool_release(pool); - - /* Restore original settings. */ - pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; - if (restore_setting) - pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; - - if (g_siprec_test_ctx.response_code != 400) { - PJ_LOG(1, (THIS_FILE, " expected 400 Bad Request, got %d", - g_siprec_test_ctx.response_code)); - return -1506; + g_ctx.answer_rollback_armed = PJ_TRUE; + g_ctx.answer_rollback_inject = PJ_FALSE; + g_ctx.answer_rollback_reject = PJ_TRUE; + g_ctx.answer_rollback_caller = callee; + g_ctx.answer_rollback_callee = caller; + g_ctx.answer_rollback_set_status = PJ_EUNKNOWN; + g_ctx.answer_rollback_reinv_code = 0; + status = pjsua_call_reinvite2(callee, NULL, NULL); + if (status != PJ_SUCCESS || + !wait_until(&answer_rollback_done, PJSUA_INVALID_ID, TEST_TIMEOUT_MSEC)) + { + rc = -1274; + goto on_return; + } + if (g_ctx.answer_rollback_set_status != PJ_SUCCESS || + g_ctx.answer_rollback_reinv_code != + PJSIP_SC_NOT_ACCEPTABLE_HERE || + pjmedia_sdp_neg_get_state(pjsua_var.calls[caller].inv->neg) != + PJMEDIA_SDP_NEG_STATE_DONE || + !call_is_confirmed(caller) || !call_is_confirmed(callee)) + { + rc = -1275; } - return 0; +on_return: + g_ctx.answer_rollback_armed = PJ_FALSE; + g_ctx.answer_rollback_inject = PJ_FALSE; + g_ctx.answer_rollback_reject = PJ_FALSE; + drain_all_calls(); + pj_pool_release(pool); + return rc; } -/* Test 2b: SIPREC INVITE with rs-metadata must notify the application via - * on_call_siprec_metadata_update() (initial metadata notification). +/* A pending LOCAL_OFFER negotiator must not let a second, offerless UPDATE + * reuse (and resend) the still-outstanding re-INVITE's offer: that would + * overlap two offer-bearing transactions on the same dialog (RFC 3311 + * Section 5.1). Must be rejected with PJ_EINVALIDOP. */ -static int test_siprec_initial_metadata_notified(void) +static int test_outgoing_app_managed_overlapping_offer(void) { - extern struct pjsua_data pjsua_var; - pjsip_tx_data *tdata; + pjsua_call_setting opt; + pjmedia_sdp_session *sdp, *reinvite_sdp; + pj_str_t uri = pj_str(g_ctx.self_uri); pj_pool_t *pool; - pj_str_t target_str; - pj_str_t siprec_str = pj_str("siprec"); - pj_str_t src_str = pj_str("+sip.src"); - pjmedia_sdp_session *sdp; - pjsip_msg_body *sdp_body, *metadata_body, *multipart_body; - pjsip_multipart_part *sdp_part, *metadata_part; pj_status_t status; - char sdp_buf[2048]; - - PJ_LOG(3, (THIS_FILE, " SIPREC initial metadata notification")); + pjsua_call_id caller = PJSUA_INVALID_ID; + pjsua_call_id callee; + pjsip_inv_session *inv; + pjsip_transaction *reinvite_tsx; + int rc = 0; - /* Enable SIPREC support with optional metadata */ - pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_OPTIONAL; - pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; + PJ_LOG(3, (THIS_FILE, " outgoing app-managed overlapping offer " + "rejected")); - g_siprec_test_ctx.request_seen = PJ_FALSE; - g_siprec_test_ctx.response_code = 0; - g_siprec_test_ctx.metadata_notified_count = 0; - g_siprec_test_ctx.metadata_last_call = PJSUA_INVALID_ID; - g_siprec_test_ctx.metadata_rdata_valid = PJ_FALSE; + pool = pjsua_pool_create("app-managed-overlap", 4096, 4096); + if (!pool) + return -1320; - pool = pjsua_pool_create("siprec-test2b", 4096, 4096); - if (!pool) { - PJ_LOG(1, (THIS_FILE, " failed to create pool")); - return -1550; + status = parse_sdp_text(pool, app_managed_audio_sdp, &sdp); + if (status != PJ_SUCCESS) { + rc = -1321; + goto on_return; } - /* Create target URI. */ - target_str = pj_str(g_ctx.self_uri); - - /* Create INVITE request. */ - status = pjsip_endpt_create_request(pjsua_var.endpt, &pjsip_invite_method, - &target_str, &target_str, &target_str, - NULL, NULL, -1, NULL, &tdata); + status = parse_sdp_text(pool, app_managed_audio_reoffer_sdp, + &reinvite_sdp); if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " failed to create INVITE (%d)", status)); - pj_pool_release(pool); - return -1552; + rc = -1322; + goto on_return; } - /* Add Require: siprec header. */ + g_ctx.incoming_seen = PJ_FALSE; + pjsua_call_setting_default(&opt); + opt.flag |= PJSUA_CALL_MEDIA_APP_MANAGED; + status = make_call_with_sdp(g_ctx.acc_id, &uri, &opt, sdp, &caller); + if (status != PJ_SUCCESS || + !wait_until(&call_is_confirmed, caller, TEST_TIMEOUT_MSEC) || + !g_ctx.incoming_seen) { - pj_str_t hname = pj_str("Require"); - pjsip_generic_string_hdr *req_hdr = - pjsip_generic_string_hdr_create(tdata->pool, &hname, &siprec_str); - pjsip_msg_add_hdr(tdata->msg, (pjsip_hdr*)req_hdr); + rc = -1323; + goto on_return; } - - /* Add Contact header with +sip.src parameter. */ - { - pjsip_contact_hdr *contact_hdr = pjsip_contact_hdr_create(tdata->pool); - contact_hdr->uri = pjsip_parse_uri(tdata->pool, g_ctx.self_uri, - pj_ansi_strlen(g_ctx.self_uri), 0); - if (contact_hdr->uri) { - pjsip_param *new_param = PJ_POOL_ALLOC_T(pool, pjsip_param); - new_param->name = src_str; - new_param->value.slen = 0; - pj_list_push_back(&contact_hdr->other_param, new_param); - } - pjsip_msg_add_hdr(tdata->msg, (pjsip_hdr*)contact_hdr); + callee = g_ctx.incoming_call_id; + if (!wait_until(&call_is_confirmed, callee, TEST_TIMEOUT_MSEC)) { + rc = -1324; + goto on_return; } - /* Create multipart body with SDP and rs-metadata parts. */ - multipart_body = pjsip_multipart_create(tdata->pool, NULL, NULL); + inv = pjsua_var.calls[caller].inv; - sdp = create_siprec_sdp(pool); - status = pjmedia_sdp_print(sdp, sdp_buf, sizeof(sdp_buf)); - if (status < 1) { - PJ_LOG(1, (THIS_FILE, " failed to print SDP")); - pjsip_tx_data_dec_ref(tdata); - pj_pool_release(pool); - return -1553; + /* Send a re-INVITE carrying a new offer, but don't pump the event + * loop yet: the INVITE transaction stays outstanding, with the + * negotiator parked in LOCAL_OFFER state and inv->invite_tsx set. + */ + status = reinvite_with_sdp(caller, NULL, reinvite_sdp); + if (status != PJ_SUCCESS || !inv->invite_tsx || + pjmedia_sdp_neg_get_state(inv->neg) != + PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER) + { + rc = -1325; + goto on_return; } + reinvite_tsx = inv->invite_tsx; - sdp_body = PJ_POOL_ZALLOC_T(tdata->pool, pjsip_msg_body); - pj_strdup2(tdata->pool, &sdp_body->content_type.type, "application"); - pj_strdup2(tdata->pool, &sdp_body->content_type.subtype, "sdp"); - sdp_body->data = pj_pool_alloc(tdata->pool, status); - pj_memcpy(sdp_body->data, sdp_buf, status); - sdp_body->len = status; - sdp_body->print_body = &pjsip_print_text_body; - - sdp_part = pjsip_multipart_create_part(tdata->pool); - sdp_part->body = sdp_body; - pjsip_multipart_add_part(tdata->pool, multipart_body, sdp_part); - + /* Attempting to send an UPDATE now (with no app-supplied offer, so + * PJSUA falls back to the call's active/pending local SDP in + * passthrough mode) must be rejected: the offer sent by the + * re-INVITE above is still outstanding and must not be duplicated + * into an overlapping UPDATE transaction. + */ + status = update_with_sdp(caller, NULL, NULL); + if (status != PJ_EINVALIDOP || + inv->invite_tsx != reinvite_tsx || + inv->update_tsx != NULL || + pjmedia_sdp_neg_get_state(inv->neg) != + PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER) { - pj_str_t metadata_xml = pj_str( - "" - "" - "complete" - "" - ); - pj_str_t type = pj_str("application"); - pj_str_t subtype = pj_str("rs-metadata+xml"); - - metadata_body = pjsip_msg_body_create(tdata->pool, &type, &subtype, - &metadata_xml); - metadata_part = pjsip_multipart_create_part(tdata->pool); - metadata_part->body = metadata_body; - pjsip_multipart_add_part(tdata->pool, multipart_body, metadata_part); + rc = -1326; + goto on_return; } - tdata->msg->body = multipart_body; + /* Let the pending re-INVITE complete normally and confirm the call + * is still healthy afterwards, and that the negotiator settles back + * to DONE (i.e. the blocked UPDATE attempt left no side effect). + */ + if (!wait_until(&reinvite_tsx_done, caller, TEST_TIMEOUT_MSEC) || + !call_is_confirmed(caller) || !call_is_confirmed(callee)) + { + rc = -1327; + goto on_return; + } - /* Send the request. */ - status = pjsip_endpt_send_request(pjsua_var.endpt, tdata, -1, - NULL, &siprec_test_request_cb); + /* The negotiator must now accept a fresh UPDATE again. */ + status = update_with_sdp(caller, NULL, NULL); if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " failed to send INVITE (%d)", status)); - pj_pool_release(pool); - return -1554; + rc = -1328; + goto on_return; } - - if (!wait_until(&siprec_response_seen, PJSUA_INVALID_ID, 8000)) { - PJ_LOG(1, (THIS_FILE, " timeout waiting for SIPREC response")); - pj_pool_release(pool); - pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; - drain_all_calls(); - return -1555; + if (!wait_until(&update_tsx_done, caller, TEST_TIMEOUT_MSEC)) { + rc = -1329; } - pj_pool_release(pool); - - pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; - +on_return: drain_all_calls(); - - if (g_siprec_test_ctx.response_code < 200 || - g_siprec_test_ctx.response_code >= 300) { - PJ_LOG(1, (THIS_FILE, " expected 2xx, got %d", - g_siprec_test_ctx.response_code)); - return -1556; - } - - /* The application must have received the initial metadata - * notification for the incoming call. - */ - if (g_siprec_test_ctx.metadata_notified_count == 0) { - PJ_LOG(1, (THIS_FILE, - " on_call_siprec_metadata_update not notified")); - return -1557; - } - if (!g_siprec_test_ctx.metadata_rdata_valid) { - PJ_LOG(1, (THIS_FILE, " metadata notification had no rdata")); - return -1558; - } - if (!pj_ansi_strstr(g_siprec_test_ctx.metadata_last_text, "recording")) { - PJ_LOG(1, (THIS_FILE, " unexpected metadata content '%s'", - g_siprec_test_ctx.metadata_last_text)); - return -1559; - } - - return 0; + pj_pool_release(pool); + return rc; } -/* Test 3: SIPREC metadata-only UPDATE with multipart body (no SDP). - * Per RFC 7866 §7.1, a mid-dialog UPDATE can contain rs-metadata - * without SDP. This test verifies that such requests are accepted - * with 200 OK when siprec_require_metadata is disabled. +/* Mirror of test_outgoing_app_managed_overlapping_offer(), reversed: an + * outstanding UPDATE offer must not be resent via a fallback re-INVITE. */ -static int test_siprec_metadata_only_update_multipart(void) +static int test_outgoing_app_managed_overlapping_reinvite(void) { - extern struct pjsua_data pjsua_var; pjsua_call_setting opt; + pjmedia_sdp_session *sdp, *update_sdp; pj_str_t uri = pj_str(g_ctx.self_uri); - pjsip_tx_data *tdata; pj_pool_t *pool; - pjsip_dialog *dlg; - static const pjsip_method UPDATE_METHOD = { PJSIP_OTHER_METHOD, {"UPDATE", 6}}; pj_status_t status; - pjsua_call_id cid = PJSUA_INVALID_ID; + pjsua_call_id caller = PJSUA_INVALID_ID; + pjsua_call_id callee; + pjsip_inv_session *inv; + pjsip_transaction *update_tsx; int rc = 0; - pjsip_msg_body *metadata_body; - pjsip_multipart_part *metadata_part; - pj_str_t metadata_xml; - pjsip_media_type multipart_ct; - pj_str_t boundary; - - PJ_LOG(3, (THIS_FILE, " SIPREC metadata-only UPDATE (multipart)")); - - /* Enable SIPREC support with optional metadata */ - pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_OPTIONAL; - pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; - g_siprec_test_ctx.request_seen = PJ_FALSE; - g_siprec_test_ctx.response_code = 0; + PJ_LOG(3, (THIS_FILE, " outgoing app-managed overlapping re-INVITE " + "rejected")); - pjsua_call_setting_default(&opt); - opt.aud_cnt = 1; - opt.vid_cnt = 0; - opt.txt_cnt = 0; + pool = pjsua_pool_create("app-managed-overlap-reinv", 4096, 4096); + if (!pool) + return -1330; - status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, &cid); + status = parse_sdp_text(pool, app_managed_audio_sdp, &sdp); if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " make_call failed (%d)", status)); - rc = -1600; + rc = -1331; goto on_return; } - if (!wait_until(&call_is_confirmed, cid, 8000)) { - PJ_LOG(1, (THIS_FILE, " call did not reach CONFIRMED in time")); - rc = -1601; + status = parse_sdp_text(pool, app_managed_audio_reoffer_sdp, + &update_sdp); + if (status != PJ_SUCCESS) { + rc = -1332; goto on_return; } - /* Get dialog for sending UPDATE */ - if (!pjsua_var.calls[cid].inv || !pjsua_var.calls[cid].inv->dlg) { - PJ_LOG(1, (THIS_FILE, " failed to get dialog")); - rc = -1602; + g_ctx.incoming_seen = PJ_FALSE; + pjsua_call_setting_default(&opt); + opt.flag |= PJSUA_CALL_MEDIA_APP_MANAGED; + status = make_call_with_sdp(g_ctx.acc_id, &uri, &opt, sdp, &caller); + if (status != PJ_SUCCESS || + !wait_until(&call_is_confirmed, caller, TEST_TIMEOUT_MSEC) || + !g_ctx.incoming_seen) + { + rc = -1333; goto on_return; } - dlg = pjsua_var.calls[cid].inv->dlg; - - /* The metadata-only UPDATE is only accepted on a recording session. */ - siprec_test_mark_siprec_sessions(); - - pool = pjsua_pool_create("siprec-update1", 4096, 4096); - if (!pool) { - PJ_LOG(1, (THIS_FILE, " failed to create pool")); - rc = -1603; + callee = g_ctx.incoming_call_id; + if (!wait_until(&call_is_confirmed, callee, TEST_TIMEOUT_MSEC)) { + rc = -1334; goto on_return; } - /* Create simple rs-metadata XML */ - metadata_xml = pj_str( - "" - "" - "complete" - "" - ); - - /* Create empty multipart body */ - pjsip_media_type_init2(&multipart_ct, "multipart", "mixed"); - boundary = pj_str("boundary123"); - metadata_body = pjsip_multipart_create(pool, &multipart_ct, &boundary); + inv = pjsua_var.calls[caller].inv; - /* Create rs-metadata part */ + /* Send an UPDATE carrying a new offer, but don't pump the event loop + * yet: the UPDATE transaction stays outstanding, with the negotiator + * parked in LOCAL_OFFER state and inv->update_tsx set. + */ + status = update_with_sdp(caller, NULL, update_sdp); + if (status != PJ_SUCCESS || !inv->update_tsx || + pjmedia_sdp_neg_get_state(inv->neg) != + PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER) { - pj_str_t type = pj_str("application"); - pj_str_t subtype = pj_str("rs-metadata+xml"); - metadata_part = pjsip_multipart_create_part(pool); - metadata_part->body = pjsip_msg_body_create(pool, &type, &subtype, - &metadata_xml); - } - - /* Add metadata part to multipart body */ - pjsip_multipart_add_part(pool, metadata_body, metadata_part); - - /* Create UPDATE request */ - status = pjsip_dlg_create_request(dlg, &UPDATE_METHOD, -1, &tdata); - if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " failed to create UPDATE (%d)", status)); - rc = -1604; - goto cleanup_pool; - } - - /* Set multipart body */ - tdata->msg->body = metadata_body; - - /* Send UPDATE statefully with callback to capture response */ - status = pjsip_endpt_send_request(pjsua_var.endpt, tdata, -1, - NULL, &siprec_test_request_cb); - if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " failed to send UPDATE (%d)", status)); - rc = -1606; - goto cleanup_pool; + rc = -1335; + goto on_return; } + update_tsx = inv->update_tsx; - /* Wait for the response to be received */ - PJ_LOG(3,(THIS_FILE, " Waiting for SIPREC UPDATE response...")); - if (!wait_until(&siprec_response_seen, PJSUA_INVALID_ID, 8000)) { - PJ_LOG(1, (THIS_FILE, " timeout waiting for SIPREC UPDATE response")); - rc = -1607; - goto cleanup_pool; + /* Attempting to send a re-INVITE now (with no app-supplied offer, so + * PJSUA falls back to the call's active/pending local SDP in + * passthrough mode) must be rejected: the offer sent by the UPDATE + * above is still outstanding and must not be duplicated into an + * overlapping re-INVITE transaction. + */ + status = reinvite_with_sdp(caller, NULL, NULL); + if (status != PJ_EINVALIDOP || + inv->update_tsx != update_tsx || + inv->invite_tsx != NULL || + pjmedia_sdp_neg_get_state(inv->neg) != + PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER) + { + rc = -1336; + goto on_return; } - PJ_LOG(3,(THIS_FILE, " SIPREC UPDATE response received: %d", - g_siprec_test_ctx.response_code)); - - /* Verify call is still established after metadata UPDATE */ + /* Let the pending UPDATE complete normally and confirm the call is + * still healthy afterwards, and that the negotiator settles back to + * DONE (i.e. the blocked re-INVITE attempt left no side effect). + */ + if (!wait_until(&update_tsx_done, caller, TEST_TIMEOUT_MSEC) || + !call_is_confirmed(caller) || !call_is_confirmed(callee)) { - pjsua_call_info ci; - if (pjsua_call_get_count() == 0 || - pjsua_call_get_info(cid, &ci) != PJ_SUCCESS || - ci.state != PJSIP_INV_STATE_CONFIRMED) { - PJ_LOG(1, (THIS_FILE, " call not established after UPDATE")); - rc = -1608; - goto cleanup_pool; - } + rc = -1337; + goto on_return; } - PJ_LOG(3,(THIS_FILE, " call still established after metadata UPDATE")); - -cleanup_pool: - pj_pool_release(pool); + /* The negotiator must now accept a fresh re-INVITE again. */ + status = reinvite_with_sdp(caller, NULL, NULL); + if (status != PJ_SUCCESS) { + rc = -1338; + } on_return: - pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; - pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; drain_all_calls(); - - if (rc != 0) - return rc; - - if (g_siprec_test_ctx.response_code < 200 || - g_siprec_test_ctx.response_code >= 300) { - PJ_LOG(1, (THIS_FILE, " expected 2xx, got %d", - g_siprec_test_ctx.response_code)); - return -1609; - } - - return 0; + pj_pool_release(pool); + return rc; } -/* Test 4: SIPREC metadata-only UPDATE with single-part rs-metadata body. - * Per RFC 7866 §7.1, a mid-dialog UPDATE can contain a single-part - * rs-metadata document. This test verifies that such requests are - * accepted with 200 OK. +/* An incoming INVITE/Replaces must be rejected as "app-managed" not only + * when the replaced call's own flag is set, but also when the account's + * media_app_managed default (inherited by the new call) is on, even if + * the replaced call predates that default. Send the Replaces INVITE + * directly so the test needs only the two existing call legs. */ -static int test_siprec_metadata_only_update_singlepart(void) +static void on_call_replace_request2(pjsua_call_id call_id, + pjsip_rx_data *rdata, + int *code, pj_str_t *text, + pjsua_call_setting *opt) { - extern struct pjsua_data pjsua_var; - pjsua_call_setting opt; + PJ_UNUSED_ARG(call_id); + PJ_UNUSED_ARG(rdata); + PJ_UNUSED_ARG(code); + PJ_UNUSED_ARG(text); + opt->flag |= PJSUA_CALL_MEDIA_APP_MANAGED; +} + +static int test_incoming_app_managed_replaces_rejected_mode(pj_bool_t callback) +{ + pjsua_acc_config acc_cfg; pj_str_t uri = pj_str(g_ctx.self_uri); - pjsip_tx_data *tdata; pj_pool_t *pool; - pjsip_dialog *dlg; - static const pjsip_method UPDATE_METHOD = { PJSIP_OTHER_METHOD, {"UPDATE", 6}}; pj_status_t status; - pjsua_call_id cid = PJSUA_INVALID_ID; + pjsua_call_id d_caller = PJSUA_INVALID_ID, d_callee; + pjsip_dialog *dlg; + pjsip_contact_hdr *contact; + pjsip_replaces_hdr *replaces; + pjsip_tx_data *tdata; + pj_bool_t acc_cfg_valid = PJ_FALSE; int rc = 0; - pjsip_msg_body *metadata_body; - pj_str_t metadata_xml; - pj_str_t metadata_type; - pj_str_t metadata_subtype; - - PJ_LOG(3, (THIS_FILE, " SIPREC metadata-only UPDATE (single-part)")); - /* Enable SIPREC support with optional metadata */ - pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_OPTIONAL; - pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; - - g_siprec_test_ctx.request_seen = PJ_FALSE; - g_siprec_test_ctx.response_code = 0; + PJ_LOG(3, (THIS_FILE, " incoming Replaces rejected for account-default " + "app-managed call")); - pjsua_call_setting_default(&opt); - opt.aud_cnt = 1; - opt.vid_cnt = 0; - opt.txt_cnt = 0; + pool = pjsua_pool_create("app-managed-replaces", 4096, 4096); + if (!pool) + return -1340; - status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, &cid); - if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " make_call failed (%d)", status)); - rc = -1700; + /* Call D: the call that will be targeted by Replaces. */ + g_ctx.incoming_seen = PJ_FALSE; + status = pjsua_call_make_call(g_ctx.acc_id, &uri, NULL, NULL, NULL, + &d_caller); + if (status != PJ_SUCCESS || + !wait_until(&call_is_confirmed, d_caller, TEST_TIMEOUT_MSEC) || + !g_ctx.incoming_seen) + { + rc = -1341; goto on_return; } - - if (!wait_until(&call_is_confirmed, cid, 8000)) { - PJ_LOG(1, (THIS_FILE, " call did not reach CONFIRMED in time")); - rc = -1701; + d_callee = g_ctx.incoming_call_id; + if (!wait_until(&call_is_confirmed, d_callee, TEST_TIMEOUT_MSEC)) { + rc = -1342; goto on_return; } - /* Get dialog for sending UPDATE */ - if (!pjsua_var.calls[cid].inv || !pjsua_var.calls[cid].inv->dlg) { - PJ_LOG(1, (THIS_FILE, " failed to get dialog")); - rc = -1702; + /* Neither leg of D is app-managed of its own accord. */ + if ((pjsua_var.calls[d_caller].opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED) || + (pjsua_var.calls[d_callee].opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED)) + { + rc = -1345; goto on_return; } - dlg = pjsua_var.calls[cid].inv->dlg; - - /* The metadata-only UPDATE is only accepted on a recording session. */ - siprec_test_mark_siprec_sessions(); - pool = pjsua_pool_create("siprec-update2", 4096, 4096); - if (!pool) { - PJ_LOG(1, (THIS_FILE, " failed to create pool")); - rc = -1703; + /* Switch the account default on with D already up. + */ + status = pjsua_acc_get_config(g_ctx.acc_id, pool, &acc_cfg); + if (status != PJ_SUCCESS) { + rc = -1346; goto on_return; } - - /* Create simple rs-metadata XML */ - metadata_xml = pj_str( - "" - "" - "complete" - "" - ); - - /* Create single-part rs-metadata body */ - metadata_type = pj_str("application"); - metadata_subtype = pj_str("rs-metadata+xml"); - metadata_body = pjsip_msg_body_create(pool, - &metadata_type, - &metadata_subtype, - &metadata_xml); - - /* Create UPDATE request */ - status = pjsip_dlg_create_request(dlg, &UPDATE_METHOD, -1, &tdata); + acc_cfg_valid = PJ_TRUE; + acc_cfg.media_app_managed = !callback; + status = pjsua_acc_modify(g_ctx.acc_id, &acc_cfg); if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " failed to create UPDATE (%d)", status)); - rc = -1704; - goto cleanup_pool; + rc = -1347; + goto on_return; } + if (callback) + pjsua_var.ua_cfg.cb.on_call_replace_request2 = + &on_call_replace_request2; - /* Set single-part body */ - tdata->msg->body = metadata_body; + g_ctx.xfer_replaces_new_code = 0; - /* Send UPDATE statefully - response will be handled by invite session */ - status = pjsip_endpt_send_request(pjsua_var.endpt, tdata, -1, - NULL, &siprec_test_request_cb); + /* Send directly: a REFER loopback would need five PJSUA call slots. + */ + status = pjsip_endpt_create_request(pjsua_var.endpt, + &pjsip_invite_method, &uri, &uri, + &uri, NULL, NULL, -1, NULL, &tdata); if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " failed to send UPDATE (%d)", status)); - rc = -1706; - goto cleanup_pool; + rc = -1348; + goto on_return; } - /* Wait for the response to be received */ - PJ_LOG(3,(THIS_FILE, " Waiting for SIPREC UPDATE response...")); - if (!wait_until(&siprec_response_seen, PJSUA_INVALID_ID, 8000)) { - PJ_LOG(1, (THIS_FILE, " timeout waiting for SIPREC UPDATE response")); - rc = -1707; - goto cleanup_pool; + contact = pjsip_contact_hdr_create(tdata->pool); + contact->uri = pjsip_parse_uri(tdata->pool, g_ctx.self_uri, + pj_ansi_strlen(g_ctx.self_uri), 0); + if (!contact->uri) { + pjsip_tx_data_dec_ref(tdata); + rc = -1348; + goto on_return; } + pjsip_msg_add_hdr(tdata->msg, (pjsip_hdr*)contact); - PJ_LOG(3,(THIS_FILE, " SIPREC UPDATE response received: %d", - g_siprec_test_ctx.response_code)); + dlg = pjsua_var.calls[d_callee].inv->dlg; + pjsip_dlg_inc_lock(dlg); + replaces = pjsip_replaces_hdr_create(tdata->pool); + pj_strdup(tdata->pool, &replaces->call_id, &dlg->call_id->id); + pj_strdup(tdata->pool, &replaces->to_tag, &dlg->local.info->tag); + pj_strdup(tdata->pool, &replaces->from_tag, &dlg->remote.info->tag); + pjsip_dlg_dec_lock(dlg); + pjsip_msg_add_hdr(tdata->msg, (pjsip_hdr*)replaces); - /* Verify call is still established after metadata UPDATE */ + status = pjsip_endpt_send_request(pjsua_var.endpt, tdata, -1, NULL, + &replaces_request_cb); + if (status != PJ_SUCCESS) { + rc = -1348; + goto on_return; + } + + if (!wait_until(&xfer_replaces_new_call_done, PJSUA_INVALID_ID, + TEST_TIMEOUT_MSEC)) { - pjsua_call_info ci; - if (pjsua_call_get_count() == 0 || - pjsua_call_get_info(cid, &ci) != PJ_SUCCESS || - ci.state != PJSIP_INV_STATE_CONFIRMED) { - PJ_LOG(1, (THIS_FILE, " call not established after UPDATE")); - rc = -1708; - goto cleanup_pool; - } + rc = -1349; + goto on_return; } - PJ_LOG(3,(THIS_FILE, " call still established after metadata UPDATE")); + if (g_ctx.xfer_replaces_new_code != PJSIP_SC_NOT_ACCEPTABLE_HERE) { + PJ_LOG(1, (THIS_FILE, " Replaces returned %d, expected 488", + g_ctx.xfer_replaces_new_code)); + rc = -1350; + goto on_return; + } -cleanup_pool: - pj_pool_release(pool); + /* D must be completely unaffected: still confirmed, not replaced. */ + if (!call_is_confirmed(d_caller) || !call_is_confirmed(d_callee)) + rc = -1351; on_return: - pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; - pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; + pjsua_var.ua_cfg.cb.on_call_replace_request2 = NULL; + g_ctx.xfer_replaces_armed = PJ_FALSE; + if (acc_cfg_valid) { + acc_cfg.media_app_managed = PJ_FALSE; + pjsua_acc_modify(g_ctx.acc_id, &acc_cfg); + } drain_all_calls(); + pj_pool_release(pool); + return rc; +} + +static int test_incoming_app_managed_replaces_rejected(void) +{ + int rc = test_incoming_app_managed_replaces_rejected_mode(PJ_FALSE); if (rc != 0) return rc; - - if (g_siprec_test_ctx.response_code < 200 || - g_siprec_test_ctx.response_code >= 300) { - PJ_LOG(1, (THIS_FILE, " expected 2xx, got %d", - g_siprec_test_ctx.response_code)); - return -1709; - } - - return 0; + return test_incoming_app_managed_replaces_rejected_mode(PJ_TRUE); } -/* Test 5: SIPREC metadata UPDATE with alternate content. - * This test verifies that metadata UPDATE requests are accepted - * regardless of the specific metadata XML content. The rs-metadata - * body is passed through without parsing of individual elements - * (datamode parsing is deferred per RFC 7865 TODO). - * - * This test uses a different XML payload than Test 4 to verify - * that acceptance is based on content-type, not content values. +/* App edits to "opt" in on_call_rx_reinvite for an offerless re-INVITE + * must be applied via apply_call_setting(), and must not touch call->opt + * unless the re-INVITE is accepted. */ -static int test_siprec_datamode_partial_element(void) +static int test_offerless_reinvite_applies_opt(void) { - extern struct pjsua_data pjsua_var; pjsua_call_setting opt; - pj_str_t uri = pj_str(g_ctx.self_uri); - pjsip_tx_data *tdata; + pjsua_acc_config acc_cfg; + pjmedia_sdp_session *sdp; + const pjmedia_sdp_session *active; pj_pool_t *pool; - pjsip_dialog *dlg; - static const pjsip_method UPDATE_METHOD = { PJSIP_OTHER_METHOD, {"UPDATE", 6}}; pj_status_t status; - pjsua_call_id cid = PJSUA_INVALID_ID; + pjsua_call_id caller = PJSUA_INVALID_ID; + pjsua_call_id callee; + pj_bool_t acc_cfg_valid = PJ_FALSE; int rc = 0; - pjsip_msg_body *metadata_body; - pj_str_t metadata_xml; - pj_str_t metadata_type; - pj_str_t metadata_subtype; - PJ_LOG(3, (THIS_FILE, " SIPREC datamode=partial element parsing")); - - /* Enable SIPREC support with optional metadata */ - pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_OPTIONAL; - pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; + PJ_LOG(3, (THIS_FILE, " offerless re-INVITE applies app opt changes")); - g_siprec_test_ctx.request_seen = PJ_FALSE; - g_siprec_test_ctx.response_code = 0; + pool = pjsua_pool_create("offerless-reinvite-opt", 4096, 4096); + if (!pool) + return -1360; - pjsua_call_setting_default(&opt); - opt.aud_cnt = 1; - opt.vid_cnt = 0; - opt.txt_cnt = 0; - - status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, &cid); + status = pjsua_acc_get_config(g_ctx.acc_id, pool, &acc_cfg); if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " make_call failed (%d)", status)); - rc = -1800; + rc = -1361; goto on_return; } - - if (!wait_until(&call_is_confirmed, cid, 8000)) { - PJ_LOG(1, (THIS_FILE, " call did not reach CONFIRMED in time")); - rc = -1801; + acc_cfg_valid = PJ_TRUE; + acc_cfg.media_app_managed = PJ_TRUE; + status = pjsua_acc_modify(g_ctx.acc_id, &acc_cfg); + if (status != PJ_SUCCESS) { + rc = -1362; goto on_return; } - /* Get dialog for sending UPDATE */ - if (!pjsua_var.calls[cid].inv || !pjsua_var.calls[cid].inv->dlg) { - PJ_LOG(1, (THIS_FILE, " failed to get dialog")); - rc = -1802; + status = parse_sdp_text(pool, app_managed_two_audio_sdp, &sdp); + if (status == PJ_SUCCESS) + status = establish_app_managed_self_call(sdp, &caller, &callee); + if (status != PJ_SUCCESS) { + rc = -1363; goto on_return; } - dlg = pjsua_var.calls[cid].inv->dlg; - - /* The metadata-only UPDATE is only accepted on a recording session. */ - siprec_test_mark_siprec_sessions(); - - pool = pjsua_pool_create("siprec-update3", 4096, 4096); - if (!pool) { - PJ_LOG(1, (THIS_FILE, " failed to create pool")); - rc = -1803; + if (!(pjsua_var.calls[caller].opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED) || + !(pjsua_var.calls[callee].opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED)) + { + /* Both legs must have inherited the account's app-managed default + * for this test to actually exercise the fixed code path. + */ + rc = -1365; goto on_return; } - /* Create rs-metadata with datamode=partial (lowercase element) */ - metadata_xml = pj_str( - "" - "" - "partial" - "" - ); - - /* Create single-part rs-metadata body */ - metadata_type = pj_str("application"); - metadata_subtype = pj_str("rs-metadata+xml"); - metadata_body = pjsip_msg_body_create(pool, - &metadata_type, - &metadata_subtype, - &metadata_xml); + /* Rejected offerless re-INVITE: the callback still edits "opt" (media + * direction) but also rejects. callee's call->opt must come out of + * this completely unchanged. + */ + g_ctx.offerless_opt_armed = PJ_TRUE; + g_ctx.offerless_opt_callee = callee; + g_ctx.offerless_opt_reject = PJ_TRUE; + g_ctx.offerless_opt_seen = PJ_FALSE; + g_ctx.first_class_offer_armed = PJ_TRUE; + g_ctx.first_class_offer_caller = caller; + g_ctx.first_class_offer_callee = callee; + g_ctx.first_class_reinv_code = 0; - /* Create UPDATE request */ - status = pjsip_dlg_create_request(dlg, &UPDATE_METHOD, -1, &tdata); - if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " failed to create UPDATE (%d)", status)); - rc = -1804; - goto cleanup_pool; + pjsua_call_setting_default(&opt); + opt.aud_cnt = 0; + opt.vid_cnt = 0; + opt.txt_cnt = 0; + opt.flag |= PJSUA_CALL_NO_SDP_OFFER; + status = pjsua_call_reinvite2(caller, &opt, NULL); + if (status != PJ_SUCCESS || + !wait_until(&reinvite_tsx_done, caller, TEST_TIMEOUT_MSEC) || + !g_ctx.offerless_opt_seen || + g_ctx.first_class_reinv_code != PJSIP_SC_NOT_ACCEPTABLE_HERE || + !call_is_confirmed(caller) || !call_is_confirmed(callee)) + { + rc = -1366; + goto on_return; } - - /* Set body */ - tdata->msg->body = metadata_body; - - /* Send UPDATE statefully - response will be handled by invite session */ - status = pjsip_endpt_send_request(pjsua_var.endpt, tdata, -1, - NULL, &siprec_test_request_cb); - if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " failed to send UPDATE (%d)", status)); - rc = -1806; - goto cleanup_pool; + if (pjsua_var.calls[callee].opt.flag & PJSUA_CALL_SET_MEDIA_DIR) { + rc = -1367; + goto on_return; } - /* Wait for the response to be received */ - PJ_LOG(3,(THIS_FILE, " Waiting for SIPREC UPDATE response...")); - if (!wait_until(&siprec_response_seen, PJSUA_INVALID_ID, 8000)) { - PJ_LOG(1, (THIS_FILE, " timeout waiting for SIPREC UPDATE response")); - rc = -1807; - goto cleanup_pool; + g_ctx.offerless_opt_reject = PJ_FALSE; + g_ctx.offerless_opt_overlimit = PJ_TRUE; + g_ctx.offerless_opt_seen = PJ_FALSE; + g_ctx.first_class_reinv_code = 0; + status = pjsua_call_reinvite2(caller, &opt, NULL); + if (status != PJ_SUCCESS || + !wait_until(&reinvite_tsx_done, caller, TEST_TIMEOUT_MSEC) || + !g_ctx.offerless_opt_seen || + g_ctx.first_class_reinv_code != PJSIP_SC_NOT_ACCEPTABLE_HERE || + (pjsua_var.calls[callee].opt.flag & PJSUA_CALL_SET_MEDIA_DIR)) + { + rc = -1370; + goto on_return; } + g_ctx.offerless_opt_overlimit = PJ_FALSE; - PJ_LOG(3,(THIS_FILE, " SIPREC UPDATE response received: %d", - g_siprec_test_ctx.response_code)); + /* Accepted offerless re-INVITE: the same media direction edit must now + * show up on callee's call->opt. + */ + g_ctx.offerless_opt_reject = PJ_FALSE; + g_ctx.offerless_opt_seen = PJ_FALSE; + g_ctx.first_class_reinv_code = 0; + g_ctx.offerless_reinv_armed = PJ_TRUE; + g_ctx.offerless_reinv_set_status = PJ_EUNKNOWN; - /* Verify call is still established after metadata UPDATE */ + status = pjsua_call_reinvite2(caller, &opt, NULL); + if (status != PJ_SUCCESS || + !wait_until(&offerless_opt_callback_seen, callee, TEST_TIMEOUT_MSEC) || + pjsua_var.calls[callee].opt.media_dir[0] != PJMEDIA_DIR_DECODING) { - pjsua_call_info ci; - if (pjsua_call_get_count() == 0 || - pjsua_call_get_info(cid, &ci) != PJ_SUCCESS || - ci.state != PJSIP_INV_STATE_CONFIRMED) { - PJ_LOG(1, (THIS_FILE, " call not established after UPDATE")); - rc = -1808; - goto cleanup_pool; - } + rc = -1368; + goto on_return; } - PJ_LOG(3,(THIS_FILE, " call still established after metadata UPDATE")); - -cleanup_pool: - pj_pool_release(pool); + status = pjmedia_sdp_neg_get_active_local( + pjsua_var.calls[callee].inv->neg, &active); + if (status == PJ_SUCCESS) + status = pjsua_call_answer_with_sdp(callee, active, NULL, + PJSIP_SC_OK, NULL, NULL); + if (status != PJ_SUCCESS || + !wait_until(&reinvite_tsx_done, caller, TEST_TIMEOUT_MSEC) || + g_ctx.first_class_reinv_code != PJSIP_SC_OK || + g_ctx.offerless_reinv_set_status != PJ_SUCCESS || + !call_is_confirmed(caller) || !call_is_confirmed(callee)) + { + rc = -1368; + goto on_return; + } + if (pjsua_var.calls[callee].opt.media_dir[0] != PJMEDIA_DIR_DECODING) + rc = -1369; on_return: - pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; - pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; - drain_all_calls(); - - if (rc != 0) - return rc; - - if (g_siprec_test_ctx.response_code < 200 || - g_siprec_test_ctx.response_code >= 300) { - PJ_LOG(1, (THIS_FILE, " expected 2xx, got %d", - g_siprec_test_ctx.response_code)); - return -1809; + g_ctx.offerless_opt_armed = PJ_FALSE; + g_ctx.offerless_opt_overlimit = PJ_FALSE; + g_ctx.first_class_offer_armed = PJ_FALSE; + g_ctx.offerless_reinv_armed = PJ_FALSE; + if (acc_cfg_valid) { + acc_cfg.media_app_managed = PJ_FALSE; + pjsua_acc_modify(g_ctx.acc_id, &acc_cfg); } - - return 0; + drain_all_calls(); + pj_pool_release(pool); + return rc; } -/* Test: mid-dialog requests with an unrecognized content-type must be - * rejected. Pre-PR pjsip rejected them via inv_check_sdp_in_incoming_msg() - * (488 for UPDATE, 415 for re-INVITE when PJSIP_INV_ACCEPT_UNKNOWN_BODY is - * disabled); this must still hold even when SIPREC is enabled. +/* Same reinit/reoffer overflow guard, but with an established call that holds + * a media slot of non-audio/video/text type (an injected m=application line). + * That slot counts toward the call's media total but is invisible to the + * per-media-type totals, so a guard that only summed per-type counts would + * under-count the existing media and still overflow. Here we keep the 1 audio + * and request PJSUA_MAX_CALL_MEDIA-1 text m-lines: the per-type view is + * 1 + (max-1) = max (looks acceptable), but the retained application m-line + * makes the real count max+1, which must be rejected. */ -static int test_siprec_unknown_body_rejected(void) +static int test_reinit_bounds_untyped_mline(void) { - extern struct pjsua_data pjsua_var; pjsua_call_setting opt; pj_str_t uri = pj_str(g_ctx.self_uri); - pjsip_tx_data *tdata; - pj_pool_t *pool; - pjsip_dialog *dlg; - static const pjsip_method UPDATE_METHOD = { PJSIP_OTHER_METHOD, - {"UPDATE", 6}}; - static const pjsip_method INVITE_METHOD = { PJSIP_INVITE_METHOD, - {"INVITE", 6}}; pj_status_t status; pjsua_call_id cid = PJSUA_INVALID_ID; - int rc = 0; - pjsip_msg_body *body; - pj_str_t bogus_data; - pj_str_t type; - pj_str_t subtype; - - PJ_LOG(3, (THIS_FILE, " SIPREC unknown content-type rejection")); - - /* Enable SIPREC support with optional metadata */ - pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_OPTIONAL; + pjsua_call_info ci; - pool = pjsua_pool_create("siprec-unknown-body", 4096, 4096); - if (!pool) { - PJ_LOG(1, (THIS_FILE, " failed to create pool")); - return -1900; - } + PJ_LOG(3, (THIS_FILE, " reinit guard with untyped (application) m-line")); - bogus_data = pj_str("not-sdp-not-metadata"); - type = pj_str("application"); - subtype = pj_str("octet-stream"); - body = pjsip_msg_body_create(pool, &type, &subtype, &bogus_data); - if (!body) { - PJ_LOG(1, (THIS_FILE, " failed to create body")); - pj_pool_release(pool); - return -1901; - } + g_ctx.inject_app_mline = PJ_TRUE; - /* Phase 1: UPDATE with unknown content-type must be rejected with 488 */ + /* Offer 1 audio + 1 application m-line to ourselves. */ pjsua_call_setting_default(&opt); opt.aud_cnt = 1; opt.vid_cnt = 0; opt.txt_cnt = 0; - status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, &cid); if (status != PJ_SUCCESS) { PJ_LOG(1, (THIS_FILE, " make_call failed (%d)", status)); - rc = -1902; - goto on_return; + g_ctx.inject_app_mline = PJ_FALSE; + drain_all_calls(); + return -1300; } + if (!wait_until(&call_is_confirmed, cid, 8000)) { PJ_LOG(1, (THIS_FILE, " call did not reach CONFIRMED in time")); - rc = -1903; - goto on_return; + g_ctx.inject_app_mline = PJ_FALSE; + drain_all_calls(); + return -1301; } - dlg = pjsua_var.calls[cid].inv->dlg; - /* Rejection must also happen on a recording session. */ - siprec_test_mark_siprec_sessions(); + /* Stop injecting; the reinvite below must not add further m-lines. */ + g_ctx.inject_app_mline = PJ_FALSE; - g_siprec_test_ctx.request_seen = PJ_FALSE; - g_siprec_test_ctx.response_code = 0; + /* Sanity/diagnostic: the call should now carry the extra m-line. The + * assertion below is self-validating regardless (PJ_ETOOMANY is only + * returned when the retained untyped slot pushes the count over max). + */ + if (pjsua_call_get_info(cid, &ci) == PJ_SUCCESS) { + PJ_LOG(3, (THIS_FILE, " established with media_cnt=%u", + ci.media_cnt)); + } - status = pjsip_dlg_create_request(dlg, &UPDATE_METHOD, -1, &tdata); - if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " failed to create UPDATE (%d)", status)); - rc = -1904; - goto on_return; + pjsua_call_setting_default(&opt); + opt.aud_cnt = 1; + opt.vid_cnt = 0; + opt.txt_cnt = PJSUA_MAX_CALL_MEDIA - 1; + status = pjsua_call_reinvite2(cid, &opt, NULL); + if (status != PJ_ETOOMANY) { + PJ_LOG(1, (THIS_FILE, " reinit overflow with untyped m-line " + "expected PJ_ETOOMANY, got %d", status)); + drain_all_calls(); + return -1302; } - tdata->msg->body = body; - status = pjsip_endpt_send_request(pjsua_var.endpt, tdata, -1, - NULL, &siprec_test_request_cb); - if (status == PJ_SUCCESS && - wait_until(&siprec_response_seen, PJSUA_INVALID_ID, 8000)) + if (pjsua_call_get_info(cid, &ci) != PJ_SUCCESS || + ci.state != PJSIP_INV_STATE_CONFIRMED) { - if (g_siprec_test_ctx.response_code != 488) { - PJ_LOG(1, (THIS_FILE, - " bogus UPDATE expected 488, got %d", - g_siprec_test_ctx.response_code)); - rc = -1905; - } else { - PJ_LOG(3,(THIS_FILE, - " bogus UPDATE rejected with 488 as expected")); - } - } else { - PJ_LOG(1, (THIS_FILE, " no response to bogus UPDATE")); - rc = -1906; + PJ_LOG(1, (THIS_FILE, " call not alive after rejected reinvite")); + drain_all_calls(); + return -1303; } drain_all_calls(); - cid = PJSUA_INVALID_ID; - if (rc != 0) - goto on_return; + return 0; +} - /* Phase 2: re-INVITE with unknown content-type must be rejected with 415 */ - status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, &cid); - if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " make_call failed (%d)", status)); - rc = -1907; - goto on_return; - } - if (!wait_until(&call_is_confirmed, cid, 8000)) { - PJ_LOG(1, (THIS_FILE, " call did not reach CONFIRMED in time")); - rc = -1908; - goto on_return; - } - dlg = pjsua_var.calls[cid].inv->dlg; +#if PJSUA_HAS_SIPREC +/* Helper to create SDP with label attribute. */ +static pjmedia_sdp_session *create_siprec_sdp(pj_pool_t *pool) +{ + pjmedia_sdp_session *sdp; + pjmedia_sdp_media *m; + pjmedia_sdp_attr *a; + pjmedia_sdp_conn *conn; - /* Rejection must also happen on a recording session. */ - siprec_test_mark_siprec_sessions(); + sdp = PJ_POOL_ZALLOC_T(pool, pjmedia_sdp_session); + pj_strdup2(pool, &sdp->origin.user, "-"); + sdp->origin.version = 0; + sdp->origin.id = 0; + pj_strdup2(pool, &sdp->origin.net_type, "IN"); + pj_strdup2(pool, &sdp->origin.addr_type, "IP4"); + pj_strdup2(pool, &sdp->origin.addr, "127.0.0.1"); + sdp->name = pj_str("SIPREC Test"); - g_siprec_test_ctx.request_seen = PJ_FALSE; - g_siprec_test_ctx.response_code = 0; + /* Add connection line at session level. */ + conn = PJ_POOL_ZALLOC_T(pool, pjmedia_sdp_conn); + pj_strdup2(pool, &conn->net_type, "IN"); + pj_strdup2(pool, &conn->addr_type, "IP4"); + pj_strdup2(pool, &conn->addr, "127.0.0.1"); + sdp->conn = conn; - status = pjsip_dlg_create_request(dlg, &INVITE_METHOD, -1, &tdata); - if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " failed to create re-INVITE (%d)", status)); - rc = -1909; - goto on_return; - } - tdata->msg->body = body; + sdp->time.start = 0; + sdp->time.stop = 0; + sdp->media_count = 1; - status = pjsip_endpt_send_request(pjsua_var.endpt, tdata, -1, - NULL, &siprec_test_request_cb); - if (status == PJ_SUCCESS && - wait_until(&siprec_response_seen, PJSUA_INVALID_ID, 8000)) + m = PJ_POOL_ZALLOC_T(pool, pjmedia_sdp_media); + pj_strdup2(pool, &m->desc.media, "audio"); + m->desc.port = 4000; + pj_strdup2(pool, &m->desc.transport, "RTP/AVP"); + m->desc.fmt_count = 1; + pj_strdup2(pool, &m->desc.fmt[0], "0"); + + /* Add connection line at media level. */ + conn = PJ_POOL_ZALLOC_T(pool, pjmedia_sdp_conn); + pj_strdup2(pool, &conn->net_type, "IN"); + pj_strdup2(pool, &conn->addr_type, "IP4"); + pj_strdup2(pool, &conn->addr, "127.0.0.1"); + m->conn = conn; + + /* Add label attribute (required for SIPREC). */ { - if (g_siprec_test_ctx.response_code != 415) { - PJ_LOG(1, (THIS_FILE, - " bogus re-INVITE expected 415, got %d", - g_siprec_test_ctx.response_code)); - rc = -1910; - } else { - PJ_LOG(3,(THIS_FILE, - " bogus re-INVITE rejected with 415 as expected")); - } + pj_str_t label_str = pj_str("1"); + a = pjmedia_sdp_attr_create(pool, "label", &label_str); + } + m->attr[m->attr_count++] = a; + + sdp->media[0] = m; + return sdp; +} + +/* State for SIPREC tests to track response code. */ +static struct { + pj_bool_t request_seen; + int response_code; + /* Initial/mid-dialog metadata notifications from pjsua layer. */ + unsigned metadata_notified_count; + int metadata_last_call; + pj_bool_t metadata_rdata_valid; + char metadata_last_text[256]; +} g_siprec_test_ctx; + +/* Capture SIPREC metadata notifications delivered to the application. */ +static void on_call_siprec_metadata_update(pjsua_call_id call_id, + const pj_str_t *old_metadata, + const pj_str_t *new_metadata, + pjsip_rx_data *rdata) +{ + unsigned n; + + PJ_UNUSED_ARG(old_metadata); + + g_siprec_test_ctx.metadata_notified_count++; + g_siprec_test_ctx.metadata_last_call = call_id; + g_siprec_test_ctx.metadata_rdata_valid = (rdata != NULL); + + if (new_metadata && new_metadata->ptr && new_metadata->slen > 0) { + n = new_metadata->slen; + if (n > sizeof(g_siprec_test_ctx.metadata_last_text) - 1) + n = sizeof(g_siprec_test_ctx.metadata_last_text) - 1; + pj_memcpy(g_siprec_test_ctx.metadata_last_text, new_metadata->ptr, n); + g_siprec_test_ctx.metadata_last_text[n] = '\0'; } else { - PJ_LOG(1, (THIS_FILE, " no response to bogus re-INVITE")); - rc = -1911; + g_siprec_test_ctx.metadata_last_text[0] = '\0'; } -on_return: - if (cid != PJSUA_INVALID_ID) - pjsua_call_hangup(cid, 0, NULL, NULL); + PJ_LOG(3,(THIS_FILE, " on_call_siprec_metadata_update call=%d len=%u", + call_id, (unsigned)(new_metadata ? new_metadata->slen : 0))); +} - pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; - drain_all_calls(); - pj_pool_release(pool); +/* Predicate to check if SIPREC response has been received */ +static pj_bool_t siprec_response_seen(pjsua_call_id call_id) +{ + PJ_UNUSED_ARG(call_id); + return g_siprec_test_ctx.request_seen; +} - return rc; +/* Callback for SIPREC test stateful requests to capture response status */ +static void siprec_test_request_cb(void *token, pjsip_event *e) +{ + PJ_UNUSED_ARG(token); + + if (e->type == PJSIP_EVENT_TSX_STATE) { + pjsip_transaction *tsx = e->body.tsx_state.tsx; + + if (tsx && tsx->status_code > 0) { + g_siprec_test_ctx.request_seen = PJ_TRUE; + g_siprec_test_ctx.response_code = tsx->status_code; + PJ_LOG(3,(THIS_FILE, " SIPREC test response received: %d", + tsx->status_code)); + } + } } -/* Test: metadata-only re-INVITE (multipart body carrying an rs-metadata - * part, RFC 7866 §7.1) is accepted with 200 OK without SDP renegotiation. +/* Test 1: SIPREC INVITE with direct application/sdp body (non-multipart) + * using default (PJ_FALSE) config. Should be accepted. */ -static int test_siprec_metadata_only_reinvite_multipart(void) +static int test_siprec_non_multipart_accepted(void) { - extern struct pjsua_data pjsua_var; - pjsua_call_setting opt; - pj_str_t uri = pj_str(g_ctx.self_uri); + extern struct pjsua_data pjsua_var; /* From pjsua_internal.h */ pjsip_tx_data *tdata; pj_pool_t *pool; - pjsip_dialog *dlg; - static const pjsip_method INVITE_METHOD = { PJSIP_INVITE_METHOD, - {"INVITE", 6}}; + pjsip_uri *target; + pj_str_t target_str; + pjsip_contact_hdr *contact_hdr; + pj_str_t str_src = pj_str("+sip.src"); + pjmedia_sdp_session *sdp; pj_status_t status; - pjsua_call_id cid = PJSUA_INVALID_ID; - int rc = 0; - pjsip_msg_body *metadata_body; - pjsip_multipart_part *metadata_part; - pj_str_t metadata_xml; - pjsip_media_type multipart_ct; - pj_str_t boundary; + pj_str_t sdp_str; + char sdp_buf[2048]; + pjsip_msg_body *body; - PJ_LOG(3, (THIS_FILE, " SIPREC metadata-only re-INVITE (multipart)")); + PJ_LOG(3, (THIS_FILE, " SIPREC non-multipart (direct SDP) acceptance")); - /* Enable SIPREC support with optional metadata */ + /* Enable SIPREC support on the account. */ pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_OPTIONAL; g_siprec_test_ctx.request_seen = PJ_FALSE; g_siprec_test_ctx.response_code = 0; - pjsua_call_setting_default(&opt); - opt.aud_cnt = 1; - opt.vid_cnt = 0; - opt.txt_cnt = 0; - - status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, &cid); - if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " make_call failed (%d)", status)); - rc = -2000; - goto on_return; - } - - if (!wait_until(&call_is_confirmed, cid, 8000)) { - PJ_LOG(1, (THIS_FILE, " call did not reach CONFIRMED in time")); - rc = -2001; - goto on_return; - } - - dlg = pjsua_var.calls[cid].inv->dlg; - - /* The metadata-only re-INVITE is only accepted on a recording - * session. */ - siprec_test_mark_siprec_sessions(); - - pool = pjsua_pool_create("siprec-reinvite", 4096, 4096); + pool = pjsua_pool_create("siprec-test1", 4096, 4096); if (!pool) { PJ_LOG(1, (THIS_FILE, " failed to create pool")); - rc = -2002; - goto on_return; + return -1400; } - metadata_xml = pj_str( - "" - "" - "complete" - "" - ); + /* Create target URI. */ + target_str = pj_str(g_ctx.self_uri); + target = pjsip_parse_uri(pool, target_str.ptr, target_str.slen, 0); + if (!target) { + PJ_LOG(1, (THIS_FILE, " failed to parse target URI")); + pj_pool_release(pool); + return -1401; + } - pjsip_media_type_init2(&multipart_ct, "multipart", "mixed"); - boundary = pj_str("boundary456"); - metadata_body = pjsip_multipart_create(pool, &multipart_ct, &boundary); + /* Create INVITE request. */ + status = pjsip_endpt_create_request(pjsua_var.endpt, &pjsip_invite_method, + &target_str, &target_str, &target_str, + NULL, NULL, -1, NULL, &tdata); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " failed to create INVITE (%d)", status)); + pj_pool_release(pool); + return -1402; + } + /* Add Require: siprec header. */ { - pj_str_t type = pj_str("application"); - pj_str_t subtype = pj_str("rs-metadata+xml"); - metadata_part = pjsip_multipart_create_part(pool); - metadata_part->body = pjsip_msg_body_create(pool, &type, &subtype, - &metadata_xml); + pj_str_t hname = pj_str("Require"); + pj_str_t hvalue = pj_str("siprec"); + pjsip_generic_string_hdr *req_hdr = + pjsip_generic_string_hdr_create(tdata->pool, &hname, &hvalue); + pjsip_msg_add_hdr(tdata->msg, (pjsip_hdr*)req_hdr); } - pjsip_multipart_add_part(pool, metadata_body, metadata_part); - status = pjsip_dlg_create_request(dlg, &INVITE_METHOD, -1, &tdata); - if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " failed to create re-INVITE (%d)", status)); - rc = -2003; - goto cleanup_pool; + /* Add Contact header with +sip.src parameter. */ + contact_hdr = pjsip_contact_hdr_create(tdata->pool); + contact_hdr->uri = pjsip_parse_uri(tdata->pool, g_ctx.self_uri, + pj_ansi_strlen(g_ctx.self_uri), 0); + if (contact_hdr->uri) { + pjsip_param *param = PJ_POOL_ALLOC_T(pool, pjsip_param); + param->name = str_src; + param->value.slen = 0; + pj_list_push_back(&contact_hdr->other_param, param); } - tdata->msg->body = metadata_body; + pjsip_msg_add_hdr(tdata->msg, (pjsip_hdr*)contact_hdr); - status = pjsip_endpt_send_request(pjsua_var.endpt, tdata, -1, - NULL, &siprec_test_request_cb); - if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " failed to send re-INVITE (%d)", status)); - rc = -2004; - goto cleanup_pool; - } + /* Create SDP body with label attribute. */ + sdp = create_siprec_sdp(pool); + status = pjmedia_sdp_print(sdp, sdp_buf, sizeof(sdp_buf)); + if (status < 1) { + PJ_LOG(1, (THIS_FILE, " failed to print SDP")); + pjsip_tx_data_dec_ref(tdata); + pj_pool_release(pool); + return -1403; + } + + /* Set application/sdp content type (direct SDP, not multipart). */ + sdp_str.ptr = (char*)pj_pool_alloc(pool, status); + pj_memcpy(sdp_str.ptr, sdp_buf, status); + sdp_str.slen = status; + + body = PJ_POOL_ZALLOC_T(tdata->pool, pjsip_msg_body); + pj_strdup2(tdata->pool, &body->content_type.type, "application"); + pj_strdup2(tdata->pool, &body->content_type.subtype, "sdp"); + body->data = sdp_str.ptr; + body->len = sdp_str.slen; + body->print_body = &pjsip_print_text_body; + tdata->msg->body = body; + + /* Send the request. */ + status = pjsip_endpt_send_request(pjsua_var.endpt, tdata, -1, + NULL, &siprec_test_request_cb); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " failed to send INVITE (%d)", status)); + pj_pool_release(pool); + return -1404; + } + + /* Wait for response. */ + PJ_LOG(3,(THIS_FILE, " Waiting for SIPREC response...")); + if (!wait_until(&siprec_response_seen, PJSUA_INVALID_ID, 8000)) { + PJ_LOG(1, (THIS_FILE, " timeout waiting for SIPREC response")); + pj_pool_release(pool); + pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; + drain_all_calls(); + return -1405; + } + + pj_pool_release(pool); + + /* Restore SIPREC setting. */ + pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; + + /* Cleanup any auto-answered call. */ + drain_all_calls(); + + if (g_siprec_test_ctx.response_code < 200 || + g_siprec_test_ctx.response_code >= 300) { + PJ_LOG(1, (THIS_FILE, " expected 2xx, got %d", + g_siprec_test_ctx.response_code)); + return -1406; + } + + return 0; +} + +/* Test 2: SIPREC INVITE without rs-metadata using PJ_TRUE config. + * Should be rejected with 400 Bad Request. + */ +static int test_siprec_no_metadata_rejected(void) +{ + extern struct pjsua_data pjsua_var; + pjsip_tx_data *tdata; + pj_pool_t *pool; + pjsip_uri *target; + pj_str_t target_str; + pj_str_t siprec_str = pj_str("siprec"); + pj_str_t src_str = pj_str("+sip.src"); + pjmedia_sdp_session *sdp; + pjsip_msg_body *sdp_body, *multipart_body; + pjsip_multipart_part *sdp_part; + pjsip_multipart_part *dummy_part; + pj_status_t status; + pj_bool_t restore_setting = PJ_FALSE; + char sdp_buf[2048]; + + PJ_LOG(3, (THIS_FILE, " SIPREC without rs-metadata rejection (strict)")); + + /* Enable SIPREC support on the account. */ + pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_OPTIONAL; + + /* Save and enable strict metadata requirement. */ + if (!pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata) { + pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_TRUE; + restore_setting = PJ_TRUE; + } + + g_siprec_test_ctx.request_seen = PJ_FALSE; + g_siprec_test_ctx.response_code = 0; + + pool = pjsua_pool_create("siprec-test2", 4096, 4096); + if (!pool) { + PJ_LOG(1, (THIS_FILE, " failed to create pool")); + if (restore_setting) + pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; + return -1500; + } + + /* Create target URI. */ + target_str = pj_str(g_ctx.self_uri); + target = pjsip_parse_uri(pool, target_str.ptr, target_str.slen, 0); + if (!target) { + PJ_LOG(1, (THIS_FILE, " failed to parse target URI")); + pj_pool_release(pool); + if (restore_setting) + pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; + return -1501; + } + + /* Create INVITE request. */ + status = pjsip_endpt_create_request(pjsua_var.endpt, &pjsip_invite_method, + &target_str, &target_str, &target_str, + NULL, NULL, -1, NULL, &tdata); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " failed to create INVITE (%d)", status)); + pj_pool_release(pool); + if (restore_setting) + pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; + return -1502; + } + + /* Add Require: siprec header. */ + { + pj_str_t hname = pj_str("Require"); + pjsip_generic_string_hdr *req_hdr = + pjsip_generic_string_hdr_create(tdata->pool, &hname, &siprec_str); + pjsip_msg_add_hdr(tdata->msg, (pjsip_hdr*)req_hdr); + } + + /* Add Contact header with +sip.src parameter. */ + { + pjsip_contact_hdr *contact_hdr = pjsip_contact_hdr_create(tdata->pool); + contact_hdr->uri = pjsip_parse_uri(tdata->pool, g_ctx.self_uri, + pj_ansi_strlen(g_ctx.self_uri), 0); + if (contact_hdr->uri) { + pjsip_param *new_param = PJ_POOL_ALLOC_T(pool, pjsip_param); + new_param->name = src_str; + new_param->value.slen = 0; + pj_list_push_back(&contact_hdr->other_param, new_param); + } + pjsip_msg_add_hdr(tdata->msg, (pjsip_hdr*)contact_hdr); + } + + /* Create multipart body with SDP but no rs-metadata. */ + multipart_body = pjsip_multipart_create(tdata->pool, NULL, NULL); + + /* Create SDP part. */ + sdp = create_siprec_sdp(pool); + status = pjmedia_sdp_print(sdp, sdp_buf, sizeof(sdp_buf)); + if (status < 1) { + PJ_LOG(1, (THIS_FILE, " failed to print SDP")); + pjsip_tx_data_dec_ref(tdata); + pj_pool_release(pool); + if (restore_setting) + pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; + return -1503; + } + + sdp_body = PJ_POOL_ZALLOC_T(tdata->pool, pjsip_msg_body); + pj_strdup2(tdata->pool, &sdp_body->content_type.type, "application"); + pj_strdup2(tdata->pool, &sdp_body->content_type.subtype, "sdp"); + sdp_body->data = pj_pool_alloc(tdata->pool, status); + pj_memcpy(sdp_body->data, sdp_buf, status); + sdp_body->len = status; + sdp_body->print_body = &pjsip_print_text_body; + + sdp_part = pjsip_multipart_create_part(tdata->pool); + sdp_part->body = sdp_body; + pjsip_multipart_add_part(tdata->pool, multipart_body, sdp_part); + + /* Add a dummy text part (not rs-metadata). */ + { + pjsip_msg_body *text_body = PJ_POOL_ZALLOC_T(tdata->pool, pjsip_msg_body); + pj_strdup2(tdata->pool, &text_body->content_type.type, "text"); + pj_strdup2(tdata->pool, &text_body->content_type.subtype, "plain"); + text_body->data = pj_pool_alloc(tdata->pool, 5); + pj_memcpy(text_body->data, "dummy", 5); + text_body->len = 5; + text_body->print_body = &pjsip_print_text_body; + + dummy_part = pjsip_multipart_create_part(tdata->pool); + dummy_part->body = text_body; + pjsip_multipart_add_part(tdata->pool, multipart_body, dummy_part); + } + + tdata->msg->body = multipart_body; + + /* Send the request. */ + status = pjsip_endpt_send_request(pjsua_var.endpt, tdata, -1, + NULL, &siprec_test_request_cb); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " failed to send INVITE (%d)", status)); + pj_pool_release(pool); + if (restore_setting) + pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; + return -1504; + } + + /* Wait for response. */ + PJ_LOG(3,(THIS_FILE, " Waiting for SIPREC response...")); + if (!wait_until(&siprec_response_seen, PJSUA_INVALID_ID, 8000)) { + PJ_LOG(1, (THIS_FILE, " timeout waiting for SIPREC response")); + pj_pool_release(pool); + pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; + if (restore_setting) + pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; + return -1505; + } + + pj_pool_release(pool); + + /* Restore original settings. */ + pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; + if (restore_setting) + pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; + + if (g_siprec_test_ctx.response_code != 400) { + PJ_LOG(1, (THIS_FILE, " expected 400 Bad Request, got %d", + g_siprec_test_ctx.response_code)); + return -1506; + } + + return 0; +} + +/* Test 2b: SIPREC INVITE with rs-metadata must notify the application via + * on_call_siprec_metadata_update() (initial metadata notification). + */ +static int test_siprec_initial_metadata_notified(void) +{ + extern struct pjsua_data pjsua_var; + pjsip_tx_data *tdata; + pj_pool_t *pool; + pj_str_t target_str; + pj_str_t siprec_str = pj_str("siprec"); + pj_str_t src_str = pj_str("+sip.src"); + pjmedia_sdp_session *sdp; + pjsip_msg_body *sdp_body, *metadata_body, *multipart_body; + pjsip_multipart_part *sdp_part, *metadata_part; + pj_status_t status; + char sdp_buf[2048]; + + PJ_LOG(3, (THIS_FILE, " SIPREC initial metadata notification")); + + /* Enable SIPREC support with optional metadata */ + pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_OPTIONAL; + pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; + + g_siprec_test_ctx.request_seen = PJ_FALSE; + g_siprec_test_ctx.response_code = 0; + g_siprec_test_ctx.metadata_notified_count = 0; + g_siprec_test_ctx.metadata_last_call = PJSUA_INVALID_ID; + g_siprec_test_ctx.metadata_rdata_valid = PJ_FALSE; + + pool = pjsua_pool_create("siprec-test2b", 4096, 4096); + if (!pool) { + PJ_LOG(1, (THIS_FILE, " failed to create pool")); + return -1550; + } + + /* Create target URI. */ + target_str = pj_str(g_ctx.self_uri); + + /* Create INVITE request. */ + status = pjsip_endpt_create_request(pjsua_var.endpt, &pjsip_invite_method, + &target_str, &target_str, &target_str, + NULL, NULL, -1, NULL, &tdata); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " failed to create INVITE (%d)", status)); + pj_pool_release(pool); + return -1552; + } + + /* Add Require: siprec header. */ + { + pj_str_t hname = pj_str("Require"); + pjsip_generic_string_hdr *req_hdr = + pjsip_generic_string_hdr_create(tdata->pool, &hname, &siprec_str); + pjsip_msg_add_hdr(tdata->msg, (pjsip_hdr*)req_hdr); + } + + /* Add Contact header with +sip.src parameter. */ + { + pjsip_contact_hdr *contact_hdr = pjsip_contact_hdr_create(tdata->pool); + contact_hdr->uri = pjsip_parse_uri(tdata->pool, g_ctx.self_uri, + pj_ansi_strlen(g_ctx.self_uri), 0); + if (contact_hdr->uri) { + pjsip_param *new_param = PJ_POOL_ALLOC_T(pool, pjsip_param); + new_param->name = src_str; + new_param->value.slen = 0; + pj_list_push_back(&contact_hdr->other_param, new_param); + } + pjsip_msg_add_hdr(tdata->msg, (pjsip_hdr*)contact_hdr); + } + + /* Create multipart body with SDP and rs-metadata parts. */ + multipart_body = pjsip_multipart_create(tdata->pool, NULL, NULL); + + sdp = create_siprec_sdp(pool); + status = pjmedia_sdp_print(sdp, sdp_buf, sizeof(sdp_buf)); + if (status < 1) { + PJ_LOG(1, (THIS_FILE, " failed to print SDP")); + pjsip_tx_data_dec_ref(tdata); + pj_pool_release(pool); + return -1553; + } + + sdp_body = PJ_POOL_ZALLOC_T(tdata->pool, pjsip_msg_body); + pj_strdup2(tdata->pool, &sdp_body->content_type.type, "application"); + pj_strdup2(tdata->pool, &sdp_body->content_type.subtype, "sdp"); + sdp_body->data = pj_pool_alloc(tdata->pool, status); + pj_memcpy(sdp_body->data, sdp_buf, status); + sdp_body->len = status; + sdp_body->print_body = &pjsip_print_text_body; + + sdp_part = pjsip_multipart_create_part(tdata->pool); + sdp_part->body = sdp_body; + pjsip_multipart_add_part(tdata->pool, multipart_body, sdp_part); + + { + pj_str_t metadata_xml = pj_str( + "" + "" + "complete" + "" + ); + pj_str_t type = pj_str("application"); + pj_str_t subtype = pj_str("rs-metadata+xml"); + + metadata_body = pjsip_msg_body_create(tdata->pool, &type, &subtype, + &metadata_xml); + metadata_part = pjsip_multipart_create_part(tdata->pool); + metadata_part->body = metadata_body; + pjsip_multipart_add_part(tdata->pool, multipart_body, metadata_part); + } + + tdata->msg->body = multipart_body; + + /* Send the request. */ + status = pjsip_endpt_send_request(pjsua_var.endpt, tdata, -1, + NULL, &siprec_test_request_cb); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " failed to send INVITE (%d)", status)); + pj_pool_release(pool); + return -1554; + } + + if (!wait_until(&siprec_response_seen, PJSUA_INVALID_ID, 8000)) { + PJ_LOG(1, (THIS_FILE, " timeout waiting for SIPREC response")); + pj_pool_release(pool); + pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; + drain_all_calls(); + return -1555; + } + + pj_pool_release(pool); + + pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; + + drain_all_calls(); + + if (g_siprec_test_ctx.response_code < 200 || + g_siprec_test_ctx.response_code >= 300) { + PJ_LOG(1, (THIS_FILE, " expected 2xx, got %d", + g_siprec_test_ctx.response_code)); + return -1556; + } + + /* The application must have received the initial metadata + * notification for the incoming call. + */ + if (g_siprec_test_ctx.metadata_notified_count == 0) { + PJ_LOG(1, (THIS_FILE, + " on_call_siprec_metadata_update not notified")); + return -1557; + } + if (!g_siprec_test_ctx.metadata_rdata_valid) { + PJ_LOG(1, (THIS_FILE, " metadata notification had no rdata")); + return -1558; + } + if (!pj_ansi_strstr(g_siprec_test_ctx.metadata_last_text, "recording")) { + PJ_LOG(1, (THIS_FILE, " unexpected metadata content '%s'", + g_siprec_test_ctx.metadata_last_text)); + return -1559; + } + + return 0; +} + +/* Test 3: SIPREC metadata-only UPDATE with multipart body (no SDP). + * Per RFC 7866 §7.1, a mid-dialog UPDATE can contain rs-metadata + * without SDP. This test verifies that such requests are accepted + * with 200 OK when siprec_require_metadata is disabled. + */ +static int test_siprec_metadata_only_update_multipart(void) +{ + extern struct pjsua_data pjsua_var; + pjsua_call_setting opt; + pj_str_t uri = pj_str(g_ctx.self_uri); + pjsip_tx_data *tdata; + pj_pool_t *pool; + pjsip_dialog *dlg; + static const pjsip_method UPDATE_METHOD = { PJSIP_OTHER_METHOD, {"UPDATE", 6}}; + pj_status_t status; + pjsua_call_id cid = PJSUA_INVALID_ID; + int rc = 0; + pjsip_msg_body *metadata_body; + pjsip_multipart_part *metadata_part; + pj_str_t metadata_xml; + pjsip_media_type multipart_ct; + pj_str_t boundary; + + PJ_LOG(3, (THIS_FILE, " SIPREC metadata-only UPDATE (multipart)")); + + /* Enable SIPREC support with optional metadata */ + pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_OPTIONAL; + pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; + + g_siprec_test_ctx.request_seen = PJ_FALSE; + g_siprec_test_ctx.response_code = 0; + + pjsua_call_setting_default(&opt); + opt.aud_cnt = 1; + opt.vid_cnt = 0; + opt.txt_cnt = 0; + + status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, &cid); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " make_call failed (%d)", status)); + rc = -1600; + goto on_return; + } + + if (!wait_until(&call_is_confirmed, cid, 8000)) { + PJ_LOG(1, (THIS_FILE, " call did not reach CONFIRMED in time")); + rc = -1601; + goto on_return; + } + + /* Get dialog for sending UPDATE */ + if (!pjsua_var.calls[cid].inv || !pjsua_var.calls[cid].inv->dlg) { + PJ_LOG(1, (THIS_FILE, " failed to get dialog")); + rc = -1602; + goto on_return; + } + dlg = pjsua_var.calls[cid].inv->dlg; + + /* The metadata-only UPDATE is only accepted on a recording session. */ + siprec_test_mark_siprec_sessions(); + + pool = pjsua_pool_create("siprec-update1", 4096, 4096); + if (!pool) { + PJ_LOG(1, (THIS_FILE, " failed to create pool")); + rc = -1603; + goto on_return; + } + + /* Create simple rs-metadata XML */ + metadata_xml = pj_str( + "" + "" + "complete" + "" + ); + + /* Create empty multipart body */ + pjsip_media_type_init2(&multipart_ct, "multipart", "mixed"); + boundary = pj_str("boundary123"); + metadata_body = pjsip_multipart_create(pool, &multipart_ct, &boundary); + + /* Create rs-metadata part */ + { + pj_str_t type = pj_str("application"); + pj_str_t subtype = pj_str("rs-metadata+xml"); + metadata_part = pjsip_multipart_create_part(pool); + metadata_part->body = pjsip_msg_body_create(pool, &type, &subtype, + &metadata_xml); + } + + /* Add metadata part to multipart body */ + pjsip_multipart_add_part(pool, metadata_body, metadata_part); + + /* Create UPDATE request */ + status = pjsip_dlg_create_request(dlg, &UPDATE_METHOD, -1, &tdata); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " failed to create UPDATE (%d)", status)); + rc = -1604; + goto cleanup_pool; + } + + /* Set multipart body */ + tdata->msg->body = metadata_body; + + /* Send UPDATE statefully with callback to capture response */ + status = pjsip_endpt_send_request(pjsua_var.endpt, tdata, -1, + NULL, &siprec_test_request_cb); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " failed to send UPDATE (%d)", status)); + rc = -1606; + goto cleanup_pool; + } + + /* Wait for the response to be received */ + PJ_LOG(3,(THIS_FILE, " Waiting for SIPREC UPDATE response...")); + if (!wait_until(&siprec_response_seen, PJSUA_INVALID_ID, 8000)) { + PJ_LOG(1, (THIS_FILE, " timeout waiting for SIPREC UPDATE response")); + rc = -1607; + goto cleanup_pool; + } + + PJ_LOG(3,(THIS_FILE, " SIPREC UPDATE response received: %d", + g_siprec_test_ctx.response_code)); + + /* Verify call is still established after metadata UPDATE */ + { + pjsua_call_info ci; + if (pjsua_call_get_count() == 0 || + pjsua_call_get_info(cid, &ci) != PJ_SUCCESS || + ci.state != PJSIP_INV_STATE_CONFIRMED) { + PJ_LOG(1, (THIS_FILE, " call not established after UPDATE")); + rc = -1608; + goto cleanup_pool; + } + } + + PJ_LOG(3,(THIS_FILE, " call still established after metadata UPDATE")); + +cleanup_pool: + pj_pool_release(pool); + +on_return: + pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; + pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; + drain_all_calls(); + + if (rc != 0) + return rc; + + if (g_siprec_test_ctx.response_code < 200 || + g_siprec_test_ctx.response_code >= 300) { + PJ_LOG(1, (THIS_FILE, " expected 2xx, got %d", + g_siprec_test_ctx.response_code)); + return -1609; + } + + return 0; +} + +/* Test 4: SIPREC metadata-only UPDATE with single-part rs-metadata body. + * Per RFC 7866 §7.1, a mid-dialog UPDATE can contain a single-part + * rs-metadata document. This test verifies that such requests are + * accepted with 200 OK. + */ +static int test_siprec_metadata_only_update_singlepart(void) +{ + extern struct pjsua_data pjsua_var; + pjsua_call_setting opt; + pj_str_t uri = pj_str(g_ctx.self_uri); + pjsip_tx_data *tdata; + pj_pool_t *pool; + pjsip_dialog *dlg; + static const pjsip_method UPDATE_METHOD = { PJSIP_OTHER_METHOD, {"UPDATE", 6}}; + pj_status_t status; + pjsua_call_id cid = PJSUA_INVALID_ID; + int rc = 0; + pjsip_msg_body *metadata_body; + pj_str_t metadata_xml; + pj_str_t metadata_type; + pj_str_t metadata_subtype; + + PJ_LOG(3, (THIS_FILE, " SIPREC metadata-only UPDATE (single-part)")); + + /* Enable SIPREC support with optional metadata */ + pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_OPTIONAL; + pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; + + g_siprec_test_ctx.request_seen = PJ_FALSE; + g_siprec_test_ctx.response_code = 0; + + pjsua_call_setting_default(&opt); + opt.aud_cnt = 1; + opt.vid_cnt = 0; + opt.txt_cnt = 0; + + status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, &cid); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " make_call failed (%d)", status)); + rc = -1700; + goto on_return; + } + + if (!wait_until(&call_is_confirmed, cid, 8000)) { + PJ_LOG(1, (THIS_FILE, " call did not reach CONFIRMED in time")); + rc = -1701; + goto on_return; + } + + /* Get dialog for sending UPDATE */ + if (!pjsua_var.calls[cid].inv || !pjsua_var.calls[cid].inv->dlg) { + PJ_LOG(1, (THIS_FILE, " failed to get dialog")); + rc = -1702; + goto on_return; + } + dlg = pjsua_var.calls[cid].inv->dlg; + + /* The metadata-only UPDATE is only accepted on a recording session. */ + siprec_test_mark_siprec_sessions(); + + pool = pjsua_pool_create("siprec-update2", 4096, 4096); + if (!pool) { + PJ_LOG(1, (THIS_FILE, " failed to create pool")); + rc = -1703; + goto on_return; + } + + /* Create simple rs-metadata XML */ + metadata_xml = pj_str( + "" + "" + "complete" + "" + ); + + /* Create single-part rs-metadata body */ + metadata_type = pj_str("application"); + metadata_subtype = pj_str("rs-metadata+xml"); + metadata_body = pjsip_msg_body_create(pool, + &metadata_type, + &metadata_subtype, + &metadata_xml); + + /* Create UPDATE request */ + status = pjsip_dlg_create_request(dlg, &UPDATE_METHOD, -1, &tdata); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " failed to create UPDATE (%d)", status)); + rc = -1704; + goto cleanup_pool; + } + + /* Set single-part body */ + tdata->msg->body = metadata_body; + + /* Send UPDATE statefully - response will be handled by invite session */ + status = pjsip_endpt_send_request(pjsua_var.endpt, tdata, -1, + NULL, &siprec_test_request_cb); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " failed to send UPDATE (%d)", status)); + rc = -1706; + goto cleanup_pool; + } + + /* Wait for the response to be received */ + PJ_LOG(3,(THIS_FILE, " Waiting for SIPREC UPDATE response...")); + if (!wait_until(&siprec_response_seen, PJSUA_INVALID_ID, 8000)) { + PJ_LOG(1, (THIS_FILE, " timeout waiting for SIPREC UPDATE response")); + rc = -1707; + goto cleanup_pool; + } + + PJ_LOG(3,(THIS_FILE, " SIPREC UPDATE response received: %d", + g_siprec_test_ctx.response_code)); + + /* Verify call is still established after metadata UPDATE */ + { + pjsua_call_info ci; + if (pjsua_call_get_count() == 0 || + pjsua_call_get_info(cid, &ci) != PJ_SUCCESS || + ci.state != PJSIP_INV_STATE_CONFIRMED) { + PJ_LOG(1, (THIS_FILE, " call not established after UPDATE")); + rc = -1708; + goto cleanup_pool; + } + } + + PJ_LOG(3,(THIS_FILE, " call still established after metadata UPDATE")); + +cleanup_pool: + pj_pool_release(pool); + +on_return: + pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; + pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; + drain_all_calls(); + + if (rc != 0) + return rc; + + if (g_siprec_test_ctx.response_code < 200 || + g_siprec_test_ctx.response_code >= 300) { + PJ_LOG(1, (THIS_FILE, " expected 2xx, got %d", + g_siprec_test_ctx.response_code)); + return -1709; + } + + return 0; +} + +/* Test 5: SIPREC metadata UPDATE with alternate content. + * This test verifies that metadata UPDATE requests are accepted + * regardless of the specific metadata XML content. The rs-metadata + * body is passed through without parsing of individual elements + * (datamode parsing is deferred per RFC 7865 TODO). + * + * This test uses a different XML payload than Test 4 to verify + * that acceptance is based on content-type, not content values. + */ +static int test_siprec_datamode_partial_element(void) +{ + extern struct pjsua_data pjsua_var; + pjsua_call_setting opt; + pj_str_t uri = pj_str(g_ctx.self_uri); + pjsip_tx_data *tdata; + pj_pool_t *pool; + pjsip_dialog *dlg; + static const pjsip_method UPDATE_METHOD = { PJSIP_OTHER_METHOD, {"UPDATE", 6}}; + pj_status_t status; + pjsua_call_id cid = PJSUA_INVALID_ID; + int rc = 0; + pjsip_msg_body *metadata_body; + pj_str_t metadata_xml; + pj_str_t metadata_type; + pj_str_t metadata_subtype; + + PJ_LOG(3, (THIS_FILE, " SIPREC datamode=partial element parsing")); + + /* Enable SIPREC support with optional metadata */ + pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_OPTIONAL; + pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; + + g_siprec_test_ctx.request_seen = PJ_FALSE; + g_siprec_test_ctx.response_code = 0; + + pjsua_call_setting_default(&opt); + opt.aud_cnt = 1; + opt.vid_cnt = 0; + opt.txt_cnt = 0; + + status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, &cid); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " make_call failed (%d)", status)); + rc = -1800; + goto on_return; + } + + if (!wait_until(&call_is_confirmed, cid, 8000)) { + PJ_LOG(1, (THIS_FILE, " call did not reach CONFIRMED in time")); + rc = -1801; + goto on_return; + } + + /* Get dialog for sending UPDATE */ + if (!pjsua_var.calls[cid].inv || !pjsua_var.calls[cid].inv->dlg) { + PJ_LOG(1, (THIS_FILE, " failed to get dialog")); + rc = -1802; + goto on_return; + } + dlg = pjsua_var.calls[cid].inv->dlg; + + /* The metadata-only UPDATE is only accepted on a recording session. */ + siprec_test_mark_siprec_sessions(); + + pool = pjsua_pool_create("siprec-update3", 4096, 4096); + if (!pool) { + PJ_LOG(1, (THIS_FILE, " failed to create pool")); + rc = -1803; + goto on_return; + } + + /* Create rs-metadata with datamode=partial (lowercase element) */ + metadata_xml = pj_str( + "" + "" + "partial" + "" + ); + + /* Create single-part rs-metadata body */ + metadata_type = pj_str("application"); + metadata_subtype = pj_str("rs-metadata+xml"); + metadata_body = pjsip_msg_body_create(pool, + &metadata_type, + &metadata_subtype, + &metadata_xml); + + /* Create UPDATE request */ + status = pjsip_dlg_create_request(dlg, &UPDATE_METHOD, -1, &tdata); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " failed to create UPDATE (%d)", status)); + rc = -1804; + goto cleanup_pool; + } + + /* Set body */ + tdata->msg->body = metadata_body; + + /* Send UPDATE statefully - response will be handled by invite session */ + status = pjsip_endpt_send_request(pjsua_var.endpt, tdata, -1, + NULL, &siprec_test_request_cb); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " failed to send UPDATE (%d)", status)); + rc = -1806; + goto cleanup_pool; + } + + /* Wait for the response to be received */ + PJ_LOG(3,(THIS_FILE, " Waiting for SIPREC UPDATE response...")); + if (!wait_until(&siprec_response_seen, PJSUA_INVALID_ID, 8000)) { + PJ_LOG(1, (THIS_FILE, " timeout waiting for SIPREC UPDATE response")); + rc = -1807; + goto cleanup_pool; + } + + PJ_LOG(3,(THIS_FILE, " SIPREC UPDATE response received: %d", + g_siprec_test_ctx.response_code)); + + /* Verify call is still established after metadata UPDATE */ + { + pjsua_call_info ci; + if (pjsua_call_get_count() == 0 || + pjsua_call_get_info(cid, &ci) != PJ_SUCCESS || + ci.state != PJSIP_INV_STATE_CONFIRMED) { + PJ_LOG(1, (THIS_FILE, " call not established after UPDATE")); + rc = -1808; + goto cleanup_pool; + } + } + + PJ_LOG(3,(THIS_FILE, " call still established after metadata UPDATE")); + +cleanup_pool: + pj_pool_release(pool); + +on_return: + pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; + pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; + drain_all_calls(); + + if (rc != 0) + return rc; + + if (g_siprec_test_ctx.response_code < 200 || + g_siprec_test_ctx.response_code >= 300) { + PJ_LOG(1, (THIS_FILE, " expected 2xx, got %d", + g_siprec_test_ctx.response_code)); + return -1809; + } + + return 0; +} + +/* Test: mid-dialog requests with an unrecognized content-type must be + * rejected. Pre-PR pjsip rejected them via inv_check_sdp_in_incoming_msg() + * (488 for UPDATE, 415 for re-INVITE when PJSIP_INV_ACCEPT_UNKNOWN_BODY is + * disabled); this must still hold even when SIPREC is enabled. + */ +static int test_siprec_unknown_body_rejected(void) +{ + extern struct pjsua_data pjsua_var; + pjsua_call_setting opt; + pj_str_t uri = pj_str(g_ctx.self_uri); + pjsip_tx_data *tdata; + pj_pool_t *pool; + pjsip_dialog *dlg; + static const pjsip_method UPDATE_METHOD = { PJSIP_OTHER_METHOD, + {"UPDATE", 6}}; + static const pjsip_method INVITE_METHOD = { PJSIP_INVITE_METHOD, + {"INVITE", 6}}; + pj_status_t status; + pjsua_call_id cid = PJSUA_INVALID_ID; + int rc = 0; + pjsip_msg_body *body; + pj_str_t bogus_data; + pj_str_t type; + pj_str_t subtype; + + PJ_LOG(3, (THIS_FILE, " SIPREC unknown content-type rejection")); + + /* Enable SIPREC support with optional metadata */ + pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_OPTIONAL; + + pool = pjsua_pool_create("siprec-unknown-body", 4096, 4096); + if (!pool) { + PJ_LOG(1, (THIS_FILE, " failed to create pool")); + return -1900; + } + + bogus_data = pj_str("not-sdp-not-metadata"); + type = pj_str("application"); + subtype = pj_str("octet-stream"); + body = pjsip_msg_body_create(pool, &type, &subtype, &bogus_data); + if (!body) { + PJ_LOG(1, (THIS_FILE, " failed to create body")); + pj_pool_release(pool); + return -1901; + } + + /* Phase 1: UPDATE with unknown content-type must be rejected with 488 */ + pjsua_call_setting_default(&opt); + opt.aud_cnt = 1; + opt.vid_cnt = 0; + opt.txt_cnt = 0; + + status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, &cid); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " make_call failed (%d)", status)); + rc = -1902; + goto on_return; + } + if (!wait_until(&call_is_confirmed, cid, 8000)) { + PJ_LOG(1, (THIS_FILE, " call did not reach CONFIRMED in time")); + rc = -1903; + goto on_return; + } + dlg = pjsua_var.calls[cid].inv->dlg; + + /* Rejection must also happen on a recording session. */ + siprec_test_mark_siprec_sessions(); + + g_siprec_test_ctx.request_seen = PJ_FALSE; + g_siprec_test_ctx.response_code = 0; + + status = pjsip_dlg_create_request(dlg, &UPDATE_METHOD, -1, &tdata); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " failed to create UPDATE (%d)", status)); + rc = -1904; + goto on_return; + } + tdata->msg->body = body; + + status = pjsip_endpt_send_request(pjsua_var.endpt, tdata, -1, + NULL, &siprec_test_request_cb); + if (status == PJ_SUCCESS && + wait_until(&siprec_response_seen, PJSUA_INVALID_ID, 8000)) + { + if (g_siprec_test_ctx.response_code != 488) { + PJ_LOG(1, (THIS_FILE, + " bogus UPDATE expected 488, got %d", + g_siprec_test_ctx.response_code)); + rc = -1905; + } else { + PJ_LOG(3,(THIS_FILE, + " bogus UPDATE rejected with 488 as expected")); + } + } else { + PJ_LOG(1, (THIS_FILE, " no response to bogus UPDATE")); + rc = -1906; + } + + drain_all_calls(); + cid = PJSUA_INVALID_ID; + if (rc != 0) + goto on_return; + + /* Phase 2: re-INVITE with unknown content-type must be rejected with 415 */ + status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, &cid); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " make_call failed (%d)", status)); + rc = -1907; + goto on_return; + } + if (!wait_until(&call_is_confirmed, cid, 8000)) { + PJ_LOG(1, (THIS_FILE, " call did not reach CONFIRMED in time")); + rc = -1908; + goto on_return; + } + dlg = pjsua_var.calls[cid].inv->dlg; + + /* Rejection must also happen on a recording session. */ + siprec_test_mark_siprec_sessions(); + + g_siprec_test_ctx.request_seen = PJ_FALSE; + g_siprec_test_ctx.response_code = 0; + + status = pjsip_dlg_create_request(dlg, &INVITE_METHOD, -1, &tdata); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " failed to create re-INVITE (%d)", status)); + rc = -1909; + goto on_return; + } + tdata->msg->body = body; + + status = pjsip_endpt_send_request(pjsua_var.endpt, tdata, -1, + NULL, &siprec_test_request_cb); + if (status == PJ_SUCCESS && + wait_until(&siprec_response_seen, PJSUA_INVALID_ID, 8000)) + { + if (g_siprec_test_ctx.response_code != 415) { + PJ_LOG(1, (THIS_FILE, + " bogus re-INVITE expected 415, got %d", + g_siprec_test_ctx.response_code)); + rc = -1910; + } else { + PJ_LOG(3,(THIS_FILE, + " bogus re-INVITE rejected with 415 as expected")); + } + } else { + PJ_LOG(1, (THIS_FILE, " no response to bogus re-INVITE")); + rc = -1911; + } + +on_return: + if (cid != PJSUA_INVALID_ID) + pjsua_call_hangup(cid, 0, NULL, NULL); + + pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; + drain_all_calls(); + pj_pool_release(pool); + + return rc; +} + +/* Test: metadata-only re-INVITE (multipart body carrying an rs-metadata + * part, RFC 7866 §7.1) is accepted with 200 OK without SDP renegotiation. + */ +static int test_siprec_metadata_only_reinvite_multipart(void) +{ + extern struct pjsua_data pjsua_var; + pjsua_call_setting opt; + pj_str_t uri = pj_str(g_ctx.self_uri); + pjsip_tx_data *tdata; + pj_pool_t *pool; + pjsip_dialog *dlg; + static const pjsip_method INVITE_METHOD = { PJSIP_INVITE_METHOD, + {"INVITE", 6}}; + pj_status_t status; + pjsua_call_id cid = PJSUA_INVALID_ID; + int rc = 0; + pjsip_msg_body *metadata_body; + pjsip_multipart_part *metadata_part; + pj_str_t metadata_xml; + pjsip_media_type multipart_ct; + pj_str_t boundary; + + PJ_LOG(3, (THIS_FILE, " SIPREC metadata-only re-INVITE (multipart)")); + + /* Enable SIPREC support with optional metadata */ + pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_OPTIONAL; + + g_siprec_test_ctx.request_seen = PJ_FALSE; + g_siprec_test_ctx.response_code = 0; + + pjsua_call_setting_default(&opt); + opt.aud_cnt = 1; + opt.vid_cnt = 0; + opt.txt_cnt = 0; + + status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, &cid); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " make_call failed (%d)", status)); + rc = -2000; + goto on_return; + } + + if (!wait_until(&call_is_confirmed, cid, 8000)) { + PJ_LOG(1, (THIS_FILE, " call did not reach CONFIRMED in time")); + rc = -2001; + goto on_return; + } + + dlg = pjsua_var.calls[cid].inv->dlg; + + /* The metadata-only re-INVITE is only accepted on a recording + * session. */ + siprec_test_mark_siprec_sessions(); + + pool = pjsua_pool_create("siprec-reinvite", 4096, 4096); + if (!pool) { + PJ_LOG(1, (THIS_FILE, " failed to create pool")); + rc = -2002; + goto on_return; + } + + metadata_xml = pj_str( + "" + "" + "complete" + "" + ); + + pjsip_media_type_init2(&multipart_ct, "multipart", "mixed"); + boundary = pj_str("boundary456"); + metadata_body = pjsip_multipart_create(pool, &multipart_ct, &boundary); + + { + pj_str_t type = pj_str("application"); + pj_str_t subtype = pj_str("rs-metadata+xml"); + metadata_part = pjsip_multipart_create_part(pool); + metadata_part->body = pjsip_msg_body_create(pool, &type, &subtype, + &metadata_xml); + } + pjsip_multipart_add_part(pool, metadata_body, metadata_part); + + status = pjsip_dlg_create_request(dlg, &INVITE_METHOD, -1, &tdata); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " failed to create re-INVITE (%d)", status)); + rc = -2003; + goto cleanup_pool; + } + tdata->msg->body = metadata_body; + + status = pjsip_endpt_send_request(pjsua_var.endpt, tdata, -1, + NULL, &siprec_test_request_cb); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " failed to send re-INVITE (%d)", status)); + rc = -2004; + goto cleanup_pool; + } + + if (!wait_until(&siprec_response_seen, PJSUA_INVALID_ID, 8000)) { + PJ_LOG(1, (THIS_FILE, " timeout waiting for re-INVITE response")); + rc = -2005; + goto cleanup_pool; + } + +cleanup_pool: + pj_pool_release(pool); + +on_return: + pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; + pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; + drain_all_calls(); + + if (rc != 0) + return rc; + + if (g_siprec_test_ctx.response_code < 200 || + g_siprec_test_ctx.response_code >= 300) { + PJ_LOG(1, (THIS_FILE, " expected 2xx, got %d", + g_siprec_test_ctx.response_code)); + return -2006; + } + + return 0; +} + +#endif + +/* RFC2543 call hold of a call whose local SDP has no connection line at all. + * + * With PJSUA_CALL_HOLD_TYPE_RFC2543, modify_sdp_of_call_hold() puts the hold + * address into the media-level c= line, falling back to the session-level one. + * Both can be missing, in which case the address used to be written through a + * NULL pointer; a connection line has to be created instead. + * + * The conn-less SDP is produced through the public on_call_sdp_created() + * callback, which is invoked by pjsua_media_channel_create_sdp() before + * create_sdp_of_call_hold() applies the hold modification. The assertions are + * end-to-end rather than on internals: pjsua_call_set_hold() must succeed + * (an SDP whose m= line resolves to no c= line at all fails + * pjmedia_sdp_validate() with PJMEDIA_SDP_EMISSINGCONN, so a re-INVITE that + * merely skipped the missing line would be rejected here), and the offer that + * reaches the peer must carry the 0.0.0.0 hold address. + */ +static int test_rfc2543_hold_without_conn(void) +{ + pjsua_call_setting opt; + pj_str_t uri = pj_str(g_ctx.self_uri); + pjsua_call_hold_type saved_hold_type; + pj_status_t status; + pjsua_call_id cid = PJSUA_INVALID_ID; + pjsua_call_info ci; + int rc = 0; + + PJ_LOG(3, (THIS_FILE, " RFC2543 hold with no c= line in the local SDP")); + + /* pjsua_call.call_hold_type is captured from the account when the call is + * created, so switch the account over before placing the call. + */ + saved_hold_type = pjsua_var.acc[g_ctx.acc_id].cfg.call_hold_type; + pjsua_var.acc[g_ctx.acc_id].cfg.call_hold_type = + PJSUA_CALL_HOLD_TYPE_RFC2543; + + pjsua_call_setting_default(&opt); + opt.aud_cnt = 1; + opt.vid_cnt = 0; + opt.txt_cnt = 0; + status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, &cid); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " make_call failed (%d)", status)); + rc = -1400; + goto on_return; + } + + if (!wait_until(&call_is_confirmed, cid, 8000)) { + PJ_LOG(1, (THIS_FILE, " call did not reach CONFIRMED in time")); + rc = -1401; + goto on_return; + } + + /* Hold, with every c= line removed from the hold offer. */ + g_ctx.strip_conn_call_id = cid; + g_ctx.strip_conn_in_offer = PJ_TRUE; + g_ctx.expect_hold_offer = PJ_TRUE; + g_ctx.hold_offer_seen = PJ_FALSE; + g_ctx.hold_offer_conn_ok = PJ_FALSE; + + /* The hold SDP is created synchronously here, so the stripping only + * applies to this one offer. + */ + status = pjsua_call_set_hold(cid, NULL); + g_ctx.strip_conn_in_offer = PJ_FALSE; + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " set_hold failed (%d)", status)); + rc = -1402; + goto on_return; + } + + if (!wait_until(&hold_offer_processed, PJSUA_INVALID_ID, 8000)) { + PJ_LOG(1, (THIS_FILE, " peer never received the hold offer")); + rc = -1403; + goto on_return; + } + + if (!g_ctx.hold_offer_conn_ok) { + PJ_LOG(1, (THIS_FILE, " hold offer has no c= line with 0.0.0.0")); + rc = -1404; + goto on_return; + } + + /* The call must survive the hold. */ + if (pjsua_call_get_info(cid, &ci) != PJ_SUCCESS || + ci.state != PJSIP_INV_STATE_CONFIRMED) + { + PJ_LOG(1, (THIS_FILE, " call not alive after hold")); + rc = -1405; + goto on_return; + } + +on_return: + g_ctx.strip_conn_in_offer = PJ_FALSE; + g_ctx.expect_hold_offer = PJ_FALSE; + pjsua_var.acc[g_ctx.acc_id].cfg.call_hold_type = saved_hold_type; + drain_all_calls(); + + return rc; +} + + +/* Place a single-audio loopback call and wait until both legs are confirmed + * and their initial media update has completed, so each leg is running + * exactly one audio stream. Resets the per-call stream and media-update + * counters first, so the caller can reason about them in absolute terms. + * Returns 0 on success, or a negative code derived from err_base. + */ +static int establish_self_call(pjsua_call_id *p_caller, + pjsua_call_id *p_callee, + int err_base) +{ + pjsua_call_setting opt; + pj_str_t uri = pj_str(g_ctx.self_uri); + pj_status_t status; + pjsua_call_id caller = PJSUA_INVALID_ID; + + g_ctx.incoming_call_id = PJSUA_INVALID_ID; + pj_bzero(g_ctx.strm_created, sizeof(g_ctx.strm_created)); + pj_bzero(g_ctx.strm_destroyed, sizeof(g_ctx.strm_destroyed)); + pj_bzero(g_ctx.med_state_cnt, sizeof(g_ctx.med_state_cnt)); + + pjsua_call_setting_default(&opt); + opt.aud_cnt = 1; + opt.vid_cnt = 0; + opt.txt_cnt = 0; + status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, + &caller); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " make_call failed (%d)", status)); + return err_base - 0; + } + + if (!wait_until(&call_is_confirmed, caller, 8000)) { + PJ_LOG(1, (THIS_FILE, " caller leg did not reach CONFIRMED")); + return err_base - 1; + } + + if (g_ctx.incoming_call_id == PJSUA_INVALID_ID || + g_ctx.incoming_call_id == caller) + { + PJ_LOG(1, (THIS_FILE, " callee leg was not identified")); + return err_base - 2; + } + + if (!wait_until(&call_is_confirmed, g_ctx.incoming_call_id, 8000)) { + PJ_LOG(1, (THIS_FILE, " callee leg did not reach CONFIRMED")); + return err_base - 3; + } + + g_ctx.med_state_target = 1; + if (!wait_until(&media_state_reached, caller, 8000) || + !wait_until(&media_state_reached, g_ctx.incoming_call_id, 8000)) + { + PJ_LOG(1, (THIS_FILE, " initial media update did not complete")); + return err_base - 4; + } + + if (g_ctx.strm_created[caller] != 1 || + g_ctx.strm_created[g_ctx.incoming_call_id] != 1) + { + PJ_LOG(1, (THIS_FILE, " expected one audio stream per leg, got " + "caller=%u callee=%u", g_ctx.strm_created[caller], + g_ctx.strm_created[g_ctx.incoming_call_id])); + return err_base - 5; + } + + *p_caller = caller; + *p_callee = g_ctx.incoming_call_id; + return 0; +} + + +/* A no-op re-INVITE must not restart the audio stream just because the remote + * SDP advertises a=rtcp-mux that we do not accept. + * + * pjmedia_stream_info_from_sdp() sets pjmedia_stream_info.rtcp_mux purely from + * the remote m= line, and apply_med_update() then clears it again because the + * account leaves enable_rtcp_mux off. The stream is created from the corrected + * info, so the value is_media_changed() compares against is PJ_FALSE. Compare + * the uncorrected PJ_TRUE with it and every single renegotiation with such a + * peer looks like a media change, tearing the stream down and rebuilding it + * for nothing. + * + * The assertion is made on the public on_stream_created2()/on_stream_destroyed() + * callbacks: on an unchanged media the counters must not move on either leg. + */ +static int test_rtcp_mux_no_spurious_restart(void) +{ + pjsua_call_setting opt; + pj_status_t status; + pjsua_call_id caller = PJSUA_INVALID_ID, callee = PJSUA_INVALID_ID; + unsigned created_a = 0, created_b = 0; + unsigned destroyed_a = 0, destroyed_b = 0; + int rc; + + PJ_LOG(3, (THIS_FILE, " no media restart on a no-op re-INVITE with " + "remote a=rtcp-mux")); + + g_ctx.inject_rtcp_mux = PJ_TRUE; + g_ctx.rx_offer_had_rtcp_mux = PJ_FALSE; + + rc = establish_self_call(&caller, &callee, -1600); + if (rc != 0) + goto on_return; + + if (caller < 0 || callee < 0) { + rc = -1609; + goto on_return; + } + + created_a = g_ctx.strm_created[caller]; + destroyed_a = g_ctx.strm_destroyed[caller]; + created_b = g_ctx.strm_created[callee]; + destroyed_b = g_ctx.strm_destroyed[callee]; + + /* A re-INVITE that changes nothing whatsoever. */ + pjsua_call_setting_default(&opt); + opt.aud_cnt = 1; + opt.vid_cnt = 0; + opt.txt_cnt = 0; + status = pjsua_call_reinvite2(caller, &opt, NULL); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " reinvite2 failed (%d)", status)); + rc = -1610; + goto on_return; + } + + g_ctx.med_state_target = 2; + if (!wait_until(&media_state_reached, callee, 8000) || + !wait_until(&media_state_reached, caller, 8000)) + { + PJ_LOG(1, (THIS_FILE, " re-INVITE media update did not complete")); + rc = -1611; + goto on_return; + } + + /* Give a teardown that trails the media update a chance to be seen. */ + wait_until(NULL, PJSUA_INVALID_ID, MEDIA_SETTLE_MSEC); + + /* Guard against the test quietly degrading into a no-op. */ + if (!g_ctx.rx_offer_had_rtcp_mux) { + PJ_LOG(1, (THIS_FILE, " re-INVITE offer carried no a=rtcp-mux")); + rc = -1612; + goto on_return; + } + + if (g_ctx.strm_created[caller] != created_a || + g_ctx.strm_destroyed[caller] != destroyed_a || + g_ctx.strm_created[callee] != created_b || + g_ctx.strm_destroyed[callee] != destroyed_b) + { + PJ_LOG(1, (THIS_FILE, + " audio stream restarted on a no-op re-INVITE: caller " + "created %u->%u destroyed %u->%u, callee created %u->%u " + "destroyed %u->%u", + created_a, g_ctx.strm_created[caller], + destroyed_a, g_ctx.strm_destroyed[caller], + created_b, g_ctx.strm_created[callee], + destroyed_b, g_ctx.strm_destroyed[callee])); + rc = -1613; + goto on_return; + } + +on_return: + g_ctx.inject_rtcp_mux = PJ_FALSE; + g_ctx.rx_offer_had_rtcp_mux = PJ_FALSE; + drain_all_calls(); + + return rc; +} + + +/* Narrowing the media direction while answering a re-INVITE must restart the + * running stream. + * + * apply_med_update() narrows the negotiated direction down to the direction + * configured for the call (PJSUA_CALL_SET_MEDIA_DIR), rewrites the a= line of + * the SDP that goes on the wire, and stores the result in call_med->dir. If + * is_media_changed() is handed a copy of the stream info taken before that + * narrowing, the direction it compares is the pre-narrowing one, the media + * looks unchanged, and the stream keeps running in its old direction - it + * carries on transmitting although both call_med->dir and the SDP on the wire + * say receive-only. + * + * The test drives exactly that situation and then asserts that the two public + * views of the direction agree. Answering with the SDP the running stream was + * built from (forced back to sendrecv) keeps every other field + * is_media_changed() inspects bit-for-bit identical, so the direction is the + * only thing that can possibly differ. + */ +static int test_dir_narrowing_restarts_stream(void) +{ + pjsua_call_setting opt; + pj_status_t status; + pjsua_call_id caller = PJSUA_INVALID_ID, callee = PJSUA_INVALID_ID; + pjsua_call_info ci; + pjsua_stream_info psi; + pj_pool_t *pool = NULL; + pjmedia_sdp_session *answer = NULL; + int rc; + + PJ_LOG(3, (THIS_FILE, " direction narrowing restarts the running " + "stream")); + + g_ctx.narrow_dir_armed = PJ_FALSE; + g_ctx.narrow_dir_call_id = PJSUA_INVALID_ID; + g_ctx.narrow_dir_reinvite_seen = PJ_FALSE; + + rc = establish_self_call(&caller, &callee, -1700); + if (rc != 0) + goto on_return; + + if (caller < 0 || callee < 0) { + rc = -1709; + goto on_return; + } + + /* Both legs are now running audio in ENCODING_DECODING. Arm the callee + * leg and poke it with an ordinary no-op re-INVITE from the caller. + */ + g_ctx.narrow_dir_call_id = callee; + g_ctx.narrow_dir_armed = PJ_TRUE; + + pjsua_call_setting_default(&opt); + opt.aud_cnt = 1; + opt.vid_cnt = 0; + opt.txt_cnt = 0; + status = pjsua_call_reinvite2(caller, &opt, NULL); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " reinvite2 failed (%d)", status)); + rc = -1710; + goto on_return; + } + + if (!wait_until(&reinvite_was_received, PJSUA_INVALID_ID, 8000)) { + PJ_LOG(1, (THIS_FILE, " on_call_rx_reinvite never ran on the " + "callee leg")); + rc = -1711; + goto on_return; + } + + /* Build the answer out of the SDP the running stream was negotiated + * from, with the direction forced to sendrecv. + */ + pool = pjsua_pool_create("dir-narrow-test", 1024, 1024); + if (!pool) { + PJ_LOG(1, (THIS_FILE, " failed to create pool")); + rc = -1712; + goto on_return; + } + + { + const pjmedia_sdp_session *active = NULL; + pjsip_inv_session *inv = pjsua_var.calls[callee].inv; + pjmedia_sdp_media *m; + + if (!inv || !inv->neg || + pjmedia_sdp_neg_get_active_local(inv->neg, &active) != PJ_SUCCESS || + active == NULL || active->media_count < 1) + { + PJ_LOG(1, (THIS_FILE, " no active local SDP on the callee leg")); + rc = -1713; + goto on_return; + } + + answer = pjmedia_sdp_session_clone(pool, active); + m = answer->media[0]; + pjmedia_sdp_media_remove_all_attr(m, "sendonly"); + pjmedia_sdp_media_remove_all_attr(m, "recvonly"); + pjmedia_sdp_media_remove_all_attr(m, "inactive"); + if (!pjmedia_sdp_attr_find2(m->attr_count, m->attr, "sendrecv", NULL) && + m->attr_count < PJMEDIA_MAX_SDP_ATTR) + { + m->attr[m->attr_count++] = + pjmedia_sdp_attr_create(pool, "sendrecv", NULL); + } + } + + status = pjsua_call_answer_with_sdp(callee, answer, NULL, 200, NULL, NULL); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " answer_with_sdp failed (%d)", status)); + rc = -1714; + goto on_return; + } + + g_ctx.med_state_target = 2; + if (!wait_until(&media_state_reached, callee, 8000) || + !wait_until(&media_state_reached, caller, 8000)) + { + PJ_LOG(1, (THIS_FILE, " re-INVITE media update did not complete")); + rc = -1715; + goto on_return; + } + + status = pjsua_call_get_info(callee, &ci); + if (status != PJ_SUCCESS || ci.media_cnt < 1) { + PJ_LOG(1, (THIS_FILE, " call_get_info(callee) failed (%d)", + status)); + rc = -1716; + goto on_return; + } + + status = pjsua_call_get_stream_info(callee, 0, &psi); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " call_get_stream_info(callee) failed (%d)", + status)); + rc = -1717; + goto on_return; + } + + /* The direction PJSUA reports for the call media and the direction of the + * stream that is actually running have to be the same one. + */ + if (ci.media[0].dir != PJMEDIA_DIR_DECODING || + psi.info.aud.dir != PJMEDIA_DIR_DECODING) + { + PJ_LOG(1, (THIS_FILE, " callee dir mismatch: call_info=%d " + "stream=%d, expected %d", ci.media[0].dir, + psi.info.aud.dir, PJMEDIA_DIR_DECODING)); + rc = -1718; + goto on_return; + } + + /* And that agreement has to come from an actual restart. */ + if (g_ctx.strm_destroyed[callee] != 1 || g_ctx.strm_created[callee] != 2) { + PJ_LOG(1, (THIS_FILE, " callee stream was not restarted exactly " + "once (created=%u destroyed=%u)", + g_ctx.strm_created[callee], g_ctx.strm_destroyed[callee])); + rc = -1719; + goto on_return; + } + + /* Cross-check from the far side: the caller offered sendrecv, so it can + * only have ended up send-only if the narrowed direction really made it + * into the answer that went out on the wire. + */ + status = pjsua_call_get_info(caller, &ci); + if (status != PJ_SUCCESS || ci.media_cnt < 1) { + PJ_LOG(1, (THIS_FILE, " call_get_info(caller) failed (%d)", + status)); + rc = -1720; + goto on_return; + } + if (ci.media[0].dir != PJMEDIA_DIR_ENCODING) { + PJ_LOG(1, (THIS_FILE, " caller dir is %d, expected %d", + ci.media[0].dir, PJMEDIA_DIR_ENCODING)); + rc = -1721; + goto on_return; + } + +on_return: + g_ctx.narrow_dir_armed = PJ_FALSE; + g_ctx.narrow_dir_call_id = PJSUA_INVALID_ID; + g_ctx.narrow_dir_reinvite_seen = PJ_FALSE; + if (pool) + pj_pool_release(pool); + drain_all_calls(); + + return rc; +} + + +/* Check the call is still confirmed, has exactly med_cnt media, and that + * media[idx] has the given type and status. + */ +static int check_call_media(pjsua_call_id call_id, const char *leg, + unsigned med_cnt, unsigned idx, + pjmedia_type type, pjsua_call_media_status st, + int err) +{ + pjsua_call_info ci; + pj_status_t status; + + status = pjsua_call_get_info(call_id, &ci); + if (status != PJ_SUCCESS || ci.state != PJSIP_INV_STATE_CONFIRMED) { + PJ_LOG(1, (THIS_FILE, " %s leg not confirmed after re-INVITE " + "(status=%d)", leg, status)); + return err; + } + if (ci.media_cnt != med_cnt) { + PJ_LOG(1, (THIS_FILE, " %s leg has %u media, expected %u", leg, + ci.media_cnt, med_cnt)); + return err - 1; + } + if (ci.media[idx].type != type || ci.media[idx].status != st) { + PJ_LOG(1, (THIS_FILE, " %s leg media[%u]: type=%s status=%d, " + "expected type=%s status=%d", leg, idx, + pjmedia_type_name(ci.media[idx].type), + ci.media[idx].status, pjmedia_type_name(type), st)); + return err - 2; + } + return 0; +} + +/* Media that pjsua does not manage (T.38 image/udptl) offered mid-call, by + * turning the audio m-line of a re-INVITE into image/udptl. + * + * 1. Plain pjsua: the re-INVITE is rejected with 488 and the running audio is + * preserved on both legs, as before. + * 2. App-managed: the callee takes over answering (on_call_rx_reinvite with + * async) and answers with an SDP keeping the image line active. Both legs + * must stay up with that slot reported as unmanaged (UNKNOWN/NONE), and + * the audio stream previously on the slot must be destroyed exactly once + * on each leg: when the answer arrives on the caller leg, and when the + * callee applies its own answer, the slot still carries the audio type and + * stream of the previous negotiation. + */ +static int test_app_managed_media(void) +{ + pjsua_call_setting opt; + pj_status_t status; + pjsua_call_id caller = PJSUA_INVALID_ID, callee = PJSUA_INVALID_ID; + pjmedia_sdp_session *answer; + const pjmedia_sdp_session *active; + pjsip_tsx_state_e tsx_state; + unsigned i, img_idx; + int rc; + + PJ_LOG(3, (THIS_FILE, " media not managed by pjsua (T.38) in re-INVITE")); + + g_ctx.app_med_pool = pjsua_pool_create("app-med-test", 1024, 1024); + if (!g_ctx.app_med_pool) { + PJ_LOG(1, (THIS_FILE, " failed to create pool")); + return -1800; + } + + rc = establish_self_call(&caller, &callee, -1801); + if (rc != 0) + goto on_return; + + g_ctx.app_med_caller = caller; + g_ctx.app_med_callee = callee; + g_ctx.app_med_take_over = PJ_FALSE; + g_ctx.app_med_reinvite_seen = PJ_FALSE; + g_ctx.app_med_reinv_code = 0; + g_ctx.app_med_offer = NULL; + g_ctx.app_med_armed = PJ_TRUE; + + pjsua_call_setting_default(&opt); + opt.aud_cnt = 1; + opt.vid_cnt = 0; + opt.txt_cnt = 0; + + /* 1. Plain pjsua rejects the T.38 offer and keeps the audio. */ + status = pjsua_call_reinvite2(caller, &opt, NULL); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " reinvite2 (T.38) failed (%d)", status)); + rc = -1810; + goto on_return; + } + if (!wait_until(&app_med_reinvite_done, PJSUA_INVALID_ID, 8000)) { + PJ_LOG(1, (THIS_FILE, " T.38 re-INVITE got no final response")); + rc = -1811; + goto on_return; + } + if (!g_ctx.app_med_reinvite_seen) { + PJ_LOG(1, (THIS_FILE, " on_call_rx_reinvite never ran on the " + "callee leg")); + rc = -1812; + goto on_return; + } + if (g_ctx.app_med_reinv_code != PJSIP_SC_NOT_ACCEPTABLE_HERE) { + PJ_LOG(1, (THIS_FILE, " plain pjsua answered the T.38 re-INVITE " + "with %d, expected 488", g_ctx.app_med_reinv_code)); + rc = -1813; + goto on_return; + } + rc = check_call_media(callee, "callee", 1, 0, PJMEDIA_TYPE_AUDIO, + PJSUA_CALL_MEDIA_ACTIVE, -1814); + if (rc != 0) + goto on_return; + rc = check_call_media(caller, "caller", 1, 0, PJMEDIA_TYPE_AUDIO, + PJSUA_CALL_MEDIA_ACTIVE, -1816); + if (rc != 0) + goto on_return; + if (g_ctx.strm_destroyed[callee] != 0 || g_ctx.strm_destroyed[caller] != 0) + { + PJ_LOG(1, (THIS_FILE, " audio stream destroyed by the rejected " + "re-INVITE (callee=%u caller=%u)", + g_ctx.strm_destroyed[callee], + g_ctx.strm_destroyed[caller])); + rc = -1818; + goto on_return; + } + + /* 2. The callee answers the T.38 offer itself, keeping the line active. */ + g_ctx.app_med_take_over = PJ_TRUE; + g_ctx.app_med_reinvite_seen = PJ_FALSE; + g_ctx.app_med_reinv_code = 0; + g_ctx.app_med_offer = NULL; + pj_memcpy(g_ctx.med_state_base, g_ctx.med_state_cnt, + sizeof(g_ctx.med_state_cnt)); + + status = pjsua_call_reinvite2(caller, &opt, NULL); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " reinvite2 (T.38, app-managed) failed (%d)", + status)); + rc = -1820; + goto on_return; + } + if (!wait_until(&app_med_reinvite_seen, PJSUA_INVALID_ID, 8000) || + g_ctx.app_med_offer == NULL) + { + PJ_LOG(1, (THIS_FILE, " on_call_rx_reinvite never ran on the " + "callee leg")); + rc = -1821; + goto on_return; + } + + /* Answer with the offer itself, moving the image line to the app's own + * (fake) UDPTL port. The audio line stays deactivated, media[0] is that + * zeroed audio line so the image has to be located by media name. + */ + answer = pjmedia_sdp_session_clone(g_ctx.app_med_pool, g_ctx.app_med_offer); + img_idx = answer->media_count; + for (i = 0; i < answer->media_count; ++i) { + if (pj_stricmp2(&answer->media[i]->desc.media, "image") == 0) { + answer->media[i]->desc.port = APP_UDPTL_PORT; + img_idx = i; + break; + } + } + if (img_idx >= answer->media_count) { + PJ_LOG(1, (THIS_FILE, " no image line in the offer to answer")); + rc = -1821; + goto on_return; + } + + tsx_state = pjsua_var.calls[callee].inv->invite_tsx->state; + pjsua_var.calls[callee].inv->invite_tsx->state = + PJSIP_TSX_STATE_COMPLETED; + status = pjsua_call_answer_with_sdp(callee, answer, NULL, 200, NULL, NULL); + pjsua_var.calls[callee].inv->invite_tsx->state = tsx_state; + if (status != PJ_EINVALIDOP || + pjmedia_sdp_neg_get_state(pjsua_var.calls[callee].inv->neg) != + PJMEDIA_SDP_NEG_STATE_REMOTE_OFFER) + { + PJ_LOG(1, (THIS_FILE, " failed answer_with_sdp was not rolled " + "back (%d)", status)); + rc = -1822; + goto on_return; + } + + status = pjsua_call_answer_with_sdp(callee, answer, NULL, 200, NULL, NULL); + if (status != PJ_SUCCESS) { + PJ_LOG(1, (THIS_FILE, " answer_with_sdp (T.38) failed (%d)", + status)); + rc = -1823; + goto on_return; + } + + if (!wait_until(&app_med_reinvite_done, PJSUA_INVALID_ID, 8000)) { + PJ_LOG(1, (THIS_FILE, " app-managed T.38 re-INVITE got no final " + "response")); + rc = -1824; + goto on_return; + } + if (g_ctx.app_med_reinv_code != PJSIP_SC_OK) { + PJ_LOG(1, (THIS_FILE, " app-managed T.38 re-INVITE answered with " + "%d, expected 200", g_ctx.app_med_reinv_code)); + rc = -1825; + goto on_return; + } + if (!wait_until(&media_state_advanced, callee, 8000) || + !wait_until(&media_state_advanced, caller, 8000)) + { + PJ_LOG(1, (THIS_FILE, " app-managed T.38 media update did not " + "complete")); + rc = -1826; + goto on_return; + } + + /* The audio line was deactivated by the offer, and the appended image + * line is reported as media pjsua does not manage. + */ + rc = check_call_media(callee, "callee", 2, 0, PJMEDIA_TYPE_AUDIO, + PJSUA_CALL_MEDIA_NONE, -1826); + if (rc != 0) + goto on_return; + rc = check_call_media(callee, "callee", 2, 1, PJMEDIA_TYPE_UNKNOWN, + PJSUA_CALL_MEDIA_NONE, -1830); + if (rc != 0) + goto on_return; + rc = check_call_media(caller, "caller", 2, 0, PJMEDIA_TYPE_AUDIO, + PJSUA_CALL_MEDIA_NONE, -1834); + if (rc != 0) + goto on_return; + rc = check_call_media(caller, "caller", 2, 1, PJMEDIA_TYPE_UNKNOWN, + PJSUA_CALL_MEDIA_NONE, -1838); + if (rc != 0) + goto on_return; + + /* The app's own UDPTL port must be what actually went on the wire, i.e. + * the answer was used as supplied and not normalized away. + */ + if (pjmedia_sdp_neg_get_active_local(pjsua_var.calls[callee].inv->neg, + &active) != PJ_SUCCESS || + img_idx >= active->media_count || + active->media[img_idx]->desc.port != APP_UDPTL_PORT) + { + PJ_LOG(1, (THIS_FILE, " negotiated local SDP does not carry the " + "app's UDPTL port %d", APP_UDPTL_PORT)); + rc = -1840; + goto on_return; + } + + /* The audio stream that was on the slot must be gone, on both legs. */ + if (g_ctx.strm_destroyed[callee] != 1 || g_ctx.strm_created[callee] != 1 || + g_ctx.strm_destroyed[caller] != 1 || g_ctx.strm_created[caller] != 1) + { + PJ_LOG(1, (THIS_FILE, " audio stream not destroyed exactly once: " + "callee created=%u destroyed=%u, caller created=%u " + "destroyed=%u", + g_ctx.strm_created[callee], g_ctx.strm_destroyed[callee], + g_ctx.strm_created[caller], g_ctx.strm_destroyed[caller])); + rc = -1842; + goto on_return; + } + +on_return: + g_ctx.app_med_armed = PJ_FALSE; + g_ctx.app_med_take_over = PJ_FALSE; + g_ctx.app_med_offer = NULL; + drain_all_calls(); + if (g_ctx.app_med_pool) { + pj_pool_release(g_ctx.app_med_pool); + g_ctx.app_med_pool = NULL; + } + + return rc; +} + +static pj_bool_t update_reinvite_seen(pjsua_call_id call_id) +{ + PJ_UNUSED_ARG(call_id); + return g_ctx.update_reinvite_seen; +} - if (!wait_until(&siprec_response_seen, PJSUA_INVALID_ID, 8000)) { - PJ_LOG(1, (THIS_FILE, " timeout waiting for re-INVITE response")); - rc = -2005; - goto cleanup_pool; - } +static pj_bool_t uas_offer_received(pjsua_call_id call_id) +{ + PJ_UNUSED_ARG(call_id); + return g_ctx.incoming_seen; +} -cleanup_pool: - pj_pool_release(pool); +static pj_bool_t uas_offer_negotiated(pjsua_call_id call_id) +{ + pjsip_inv_session *inv = pjsua_var.calls[call_id].inv; -on_return: - pjsua_var.acc[g_ctx.acc_id].cfg.use_siprec = PJSUA_SIP_SIPREC_INACTIVE; - pjsua_var.acc[g_ctx.acc_id].cfg.siprec_require_metadata = PJ_FALSE; - drain_all_calls(); + return inv && inv->neg && + pjmedia_sdp_neg_get_state(inv->neg) == PJMEDIA_SDP_NEG_STATE_DONE; +} - if (rc != 0) - return rc; +static pj_bool_t uas_reinvite_received(pjsua_call_id call_id) +{ + pjsip_inv_session *inv = pjsua_var.calls[call_id].inv; - if (g_siprec_test_ctx.response_code < 200 || - g_siprec_test_ctx.response_code >= 300) { - PJ_LOG(1, (THIS_FILE, " expected 2xx, got %d", - g_siprec_test_ctx.response_code)); - return -2006; - } + return inv && inv->invite_tsx && + inv->invite_tsx->role == PJSIP_ROLE_UAS; +} - return 0; +static pj_bool_t managed_refer_done(pjsua_call_id call_id) +{ + PJ_UNUSED_ARG(call_id); + return g_ctx.managed_refer_code >= 200; } -#endif +static pj_bool_t managed_offerless_pending(pjsua_call_id call_id) +{ + pjsip_inv_session *inv = pjsua_var.calls[call_id].inv; + pj_bool_t pending; -/* RFC2543 call hold of a call whose local SDP has no connection line at all. - * - * With PJSUA_CALL_HOLD_TYPE_RFC2543, modify_sdp_of_call_hold() puts the hold - * address into the media-level c= line, falling back to the session-level one. - * Both can be missing, in which case the address used to be written through a - * NULL pointer; a connection line has to be created instead. - * - * The conn-less SDP is produced through the public on_call_sdp_created() - * callback, which is invoked by pjsua_media_channel_create_sdp() before - * create_sdp_of_call_hold() applies the hold modification. The assertions are - * end-to-end rather than on internals: pjsua_call_set_hold() must succeed - * (an SDP whose m= line resolves to no c= line at all fails - * pjmedia_sdp_validate() with PJMEDIA_SDP_EMISSINGCONN, so a re-INVITE that - * merely skipped the missing line would be rejected here), and the offer that - * reaches the peer must carry the 0.0.0.0 hold address. - */ -static int test_rfc2543_hold_without_conn(void) + if (!inv) + return PJ_FALSE; + pjsip_dlg_inc_lock(inv->dlg); + pending = pjsip_inv_has_pending_offerless_invite(inv); + pjsip_dlg_dec_lock(inv->dlg); + return pending; +} + +static int test_managed_reinvite_responses(pjsua_call_id caller, + pjsua_call_id callee, + const pjsua_call_setting *opt) { - pjsua_call_setting opt; + const pjsip_status_code codes[] = { + PJSIP_SC_RINGING, PJSIP_SC_ACCEPTED, PJSIP_SC_NOT_ACCEPTABLE_HERE + }; + pjsip_tx_data *tdata; pj_str_t uri = pj_str(g_ctx.self_uri); - pjsua_call_hold_type saved_hold_type; pj_status_t status; - pjsua_call_id cid = PJSUA_INVALID_ID; - pjsua_call_info ci; + unsigned i; int rc = 0; - PJ_LOG(3, (THIS_FILE, " RFC2543 hold with no c= line in the local SDP")); - - /* pjsua_call.call_hold_type is captured from the account when the call is - * created, so switch the account over before placing the call. - */ - saved_hold_type = pjsua_var.acc[g_ctx.acc_id].cfg.call_hold_type; - pjsua_var.acc[g_ctx.acc_id].cfg.call_hold_type = - PJSUA_CALL_HOLD_TYPE_RFC2543; - - pjsua_call_setting_default(&opt); - opt.aud_cnt = 1; - opt.vid_cnt = 0; - opt.txt_cnt = 0; - status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, &cid); - if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " make_call failed (%d)", status)); - rc = -1400; + for (i = 0; i < PJ_ARRAY_SIZE(codes); ++i) { + g_ctx.managed_reinv_response = codes[i]; + g_ctx.app_med_reinv_code = 0; + status = pjsua_call_reinvite2(caller, opt, NULL); + if (status != PJ_SUCCESS || + !wait_until(&reinvite_tsx_done, caller, TEST_TIMEOUT_MSEC) || + !wait_until(&reinvite_tsx_done, callee, TEST_TIMEOUT_MSEC) || + g_ctx.app_med_reinv_code != + (i == 0 ? PJSIP_SC_INTERNAL_SERVER_ERROR : + i == 1 ? PJSIP_SC_OK : PJSIP_SC_NOT_ACCEPTABLE_HERE) || + managed_offerless_pending(callee) || + !call_is_confirmed(caller) || !call_is_confirmed(callee)) + { + rc = -1990; + goto on_return; + } + } + g_ctx.managed_reinv_response = 0; + g_ctx.uas_offer_armed = PJ_TRUE; + g_ctx.app_med_reinv_code = 0; + status = pjsua_call_reinvite2(caller, opt, NULL); + if (status != PJ_SUCCESS || + !wait_until(&managed_offerless_pending, callee, TEST_TIMEOUT_MSEC)) + { + rc = -1991; goto on_return; } - - if (!wait_until(&call_is_confirmed, cid, 8000)) { - PJ_LOG(1, (THIS_FILE, " call did not reach CONFIRMED in time")); - rc = -1401; + status = pjsip_endpt_create_cancel( + pjsua_var.endpt, + pjsua_var.calls[caller].inv->invite_tsx->last_tx, + &tdata); + if (status == PJ_SUCCESS) + status = pjsip_endpt_send_request(pjsua_var.endpt, tdata, -1, + NULL, NULL); + if (status != PJ_SUCCESS || + !wait_until(&reinvite_tsx_done, caller, TEST_TIMEOUT_MSEC) || + !wait_until(&reinvite_tsx_done, callee, TEST_TIMEOUT_MSEC) || + g_ctx.app_med_reinv_code != PJSIP_SC_REQUEST_TERMINATED || + managed_offerless_pending(callee)) + { + rc = -1992; goto on_return; } + g_ctx.uas_offer_armed = PJ_FALSE; + g_ctx.managed_refer_code = 0; + status = pjsua_call_xfer(caller, &uri, NULL); + if (status != PJ_SUCCESS || + !wait_until(&managed_refer_done, caller, TEST_TIMEOUT_MSEC) || + g_ctx.managed_refer_code != PJSIP_SC_NOT_ACCEPTABLE_HERE || + pjsua_call_get_count() != 2 || + !call_is_confirmed(caller) || !call_is_confirmed(callee)) + { + rc = -1993; + } - /* Hold, with every c= line removed from the hold offer. */ - g_ctx.strip_conn_call_id = cid; - g_ctx.strip_conn_in_offer = PJ_TRUE; - g_ctx.expect_hold_offer = PJ_TRUE; - g_ctx.hold_offer_seen = PJ_FALSE; - g_ctx.hold_offer_conn_ok = PJ_FALSE; +on_return: + g_ctx.managed_reinv_response = 0; + g_ctx.uas_offer_armed = PJ_FALSE; + return rc; +} - /* The hold SDP is created synchronously here, so the stripping only - * applies to this one offer. - */ - status = pjsua_call_set_hold(cid, NULL); - g_ctx.strip_conn_in_offer = PJ_FALSE; +static int test_app_managed_reoffers_preserve_media_count(unsigned count, + pj_bool_t inactive) +{ + pj_pool_t *pool; + pjmedia_sdp_session *sdp, *short_offer; + const pjmedia_sdp_session *old_local, *active_local; + pjsua_acc_config saved_acc_cfg, acc_cfg; + pjsua_call_setting opt, rejected_opt, saved_opt; + pjsua_call_id caller = PJSUA_INVALID_ID, callee = PJSUA_INVALID_ID; + pj_str_t uri = pj_str(g_ctx.self_uri); + pj_status_t status; + pj_bool_t acc_changed = PJ_FALSE; + unsigned i; + int rc = 0; + + PJ_LOG(3,(THIS_FILE, " app-managed reoffers preserve %u media slots", + count)); + pool = pjsua_pool_create("managed-media-count", 4096, 4096); + if (!pool) + return -1980; + status = parse_sdp_text(pool, app_managed_audio_sdp, &sdp); + if (status != PJ_SUCCESS || + pjsua_acc_get_config(g_ctx.acc_id, pool, &saved_acc_cfg) != PJ_SUCCESS || + pjsua_acc_get_config(g_ctx.acc_id, pool, &acc_cfg) != PJ_SUCCESS) + { + rc = -1981; + goto on_return; + } + for (i = sdp->media_count; i < count; ++i) + sdp->media[i] = pjmedia_sdp_media_clone(pool, sdp->media[0]); + sdp->media_count = count; + if (inactive) { + for (i = 0; i < count; ++i) + sdp->media[i]->desc.port = 0; + } + acc_cfg.media_app_managed = PJ_TRUE; + status = pjsua_acc_modify(g_ctx.acc_id, &acc_cfg); if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " set_hold failed (%d)", status)); - rc = -1402; + rc = -1982; goto on_return; } - - if (!wait_until(&hold_offer_processed, PJSUA_INVALID_ID, 8000)) { - PJ_LOG(1, (THIS_FILE, " peer never received the hold offer")); - rc = -1403; + acc_changed = PJ_TRUE; + g_ctx.uas_offer_armed = PJ_TRUE; + g_ctx.incoming_seen = PJ_FALSE; + pjsua_call_setting_default(&opt); + opt.vid_cnt = opt.txt_cnt = 0; + status = make_call_with_sdp(g_ctx.acc_id, &uri, &opt, sdp, &caller); + if (status != PJ_SUCCESS || + !wait_until(&uas_offer_received, caller, TEST_TIMEOUT_MSEC)) + { + rc = -1983; goto on_return; } - - if (!g_ctx.hold_offer_conn_ok) { - PJ_LOG(1, (THIS_FILE, " hold offer has no c= line with 0.0.0.0")); - rc = -1404; + callee = g_ctx.incoming_call_id; + saved_opt = pjsua_var.calls[callee].opt; + rejected_opt = opt; + rejected_opt.aud_cnt = 0; + status = pjsua_call_answer2(callee, &rejected_opt, PJSIP_SC_OK, + NULL, NULL); + if (status != PJ_EINVALIDOP || + pjsua_var.calls[callee].opt_inited || + pj_memcmp(&pjsua_var.calls[callee].opt, &saved_opt, + sizeof(saved_opt)) != 0) + { + rc = -1989; goto on_return; } - - /* The call must survive the hold. */ - if (pjsua_call_get_info(cid, &ci) != PJ_SUCCESS || - ci.state != PJSIP_INV_STATE_CONFIRMED) + status = pjsua_call_answer_with_sdp(callee, sdp, &opt, PJSIP_SC_OK, + NULL, NULL); + if (status != PJ_SUCCESS || + !pjsua_var.calls[callee].opt_inited || + pjsua_var.calls[callee].opt.aud_cnt != opt.aud_cnt || + !wait_until(&call_is_confirmed, caller, TEST_TIMEOUT_MSEC) || + !wait_until(&call_is_confirmed, callee, TEST_TIMEOUT_MSEC)) { - PJ_LOG(1, (THIS_FILE, " call not alive after hold")); - rc = -1405; + rc = -1984; + goto on_return; + } + g_ctx.uas_offer_armed = PJ_FALSE; + if (inactive) { + if (pjsua_var.calls[caller].med_cnt != count || + pjsua_var.calls[callee].med_cnt != count || + pjsua_var.calls[caller].audio_idx != -1 || + pjsua_var.calls[callee].audio_idx != -1) + { + rc = -1996; + } + for (i = 0; i < count; ++i) { + if (pjsua_var.calls[caller].media[i].tp || + pjsua_var.calls[callee].media[i].tp || + pjsua_var.calls[caller].media[i].state != + PJSUA_CALL_MEDIA_NONE || + pjsua_var.calls[callee].media[i].state != + PJSUA_CALL_MEDIA_NONE) + { + rc = -1997; + } + } + goto on_return; + } + status = pjmedia_sdp_neg_get_active_local(pjsua_var.calls[caller].inv->neg, + &old_local); + if (status != PJ_SUCCESS) { + rc = -1994; + goto on_return; + } + short_offer = pjmedia_sdp_session_clone(pool, sdp); + --short_offer->media_count; + status = pjsua_call_set_sdp(caller, sdp); + if (status != PJ_SUCCESS || + pjsua_call_set_sdp(caller, NULL) != PJ_SUCCESS || + pjmedia_sdp_neg_get_state(pjsua_var.calls[caller].inv->neg) != + PJMEDIA_SDP_NEG_STATE_DONE || + pjmedia_sdp_neg_get_active_local(pjsua_var.calls[caller].inv->neg, + &active_local) != PJ_SUCCESS || + active_local != old_local || + pjsua_call_set_sdp(caller, NULL) != PJ_EINVALIDOP) + { + rc = -1998; goto on_return; } + for (i = 0; i < 3; ++i) { + if (i == 0) + status = reinvite_with_sdp(caller, &opt, short_offer); + else if (i == 1) + status = update_with_sdp(caller, &opt, short_offer); + else + status = pjsua_call_set_sdp(caller, short_offer); + if (status != PJMEDIA_SDPNEG_EMISMEDIA || + pjmedia_sdp_neg_get_state(pjsua_var.calls[caller].inv->neg) != + PJMEDIA_SDP_NEG_STATE_DONE || + pjmedia_sdp_neg_get_active_local(pjsua_var.calls[caller].inv->neg, + &active_local) != PJ_SUCCESS || + active_local != old_local || + pjsua_var.calls[caller].inv->invite_tsx || + pjsua_var.calls[caller].med_cnt != count || + pjsua_var.calls[caller].med_prov_cnt != count) + { + rc = -1995; + goto on_return; + } + } + g_ctx.first_class_offer_armed = PJ_TRUE; + g_ctx.first_class_offer_caller = caller; + g_ctx.first_class_offer_callee = callee; + for (i = 0; i < 8; ++i) { + if (i % 2 == 0) { + status = reinvite_with_sdp(caller, &opt, i < 4 ? sdp : NULL); + if (status == PJ_SUCCESS && + !wait_until(&reinvite_tsx_done, caller, TEST_TIMEOUT_MSEC)) + status = PJ_ETIMEDOUT; + } else { + status = update_with_sdp(caller, &opt, i < 4 ? sdp : NULL); + if (status == PJ_SUCCESS && + !wait_until(&update_tsx_done, caller, TEST_TIMEOUT_MSEC)) + status = PJ_ETIMEDOUT; + } + if (status != PJ_SUCCESS || + pjsua_var.calls[caller].med_cnt != count || + pjsua_var.calls[caller].med_prov_cnt != count || + pjsua_var.calls[callee].med_cnt != count || + pjsua_var.calls[callee].med_prov_cnt != count) + { + rc = -1985; + goto on_return; + } + } + g_ctx.first_class_offer_armed = PJ_FALSE; + g_ctx.offerless_armed = PJ_TRUE; + g_ctx.app_med_armed = PJ_TRUE; + g_ctx.app_med_caller = caller; + g_ctx.app_med_reinv_code = 0; + opt.flag |= PJSUA_CALL_NO_SDP_OFFER; + status = pjsua_call_reinvite2(caller, &opt, NULL); + if (status != PJ_SUCCESS || + !wait_until(&reinvite_tsx_done, caller, TEST_TIMEOUT_MSEC) || + !wait_until(&reinvite_tsx_done, callee, TEST_TIMEOUT_MSEC) || + g_ctx.app_med_reinv_code != PJSIP_SC_OK || + g_ctx.offerless_caller_set_status != PJ_SUCCESS || + pjsua_var.calls[caller].med_cnt != count || + pjsua_var.calls[caller].med_prov_cnt != count || + pjsua_var.calls[callee].med_cnt != count || + pjsua_var.calls[callee].med_prov_cnt != count || + !call_is_confirmed(caller) || !call_is_confirmed(callee)) + { + rc = -1986; + } + if (rc == 0) + rc = test_managed_reinvite_responses(caller, callee, &opt); + for (i = 0; i < count && rc == 0; ++i) { + if (pjsua_var.calls[caller].media[i].tp || + pjsua_var.calls[caller].media_prov[i].tp || + pjsua_var.calls[callee].media[i].tp || + pjsua_var.calls[callee].media_prov[i].tp) + { + rc = -1988; + } + } on_return: - g_ctx.strip_conn_in_offer = PJ_FALSE; - g_ctx.expect_hold_offer = PJ_FALSE; - pjsua_var.acc[g_ctx.acc_id].cfg.call_hold_type = saved_hold_type; + g_ctx.uas_offer_armed = PJ_FALSE; + g_ctx.first_class_offer_armed = PJ_FALSE; + g_ctx.offerless_armed = PJ_FALSE; + g_ctx.app_med_armed = PJ_FALSE; drain_all_calls(); - + if (acc_changed) { + status = pjsua_acc_modify(g_ctx.acc_id, &saved_acc_cfg); + if (status != PJ_SUCCESS && rc == 0) + rc = -1987; + } + pj_pool_release(pool); return rc; } +static pj_bool_t reliable_early_sdp_done(pjsua_call_id call_id) +{ + pjsip_inv_session *inv = pjsua_var.calls[call_id].inv; -/* Place a single-audio loopback call and wait until both legs are confirmed - * and their initial media update has completed, so each leg is running - * exactly one audio stream. Resets the per-call stream and media-update - * counters first, so the caller can reason about them in absolute terms. - * Returns 0 on success, or a negative code derived from err_base. - */ -static int establish_self_call(pjsua_call_id *p_caller, - pjsua_call_id *p_callee, - int err_base) + return inv && inv->state == PJSIP_INV_STATE_EARLY && + inv->sdp_done_early_rel && inv->neg && + pjmedia_sdp_neg_get_state(inv->neg) == PJMEDIA_SDP_NEG_STATE_DONE; +} + +static int test_app_managed_response_offer_blocks_update(pj_bool_t queued, + pj_bool_t initial_offer) { - pjsua_call_setting opt; + pjsua_acc_config acc_cfg, saved_acc_cfg; + pjsua_call_setting opt, answer_opt; + pjsua_call_id caller = PJSUA_INVALID_ID, callee = PJSUA_INVALID_ID; + pj_pool_t *pool; + pjmedia_sdp_session *sdp, *different_offer, *matching_offer; + const pjmedia_sdp_session *pending_offer; + pjsip_tx_data *tdata = NULL; pj_str_t uri = pj_str(g_ctx.self_uri); + pj_str_t part_type = pj_str("text"), part_subtype = pj_str("plain"); + pj_str_t part_text = pj_str("UPDATE offer tracking"); + pjsua_msg_data msg_data; + pjsip_multipart_part *part; + pjsua_call_op_param param; + pjsip_inv_session *inv; + pjsip_transaction *tsx; pj_status_t status; - pjsua_call_id caller = PJSUA_INVALID_ID; - - g_ctx.incoming_call_id = PJSUA_INVALID_ID; - pj_bzero(g_ctx.strm_created, sizeof(g_ctx.strm_created)); - pj_bzero(g_ctx.strm_destroyed, sizeof(g_ctx.strm_destroyed)); - pj_bzero(g_ctx.med_state_cnt, sizeof(g_ctx.med_state_cnt)); + pj_bool_t acc_changed = PJ_FALSE; + int rc = 0; - pjsua_call_setting_default(&opt); - opt.aud_cnt = 1; - opt.vid_cnt = 0; - opt.txt_cnt = 0; - status = pjsua_call_make_call(g_ctx.acc_id, &uri, &opt, NULL, NULL, - &caller); + PJ_LOG(3,(THIS_FILE, " app-managed response offers block UPDATE " + "until ACK/PRACK (queued=%d)", queued)); + pool = pjsua_pool_create("uas-offer-test", 4096, 4096); + if (!pool) + return -1950; + status = parse_sdp_text(pool, app_managed_audio_sdp, &sdp); if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " make_call failed (%d)", status)); - return err_base - 0; + rc = -1951; + goto on_return; } - - if (!wait_until(&call_is_confirmed, caller, 8000)) { - PJ_LOG(1, (THIS_FILE, " caller leg did not reach CONFIRMED")); - return err_base - 1; + status = pjsua_acc_get_config(g_ctx.acc_id, pool, &saved_acc_cfg); + if (status != PJ_SUCCESS) { + rc = -1952; + goto on_return; } - - if (g_ctx.incoming_call_id == PJSUA_INVALID_ID || - g_ctx.incoming_call_id == caller) + status = pjsua_acc_get_config(g_ctx.acc_id, pool, &acc_cfg); + if (status != PJ_SUCCESS) { + rc = -1952; + goto on_return; + } + acc_cfg.media_app_managed = PJ_TRUE; + acc_cfg.require_100rel = PJSUA_100REL_MANDATORY; + status = pjsua_acc_modify(g_ctx.acc_id, &acc_cfg); + if (status != PJ_SUCCESS) { + rc = -1953; + goto on_return; + } + acc_changed = PJ_TRUE; + g_ctx.uas_offer_armed = PJ_TRUE; + g_ctx.incoming_seen = PJ_FALSE; + g_ctx.uas_offer_answer_status = PJ_EUNKNOWN; + pjsua_call_setting_default(&opt); + opt.aud_cnt = opt.vid_cnt = opt.txt_cnt = 0; + if (!initial_offer) + opt.flag |= PJSUA_CALL_NO_SDP_OFFER; + answer_opt = opt; + answer_opt.flag &= ~PJSUA_CALL_NO_SDP_OFFER; + status = make_call_with_sdp(g_ctx.acc_id, &uri, &opt, + initial_offer ? sdp : NULL, &caller); + if (status == PJ_SUCCESS && !initial_offer && + (pjsua_call_set_sdp(caller, sdp) != PJ_EINVALIDOP || + pjsua_var.calls[caller].inv->neg)) { - PJ_LOG(1, (THIS_FILE, " callee leg was not identified")); - return err_base - 2; + rc = -1973; + goto on_return; } - - if (!wait_until(&call_is_confirmed, g_ctx.incoming_call_id, 8000)) { - PJ_LOG(1, (THIS_FILE, " callee leg did not reach CONFIRMED")); - return err_base - 3; + if (status != PJ_SUCCESS || + !wait_until(&uas_offer_received, caller, TEST_TIMEOUT_MSEC)) + { + rc = -1954; + goto on_return; + } + callee = g_ctx.incoming_call_id; + inv = pjsua_var.calls[callee].inv; + if (!initial_offer && + (update_with_sdp(caller, &answer_opt, sdp) != PJ_EINVALIDOP || + reinvite_with_sdp(caller, &answer_opt, sdp) != PJSIP_ESESSIONSTATE || + pjsua_var.calls[caller].inv->neg)) + { + rc = -1975; + goto on_return; + } + if (queued) { + status = pjsua_call_answer2(callee, &answer_opt, PJSIP_SC_RINGING, + NULL, NULL); + if (status != PJ_SUCCESS || !inv->invite_tsx->last_tx || + inv->invite_tsx->last_tx->msg->body != NULL) + { + rc = -1955; + goto on_return; + } + } + status = pjsua_call_answer_with_sdp(callee, sdp, &answer_opt, + PJSIP_SC_PROGRESS, NULL, NULL); + if (status != PJ_SUCCESS || + !(inv->options & PJSIP_INV_REQUIRE_100REL) || + (queued && + (inv->invite_tsx->status_code != PJSIP_SC_RINGING || + inv->invite_tsx->last_tx->msg->body != NULL)) || + pjmedia_sdp_neg_get_state(inv->neg) != + (initial_offer ? PJMEDIA_SDP_NEG_STATE_DONE : + PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER)) + { + rc = -1955; + goto on_return; + } + tsx = inv->invite_tsx; + /* No polling: the 183 offer is sent or queued, but not acknowledged. */ + if (!initial_offer) { + status = pjsua_call_update(callee, 0, NULL); + if (status != PJ_EINVALIDOP || inv->invite_tsx != tsx || + inv->update_tsx || + pjmedia_sdp_neg_get_state(inv->neg) != + PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER) + { + rc = -1956; + goto on_return; + } + } + if (!wait_until(&uas_offer_negotiated, callee, TEST_TIMEOUT_MSEC) || + (initial_offer && + !wait_until(&reliable_early_sdp_done, caller, TEST_TIMEOUT_MSEC)) || + (!initial_offer && g_ctx.uas_offer_answer_status != PJ_SUCCESS) || + (!initial_offer && !inv->sdp_done_early_rel) || + inv->state != PJSIP_INV_STATE_EARLY || + inv->invite_tsx != tsx) + { + rc = -1957; + goto on_return; + } + if (initial_offer) { + status = pjsua_call_answer_with_sdp(callee, sdp, &answer_opt, + PJSIP_SC_OK, NULL, NULL); + if (status != PJ_EINVALIDOP || + inv->invite_tsx != tsx || tsx->status_code >= 200 || + pjmedia_sdp_neg_get_state(inv->neg) != + PJMEDIA_SDP_NEG_STATE_DONE) + { + rc = -1972; + goto on_return; + } + status = pjsua_call_set_sdp(caller, sdp); + if (status != PJ_SUCCESS || + pjmedia_sdp_neg_get_neg_local(pjsua_var.calls[caller].inv->neg, + &pending_offer) != PJ_SUCCESS) + { + rc = -1968; + goto on_return; + } + different_offer = pjmedia_sdp_session_clone(pool, pending_offer); + ++different_offer->media[0]->desc.port; + status = pjsip_inv_update(pjsua_var.calls[caller].inv, NULL, + different_offer, &tdata); + if (status != PJMEDIA_SDPNEG_EINSTATE || tdata || + pjmedia_sdp_neg_get_state(pjsua_var.calls[caller].inv->neg) != + PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER) + { + rc = -1969; + goto on_return; + } + matching_offer = pjmedia_sdp_session_clone(pool, sdp); + matching_offer->origin.id += 100; + matching_offer->origin.version += 100; + status = pjsip_inv_update(pjsua_var.calls[caller].inv, NULL, + matching_offer, &tdata); + if (status != PJ_SUCCESS || !tdata) { + rc = -1970; + goto on_return; + } + pjsip_tx_data_dec_ref(tdata); + tdata = NULL; + pjsua_msg_data_init(&msg_data); + pjsip_media_type_init2(&msg_data.multipart_ctype, "multipart", "mixed"); + part = pjsip_multipart_create_part(pool); + part->body = pjsip_msg_body_create(pool, &part_type, &part_subtype, + &part_text); + pj_list_push_back(&msg_data.multipart_parts, part); + pjsua_call_op_param_default(¶m); + param.msg_data = &msg_data; + status = pjsua_call_update3(caller, ¶m); + if (status != PJ_SUCCESS || + !pjsua_var.calls[caller].inv->update_tsx || + pj_stricmp2(&pjsua_var.calls[caller].inv->update_tsx-> + last_tx->msg->body->content_type.type, + "multipart") != 0 || + pjsip_inv_reinvite(pjsua_var.calls[caller].inv, NULL, NULL, + &tdata) != PJ_EINVALIDOP || + pjsua_call_set_sdp(caller, NULL) != PJ_EINVALIDOP || + pjmedia_sdp_neg_get_state(pjsua_var.calls[caller].inv->neg) != + PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER || + pjsua_call_update(caller, 0, NULL) != PJ_EINVALIDOP) + { + rc = -1974; + goto on_return; + } + if (status != PJ_SUCCESS || + !wait_until(&update_tsx_done, caller, TEST_TIMEOUT_MSEC) || + !pjsua_var.calls[caller].inv->invite_tsx || + pjsua_var.calls[caller].inv->state != PJSIP_INV_STATE_EARLY || + g_ctx.uas_offer_answer_status != PJ_SUCCESS) + { + rc = -1971; + goto on_return; + } } - g_ctx.med_state_target = 1; - if (!wait_until(&media_state_reached, caller, 8000) || - !wait_until(&media_state_reached, g_ctx.incoming_call_id, 8000)) { - PJ_LOG(1, (THIS_FILE, " initial media update did not complete")); - return err_base - 4; + /* An incoming INVITE must not block a new offer after PRACK. */ + status = pjsua_call_set_sdp(callee, sdp); + if (status == PJ_SUCCESS) + status = pjsua_call_update(callee, 0, NULL); + if (status != PJ_SUCCESS || + !wait_until(&update_tsx_done, callee, TEST_TIMEOUT_MSEC) || + inv->invite_tsx != tsx || + g_ctx.uas_offer_answer_status != PJ_SUCCESS) + { + rc = -1958; + goto on_return; + } } - - if (g_ctx.strm_created[caller] != 1 || - g_ctx.strm_created[g_ctx.incoming_call_id] != 1) + status = pjsua_call_answer(callee, PJSIP_SC_OK, NULL, NULL); + if (status != PJ_SUCCESS || + !wait_until(&call_is_confirmed, caller, TEST_TIMEOUT_MSEC) || + !wait_until(&call_is_confirmed, callee, TEST_TIMEOUT_MSEC)) { - PJ_LOG(1, (THIS_FILE, " expected one audio stream per leg, got " - "caller=%u callee=%u", g_ctx.strm_created[caller], - g_ctx.strm_created[g_ctx.incoming_call_id])); - return err_base - 5; - } - - *p_caller = caller; - *p_callee = g_ctx.incoming_call_id; - return 0; -} - - -/* A no-op re-INVITE must not restart the audio stream just because the remote - * SDP advertises a=rtcp-mux that we do not accept. - * - * pjmedia_stream_info_from_sdp() sets pjmedia_stream_info.rtcp_mux purely from - * the remote m= line, and apply_med_update() then clears it again because the - * account leaves enable_rtcp_mux off. The stream is created from the corrected - * info, so the value is_media_changed() compares against is PJ_FALSE. Compare - * the uncorrected PJ_TRUE with it and every single renegotiation with such a - * peer looks like a media change, tearing the stream down and rebuilding it - * for nothing. - * - * The assertion is made on the public on_stream_created2()/on_stream_destroyed() - * callbacks: on an unchanged media the counters must not move on either leg. - */ -static int test_rtcp_mux_no_spurious_restart(void) -{ - pjsua_call_setting opt; - pj_status_t status; - pjsua_call_id caller = PJSUA_INVALID_ID, callee = PJSUA_INVALID_ID; - unsigned created_a = 0, created_b = 0; - unsigned destroyed_a = 0, destroyed_b = 0; - int rc; - - PJ_LOG(3, (THIS_FILE, " no media restart on a no-op re-INVITE with " - "remote a=rtcp-mux")); - - g_ctx.inject_rtcp_mux = PJ_TRUE; - g_ctx.rx_offer_had_rtcp_mux = PJ_FALSE; - - rc = establish_self_call(&caller, &callee, -1600); - if (rc != 0) - goto on_return; - - if (caller < 0 || callee < 0) { - rc = -1609; + rc = -1959; goto on_return; } - created_a = g_ctx.strm_created[caller]; - destroyed_a = g_ctx.strm_destroyed[caller]; - created_b = g_ctx.strm_created[callee]; - destroyed_b = g_ctx.strm_destroyed[callee]; - - /* A re-INVITE that changes nothing whatsoever. */ - pjsua_call_setting_default(&opt); - opt.aud_cnt = 1; - opt.vid_cnt = 0; - opt.txt_cnt = 0; + opt.flag |= PJSUA_CALL_NO_SDP_OFFER; status = pjsua_call_reinvite2(caller, &opt, NULL); - if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " reinvite2 failed (%d)", status)); - rc = -1610; + if (status != PJ_SUCCESS || + pjsua_call_set_sdp(caller, sdp) != PJ_EINVALIDOP || + pjmedia_sdp_neg_get_state(pjsua_var.calls[caller].inv->neg) != + PJMEDIA_SDP_NEG_STATE_DONE || + !wait_until(&uas_reinvite_received, callee, TEST_TIMEOUT_MSEC)) + { + rc = -1960; goto on_return; } - - g_ctx.med_state_target = 2; - if (!wait_until(&media_state_reached, callee, 8000) || - !wait_until(&media_state_reached, caller, 8000)) + g_ctx.uas_offer_answer_status = PJ_EUNKNOWN; + tsx = inv->invite_tsx; + status = pjsua_call_answer2(callee, NULL, PJSIP_SC_OK, NULL, NULL); + if (status != PJ_EINVALIDOP || inv->invite_tsx != tsx || + tsx->status_code >= 200 || + pjmedia_sdp_neg_get_state(inv->neg) != PJMEDIA_SDP_NEG_STATE_DONE) { - PJ_LOG(1, (THIS_FILE, " re-INVITE media update did not complete")); - rc = -1611; + rc = -1967; goto on_return; } - - /* Give a teardown that trails the media update a chance to be seen. */ - wait_until(NULL, PJSUA_INVALID_ID, 500); - - /* Guard against the test quietly degrading into a no-op. */ - if (!g_ctx.rx_offer_had_rtcp_mux) { - PJ_LOG(1, (THIS_FILE, " re-INVITE offer carried no a=rtcp-mux")); - rc = -1612; + status = pjsua_call_answer_with_sdp(callee, sdp, &answer_opt, PJSIP_SC_OK, + NULL, NULL); + if (status != PJ_SUCCESS || + pjmedia_sdp_neg_get_state(inv->neg) != + PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER) + { + rc = -1961; goto on_return; } - - if (g_ctx.strm_created[caller] != created_a || - g_ctx.strm_destroyed[caller] != destroyed_a || - g_ctx.strm_created[callee] != created_b || - g_ctx.strm_destroyed[callee] != destroyed_b) + tsx = inv->invite_tsx; + /* No polling: the 200 offer has not received its ACK. */ + status = pjsua_call_update(callee, 0, NULL); + if (status != PJ_EINVALIDOP || inv->invite_tsx != tsx || + inv->update_tsx || + pjmedia_sdp_neg_get_state(inv->neg) != + PJMEDIA_SDP_NEG_STATE_LOCAL_OFFER) { - PJ_LOG(1, (THIS_FILE, - " audio stream restarted on a no-op re-INVITE: caller " - "created %u->%u destroyed %u->%u, callee created %u->%u " - "destroyed %u->%u", - created_a, g_ctx.strm_created[caller], - destroyed_a, g_ctx.strm_destroyed[caller], - created_b, g_ctx.strm_created[callee], - destroyed_b, g_ctx.strm_destroyed[callee])); - rc = -1613; + rc = -1962; goto on_return; } + if (!wait_until(&reinvite_tsx_done, caller, TEST_TIMEOUT_MSEC) || + !wait_until(&reinvite_tsx_done, callee, TEST_TIMEOUT_MSEC) || + g_ctx.uas_offer_answer_status != PJ_SUCCESS) + { + rc = -1963; + goto on_return; + } + status = pjsua_call_update(callee, 0, NULL); + if (status != PJ_SUCCESS || + !wait_until(&update_tsx_done, callee, TEST_TIMEOUT_MSEC) || + !call_is_confirmed(caller) || !call_is_confirmed(callee)) + { + rc = -1964; + } on_return: - g_ctx.inject_rtcp_mux = PJ_FALSE; - g_ctx.rx_offer_had_rtcp_mux = PJ_FALSE; + if (tdata) + pjsip_tx_data_dec_ref(tdata); + g_ctx.uas_offer_armed = PJ_FALSE; drain_all_calls(); - + if (acc_changed) { + status = pjsua_acc_modify(g_ctx.acc_id, &saved_acc_cfg); + if (status != PJ_SUCCESS && rc == 0) + rc = -1965; + } + pj_pool_release(pool); return rc; } - -/* Narrowing the media direction while answering a re-INVITE must restart the - * running stream. - * - * apply_med_update() narrows the negotiated direction down to the direction - * configured for the call (PJSUA_CALL_SET_MEDIA_DIR), rewrites the a= line of - * the SDP that goes on the wire, and stores the result in call_med->dir. If - * is_media_changed() is handed a copy of the stream info taken before that - * narrowing, the direction it compares is the pre-narrowing one, the media - * looks unchanged, and the stream keeps running in its old direction - it - * carries on transmitting although both call_med->dir and the SDP on the wire - * say receive-only. - * - * The test drives exactly that situation and then asserts that the two public - * views of the direction agree. Answering with the SDP the running stream was - * built from (forced back to sendrecv) keeps every other field - * is_media_changed() inspects bit-for-bit identical, so the direction is the - * only thing that can possibly differ. - */ -static int test_dir_narrowing_restarts_stream(void) +static int test_hold_during_sdpless_update_and_reinvite_on_update_response(void) { + pjsua_call_id caller = PJSUA_INVALID_ID, callee = PJSUA_INVALID_ID; pjsua_call_setting opt; pj_status_t status; - pjsua_call_id caller = PJSUA_INVALID_ID, callee = PJSUA_INVALID_ID; - pjsua_call_info ci; - pjsua_stream_info psi; - pj_pool_t *pool = NULL; - pjmedia_sdp_session *answer = NULL; + unsigned i; int rc; - PJ_LOG(3, (THIS_FILE, " direction narrowing restarts the running " - "stream")); - - g_ctx.narrow_dir_armed = PJ_FALSE; - g_ctx.narrow_dir_call_id = PJSUA_INVALID_ID; - g_ctx.narrow_dir_reinvite_seen = PJ_FALSE; + PJ_LOG(3, (THIS_FILE, " UPDATE offer transaction tracking")); - rc = establish_self_call(&caller, &callee, -1700); + rc = establish_self_call(&caller, &callee, -1940); if (rc != 0) goto on_return; - if (caller < 0 || callee < 0) { - rc = -1709; - goto on_return; - } - - /* Both legs are now running audio in ENCODING_DECODING. Arm the callee - * leg and poke it with an ordinary no-op re-INVITE from the caller. - */ - g_ctx.narrow_dir_call_id = callee; - g_ctx.narrow_dir_armed = PJ_TRUE; - pjsua_call_setting_default(&opt); - opt.aud_cnt = 1; opt.vid_cnt = 0; opt.txt_cnt = 0; - status = pjsua_call_reinvite2(caller, &opt, NULL); - if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " reinvite2 failed (%d)", status)); - rc = -1710; + opt.flag |= PJSUA_CALL_NO_SDP_OFFER; + status = pjsua_call_update2(caller, &opt, NULL); + if (status != PJ_SUCCESS || pjsua_var.calls[caller].inv->update_tsx) { + rc = -1941; goto on_return; } - if (!wait_until(&reinvite_was_received, PJSUA_INVALID_ID, 8000)) { - PJ_LOG(1, (THIS_FILE, " on_call_rx_reinvite never ran on the " - "callee leg")); - rc = -1711; + /* No event pumping: the SDP-less UPDATE is still outstanding. */ + status = pjsua_call_set_hold(caller, NULL); + if (status != PJ_SUCCESS || + !wait_until(&reinvite_tsx_done, caller, TEST_TIMEOUT_MSEC)) + { + rc = -1942; goto on_return; } - - /* Build the answer out of the SDP the running stream was negotiated - * from, with the direction forced to sendrecv. - */ - pool = pjsua_pool_create("dir-narrow-test", 1024, 1024); - if (!pool) { - PJ_LOG(1, (THIS_FILE, " failed to create pool")); - rc = -1712; + pjsua_call_setting_default(&opt); + opt.vid_cnt = 0; + opt.txt_cnt = 0; + opt.flag |= PJSUA_CALL_UNHOLD; + status = pjsua_call_reinvite2(caller, &opt, NULL); + if (status != PJ_SUCCESS || + !wait_until(&reinvite_tsx_done, caller, TEST_TIMEOUT_MSEC)) + { + rc = -1943; goto on_return; } - { - const pjmedia_sdp_session *active = NULL; - pjsip_inv_session *inv = pjsua_var.calls[callee].inv; - pjmedia_sdp_media *m; - - if (!inv || !inv->neg || - pjmedia_sdp_neg_get_active_local(inv->neg, &active) != PJ_SUCCESS || - active == NULL || active->media_count < 1) + for (i = 0; i < 2; ++i) { + g_ctx.update_reinvite_caller = caller; + g_ctx.update_reinvite_seen = PJ_FALSE; + g_ctx.update_reinvite_status = PJ_EUNKNOWN; + g_ctx.update_reinvite_armed = PJ_TRUE; + pjsua_call_setting_default(&opt); + opt.vid_cnt = 0; + opt.txt_cnt = 0; + if (i == 0) + opt.flag |= PJSUA_CALL_NO_SDP_OFFER; + status = pjsua_call_update2(caller, &opt, NULL); + if (status != PJ_SUCCESS || + !wait_until(&update_reinvite_seen, caller, TEST_TIMEOUT_MSEC) || + g_ctx.update_reinvite_status != PJ_SUCCESS || + !wait_until(&reinvite_tsx_done, caller, TEST_TIMEOUT_MSEC)) { - PJ_LOG(1, (THIS_FILE, " no active local SDP on the callee leg")); - rc = -1713; + PJ_LOG(1, (THIS_FILE, " re-INVITE from UPDATE callback " + "failed (SDP=%u, status=%d)", + i, g_ctx.update_reinvite_status)); + rc = -1944; goto on_return; } - - answer = pjmedia_sdp_session_clone(pool, active); - m = answer->media[0]; - pjmedia_sdp_media_remove_all_attr(m, "sendonly"); - pjmedia_sdp_media_remove_all_attr(m, "recvonly"); - pjmedia_sdp_media_remove_all_attr(m, "inactive"); - if (!pjmedia_sdp_attr_find2(m->attr_count, m->attr, "sendrecv", NULL) && - m->attr_count < PJMEDIA_MAX_SDP_ATTR) - { - m->attr[m->attr_count++] = - pjmedia_sdp_attr_create(pool, "sendrecv", NULL); - } } + rc = check_call_media(caller, "caller", 1, 0, PJMEDIA_TYPE_AUDIO, + PJSUA_CALL_MEDIA_ACTIVE, -1945); + if (rc == 0) + rc = check_call_media(callee, "callee", 1, 0, PJMEDIA_TYPE_AUDIO, + PJSUA_CALL_MEDIA_ACTIVE, -1948); - status = pjsua_call_answer_with_sdp(callee, answer, NULL, 200, NULL, NULL); - if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " answer_with_sdp failed (%d)", status)); - rc = -1714; +on_return: + g_ctx.update_reinvite_armed = PJ_FALSE; + drain_all_calls(); + return rc; +} + +/* Ordinary async re-INVITEs still encode transport SDP and notify the app + * before the app supplies its final answer. + */ +static int test_async_reinvite_creates_sdp(void) +{ + pjsua_call_id caller = PJSUA_INVALID_ID, callee = PJSUA_INVALID_ID; + pj_status_t status; + pjsip_transaction *tsx; + pjsip_tx_data *tdata = NULL; + pjmedia_sdp_neg_state neg_state; + int rc; + + rc = test_app_managed_reoffers_preserve_media_count(0, PJ_TRUE); + if (rc != 0) + return rc; + rc = test_app_managed_reoffers_preserve_media_count(2, PJ_TRUE); + if (rc != 0) + return rc; + rc = test_app_managed_reoffers_preserve_media_count(2, PJ_FALSE); + if (rc != 0) + return rc; + rc = test_app_managed_reoffers_preserve_media_count(PJSUA_MAX_CALL_MEDIA, + PJ_FALSE); + if (rc != 0) + return rc; + rc = test_app_managed_response_offer_blocks_update(PJ_FALSE, PJ_FALSE); + if (rc != 0) + return rc; + rc = test_app_managed_response_offer_blocks_update(PJ_TRUE, PJ_FALSE); + if (rc != 0) + return rc; + rc = test_app_managed_response_offer_blocks_update(PJ_FALSE, PJ_TRUE); + if (rc != 0) + return rc; + + rc = test_hold_during_sdpless_update_and_reinvite_on_update_response(); + if (rc != 0) + return rc; + + PJ_LOG(3, (THIS_FILE, " ordinary async re-INVITE generates SDP")); + + rc = establish_self_call(&caller, &callee, -1930); + if (rc != 0) goto on_return; - } - g_ctx.med_state_target = 2; - if (!wait_until(&media_state_reached, callee, 8000) || - !wait_until(&media_state_reached, caller, 8000)) + if ((pjsua_var.calls[caller].opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED) || + (pjsua_var.calls[callee].opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED)) { - PJ_LOG(1, (THIS_FILE, " re-INVITE media update did not complete")); - rc = -1715; + rc = -1931; goto on_return; } - status = pjsua_call_get_info(callee, &ci); - if (status != PJ_SUCCESS || ci.media_cnt < 1) { - PJ_LOG(1, (THIS_FILE, " call_get_info(callee) failed (%d)", - status)); - rc = -1716; + pjsua_var.calls[callee].offer_app_managed = PJ_TRUE; + if (pjsua_call_media_is_app_managed(&pjsua_var.calls[callee])) { + pjsua_var.calls[callee].offer_app_managed = PJ_FALSE; + rc = -1975; goto on_return; } + pjsua_var.calls[callee].offer_app_managed = PJ_FALSE; - status = pjsua_call_get_stream_info(callee, 0, &psi); - if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " call_get_stream_info(callee) failed (%d)", - status)); - rc = -1717; + g_ctx.async_sdp_pool = pjsua_pool_create("async-sdp-test", 4096, 4096); + if (!g_ctx.async_sdp_pool) { + rc = -1932; goto on_return; } - - /* The direction PJSUA reports for the call media and the direction of the - * stream that is actually running have to be the same one. - */ - if (ci.media[0].dir != PJMEDIA_DIR_DECODING || - psi.info.aud.dir != PJMEDIA_DIR_DECODING) + g_ctx.async_sdp_callee = callee; + g_ctx.async_sdp_answer = NULL; + g_ctx.async_sdp_count = 0; + g_ctx.async_sdp_armed = PJ_TRUE; + + status = pjsua_call_reinvite2(caller, NULL, NULL); + if (status != PJ_SUCCESS || + !wait_until(&async_sdp_created, callee, TEST_TIMEOUT_MSEC) || + g_ctx.async_sdp_count != 1 || + !pjsua_var.calls[callee].inv->invite_tsx || + pjsua_var.calls[callee].inv->invite_tsx->status_code >= 200) { - PJ_LOG(1, (THIS_FILE, " callee dir mismatch: call_info=%d " - "stream=%d, expected %d", ci.media[0].dir, - psi.info.aud.dir, PJMEDIA_DIR_DECODING)); - rc = -1718; + PJ_LOG(1, (THIS_FILE, " async re-INVITE did not generate an " + "answer before the final response (status=%d, count=%u)", + status, g_ctx.async_sdp_count)); + rc = -1933; goto on_return; } - /* And that agreement has to come from an actual restart. */ - if (g_ctx.strm_destroyed[callee] != 1 || g_ctx.strm_created[callee] != 2) { - PJ_LOG(1, (THIS_FILE, " callee stream was not restarted exactly " - "once (created=%u destroyed=%u)", - g_ctx.strm_created[callee], g_ctx.strm_destroyed[callee])); - rc = -1719; + tsx = pjsua_var.calls[callee].inv->invite_tsx; + neg_state = pjmedia_sdp_neg_get_state(pjsua_var.calls[callee].inv->neg); + status = pjsip_inv_reinvite(pjsua_var.calls[callee].inv, NULL, + g_ctx.async_sdp_answer, &tdata); + if (status != PJ_EINVALIDOP || tdata || + pjsua_var.calls[callee].inv->invite_tsx != tsx || + pjmedia_sdp_neg_get_state(pjsua_var.calls[callee].inv->neg) != neg_state) + { + if (tdata) + pjsip_tx_data_dec_ref(tdata); + rc = -1966; goto on_return; } - - /* Cross-check from the far side: the caller offered sendrecv, so it can - * only have ended up send-only if the narrowed direction really made it - * into the answer that went out on the wire. - */ - status = pjsua_call_get_info(caller, &ci); - if (status != PJ_SUCCESS || ci.media_cnt < 1) { - PJ_LOG(1, (THIS_FILE, " call_get_info(caller) failed (%d)", - status)); - rc = -1720; + status = pjsua_call_reinvite2(callee, NULL, NULL); + if (status != PJ_EINVALIDOP || + pjsua_var.calls[callee].inv->invite_tsx != tsx || + pjmedia_sdp_neg_get_state(pjsua_var.calls[callee].inv->neg) != neg_state) + { + rc = -1966; goto on_return; } - if (ci.media[0].dir != PJMEDIA_DIR_ENCODING) { - PJ_LOG(1, (THIS_FILE, " caller dir is %d, expected %d", - ci.media[0].dir, PJMEDIA_DIR_ENCODING)); - rc = -1721; + + status = pjsua_call_answer_with_sdp(callee, g_ctx.async_sdp_answer, + NULL, PJSIP_SC_OK, NULL, NULL); + if (status != PJ_SUCCESS || + !wait_until(&reinvite_tsx_done, caller, TEST_TIMEOUT_MSEC)) + { + PJ_LOG(1, (THIS_FILE, " async re-INVITE answer failed (%d)", + status)); + rc = -1934; goto on_return; } + rc = check_call_media(caller, "caller", 1, 0, PJMEDIA_TYPE_AUDIO, + PJSUA_CALL_MEDIA_ACTIVE, -1935); + if (rc == 0) + rc = check_call_media(callee, "callee", 1, 0, PJMEDIA_TYPE_AUDIO, + PJSUA_CALL_MEDIA_ACTIVE, -1938); on_return: - g_ctx.narrow_dir_armed = PJ_FALSE; - g_ctx.narrow_dir_call_id = PJSUA_INVALID_ID; - g_ctx.narrow_dir_reinvite_seen = PJ_FALSE; - if (pool) - pj_pool_release(pool); + g_ctx.async_sdp_armed = PJ_FALSE; drain_all_calls(); - - return rc; -} - - -/* Check the call is still confirmed, has exactly med_cnt media, and that - * media[idx] has the given type and status. - */ -static int check_call_media(pjsua_call_id call_id, const char *leg, - unsigned med_cnt, unsigned idx, - pjmedia_type type, pjsua_call_media_status st, - int err) -{ - pjsua_call_info ci; - pj_status_t status; - - status = pjsua_call_get_info(call_id, &ci); - if (status != PJ_SUCCESS || ci.state != PJSIP_INV_STATE_CONFIRMED) { - PJ_LOG(1, (THIS_FILE, " %s leg not confirmed after re-INVITE " - "(status=%d)", leg, status)); - return err; - } - if (ci.media_cnt != med_cnt) { - PJ_LOG(1, (THIS_FILE, " %s leg has %u media, expected %u", leg, - ci.media_cnt, med_cnt)); - return err - 1; + g_ctx.async_sdp_answer = NULL; + if (g_ctx.async_sdp_pool) { + pj_pool_release(g_ctx.async_sdp_pool); + g_ctx.async_sdp_pool = NULL; } - if (ci.media[idx].type != type || ci.media[idx].status != st) { - PJ_LOG(1, (THIS_FILE, " %s leg media[%u]: type=%s status=%d, " - "expected type=%s status=%d", leg, idx, - pjmedia_type_name(ci.media[idx].type), - ci.media[idx].status, pjmedia_type_name(type), st)); - return err - 2; - } - return 0; + return rc; } -/* Media that pjsua does not manage (T.38 image/udptl) offered mid-call, by - * turning the audio m-line of a re-INVITE into image/udptl. +/* An async re-offer with no active media must still get 488 (the #5068 + * T.38 exception requires sdp_has_active_media()), while a persistently + * app-managed call (PJSUA_CALL_MEDIA_APP_MANAGED) must accept it + * unconditionally, since the application owns all of its media. * - * 1. Plain pjsua: the re-INVITE is rejected with 488 and the running audio is - * preserved on both legs, as before. - * 2. App-managed: the callee takes over answering (on_call_rx_reinvite with - * async) and answers with an SDP keeping the image line active. Both legs - * must stay up with that slot reported as unmanaged (UNKNOWN/NONE), and - * the audio stream previously on the slot must be destroyed exactly once - * on each leg: when the answer arrives on the caller leg, and when the - * callee applies its own answer, the slot still carries the audio type and - * stream of the previous negotiation. + * 1. Ordinary call + async take-over: same offer as test_app_managed_media() + * but with no active media -> still rejected with 488, audio preserved. + * 2. Persistently app-managed call (account default): same offer reaches + * the negotiator unrejected, left for the app to answer. */ -static int test_app_managed_media(void) +static int test_async_reoffer_no_active_media(void) { pjsua_call_setting opt; + pjsua_acc_config acc_cfg; pj_status_t status; pjsua_call_id caller = PJSUA_INVALID_ID, callee = PJSUA_INVALID_ID; - pjmedia_sdp_session *answer; - const pjmedia_sdp_session *active; - unsigned i, img_idx; + pjmedia_sdp_session *answer, *sdp; + pj_bool_t acc_cfg_valid = PJ_FALSE; int rc; - PJ_LOG(3, (THIS_FILE, " media not managed by pjsua (T.38) in re-INVITE")); + rc = test_async_reinvite_creates_sdp(); + if (rc != 0) + return rc; - g_ctx.app_med_pool = pjsua_pool_create("app-med-test", 1024, 1024); + PJ_LOG(3, (THIS_FILE, " async re-offer with no active media")); + + g_ctx.app_med_pool = pjsua_pool_create("no-active-media-test", 1024, 1024); if (!g_ctx.app_med_pool) { PJ_LOG(1, (THIS_FILE, " failed to create pool")); - return -1800; + return -1900; } - rc = establish_self_call(&caller, &callee, -1801); + rc = establish_self_call(&caller, &callee, -1901); if (rc != 0) goto on_return; g_ctx.app_med_caller = caller; g_ctx.app_med_callee = callee; - g_ctx.app_med_take_over = PJ_FALSE; + g_ctx.app_med_take_over = PJ_TRUE; + g_ctx.app_med_zero_media = PJ_TRUE; g_ctx.app_med_reinvite_seen = PJ_FALSE; g_ctx.app_med_reinv_code = 0; g_ctx.app_med_offer = NULL; @@ -2751,172 +5845,150 @@ static int test_app_managed_media(void) opt.vid_cnt = 0; opt.txt_cnt = 0; - /* 1. Plain pjsua rejects the T.38 offer and keeps the audio. */ + /* 1. Ordinary call: must still be rejected with 488. */ status = pjsua_call_reinvite2(caller, &opt, NULL); if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " reinvite2 (T.38) failed (%d)", status)); - rc = -1810; + PJ_LOG(1, (THIS_FILE, " reinvite2 (no active media) failed (%d)", + status)); + rc = -1910; goto on_return; } - if (!wait_until(&app_med_reinvite_done, PJSUA_INVALID_ID, 8000)) { - PJ_LOG(1, (THIS_FILE, " T.38 re-INVITE got no final response")); - rc = -1811; + if (!wait_until(&app_med_reinvite_done, PJSUA_INVALID_ID, + TEST_TIMEOUT_MSEC)) + { + PJ_LOG(1, (THIS_FILE, " re-INVITE got no final response")); + rc = -1911; goto on_return; } if (!g_ctx.app_med_reinvite_seen) { PJ_LOG(1, (THIS_FILE, " on_call_rx_reinvite never ran on the " "callee leg")); - rc = -1812; + rc = -1912; goto on_return; } if (g_ctx.app_med_reinv_code != PJSIP_SC_NOT_ACCEPTABLE_HERE) { - PJ_LOG(1, (THIS_FILE, " plain pjsua answered the T.38 re-INVITE " - "with %d, expected 488", g_ctx.app_med_reinv_code)); - rc = -1813; + PJ_LOG(1, (THIS_FILE, " ordinary call accepted an async " + "re-offer with no active media (%d), expected 488", + g_ctx.app_med_reinv_code)); + rc = -1913; goto on_return; } rc = check_call_media(callee, "callee", 1, 0, PJMEDIA_TYPE_AUDIO, - PJSUA_CALL_MEDIA_ACTIVE, -1814); + PJSUA_CALL_MEDIA_ACTIVE, -1914); if (rc != 0) goto on_return; rc = check_call_media(caller, "caller", 1, 0, PJMEDIA_TYPE_AUDIO, - PJSUA_CALL_MEDIA_ACTIVE, -1816); + PJSUA_CALL_MEDIA_ACTIVE, -1916); if (rc != 0) goto on_return; - if (g_ctx.strm_destroyed[callee] != 0 || g_ctx.strm_destroyed[caller] != 0) + + g_ctx.app_med_armed = PJ_FALSE; + drain_all_calls(); + caller = callee = PJSUA_INVALID_ID; + + /* 2. Persistently app-managed call (account default on both legs): the + * same offer must not be auto-rejected. + */ + status = pjsua_acc_get_config(g_ctx.acc_id, g_ctx.app_med_pool, &acc_cfg); + if (status != PJ_SUCCESS) { + rc = -1920; + goto on_return; + } + acc_cfg_valid = PJ_TRUE; + acc_cfg.media_app_managed = PJ_TRUE; + status = pjsua_acc_modify(g_ctx.acc_id, &acc_cfg); + if (status != PJ_SUCCESS) { + rc = -1921; + goto on_return; + } + + status = parse_sdp_text(g_ctx.app_med_pool, + app_managed_two_audio_sdp, &sdp); + if (status == PJ_SUCCESS) + status = establish_app_managed_self_call(sdp, &caller, &callee); + if (status != PJ_SUCCESS) { + rc = -1922; + goto on_return; + } + + if (!(pjsua_var.calls[caller].opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED) || + !(pjsua_var.calls[callee].opt.flag & PJSUA_CALL_MEDIA_APP_MANAGED)) { - PJ_LOG(1, (THIS_FILE, " audio stream destroyed by the rejected " - "re-INVITE (callee=%u caller=%u)", - g_ctx.strm_destroyed[callee], - g_ctx.strm_destroyed[caller])); - rc = -1818; + /* Both legs must have inherited the account's app-managed default + * for this test to actually exercise the fixed code path. + */ + rc = -1923; goto on_return; } - /* 2. The callee answers the T.38 offer itself, keeping the line active. */ + g_ctx.app_med_caller = caller; + g_ctx.app_med_callee = callee; g_ctx.app_med_take_over = PJ_TRUE; + g_ctx.app_med_zero_media = PJ_TRUE; g_ctx.app_med_reinvite_seen = PJ_FALSE; g_ctx.app_med_reinv_code = 0; g_ctx.app_med_offer = NULL; - pj_memcpy(g_ctx.med_state_base, g_ctx.med_state_cnt, - sizeof(g_ctx.med_state_cnt)); + g_ctx.app_med_armed = PJ_TRUE; - status = pjsua_call_reinvite2(caller, &opt, NULL); + answer = pjmedia_sdp_session_clone(g_ctx.app_med_pool, sdp); + answer->media[0]->desc.port = 0; + answer->media[1]->desc.port = 0; + status = reinvite_with_sdp(caller, &opt, answer); if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " reinvite2 (T.38, app-managed) failed (%d)", - status)); - rc = -1820; + PJ_LOG(1, (THIS_FILE, " reinvite2 (app-managed, no active " + "media) failed (%d)", status)); + rc = -1924; goto on_return; } - if (!wait_until(&app_med_reinvite_seen, PJSUA_INVALID_ID, 8000) || + if (!wait_until(&app_med_reinvite_seen, PJSUA_INVALID_ID, + TEST_TIMEOUT_MSEC) || g_ctx.app_med_offer == NULL) { PJ_LOG(1, (THIS_FILE, " on_call_rx_reinvite never ran on the " "callee leg")); - rc = -1821; + rc = -1925; goto on_return; } - /* Answer with the offer itself, moving the image line to the app's own - * (fake) UDPTL port. The audio line stays deactivated, media[0] is that - * zeroed audio line so the image has to be located by media name. + /* The offer reached the negotiator (i.e. apply_call_setting() did not + * reject it for lacking active media): answer with the same + * all-ports-zero SDP, a legal "all media deactivated" answer. */ answer = pjmedia_sdp_session_clone(g_ctx.app_med_pool, g_ctx.app_med_offer); - img_idx = answer->media_count; - for (i = 0; i < answer->media_count; ++i) { - if (pj_stricmp2(&answer->media[i]->desc.media, "image") == 0) { - answer->media[i]->desc.port = APP_UDPTL_PORT; - img_idx = i; - break; - } - } - if (img_idx >= answer->media_count) { - PJ_LOG(1, (THIS_FILE, " no image line in the offer to answer")); - rc = -1821; - goto on_return; - } - status = pjsua_call_answer_with_sdp(callee, answer, NULL, 200, NULL, NULL); if (status != PJ_SUCCESS) { - PJ_LOG(1, (THIS_FILE, " answer_with_sdp (T.38) failed (%d)", - status)); - rc = -1822; + PJ_LOG(1, (THIS_FILE, " answer_with_sdp (no active media) " + "failed (%d)", status)); + rc = -1926; goto on_return; } - if (!wait_until(&app_med_reinvite_done, PJSUA_INVALID_ID, 8000)) { - PJ_LOG(1, (THIS_FILE, " app-managed T.38 re-INVITE got no final " + if (!wait_until(&app_med_reinvite_done, PJSUA_INVALID_ID, + TEST_TIMEOUT_MSEC)) + { + PJ_LOG(1, (THIS_FILE, " app-managed re-INVITE got no final " "response")); - rc = -1823; + rc = -1927; goto on_return; } if (g_ctx.app_med_reinv_code != PJSIP_SC_OK) { - PJ_LOG(1, (THIS_FILE, " app-managed T.38 re-INVITE answered with " - "%d, expected 200", g_ctx.app_med_reinv_code)); - rc = -1824; - goto on_return; - } - if (!wait_until(&media_state_advanced, callee, 8000) || - !wait_until(&media_state_advanced, caller, 8000)) - { - PJ_LOG(1, (THIS_FILE, " app-managed T.38 media update did not " - "complete")); - rc = -1825; - goto on_return; - } - - /* The audio line was deactivated by the offer, and the appended image - * line is reported as media pjsua does not manage. - */ - rc = check_call_media(callee, "callee", 2, 0, PJMEDIA_TYPE_AUDIO, - PJSUA_CALL_MEDIA_NONE, -1826); - if (rc != 0) - goto on_return; - rc = check_call_media(callee, "callee", 2, 1, PJMEDIA_TYPE_UNKNOWN, - PJSUA_CALL_MEDIA_NONE, -1830); - if (rc != 0) - goto on_return; - rc = check_call_media(caller, "caller", 2, 0, PJMEDIA_TYPE_AUDIO, - PJSUA_CALL_MEDIA_NONE, -1834); - if (rc != 0) - goto on_return; - rc = check_call_media(caller, "caller", 2, 1, PJMEDIA_TYPE_UNKNOWN, - PJSUA_CALL_MEDIA_NONE, -1838); - if (rc != 0) - goto on_return; - - /* The app's own UDPTL port must be what actually went on the wire, i.e. - * the answer was used as supplied and not normalized away. - */ - if (pjmedia_sdp_neg_get_active_local(pjsua_var.calls[callee].inv->neg, - &active) != PJ_SUCCESS || - img_idx >= active->media_count || - active->media[img_idx]->desc.port != APP_UDPTL_PORT) - { - PJ_LOG(1, (THIS_FILE, " negotiated local SDP does not carry the " - "app's UDPTL port %d", APP_UDPTL_PORT)); - rc = -1840; - goto on_return; - } - - /* The audio stream that was on the slot must be gone, on both legs. */ - if (g_ctx.strm_destroyed[callee] != 1 || g_ctx.strm_created[callee] != 1 || - g_ctx.strm_destroyed[caller] != 1 || g_ctx.strm_created[caller] != 1) - { - PJ_LOG(1, (THIS_FILE, " audio stream not destroyed exactly once: " - "callee created=%u destroyed=%u, caller created=%u " - "destroyed=%u", - g_ctx.strm_created[callee], g_ctx.strm_destroyed[callee], - g_ctx.strm_created[caller], g_ctx.strm_destroyed[caller])); - rc = -1842; + PJ_LOG(1, (THIS_FILE, " app-managed call rejected its own " + "no-active-media answer (%d), expected 200", + g_ctx.app_med_reinv_code)); + rc = -1928; goto on_return; } on_return: g_ctx.app_med_armed = PJ_FALSE; g_ctx.app_med_take_over = PJ_FALSE; + g_ctx.app_med_zero_media = PJ_FALSE; g_ctx.app_med_offer = NULL; drain_all_calls(); + if (acc_cfg_valid) { + acc_cfg.media_app_managed = PJ_FALSE; + pjsua_acc_modify(g_ctx.acc_id, &acc_cfg); + } if (g_ctx.app_med_pool) { pj_pool_release(g_ctx.app_med_pool); g_ctx.app_med_pool = NULL; @@ -2975,6 +6047,7 @@ int pjsua_call_test(void) * that test arms it, and leaving async at PJ_FALSE keeps on_call_rx_offer * in the loop for the other sub-tests. */ ua_cfg.cb.on_call_rx_reinvite = &on_call_rx_reinvite; + ua_cfg.cb.on_call_transfer_request2 = &on_call_transfer_request2; ua_cfg.cb.on_stream_created2 = &on_stream_created2; ua_cfg.cb.on_stream_destroyed = &on_stream_destroyed; ua_cfg.cb.on_call_media_state = &on_call_media_state; @@ -3068,6 +6141,30 @@ int pjsua_call_test(void) rc = test_reinvite_reinit_bounds(); if (rc != 0) goto on_return; + rc = test_app_managed_mode_immutability(); + if (rc != 0) goto on_return; + + rc = test_initial_app_managed_answer(); + if (rc != 0) goto on_return; + + rc = test_app_managed_offerless_invite(); + if (rc != 0) goto on_return; + + rc = test_outgoing_app_managed_reoffer_rejection(); + if (rc != 0) goto on_return; + + rc = test_outgoing_app_managed_overlapping_offer(); + if (rc != 0) goto on_return; + + rc = test_outgoing_app_managed_overlapping_reinvite(); + if (rc != 0) goto on_return; + + rc = test_incoming_app_managed_replaces_rejected(); + if (rc != 0) goto on_return; + + rc = test_offerless_reinvite_applies_opt(); + if (rc != 0) goto on_return; + rc = test_reinit_bounds_untyped_mline(); if (rc != 0) goto on_return; @@ -3120,6 +6217,9 @@ int pjsua_call_test(void) rc = test_app_managed_media(); if (rc != 0) goto on_return; + rc = test_async_reoffer_no_active_media(); + if (rc != 0) goto on_return; + on_return: drain_all_calls(); restore_msg_size(&lib_settings); diff --git a/tests/pjsua/runall.py b/tests/pjsua/runall.py index f9d300718d..84170fa7a1 100644 --- a/tests/pjsua/runall.py +++ b/tests/pjsua/runall.py @@ -48,6 +48,11 @@ "alt-pjsua-uas-custom-sdp", "alt-pjsua-uas-amr-sdp", "alt-pjsua-uas-static-pt-no-rtpmap", + "alt-pjsua-uac-sdp-passthrough", + "alt-pjsua-uas-sdp-passthrough", + "alt-pjsua-uas-multiple-amr", + "alt-pjsua-uas-media-app-managed", + "alt-pjsua-uas-early-update-app-managed", ] # Exclude scripts-sipp/uac-reinvite-bad-via-branch on MacOS due to unreliable result diff --git a/tests/pjsua/scripts-sipp/alt-pjsua-uac-sdp-passthrough.py b/tests/pjsua/scripts-sipp/alt-pjsua-uac-sdp-passthrough.py new file mode 100644 index 0000000000..ba90c1e59e --- /dev/null +++ b/tests/pjsua/scripts-sipp/alt-pjsua-uac-sdp-passthrough.py @@ -0,0 +1,40 @@ +# +# Test driver for alt-pjsua-uac-sdp-passthrough.xml +# +# alt_pjsua acts as UAC with --sdp-passthrough. It places a call to SIPp +# (UAS) using a real-world SDP offer through pjsua_call_make_call2() (see +# real-world-sdp.txt: AMR-WB/AMR/ +# telephone-event, 6 payload types). SIPp answers with the real-world +# answer verbatim (only 2 payload types, with fmtp params that differ from +# the offer's). With PJSUA_CALL_MEDIA_APP_MANAGED set, pjmedia's SDP +# negotiator must not reconcile/rewrite this answer against the offer: the +# 200 OK body received by alt_pjsua must show up unmodified in its log, and +# the call should still reach STATE_CONFIRMED. +# +# Run with alt_pjsua: +# cd tests/pjsua && python3 run.py \ +# --exe ../../pjsip-apps/src/3rdparty_media_sample/alt_pjsua \ +# mod_sipp.py scripts-sipp/alt-pjsua-uac-sdp-passthrough.xml +# +import inc_const as const + +# pjsua instance: UAC. No URI in args so the call is initiated via CLI +# after cli_main() has set up the telnet log redirect. +PJSUA = [ + "--null-audio --max-calls=1 --no-tcp --sdp-passthrough " + "--custom-sdp \"v=0\\r\\no=- 893867431174198 4062473431 IN IP4 10.162.173.125\\r\\ns=SS VOIP\\r\\nc=IN IP4 10.186.99.36\\r\\nt=0 0\\r\\nm=audio 56832 RTP/AVP 116 107 118 96 111 110\\r\\nb=AS:41\\r\\nb=RS:512\\r\\nb=RR:1537\\r\\na=rtpmap:116 AMR-WB/16000/1\\r\\na=fmtp:116 mode-change-capability=2;max-red=220\\r\\na=rtpmap:107 AMR-WB/16000/1\\r\\na=fmtp:107 octet-align=1;mode-change-capability=2;max-red=220\\r\\na=rtpmap:118 AMR/8000/1\\r\\na=fmtp:118 mode-change-capability=2;max-red=220\\r\\na=rtpmap:96 AMR/8000/1\\r\\na=fmtp:96 octet-align=1;mode-change-capability=2;max-red=220\\r\\na=rtpmap:111 telephone-event/16000\\r\\na=fmtp:111 0-15\\r\\na=rtpmap:110 telephone-event/8000\\r\\na=fmtp:110 0-15\\r\\na=curr:qos local none\\r\\na=curr:qos remote none\\r\\na=des:qos mandatory local sendrecv\\r\\na=des:qos optional remote sendrecv\\r\\na=sendrecv\\r\\na=ptime:20\\r\\na=maxptime:240\"" +] + +PJSUA_CLI_EXPECTS = [ + [0, "", "call new $SIPP_URI"], + # Verify the real-world answer SIPp sends in its 200 OK is received by + # alt_pjsua unmodified: the negotiated fmtp params and codec set stay + # exactly as SIPp sent them (only 116/111, fmtp diverging from the + # offer), instead of being reconciled against the 6-codec offer. + [0, r"m=audio 56384 RTP/AVP 116 111", ""], + [0, r"a=rtpmap:116 AMR-WB/16000", ""], + [0, r"a=fmtp:116 max-red=0; mode-change-capability=2; " + r"mode-change-neighbor=1; mode-change-period=2", ""], + [0, r"a=rtpmap:111 telephone-event/16000", ""], + [0, const.STATE_CONFIRMED, "call hangup"], +] diff --git a/tests/pjsua/scripts-sipp/alt-pjsua-uac-sdp-passthrough.xml b/tests/pjsua/scripts-sipp/alt-pjsua-uac-sdp-passthrough.xml new file mode 100644 index 0000000000..f6fa9c97f0 --- /dev/null +++ b/tests/pjsua/scripts-sipp/alt-pjsua-uac-sdp-passthrough.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/pjsua/scripts-sipp/alt-pjsua-uas-early-update-app-managed.py b/tests/pjsua/scripts-sipp/alt-pjsua-uas-early-update-app-managed.py new file mode 100644 index 0000000000..2b9d150ffa --- /dev/null +++ b/tests/pjsua/scripts-sipp/alt-pjsua-uas-early-update-app-managed.py @@ -0,0 +1,15 @@ +import inc_const as const + +PJSUA = [ + "--null-audio --max-calls=1 --no-tcp " + "--id sip:pjsua@localhost:$SIPP_PORT " + "--acc-media-app-managed --sdp-passthrough --auto-answer=183 " + "--custom-sdp \"v=0\\r\\no=- 1 1 IN IP4 127.0.0.1\\r\\n" + "s=-\\r\\nc=IN IP4 127.0.0.1\\r\\nt=0 0\\r\\n" + "m=audio 4000 RTP/AVP 0\\r\\n\"" +] + +PJSUA_EXPECTS = [ + [0, const.STATE_EARLY, ""], + [0, const.STATE_DISCONNECTED, ""], +] diff --git a/tests/pjsua/scripts-sipp/alt-pjsua-uas-early-update-app-managed.xml b/tests/pjsua/scripts-sipp/alt-pjsua-uas-early-update-app-managed.xml new file mode 100644 index 0000000000..cd6eafab6c --- /dev/null +++ b/tests/pjsua/scripts-sipp/alt-pjsua-uas-early-update-app-managed.xml @@ -0,0 +1,101 @@ + + + + + + ;tag=[call_number] + To: sut + Call-ID: [call_id] + CSeq: 1 INVITE + Contact: sip:sipp@[local_ip]:[local_port] + Max-Forwards: 70 + Content-Type: application/sdp + Content-Length: [len] + + v=0 + o=- 1 1 IN IP4 127.0.0.1 + s=- + c=IN IP4 127.0.0.1 + t=0 0 + m=audio 4000 RTP/AVP 0 + + ]]> + + + + + + + ;tag=[call_number] + To: sut [peer_tag_param] + Call-ID: [call_id] + CSeq: 2 UPDATE + Contact: sip:sipp@[local_ip]:[local_port] + Max-Forwards: 70 + Content-Type: application/sdp + Content-Length: [len] + + v=0 + o=- 1 2 IN IP4 127.0.0.1 + s=- + c=IN IP4 127.0.0.1 + t=0 0 + m=audio 4002 RTP/AVP 0 + + ]]> + + + + + + + + + + + ;tag=[call_number] + To: sut [peer_tag_param] + Call-ID: [call_id] + CSeq: 1 CANCEL + Contact: sip:sipp@[local_ip]:[local_port] + Max-Forwards: 70 + Content-Length: 0 + + ]]> + + + + + + + ;tag=[call_number] + To: sut [peer_tag_param] + Call-ID: [call_id] + CSeq: 1 ACK + Contact: sip:sipp@[local_ip]:[local_port] + Max-Forwards: 70 + Content-Length: 0 + + ]]> + + + + + diff --git a/tests/pjsua/scripts-sipp/alt-pjsua-uas-media-app-managed.py b/tests/pjsua/scripts-sipp/alt-pjsua-uas-media-app-managed.py new file mode 100644 index 0000000000..df15b5512c --- /dev/null +++ b/tests/pjsua/scripts-sipp/alt-pjsua-uas-media-app-managed.py @@ -0,0 +1,52 @@ +# +# Test driver for alt-pjsua-uas-media-app-managed.xml +# +# Same call flow as alt-pjsua-uas-sdp-passthrough.py, but alt_pjsua is +# configured via --id + --acc-media-app-managed (pjsua_acc_config's +# media_app_managed) instead of only --sdp-passthrough. This exercises the +# account-level default: PJSUA_CALL_MEDIA_APP_MANAGED is applied to the +# call as soon as the account is resolved for the incoming INVITE, so no +# media transport is created for the initial offer. --sdp-passthrough also +# enables the account default and avoids initial media transports. --id is +# required so alt_pjsua actually calls pjsua_acc_add() (and thus applies +# media_app_managed) instead of falling back to pjsua_acc_add_local(), +# which does not consult app_config.acc_cfg[]. +# +# Run with alt_pjsua: +# cd tests/pjsua && python3 run.py \ +# --exe ../../pjsip-apps/src/3rdparty_media_sample/alt_pjsua \ +# mod_sipp.py scripts-sipp/alt-pjsua-uas-media-app-managed.xml +# +import inc_const as const + +# pjsua instance: UAS, auto-answers with both media lines verbatim. The +# second line uses an intentionally unsupported codec to verify app-managed +# calls do not depend on pjsua's codec registry or media-count policy. +PJSUA = [ + "--null-audio --max-calls=2 --no-tcp " + "--id sip:pjsua@localhost:$SIPP_PORT " + "--acc-media-app-managed --sdp-passthrough " + "--auto-answer=200 " + "--custom-sdp \"v=0\\r\\no=- 893867432491909 4062736973 IN IP4 10.162.173.125\\r\\ns=-\\r\\nt=0 0\\r\\na=msid-semantic: WMS\\r\\nm=audio 56384 RTP/AVP 116 111\\r\\nc=IN IP4 10.186.99.38\\r\\nb=AS:41\\r\\na=rtpmap:116 AMR-WB/16000\\r\\na=fmtp:116 max-red=0; mode-change-capability=2; mode-change-neighbor=1; mode-change-period=2\\r\\na=rtpmap:111 telephone-event/16000\\r\\na=fmtp:111 0-15\\r\\na=ptime:20\\r\\na=maxptime:40\\r\\na=msid:- 415221c9-141a-4cfa-905a-024af91d0e7c\\r\\na=ssrc:18411299 cname:NNI4WBv5F7m8JUWO\\r\\na=sendrecv\\r\\nm=audio 56386 RTP/AVP 123\\r\\nc=IN IP4 10.186.99.39\\r\\na=rtpmap:123 X-APP-MANAGED/8000\\r\\na=sendrecv\"" +] + +PJSUA_CLI_EXPECTS = [ + # Verify the real-world offer SIPp sends in its INVITE is received by + # alt_pjsua unmodified. + [0, r"m=audio 56832 RTP/AVP 116 107 118 96 111 110", ""], + [0, r"a=rtpmap:116 AMR-WB/16000/1", ""], + [0, r"a=fmtp:116 mode-change-capability=2;max-red=220", ""], + [0, r"m=audio 56834 RTP/AVP 123", ""], + [0, r"a=rtpmap:123 X-APP-MANAGED/8000", ""], + # Confirm is_stream_media() leaves negotiated media to the application + # in pjsua_media_channel_update(). This log does not verify transport + # initialization; pjsua_call_media_is_app_managed() guards that earlier. + [0, r"is left to the application", ""], + [0, const.STATE_CONFIRMED, ""], +] + +# No "UDP media transport created"/"RTP socket reachable" log lines should +# ever appear for this call or alt-pjsua-uas-sdp-passthrough.py. +# There is no PJSUA_CLI_EXPECTS negative match helper in this framework, +# so this is verified manually (see the +# session notes / PR description) rather than asserted here. diff --git a/tests/pjsua/scripts-sipp/alt-pjsua-uas-media-app-managed.xml b/tests/pjsua/scripts-sipp/alt-pjsua-uas-media-app-managed.xml new file mode 100644 index 0000000000..58069fbcc4 --- /dev/null +++ b/tests/pjsua/scripts-sipp/alt-pjsua-uas-media-app-managed.xml @@ -0,0 +1,196 @@ + + + + + + + + ;tag=[call_number] + To: sut + Call-ID: [call_id] + CSeq: 1 INVITE + Contact: sip:sipp@[local_ip]:[local_port] + Max-Forwards: 70 + Subject: alt_pjsua UAS SDP passthrough test + Content-Type: application/sdp + Content-Length: [len] + +v=0 +o=- 893867431174198 4062473431 IN IP4 10.162.173.125 +s=SS VOIP +c=IN IP4 10.186.99.36 +t=0 0 +m=audio 56832 RTP/AVP 116 107 118 96 111 110 +b=AS:41 +b=RS:512 +b=RR:1537 +a=rtpmap:116 AMR-WB/16000/1 +a=fmtp:116 mode-change-capability=2;max-red=220 +a=rtpmap:107 AMR-WB/16000/1 +a=fmtp:107 octet-align=1;mode-change-capability=2;max-red=220 +a=rtpmap:118 AMR/8000/1 +a=fmtp:118 mode-change-capability=2;max-red=220 +a=rtpmap:96 AMR/8000/1 +a=fmtp:96 octet-align=1;mode-change-capability=2;max-red=220 +a=rtpmap:111 telephone-event/16000 +a=fmtp:111 0-15 +a=rtpmap:110 telephone-event/8000 +a=fmtp:110 0-15 +a=curr:qos local none +a=curr:qos remote none +a=des:qos mandatory local sendrecv +a=des:qos optional remote sendrecv +a=sendrecv +a=ptime:20 +a=maxptime:240 +m=audio 56834 RTP/AVP 123 +c=IN IP4 10.186.99.37 +a=rtpmap:123 X-APP-MANAGED/8000 +a=sendrecv + + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ;tag=[call_number] + To: sut [peer_tag_param] + Call-ID: [call_id] + CSeq: 1 ACK + Contact: sip:sipp@[local_ip]:[local_port] + Max-Forwards: 70 + Content-Length: 0 + + ]]> + + + + + + + ;tag=replacer-[call_number] + To: sut + Call-ID: [call_id] + CSeq: 1 INVITE + Contact: sip:sipp@[local_ip]:[local_port] + Max-Forwards: 70 + Replaces: [call_id];to-tag=[$8];from-tag=[call_number] + Content-Type: application/sdp + Content-Length: [len] + +v=0 +o=- 893867431174199 4062473432 IN IP4 10.162.173.125 +s=Replaces offer +c=IN IP4 10.186.99.36 +t=0 0 +m=audio 56836 RTP/AVP 0 +a=sendrecv + + ]]> + + + + + + + + + + + + + + ;tag=[call_number] + To: sut ;tag=[$8] + Call-ID: [call_id] + CSeq: 2 BYE + Contact: sip:sipp@[local_ip]:[local_port] + Max-Forwards: 70 + Content-Length: 0 + + ]]> + + + + + + + + + diff --git a/tests/pjsua/scripts-sipp/alt-pjsua-uas-multiple-amr.py b/tests/pjsua/scripts-sipp/alt-pjsua-uas-multiple-amr.py new file mode 100644 index 0000000000..2344fc396a --- /dev/null +++ b/tests/pjsua/scripts-sipp/alt-pjsua-uas-multiple-amr.py @@ -0,0 +1,62 @@ +# +# Test driver for alt-pjsua-uas-multiple-amr.xml +# +# alt_pjsua acts as UAS with --sdp-passthrough and --auto-answer=200. SIPp +# sends an INVITE with a real-world SDP offer advertising multiple AMR +# variants (AMR-WB PT 116 and PT 107, AMR PT 118 and PT 96 -- same rtpmap, +# different fmtp octet-align --, G.722 PT 9, and two telephone-event +# payloads PT 111 and PT 110). alt_pjsua answers with a custom SDP (via +# --custom-sdp) that picks PT 118 only. With PJSUA_CALL_MEDIA_APP_MANAGED +# set, the 200 OK body must carry this answer unmodified -- pjmedia's SDP +# negotiator must not reconcile/merge/reject it against the offer's +# duplicate-rtpmap payload types, which is the original problem this test +# exists for (see alt-pjsua-uas-amr-sdp for the plain, non-duplicate case). +# +# Once the call is confirmed, alt_pjsua is driven via its CLI to send a +# re-INVITE ("call reinvite") and UPDATE ("call update") without supplying +# another SDP. App-managed calls must reuse their active local SDP instead +# of generating PJSUA's all-port-zero placeholder. SIPp verifies both +# request bodies still carry PT 118 unmodified before answering them. +# +# Run with alt_pjsua: +# cd tests/pjsua && python3 run.py \ +# --exe ../../pjsip-apps/src/3rdparty_media_sample/alt_pjsua \ +# mod_sipp.py scripts-sipp/alt-pjsua-uas-multiple-amr.xml +# +import inc_const as const + +# pjsua instance: UAS, auto-answers with a custom SDP that selects AMR +# (PT 118, octet-align=0) out of the offer's duplicate-rtpmap PT 118/PT 96. +PJSUA = [ + "--null-audio --max-calls=1 --no-tcp --sdp-passthrough " + "--auto-answer=200 " + "--custom-sdp \"v=0\\r\\no=- 3832217418 3832217418 IN IP4 10.162.173.125" + "\\r\\ns=-" + "\\r\\nc=IN IP4 10.186.99.39" + "\\r\\nt=0 0" + "\\r\\nm=audio 4002 RTP/AVP 118 110" + "\\r\\nb=AS:29" + "\\r\\na=rtpmap:118 AMR/8000" + "\\r\\na=fmtp:118 octet-align=0;mode-change-capability=2;max-red=0" + "\\r\\na=rtpmap:110 telephone-event/8000" + "\\r\\na=fmtp:110 0-15" + "\\r\\na=rtcp:4003 IN IP4 10.186.99.39" + "\\r\\na=sendrecv" + "\\r\\na=ptime:20" + "\\r\\na=maxptime:40\"" +] + +PJSUA_CLI_EXPECTS = [ + # Verify the multiple AMR/AMR-WB rtpmaps from SIPp's INVITE are received + # unmodified (passthrough must not touch the incoming offer either). + [0, r"a=rtpmap:116 AMR-WB/16000", ""], + [0, r"a=rtpmap:118 AMR/8000", ""], + [0, r"a=fmtp:96 octet-align=1", ""], + # The call must reach CONFIRMED state. Once it does, drive a re-INVITE + # from alt_pjsua's side via its CLI ("call reinvite", sc='v'). + [0, const.STATE_CONFIRMED, "call reinvite"], + [0, r"is left to the application", ""], + # SIPp sends OPTIONS after the re-INVITE ACK, ensuring that transaction + # is complete before the UPDATE is started. + [0, r"Request msg OPTIONS", "call update"], +] diff --git a/tests/pjsua/scripts-sipp/alt-pjsua-uas-multiple-amr.xml b/tests/pjsua/scripts-sipp/alt-pjsua-uas-multiple-amr.xml new file mode 100644 index 0000000000..be4f2c5dd8 --- /dev/null +++ b/tests/pjsua/scripts-sipp/alt-pjsua-uas-multiple-amr.xml @@ -0,0 +1,248 @@ + + + + + + + + + ;tag=[call_number] + To: sut + Call-ID: [call_id] + CSeq: 1 INVITE + Contact: sip:sipp@[local_ip]:[local_port] + Max-Forwards: 70 + Subject: alt_pjsua UAS multiple-AMR SDP passthrough test + Content-Type: application/sdp + Content-Length: [len] + +v=0 +o=- 3832217418 3832217418 IN IP[local_ip_type] [local_ip] +s=SS VOIP +c=IN IP[media_ip_type] [media_ip] +t=0 0 +m=audio [media_port] RTP/AVP 116 107 118 96 9 111 110 +b=AS:41 +b=RS:512 +b=RR:1537 +a=rtpmap:116 AMR-WB/16000 +a=fmtp:116 mode-change-capability=2;max-red=220 +a=rtpmap:107 AMR-WB/16000 +a=fmtp:107 octet-align=1;mode-change-capability=2;max-red=220 +a=rtpmap:118 AMR/8000 +a=fmtp:118 octet-align=0;mode-change-capability=2;max-red=220 +a=rtpmap:96 AMR/8000 +a=fmtp:96 octet-align=1;mode-change-capability=2;max-red=220 +a=rtpmap:9 G722/8000 +a=rtpmap:111 telephone-event/16000 +a=fmtp:111 0-15 +a=rtpmap:110 telephone-event/8000 +a=fmtp:110 0-15 +a=curr:qos local none +a=curr:qos remote none +a=des:qos mandatory local sendrecv +a=des:qos optional remote sendrecv +a=rtcp:[media_port+1] IN IP[media_ip_type] [media_ip] +a=sendrecv +a=ptime:20 +a=maxptime:240 + + ]]> + + + + + + + + + + + + + + + + + + + + ;tag=[call_number] + To: sut [peer_tag_param] + Call-ID: [call_id] + CSeq: 1 ACK + Contact: sip:sipp@[local_ip]:[local_port] + Max-Forwards: 70 + Content-Length: 0 + + ]]> + + + + + + + + + + + + + + + + + + + + + + + ;tag=[call_number] + To: sut [peer_tag_param] + Call-ID: [call_id] + CSeq: 2 OPTIONS + Contact: sip:sipp@[local_ip]:[local_port] + Max-Forwards: 70 + Content-Length: 0 + + ]]> + + + + + + + + + + + + + + + + + + + + + ;tag=[call_number] + To: sut [peer_tag_param] + Call-ID: [call_id] + CSeq: 3 BYE + Contact: sip:sipp@[local_ip]:[local_port] + Max-Forwards: 70 + Content-Length: 0 + + ]]> + + + + + + + + + diff --git a/tests/pjsua/scripts-sipp/alt-pjsua-uas-sdp-passthrough.py b/tests/pjsua/scripts-sipp/alt-pjsua-uas-sdp-passthrough.py new file mode 100644 index 0000000000..c6050ea1e6 --- /dev/null +++ b/tests/pjsua/scripts-sipp/alt-pjsua-uas-sdp-passthrough.py @@ -0,0 +1,34 @@ +# +# Test driver for alt-pjsua-uas-sdp-passthrough.xml +# +# alt_pjsua acts as UAS with --sdp-passthrough and --auto-answer=200. SIPp +# sends an INVITE with the real-world SDP offer from real-world-sdp.txt +# (AMR-WB/AMR/telephone-event, 6 payload types). alt_pjsua answers with the +# real-world answer (via --custom-sdp), which only has 2 payload types and +# fmtp params that would normally be reconciled/filtered against the offer +# by pjmedia's SDP negotiator. With PJSUA_CALL_MEDIA_APP_MANAGED set, the +# 200 OK body must carry this answer unmodified, and the call should still +# reach STATE_CONFIRMED. +# +# Run with alt_pjsua: +# cd tests/pjsua && python3 run.py \ +# --exe ../../pjsip-apps/src/3rdparty_media_sample/alt_pjsua \ +# mod_sipp.py scripts-sipp/alt-pjsua-uas-sdp-passthrough.xml +# +import inc_const as const + +# pjsua instance: UAS, auto-answers with the real-world answer verbatim. +PJSUA = [ + "--null-audio --max-calls=1 --no-tcp --sdp-passthrough " + "--auto-answer=200 " + "--custom-sdp \"v=0\\r\\no=- 893867432491909 4062736973 IN IP4 10.162.173.125\\r\\ns=-\\r\\nt=0 0\\r\\na=msid-semantic: WMS\\r\\nm=audio 56384 RTP/AVP 116 111\\r\\nc=IN IP4 10.186.99.38\\r\\nb=AS:41\\r\\na=rtpmap:116 AMR-WB/16000\\r\\na=fmtp:116 max-red=0; mode-change-capability=2; mode-change-neighbor=1; mode-change-period=2\\r\\na=rtpmap:111 telephone-event/16000\\r\\na=fmtp:111 0-15\\r\\na=ptime:20\\r\\na=maxptime:40\\r\\na=msid:- 415221c9-141a-4cfa-905a-024af91d0e7c\\r\\na=ssrc:18411299 cname:NNI4WBv5F7m8JUWO\\r\\na=sendrecv\"" +] + +PJSUA_CLI_EXPECTS = [ + # Verify the real-world offer SIPp sends in its INVITE is received by + # alt_pjsua unmodified. + [0, r"m=audio 56832 RTP/AVP 116 107 118 96 111 110", ""], + [0, r"a=rtpmap:116 AMR-WB/16000/1", ""], + [0, r"a=fmtp:116 mode-change-capability=2;max-red=220", ""], + [0, const.STATE_CONFIRMED, ""], +] diff --git a/tests/pjsua/scripts-sipp/alt-pjsua-uas-sdp-passthrough.xml b/tests/pjsua/scripts-sipp/alt-pjsua-uas-sdp-passthrough.xml new file mode 100644 index 0000000000..69afb8b119 --- /dev/null +++ b/tests/pjsua/scripts-sipp/alt-pjsua-uas-sdp-passthrough.xml @@ -0,0 +1,131 @@ + + + + + + + + + ;tag=[call_number] + To: sut + Call-ID: [call_id] + CSeq: 1 INVITE + Contact: sip:sipp@[local_ip]:[local_port] + Max-Forwards: 70 + Subject: alt_pjsua UAS SDP passthrough test + Content-Type: application/sdp + Content-Length: [len] + +v=0 +o=- 893867431174198 4062473431 IN IP4 10.162.173.125 +s=SS VOIP +c=IN IP4 10.186.99.36 +t=0 0 +m=audio 56832 RTP/AVP 116 107 118 96 111 110 +b=AS:41 +b=RS:512 +b=RR:1537 +a=rtpmap:116 AMR-WB/16000/1 +a=fmtp:116 mode-change-capability=2;max-red=220 +a=rtpmap:107 AMR-WB/16000/1 +a=fmtp:107 octet-align=1;mode-change-capability=2;max-red=220 +a=rtpmap:118 AMR/8000/1 +a=fmtp:118 mode-change-capability=2;max-red=220 +a=rtpmap:96 AMR/8000/1 +a=fmtp:96 octet-align=1;mode-change-capability=2;max-red=220 +a=rtpmap:111 telephone-event/16000 +a=fmtp:111 0-15 +a=rtpmap:110 telephone-event/8000 +a=fmtp:110 0-15 +a=curr:qos local none +a=curr:qos remote none +a=des:qos mandatory local sendrecv +a=des:qos optional remote sendrecv +a=sendrecv +a=ptime:20 +a=maxptime:240 + + ]]> + + + + + + + + + + + + + + + + + + + + + + + + ;tag=[call_number] + To: sut [peer_tag_param] + Call-ID: [call_id] + CSeq: 1 ACK + Contact: sip:sipp@[local_ip]:[local_port] + Max-Forwards: 70 + Content-Length: 0 + + ]]> + + + + + + ;tag=[call_number] + To: sut [peer_tag_param] + Call-ID: [call_id] + CSeq: 2 BYE + Contact: sip:sipp@[local_ip]:[local_port] + Max-Forwards: 70 + Content-Length: 0 + + ]]> + + + + + + + + +