From bac6a6e98151528b42b808cc0f05da3777ea4c47 Mon Sep 17 00:00:00 2001
From: Mohamed Chibani <mohamed.chibani@savoirfairelinux.com>
Date: Wed, 31 Mar 2021 17:23:08 -0400
Subject: [PATCH] multi-stream: report an incoming call with a media list

Currently, an incoming call is always assumed to have either audio
and video media or only audio media.
This assumption was removed and the incoming call are reported with
the list of included media with some of their attributes as found in
the call invite (SDP). This will allow to process calls with an
arbitrary number of media.
It will also allow to add new media to a call by requesting a media
change using a SIP re-invite (with new SDP). For instance, add video
to an audio-only call. The peer will receive the new offer and may
choose to accept or not the new media.
Not all media change requests require validation from the user/client.
Meaning that for instance, if a new SDP (media change request) is
received to notify that the peer muted it's audio, the media change
request can be processed without requiring validation from the
user/client.

Gitlab: #445

Change-Id: Ibc2b2501a3ec7e2c22f4e9d47cec3eda3dd43fef
---
 bin/dbus/cx.ring.Ring.CallManager.xml         | 101 ++-
 bin/dbus/dbuscallmanager.cpp                  |  16 +
 bin/dbus/dbuscallmanager.h                    |   4 +
 bin/dbus/dbusclient.cpp                       |   4 +
 bin/jni/callmanager.i                         |  10 +
 bin/jni/jni_interface.i                       |   2 +
 bin/nodejs/callmanager.i                      |   8 +
 bin/nodejs/nodejs_interface.i                 |   2 +
 src/account.cpp                               |   1 +
 src/account.h                                 |  14 +-
 src/call.cpp                                  | 118 +--
 src/call.h                                    |  27 +-
 src/client/callmanager.cpp                    |  12 +
 src/client/ring_signal.cpp                    |   2 +
 src/dring/callmanager_interface.h             |  21 +-
 src/ice_transport.cpp                         |   6 +-
 src/jamidht/jamiaccount.cpp                   |  57 +-
 src/jamidht/jamiaccount.h                     |  20 +-
 src/manager.cpp                               | 354 +++++---
 src/manager.h                                 |  52 +-
 src/sip/sdes_negotiator.cpp                   |   8 +-
 src/sip/sdes_negotiator.h                     |  25 +-
 src/sip/sdp.cpp                               | 176 +++-
 src/sip/sdp.h                                 |  34 +-
 src/sip/sipaccount.cpp                        |  17 +-
 src/sip/sipaccount.h                          |  21 +-
 src/sip/sipaccountbase.cpp                    |   3 +-
 src/sip/sipaccountbase.h                      |  14 +-
 src/sip/sipcall.cpp                           | 529 +++++++++--
 src/sip/sipcall.h                             |  82 +-
 src/sip/siptransport.cpp                      |   4 -
 src/sip/sipvoiplink.cpp                       | 106 +--
 test/unitTest/Makefile.am                     |   6 +-
 .../media_negotiation/media_negotiation.cpp   | 849 ++++++++++++++++++
 34 files changed, 2214 insertions(+), 491 deletions(-)
 create mode 100644 test/unitTest/media_negotiation/media_negotiation.cpp

diff --git a/bin/dbus/cx.ring.Ring.CallManager.xml b/bin/dbus/cx.ring.Ring.CallManager.xml
index 0ae9e74ed4..83ffb8df0e 100644
--- a/bin/dbus/cx.ring.Ring.CallManager.xml
+++ b/bin/dbus/cx.ring.Ring.CallManager.xml
@@ -124,6 +124,47 @@
             <arg type="b" name="acceptSucceeded" direction="out"/>
         </method>
 
+        <method name="acceptWithMedia" tp:name-for-bindings="acceptWithMedia">
+            <tp:added version="9.10.0"/>
+            <tp:docstring>
+              Answer an incoming call with the list of media. This version to control
+              attributes for each media (accepted, muted, ...)
+            </tp:docstring>
+            <arg type="s" name="callID" direction="in">
+              <tp:docstring>
+                The callID.
+              </tp:docstring>
+            </arg>
+            <annotation name="org.qtproject.QtDBus.QtTypeName.In1" value="VectorMapStringString"/>
+            <arg type="aa{ss}" name="mediaList" direction="in">
+              <tp:docstring>
+                The list of media.
+              </tp:docstring>
+            </arg>
+            <arg type="b" name="acceptSucceeded" direction="out"/>
+        </method>
+
+        <method name="answerMediaChangeRequest" tp:name-for-bindings="answerMediaChangeRequest">
+            <tp:added version="9.10.0"/>
+            <tp:docstring>
+              Answer to a media update request.
+            </tp:docstring>
+            <arg type="s" name="callID" direction="in">
+              <tp:docstring>
+                The callID.
+              </tp:docstring>
+            </arg>
+            <annotation name="org.qtproject.QtDBus.QtTypeName.In1" value="VectorMapStringString"/>
+            <arg type="aa{ss}" name="mediaList" direction="in">
+              <tp:docstring>
+                The list of media. Shoudld be of the same size as the media list in
+                the media update request. An empty media list means that the media
+                update request was refused.
+              </tp:docstring>
+            </arg>
+            <arg type="b" name="answerMediaChangeRequestSucceeded" direction="out"/>
+        </method>
+
         <method name="hangUp" tp:name-for-bindings="hangUp">
             <tp:docstring>
               Hangup a call in state "CURRENT" or "HOLD".
@@ -628,6 +669,65 @@
             </arg>
         </signal>
 
+        <signal name="incomingCallWithMedia" tp:name-for-bindings="incomingCallWithMedia">
+            <tp:added version="9.10.0"/>
+            <tp:docstring>
+              <p>Notify an incoming call.</p>
+              <p>This signal is emitted when a new call is received. The list of media
+              included in the invite is reported. The client can control the media status
+              of the answer through the media attributes when when accepting the call.</p>
+              <tp:rationale>The client must subscribe to this signal to handle incoming calls.</tp:rationale>
+            </tp:docstring>
+            <arg type="s" name="accountID">
+              <tp:docstring>
+                The account ID of the callee.
+              </tp:docstring>
+            </arg>
+            <arg type="s" name="callID">
+              <tp:docstring>
+                Call ID of the incoming call.
+              </tp:docstring>
+            </arg>
+            <arg type="s" name="from">
+              <tp:docstring>
+                The caller phone number.
+              </tp:docstring>
+            </arg>
+            <annotation name="org.qtproject.QtDBus.QtTypeName.In1" value="VectorMapStringString"/>
+            <arg type="aa{ss}" name="mediaList" direction="in">
+              <tp:docstring>
+                The list of media offered in the incoming call.
+              </tp:docstring>
+            </arg>
+        </signal>
+
+        <signal name="mediaChangeRequested" tp:name-for-bindings="mediaChangeRequested">
+            <tp:added version="9.10.0"/>
+            <tp:docstring>
+              <p>Notify a media update on the current call.</p>
+              <p>This signal is emitted when a media update is received from the peer. The list
+              of media included in the request is reported. The client can control the media status
+              of the answer through the media attributes when when accepting the request.</p>
+              <tp:rationale>The client must subscribe to this signal to handle incoming calls.</tp:rationale>
+            </tp:docstring>
+            <arg type="s" name="accountID">
+              <tp:docstring>
+                The account ID of the callee.
+              </tp:docstring>
+            </arg>
+            <arg type="s" name="callID">
+              <tp:docstring>
+                Call ID of the incoming call.
+              </tp:docstring>
+            </arg>
+            <annotation name="org.qtproject.QtDBus.QtTypeName.In2" value="VectorMapStringString"/>
+            <arg type="aa{ss}" name="mediaList" direction="in">
+              <tp:docstring>
+                The list of media offered in the incoming call.
+              </tp:docstring>
+            </arg>
+        </signal>
+
         <signal name="incomingMessage" tp:name-for-bindings="incomingMessage">
             <tp:docstring>
                 Notify clients that new messages have been received. The key is
@@ -928,6 +1028,5 @@
             <arg type="s" name="callID" />
             <arg type="s" name="event" />
         </signal>
-
     </interface>
 </node>
diff --git a/bin/dbus/dbuscallmanager.cpp b/bin/dbus/dbuscallmanager.cpp
index 2f47339411..baed40d691 100644
--- a/bin/dbus/dbuscallmanager.cpp
+++ b/bin/dbus/dbuscallmanager.cpp
@@ -70,6 +70,22 @@ DBusCallManager::accept(const std::string& callID) -> decltype(DRing::accept(cal
     return DRing::accept(callID);
 }
 
+auto
+DBusCallManager::acceptWithMedia(const std::string& callID,
+                                 const std::vector<std::map<std::string, std::string>>& mediaList)
+    -> decltype(DRing::acceptWithMedia(callID, mediaList))
+{
+    return DRing::acceptWithMedia(callID, mediaList);
+}
+
+auto
+DBusCallManager::answerMediaChangeRequest(
+    const std::string& callID, const std::vector<std::map<std::string, std::string>>& mediaList)
+    -> decltype(DRing::answerMediaChangeRequest(callID, mediaList))
+{
+    return DRing::answerMediaChangeRequest(callID, mediaList);
+}
+
 auto
 DBusCallManager::hangUp(const std::string& callID) -> decltype(DRing::hangUp(callID))
 {
diff --git a/bin/dbus/dbuscallmanager.h b/bin/dbus/dbuscallmanager.h
index a213d12df5..84222523b5 100644
--- a/bin/dbus/dbuscallmanager.h
+++ b/bin/dbus/dbuscallmanager.h
@@ -68,6 +68,10 @@ public:
 
     bool refuse(const std::string& callID);
     bool accept(const std::string& callID);
+    bool acceptWithMedia(const std::string& callID,
+                         const std::vector<std::map<std::string, std::string>>& mediaList);
+    bool answerMediaChangeRequest(const std::string& callID,
+                                  const std::vector<std::map<std::string, std::string>>& mediaList);
     bool hangUp(const std::string& callID);
     bool hold(const std::string& callID);
     bool unhold(const std::string& callID);
diff --git a/bin/dbus/dbusclient.cpp b/bin/dbus/dbusclient.cpp
index 6724539a51..59a1f30312 100644
--- a/bin/dbus/dbusclient.cpp
+++ b/bin/dbus/dbusclient.cpp
@@ -169,6 +169,10 @@ DBusClient::initLibrary(int flags)
                bind(&DBusCallManager::incomingMessage, callM, _1, _2, _3)),
            exportable_callback<CallSignal::IncomingCall>(
                bind(&DBusCallManager::incomingCall, callM, _1, _2, _3)),
+           exportable_callback<CallSignal::IncomingCallWithMedia>(
+               bind(&DBusCallManager::incomingCallWithMedia, callM, _1, _2, _3, _4)),
+           exportable_callback<CallSignal::MediaChangeRequested>(
+               bind(&DBusCallManager::mediaChangeRequested, callM, _1, _2, _3)),
            exportable_callback<CallSignal::RecordPlaybackFilepath>(
                bind(&DBusCallManager::recordPlaybackFilepath, callM, _1, _2)),
            exportable_callback<CallSignal::ConferenceCreated>(
diff --git a/bin/jni/callmanager.i b/bin/jni/callmanager.i
index 745dcaad85..a7ae0bd43c 100644
--- a/bin/jni/callmanager.i
+++ b/bin/jni/callmanager.i
@@ -35,6 +35,10 @@ public:
     virtual void voiceMailNotify(const std::string& accountId, int newCount, int oldCount, int urgentCount){}
     virtual void incomingMessage(const std::string& id, const std::string& from, const std::map<std::string, std::string>& messages){}
     virtual void incomingCall(const std::string& account_id, const std::string& call_id, const std::string& from){}
+    virtual void incomingCallWithMedia(const std::string& account_id, const std::string& call_id, const std::string& from,
+        const std::vector<std::map<std::string, std::string>>& mediaList){}
+    virtual void mediaChangeRequested(const std::string& account_id, const std::string& call_id,
+        const std::vector<std::map<std::string, std::string>>& mediaList){}
     virtual void recordPlaybackFilepath(const std::string& id, const std::string& filename){}
     virtual void conferenceCreated(const std::string& conf_id){}
     virtual void conferenceChanged(const std::string& conf_id, const std::string& state){}
@@ -67,6 +71,8 @@ std::string placeCallWithMedia(const std::string& accountID,
 bool requestMediaChange(const std::string& callID, const std::vector<std::map<std::string, std::string>>& mediaList);
 bool refuse(const std::string& callID);
 bool accept(const std::string& callID);
+bool acceptWithMedia(const std::string& callID, const std::vector<std::map<std::string, std::string>>& mediaList);
+bool answerMediaChangeRequest(const std::string& callID, const std::vector<std::map<std::string, std::string>>& mediaList);
 bool hangUp(const std::string& callID);
 bool hold(const std::string& callID);
 bool unhold(const std::string& callID);
@@ -131,6 +137,10 @@ public:
     virtual void voiceMailNotify(const std::string& accountId, int newCount, int oldCount, int urgentCount){}
     virtual void incomingMessage(const std::string& id, const std::string& from, const std::map<std::string, std::string>& messages){}
     virtual void incomingCall(const std::string& account_id, const std::string& call_id, const std::string& from){}
+    virtual void incomingCallWithMedia(const std::string& account_id, const std::string& call_id, const std::string& from,
+        const std::vector<std::map<std::string, std::string>>& mediaList){}
+    virtual void mediaChangeRequested(const std::string& account_id, const std::string& call_id,
+        const std::vector<std::map<std::string, std::string>>& mediaList){}
     virtual void recordPlaybackFilepath(const std::string& id, const std::string& filename){}
     virtual void conferenceCreated(const std::string& conf_id){}
     virtual void conferenceChanged(const std::string& conf_id, const std::string& state){}
diff --git a/bin/jni/jni_interface.i b/bin/jni/jni_interface.i
index da3e951268..8687220dd5 100644
--- a/bin/jni/jni_interface.i
+++ b/bin/jni/jni_interface.i
@@ -242,6 +242,8 @@ void init(ConfigurationCallback* confM, Callback* callM, PresenceCallback* presM
         exportable_callback<CallSignal::VoiceMailNotify>(bind(&Callback::voiceMailNotify, callM, _1, _2, _3, _4)),
         exportable_callback<CallSignal::IncomingMessage>(bind(&Callback::incomingMessage, callM, _1, _2, _3)),
         exportable_callback<CallSignal::IncomingCall>(bind(&Callback::incomingCall, callM, _1, _2, _3)),
+        exportable_callback<CallSignal::IncomingCallWithMedia>(bind(&Callback::incomingCallWithMedia, callM, _1, _2, _3, _4)),
+        exportable_callback<CallSignal::MediaChangeRequested>(bind(&Callback::mediaChangeRequested, callM, _1, _2, _3)),
         exportable_callback<CallSignal::RecordPlaybackFilepath>(bind(&Callback::recordPlaybackFilepath, callM, _1, _2)),
         exportable_callback<CallSignal::ConferenceCreated>(bind(&Callback::conferenceCreated, callM, _1)),
         exportable_callback<CallSignal::ConferenceChanged>(bind(&Callback::conferenceChanged, callM, _1, _2)),
diff --git a/bin/nodejs/callmanager.i b/bin/nodejs/callmanager.i
index dbcd364b5d..8767be2195 100644
--- a/bin/nodejs/callmanager.i
+++ b/bin/nodejs/callmanager.i
@@ -35,6 +35,10 @@ public:
     virtual void voiceMailNotify(const std::string& accountId, int newCount, int oldCount, int urgentCount){}
     virtual void incomingMessage(const std::string& id, const std::string& from, const std::map<std::string, std::string>& messages){}
     virtual void incomingCall(const std::string& account_id, const std::string& call_id, const std::string& from){}
+    virtual void incomingCallWithMedia(const std::string& account_id, const std::string& call_id, const std::string& from,
+        const std::vector<std::map<std::string, std::string>>& mediaList){}
+    virtual void mediaChangeRequested(const std::string& account_id, const std::string& call_id,
+        const std::vector<std::map<std::string, std::string>>& mediaList){}
     virtual void recordPlaybackFilepath(const std::string& id, const std::string& filename){}
     virtual void conferenceCreated(const std::string& conf_id){}
     virtual void conferenceChanged(const std::string& conf_id, const std::string& state){}
@@ -126,6 +130,10 @@ public:
     virtual void voiceMailNotify(const std::string& accountId, int newCount, int oldCount, int urgentCount){}
     virtual void incomingMessage(const std::string& id, const std::string& from, const std::map<std::string, std::string>& messages){}
     virtual void incomingCall(const std::string& account_id, const std::string& call_id, const std::string& from){}
+    virtual void incomingCallWithMedia(const std::string& account_id, const std::string& call_id, const std::string& from,
+        const std::vector<std::map<std::string, std::string>>& mediaList){}
+    virtual void mediaChangeRequested(const std::string& account_id, const std::string& call_id,
+        const std::vector<std::map<std::string, std::string>>& mediaList){}
     virtual void recordPlaybackFilepath(const std::string& id, const std::string& filename){}
     virtual void conferenceCreated(const std::string& conf_id){}
     virtual void conferenceChanged(const std::string& conf_id, const std::string& state){}
diff --git a/bin/nodejs/nodejs_interface.i b/bin/nodejs/nodejs_interface.i
index ece8b9c0d8..589c5d6afd 100644
--- a/bin/nodejs/nodejs_interface.i
+++ b/bin/nodejs/nodejs_interface.i
@@ -123,6 +123,8 @@ void init(const SWIGV8_VALUE& funcMap){
         exportable_callback<CallSignal::StateChange>(bind(&callStateChanged, _1, _2, _3)),
         exportable_callback<CallSignal::IncomingMessage>(bind(&incomingMessage, _1, _2, _3)),
         exportable_callback<CallSignal::IncomingCall>(bind(&incomingCall, _1, _2, _3)),
+        exportable_callback<CallSignal::IncomingCallWithMedia>(bind(&incomingCallWithMedia, _1, _2, _3, _4)),
+        exportable_callback<CallSignal::MediaChangeRequested>(bind(&mediaChangeRequested, _1, _2, _3)
     };
 
     const std::map<std::string, SharedCallback> configEvHandlers = {
diff --git a/src/account.cpp b/src/account.cpp
index 521e566a93..0916b1af5e 100644
--- a/src/account.cpp
+++ b/src/account.cpp
@@ -123,6 +123,7 @@ Account::Account(const std::string& accountID)
     , upnpEnabled_(true)
     , localModeratorsEnabled_(true)
     , allModeratorsEnabled_(true)
+    , multiStreamEnabled_(false)
 {
     // Initialize the codec order, used when creating a new account
     loadDefaultCodecs();
diff --git a/src/account.h b/src/account.h
index 0282713af4..607a798d9a 100644
--- a/src/account.h
+++ b/src/account.h
@@ -165,10 +165,6 @@ public:
                                                   const std::vector<MediaAttribute>& mediaList)
         = 0;
 
-    /* Note: we forbid incoming call creation from an instance of Account.
-     * This is why no newIncomingCall() method exist here.
-     */
-
     /**
      * If supported, send a text message from this account.
      * @return a token to query the message status
@@ -350,6 +346,14 @@ public:
         allModeratorsEnabled_ = isAllModeratorEnabled;
     }
 
+    // Enable/disable multi-stream feature.
+    // Multi-stream feature changes the callflow of the re-invite process. All
+    // clients must support this feature before it can be enabled by default.
+    // These two internal APIs allow controlling the callflow accordingly. They
+    // should be removed once the multi-stream feature is fully supported.
+    bool isMultiStreamEnabled() const { return multiStreamEnabled_; }
+    void enableMultiStream(bool enable) { multiStreamEnabled_ = enable; }
+
 public:
     // virtual methods that has to be implemented by concrete classes
     /**
@@ -546,6 +550,8 @@ protected:
     bool localModeratorsEnabled_;
     bool allModeratorsEnabled_;
 
+    bool multiStreamEnabled_;
+
     /**
      * private account codec searching functions
      */
diff --git a/src/call.cpp b/src/call.cpp
index 006a8f155c..c47c194d23 100644
--- a/src/call.cpp
+++ b/src/call.cpp
@@ -59,16 +59,12 @@ namespace jami {
 /// The predicate should have <code>bool(Call*) signature</code>.
 template<typename T>
 inline void
-hangupCallsIf(Call::SubcallSet&& calls,
-              int errcode,
-              T pred)
+hangupCallsIf(Call::SubcallSet&& calls, int errcode, T pred)
 {
     for (auto& call : calls) {
         if (not pred(call.get()))
             continue;
-        dht::ThreadPool::io().run([call = std::move(call), errcode] {
-            call->hangup(errcode);
-        });
+        dht::ThreadPool::io().run([call = std::move(call), errcode] { call->hangup(errcode); });
     }
 }
 
@@ -93,58 +89,58 @@ Call::Call(const std::shared_ptr<Account>& account,
 {
     updateDetails(details);
 
-    addStateListener(
-        [this](Call::CallState call_state, Call::ConnectionState cnx_state, UNUSED int code) {
-            checkPendingIM();
-            std::weak_ptr<Call> callWkPtr = shared_from_this();
-            runOnMainThread([callWkPtr] {
-                if (auto call = callWkPtr.lock())
-                    call->checkAudio();
-            });
+    addStateListener([this](Call::CallState call_state,
+                            Call::ConnectionState cnx_state,
+                            UNUSED int code) {
+        checkPendingIM();
+        std::weak_ptr<Call> callWkPtr = shared_from_this();
+        runOnMainThread([callWkPtr] {
+            if (auto call = callWkPtr.lock())
+                call->checkAudio();
+        });
 
-            // if call just started ringing, schedule call timeout
-            if (type_ == CallType::INCOMING and cnx_state == ConnectionState::RINGING) {
-                auto timeout = Manager::instance().getRingingTimeout();
-                JAMI_DBG("Scheduling call timeout in %d seconds", timeout);
-
-                std::weak_ptr<Call> callWkPtr = shared_from_this();
-                Manager::instance().scheduler().scheduleIn(
-                    [callWkPtr] {
-                        if (auto callShPtr = callWkPtr.lock()) {
-                            if (callShPtr->getConnectionState() == Call::ConnectionState::RINGING) {
-                                JAMI_DBG(
-                                    "Call %s is still ringing after timeout, setting state to BUSY",
-                                    callShPtr->getCallId().c_str());
-                                callShPtr->hangup(PJSIP_SC_BUSY_HERE);
-                                Manager::instance().callFailure(*callShPtr);
-                            }
-                        }
-                    },
-                    std::chrono::seconds(timeout));
-            }
+        // if call just started ringing, schedule call timeout
+        if (type_ == CallType::INCOMING and cnx_state == ConnectionState::RINGING) {
+            auto timeout = Manager::instance().getRingingTimeout();
+            JAMI_DBG("Scheduling call timeout in %d seconds", timeout);
 
-            if (!isSubcall() && getCallType() == CallType::OUTGOING) {
-                if (cnx_state == ConnectionState::CONNECTED && duration_start_ == time_point::min())
-                    duration_start_ = clock::now();
-                else if (cnx_state == ConnectionState::DISCONNECTED && call_state == CallState::OVER) {
-                    if (auto jamiAccount = std::dynamic_pointer_cast<JamiAccount>(
-                            getAccount().lock())) {
-                        auto duration = duration_start_ == time_point::min()
-                                            ? 0
-                                            : std::chrono::duration_cast<std::chrono::milliseconds>(
-                                                  clock::now() - duration_start_)
-                                                  .count();
-                        jamiAccount->addCallHistoryMessage(getPeerNumber(), duration);
-
-                        monitor();
+            std::weak_ptr<Call> callWkPtr = shared_from_this();
+            Manager::instance().scheduler().scheduleIn(
+                [callWkPtr] {
+                    if (auto callShPtr = callWkPtr.lock()) {
+                        if (callShPtr->getConnectionState() == Call::ConnectionState::RINGING) {
+                            JAMI_DBG(
+                                "Call %s is still ringing after timeout, setting state to BUSY",
+                                callShPtr->getCallId().c_str());
+                            callShPtr->hangup(PJSIP_SC_BUSY_HERE);
+                            Manager::instance().callFailure(*callShPtr);
+                        }
                     }
+                },
+                std::chrono::seconds(timeout));
+        }
+
+        if (!isSubcall() && getCallType() == CallType::OUTGOING) {
+            if (cnx_state == ConnectionState::CONNECTED && duration_start_ == time_point::min())
+                duration_start_ = clock::now();
+            else if (cnx_state == ConnectionState::DISCONNECTED && call_state == CallState::OVER) {
+                if (auto jamiAccount = std::dynamic_pointer_cast<JamiAccount>(getAccount().lock())) {
+                    auto duration = duration_start_ == time_point::min()
+                                        ? 0
+                                        : std::chrono::duration_cast<std::chrono::milliseconds>(
+                                              clock::now() - duration_start_)
+                                              .count();
+                    jamiAccount->addCallHistoryMessage(getPeerNumber(), duration);
+
+                    monitor();
                 }
             }
+        }
 
-            // kill pending subcalls at disconnect
-            if (call_state == CallState::OVER)
-                hangupCalls(safePopSubcalls(), 0);
-        });
+        // kill pending subcalls at disconnect
+        if (call_state == CallState::OVER)
+            hangupCalls(safePopSubcalls(), 0);
+    });
 
     time(&timestamp_start_);
     if (auto shared = account_.lock())
@@ -381,6 +377,7 @@ std::map<std::string, std::string>
 Call::getDetails() const
 {
     return {
+
         {DRing::Call::Details::CALL_TYPE, std::to_string((unsigned) type_)},
         {DRing::Call::Details::PEER_NUMBER, peerNumber_},
         {DRing::Call::Details::DISPLAY_NAME, peerDisplayName_},
@@ -388,9 +385,9 @@ Call::getDetails() const
         {DRing::Call::Details::CONF_ID, confID_},
         {DRing::Call::Details::TIMESTAMP_START, std::to_string(timestamp_start_)},
         {DRing::Call::Details::ACCOUNTID, getAccountId()},
-        {DRing::Call::Details::AUDIO_MUTED, std::string(bool_to_str(isAudioMuted_))},
-        {DRing::Call::Details::VIDEO_MUTED, std::string(bool_to_str(isVideoMuted_))},
-        {DRing::Call::Details::AUDIO_ONLY, std::string(bool_to_str(isAudioOnly_))},
+        {DRing::Call::Details::AUDIO_MUTED, std::string(bool_to_str(isAudioMuted()))},
+        {DRing::Call::Details::VIDEO_MUTED, std::string(bool_to_str(isVideoMuted()))},
+        {DRing::Call::Details::AUDIO_ONLY, std::string(bool_to_str(hasVideo()))},
     };
 }
 
@@ -436,8 +433,12 @@ Call::onTextMessage(std::map<std::string, std::string>&& messages)
 #ifdef ENABLE_PLUGIN
     auto& pluginChatManager
         = jami::Manager::instance().getJamiPluginManager().getChatServicesManager();
-    std::shared_ptr<JamiMessage> cm = std::make_shared<JamiMessage>(
-        getAccountId(), getPeerNumber(), true, const_cast<std::map<std::string, std::string>&>(messages), false);
+    std::shared_ptr<JamiMessage> cm
+        = std::make_shared<JamiMessage>(getAccountId(),
+                                        getPeerNumber(),
+                                        true,
+                                        const_cast<std::map<std::string, std::string>&>(messages),
+                                        false);
     pluginChatManager.publishMessage(cm);
 
 #endif
@@ -695,7 +696,8 @@ Call::setConferenceInfo(const std::string& msg)
             // confID_ empty -> participant set confInfo with the received one
             confInfo_ = std::move(newInfo);
             // Inform client that layout has changed
-            jami::emitSignal<DRing::CallSignal::OnConferenceInfosUpdated>(id_, confInfo_.toVectorMapStringString());
+            jami::emitSignal<DRing::CallSignal::OnConferenceInfosUpdated>(
+                id_, confInfo_.toVectorMapStringString());
         } else if (auto conf = Manager::instance().getConferenceFromID(confID_)) {
             conf->mergeConfInfo(newInfo, getPeerNumber());
         }
diff --git a/src/call.h b/src/call.h
index f3374befc8..9ecb37cb78 100644
--- a/src/call.h
+++ b/src/call.h
@@ -209,6 +209,25 @@ public:
      */
     virtual void answer() = 0;
 
+    /**
+     * Answer a call with a list of media attributes.
+     * @param mediaList The list of the media attributes.
+     * The media attributes set by the caller of this method will
+     * determine the response sent to the peer and the configuration
+     * of the local media.
+     */
+    virtual void answer(const std::vector<MediaAttribute>& mediaList) = 0;
+
+    /**
+     * Answer to a media update request. The media attributes set by the
+     * caller of this method will determine the response sent to the
+     * peer and the configuration of the local media.
+     * @param mediaList The list of media attributes. An empty media
+     * list means the media update request was not accepted, meaning the
+     * call continue with the current media. It's up to the implementation
+     * to determine wether an answer will be sent to the peer.
+     */
+    virtual void answerMediaChangeRequest(const std::vector<MediaAttribute>& mediaList) = 0;
     /**
      * Hang up the call
      * @param reason
@@ -335,7 +354,10 @@ public: // media management
      */
     void updateDetails(const std::map<std::string, std::string>& details);
 
-    bool hasVideo() const { return not isVideoMuted_; }
+    // Media status methods
+    virtual bool hasVideo() const = 0;
+    virtual bool isAudioMuted() const = 0;
+    virtual bool isVideoMuted() const = 0;
 
     /**
      * A Call can be in a conference. If this is the case, the other side
@@ -380,8 +402,6 @@ protected:
     // TODO all these members are not protected against multi-thread access
 
     const std::string id_ {};
-    bool isAudioMuted_ {false};
-    bool isVideoMuted_ {false};
 
     ///< MultiDevice: parent call, nullptr otherwise. Access protected by callMutex_.
     mutable std::shared_ptr<Call> parent_;
@@ -414,6 +434,7 @@ private:
 
     std::vector<StateListenerCb> stateChangedListeners_ {};
 
+protected:
     /** Unique conference ID, used exclusively in case of a conference */
     std::string confID_ {};
 
diff --git a/src/client/callmanager.cpp b/src/client/callmanager.cpp
index 50e58032ff..841327b131 100644
--- a/src/client/callmanager.cpp
+++ b/src/client/callmanager.cpp
@@ -100,6 +100,18 @@ accept(const std::string& callID)
     return jami::Manager::instance().answerCall(callID);
 }
 
+bool
+acceptWithMedia(const std::string& callID, const std::vector<DRing::MediaMap>& mediaList)
+{
+    return jami::Manager::instance().answerCallWithMedia(callID, mediaList);
+}
+
+bool
+answerMediaChangeRequest(const std::string& callID, const std::vector<DRing::MediaMap>& mediaList)
+{
+    return jami::Manager::instance().answerMediaChangeRequest(callID, mediaList);
+}
+
 bool
 hangUp(const std::string& callID)
 {
diff --git a/src/client/ring_signal.cpp b/src/client/ring_signal.cpp
index 4397e6d482..a306c07654 100644
--- a/src/client/ring_signal.cpp
+++ b/src/client/ring_signal.cpp
@@ -34,6 +34,8 @@ getSignalHandlers()
         exported_callback<DRing::CallSignal::VoiceMailNotify>(),
         exported_callback<DRing::CallSignal::IncomingMessage>(),
         exported_callback<DRing::CallSignal::IncomingCall>(),
+        exported_callback<DRing::CallSignal::IncomingCallWithMedia>(),
+        exported_callback<DRing::CallSignal::MediaChangeRequested>(),
         exported_callback<DRing::CallSignal::RecordPlaybackFilepath>(),
         exported_callback<DRing::CallSignal::ConferenceCreated>(),
         exported_callback<DRing::CallSignal::ConferenceChanged>(),
diff --git a/src/dring/callmanager_interface.h b/src/dring/callmanager_interface.h
index f68d94b2db..541f5e3635 100644
--- a/src/dring/callmanager_interface.h
+++ b/src/dring/callmanager_interface.h
@@ -58,13 +58,17 @@ DRING_PUBLIC bool attendedTransfer(const std::string& transferID, const std::str
 DRING_PUBLIC std::map<std::string, std::string> getCallDetails(const std::string& callID);
 DRING_PUBLIC std::vector<std::string> getCallList();
 
-/* APIs that supports arbitrary number of medias*/
+/* APIs that supports an arbitrary number of media */
 DRING_PUBLIC std::string placeCall(const std::string& accountID,
                                    const std::string& to,
                                    const std::vector<DRing::MediaMap>& mediaList);
 DRING_PUBLIC bool accept(const std::string& callID, const std::vector<DRing::MediaMap>& mediaList);
+DRING_PUBLIC bool acceptWithMedia(const std::string& callID,
+                                  const std::vector<DRing::MediaMap>& mediaList);
 DRING_PUBLIC bool requestMediaChange(const std::string& callID,
                                      const std::vector<DRing::MediaMap>& mediaList);
+DRING_PUBLIC bool answerMediaChangeRequest(const std::string& callID,
+                                           const std::vector<DRing::MediaMap>& mediaList);
 
 /* Conference related methods */
 DRING_PUBLIC void removeConference(const std::string& conference_id);
@@ -164,6 +168,21 @@ struct DRING_PUBLIC CallSignal
         constexpr static const char* name = "IncomingCall";
         using cb_type = void(const std::string&, const std::string&, const std::string&);
     };
+    struct DRING_PUBLIC IncomingCallWithMedia
+    {
+        constexpr static const char* name = "IncomingCallWithMedia";
+        using cb_type = void(const std::string&,
+                             const std::string&,
+                             const std::string&,
+                             const std::vector<std::map<std::string, std::string>>&);
+    };
+    struct DRING_PUBLIC MediaChangeRequested
+    {
+        constexpr static const char* name = "MediaChangeRequested";
+        using cb_type = void(const std::string&,
+                             const std::string&,
+                             const std::vector<std::map<std::string, std::string>>&);
+    };
     struct DRING_PUBLIC RecordPlaybackFilepath
     {
         constexpr static const char* name = "RecordPlaybackFilepath";
diff --git a/src/ice_transport.cpp b/src/ice_transport.cpp
index 613e8ecf75..7a8e55c53c 100644
--- a/src/ice_transport.cpp
+++ b/src/ice_transport.cpp
@@ -1342,6 +1342,10 @@ IceTransport::packIceMsg(uint8_t version) const
 bool
 IceTransport::getCandidateFromSDP(const std::string& line, IceCandidate& cand) const
 {
+    // Silently ignore empty lines
+    if (line.empty())
+        return false;
+
     /**   Section 4.5, RFC 6544 (https://tools.ietf.org/html/rfc6544)
      *    candidate-attribute   = "candidate" ":" foundation SP component-id SP
      *                             "TCP" SP
@@ -1376,7 +1380,7 @@ IceTransport::getCandidateFromSDP(const std::string& line, IceCandidate& cand) c
                  type,
                  tcp_type);
     if (cnt != 7 && cnt != 8) {
-        JAMI_ERR("[ice:%p] Invalid ICE candidate line", pimpl_.get());
+        JAMI_ERR("[ice:%p] Invalid ICE candidate line: %s", pimpl_.get(), line.c_str());
         return false;
     }
 
diff --git a/src/jamidht/jamiaccount.cpp b/src/jamidht/jamiaccount.cpp
index 28b3103767..21bcf17690 100644
--- a/src/jamidht/jamiaccount.cpp
+++ b/src/jamidht/jamiaccount.cpp
@@ -423,6 +423,55 @@ JamiAccount::newIncomingCall(const std::string& from,
     return nullptr;
 }
 
+std::shared_ptr<SIPCall>
+JamiAccount::newIncomingCall(const std::string& from,
+                             const std::vector<MediaAttribute>& mediaList,
+                             const std::shared_ptr<SipTransport>& sipTransp)
+{
+    JAMI_DBG("New incoming call from %s with %lu media", from.c_str(), mediaList.size());
+
+    if (sipTransp) {
+        std::unique_lock<std::mutex> connLock(sipConnsMtx_);
+        for (auto& [key, value] : sipConns_) {
+            if (key.first == from) {
+                // Search for a matching linked SipTransport in connection list.
+                for (auto conIter = value.rbegin(); conIter != value.rend(); conIter++) {
+                    if (conIter->transport != sipTransp)
+                        continue;
+
+                    auto call = Manager::instance().callFactory.newSipCall(shared(),
+                                                                           Call::CallType::INCOMING,
+                                                                           mediaList);
+                    call->setPeerUri(RING_URI_PREFIX + from);
+                    call->setPeerNumber(from);
+                    return call;
+                }
+            }
+        }
+    }
+
+    std::lock_guard<std::mutex> lock(callsMutex_);
+    auto call_it = pendingSipCalls_.begin();
+    while (call_it != pendingSipCalls_.end()) {
+        auto call = call_it->call.lock();
+        if (not call) {
+            JAMI_WARN("newIncomingCall: discarding deleted call");
+            call_it = pendingSipCalls_.erase(call_it);
+        } else if (call->getPeerNumber() == from
+                   || (call_it->from_cert and call_it->from_cert->issuer
+                       and call_it->from_cert->issuer->getId().toString() == from)) {
+            JAMI_DBG("newIncomingCall: found matching call for %s", from.c_str());
+            pendingSipCalls_.erase(call_it);
+            return call;
+        } else {
+            call_it++;
+        }
+    }
+
+    JAMI_ERR("newIncomingCall: can't find matching call for %s", from.c_str());
+    return nullptr;
+}
+
 std::shared_ptr<Call>
 JamiAccount::newOutgoingCall(std::string_view toUrl,
                              const std::map<std::string, std::string>& volatileCallDetails)
@@ -464,7 +513,6 @@ JamiAccount::newOutgoingCallHelper(const std::shared_ptr<SIPCall>& call, std::st
     auto suffix = stripPrefix(toUri);
     JAMI_DBG() << *this << "Calling DHT peer " << suffix;
 
-    call->setIPToIP(true);
     call->setSecure(isTlsEnabled());
 
     try {
@@ -580,7 +628,6 @@ JamiAccount::startOutgoingCall(const std::shared_ptr<SIPCall>& call, const std::
     // cached connection is failing with ICE (close event still not detected).
     auto dummyCall = createSubCall(call);
 
-    dummyCall->setIPToIP(true);
     dummyCall->setSecure(isTlsEnabled());
     call->addSubCall(*dummyCall);
 
@@ -602,7 +649,6 @@ JamiAccount::startOutgoingCall(const std::shared_ptr<SIPCall>& call, const std::
                 return;
 
             auto dev_call = createSubCall(call);
-            dev_call->setIPToIP(true);
             dev_call->setSecure(isTlsEnabled());
             dev_call->setState(Call::ConnectionState::TRYING);
             call->addStateListener(
@@ -647,7 +693,6 @@ JamiAccount::startOutgoingCall(const std::shared_ptr<SIPCall>& call, const std::
 
         auto dev_call = createSubCall(call);
 
-        dev_call->setIPToIP(true);
         dev_call->setSecure(isTlsEnabled());
         dev_call->setTransport(transport);
         call->addSubCall(*dev_call);
@@ -786,7 +831,7 @@ JamiAccount::onConnectedOutgoingCall(const std::shared_ptr<SIPCall>& call,
 bool
 JamiAccount::SIPStartCall(SIPCall& call, IpAddr target)
 {
-    call.setupLocalSDPFromIce();
+    call.addLocalIceAttributes();
     std::string toUri(getToUri(call.getPeerNumber() + "@"
                                + target.toString(true))); // expecting a fully well formed sip uri
 
@@ -1585,7 +1630,7 @@ JamiAccount::registerName(const std::string& password, const std::string& name)
                         this_->registeredName_ = name;
                         this_->saveConfig();
                         emitSignal<DRing::ConfigurationSignal::VolatileDetailsChanged>(
-                                this_->accountID_, this_->getVolatileAccountDetails());
+                            this_->accountID_, this_->getVolatileAccountDetails());
                     }
                 }
                 emitSignal<DRing::ConfigurationSignal::NameRegistrationEnded>(acc, res, name);
diff --git a/src/jamidht/jamiaccount.h b/src/jamidht/jamiaccount.h
index 4e30c5bdd2..b075620119 100644
--- a/src/jamidht/jamiaccount.h
+++ b/src/jamidht/jamiaccount.h
@@ -276,18 +276,26 @@ public:
         const std::map<std::string, std::string>& details = {},
         const std::shared_ptr<SipTransport>& sipTr = nullptr) override;
 
+    /**
+     * Create incoming SIPCall.
+     * @param[in] from The origin of the call
+     * @param mediaList A list of media
+     * @param sipTr: SIP Transport
+     * @return A shared pointer on the created call.
+     */
+    std::shared_ptr<SIPCall> newIncomingCall(
+        const std::string& from,
+        const std::vector<MediaAttribute>& mediaList,
+        const std::shared_ptr<SipTransport>& sipTr = {}) override;
+
     void onTextMessage(const std::string& id,
                        const std::string& from,
                        const std::map<std::string, std::string>& payloads) override;
 
     virtual bool isTlsEnabled() const override { return true; }
 
-    virtual bool isSrtpEnabled() const { return true; }
-
-    virtual KeyExchangeProtocol getSrtpKeyExchange() const override
-    {
-        return KeyExchangeProtocol::SDES;
-    }
+    bool isSrtpEnabled() const override { return true; }
+    KeyExchangeProtocol getSrtpKeyExchange() const { return KeyExchangeProtocol::SDES; }
 
     virtual bool getSrtpFallback() const override { return false; }
 
diff --git a/src/manager.cpp b/src/manager.cpp
index cb30cc0491..ead8170ba8 100644
--- a/src/manager.cpp
+++ b/src/manager.cpp
@@ -356,6 +356,9 @@ struct Manager::ManagerPimpl
 
     void initAudioDriver();
 
+    void processIncomingCall(Call& incomCall, const std::string& accountId);
+    static void stripSipPrefix(Call& incomCall);
+
     Manager& base_; // pimpl back-pointer
 
     std::shared_ptr<asio::io_context> ioContext_;
@@ -1130,6 +1133,91 @@ Manager::answerCall(const std::string& call_id)
     return result;
 }
 
+bool
+Manager::answerCallWithMedia(const std::string& callId,
+                             const std::vector<DRing::MediaMap>& mediaList)
+{
+    JAMI_INFO("Answer call %s with %lu media", callId.c_str(), mediaList.size());
+
+    bool result = true;
+
+    auto call = getCallFromCallID(callId);
+
+    if (not call) {
+        JAMI_ERR("Call with ID %s does not exist", callId.c_str());
+        return false;
+    }
+
+    if (call->getConnectionState() != Call::ConnectionState::RINGING) {
+        // The call was already answered
+        return true;
+    }
+
+    // If ringing
+    stopTone();
+
+    try {
+        auto mediaAttrList = MediaAttribute::parseMediaList(mediaList);
+        call->answer(mediaAttrList);
+    } catch (const std::runtime_error& e) {
+        JAMI_ERR("%s", e.what());
+        result = false;
+    }
+
+    pimpl_->removeWaitingCall(callId);
+
+    if (not result) {
+        // Abort on error.
+        return false;
+    }
+
+    // Check if the call is part of a conference and
+    // update accordingly.
+    if (isConferenceParticipant(callId))
+        pimpl_->switchCall(call->getConfId());
+    else
+        pimpl_->switchCall(call);
+
+    addAudio(*call);
+
+    // Start recording if set in preference
+    if (audioPreference.getIsAlwaysRecording())
+        toggleRecordingCall(callId);
+
+    return result;
+}
+
+bool
+Manager::answerMediaChangeRequest(const std::string& callId,
+                                  const std::vector<DRing::MediaMap>& mediaList)
+{
+    JAMI_INFO("Answer to media change request on call %s", callId.c_str());
+
+    bool result = true;
+
+    auto call = getCallFromCallID(callId);
+
+    if (not call) {
+        JAMI_ERR("Call with ID %s does not exist", callId.c_str());
+        return false;
+    }
+
+    try {
+        auto mediaAttrList = MediaAttribute::parseMediaList(mediaList);
+        call->answerMediaChangeRequest(mediaAttrList);
+    } catch (const std::runtime_error& e) {
+        JAMI_ERR("%s", e.what());
+        result = false;
+    }
+
+    if (not result) {
+        // Abort on error.
+        return false;
+    }
+
+    return result;
+}
+
 // THREAD=Main
 bool
 Manager::hangupCall(const std::string& callId)
@@ -1932,124 +2020,66 @@ Manager::incomingCallsWaiting()
     return not pimpl_->waitingCalls_.empty();
 }
 
-///////////////////////////////////////////////////////////////////////////////
-// Management of event peer IP-phone
-////////////////////////////////////////////////////////////////////////////////
-// SipEvent Thread
 void
 Manager::incomingCall(Call& call, const std::string& accountId)
 {
-    JAMI_INFO("Incoming call %s on account %s)", call.getCallId().c_str(), accountId.c_str());
-
-    stopTone();
-    const std::string callID(call.getCallId());
-
-    if (accountId.empty())
-        call.setIPToIP(true);
-    else {
-        // strip sip: which is not required and bring confusion with ip to ip calls
-        // when placing new call from history.
-        std::string peerNumber(call.getPeerNumber());
-
-        const char SIP_PREFIX[] = "sip:";
-        size_t startIndex = peerNumber.find(SIP_PREFIX);
-
-        if (startIndex != std::string::npos)
-            call.setPeerNumber(peerNumber.substr(startIndex + sizeof(SIP_PREFIX) - 1));
+    if (not accountId.empty()) {
+        pimpl_->stripSipPrefix(call);
     }
 
-    auto w = call.getAccount();
-    auto account = w.lock();
-    if (!account) {
-        JAMI_ERR("No account detected");
-        return;
-    }
+    std::string from("<" + call.getPeerNumber() + ">");
 
-    if (not hasCurrentCall()) {
-        call.setState(Call::ConnectionState::RINGING);
-#if !defined(RING_UWP) && !(defined(TARGET_OS_IOS) && TARGET_OS_IOS)
-        if (not account->isRendezVous())
-            playRingtone(accountId);
-#endif
+    auto const& account = getAccount(accountId);
+    if (not account) {
+        JAMI_ERR("Incoming call %s on unknown account %s",
+                 call.getCallId().c_str(),
+                 accountId.c_str());
+        return;
     }
 
-    pimpl_->addWaitingCall(callID);
+    if (account->isMultiStreamEnabled()) {
+        // Report incoming call using "CallSignal::IncomingCallWithMedia" signal.
+        auto const& mediaList = MediaAttribute::mediaAttributesToMediaMaps(
+            call.getMediaAttributeList());
 
-    std::string number(call.getPeerNumber());
+        if (mediaList.empty()) {
+            JAMI_WARN("Incoming call %s has an empty media list", call.getCallId().c_str());
+        }
 
-    std::string from("<" + number + ">");
+        JAMI_INFO("Incoming call %s on account %s with %lu media",
+                  call.getCallId().c_str(),
+                  accountId.c_str(),
+                  mediaList.size());
 
-    emitSignal<DRing::CallSignal::IncomingCall>(accountId,
-                                                callID,
-                                                call.getPeerDisplayName() + " " + from);
+        // Report the call using new API.
+        emitSignal<DRing::CallSignal::IncomingCallWithMedia>(accountId,
+                                                             call.getCallId(),
+                                                             call.getPeerDisplayName() + " " + from,
+                                                             mediaList);
+    } else {
+        JAMI_INFO("Incoming call %s on account %s", call.getCallId().c_str(), accountId.c_str());
 
-    auto currentCall = getCurrentCall();
-    if (account->isRendezVous()) {
-        dht::ThreadPool::io().run([this, callID] {
-            answerCall(callID);
-            auto call = getCallFromCallID(callID);
-            auto accountId = call->getAccountId();
-            for (const auto& cid : getCallList()) {
-                if (auto call = getCallFromCallID(cid)) {
-                    if (call->getState() != Call::CallState::ACTIVE)
-                        continue;
-                    if (call->getAccountId() == accountId) {
-                        if (cid != callID) {
-                            if (call->getConfId().empty()) {
-                                joinParticipant(callID, cid, false);
-                            } else {
-                                addParticipant(callID, call->getConfId());
-                            }
-                            return;
-                        }
-                    }
-                }
-            }
-            // First call
-            auto conf = std::make_shared<Conference>();
-            pimpl_->conferenceMap_.emplace(conf->getConfID(), conf);
+        emitSignal<DRing::CallSignal::IncomingCall>(accountId,
+                                                    call.getCallId(),
+                                                    call.getPeerDisplayName() + " " + from);
+    }
 
-            // Bind calls according to their state
-            pimpl_->bindCallToConference(*call, *conf);
-            conf->detach();
+    // Process the call.
+    pimpl_->processIncomingCall(call, accountId);
+}
 
-            emitSignal<DRing::CallSignal::ConferenceCreated>(conf->getConfID());
-        });
-    } else if (pimpl_->autoAnswer_) {
-        dht::ThreadPool::io().run([this, callID] { answerCall(callID); });
-    } else if (currentCall) {
-        // Test if already calling this person
-        if (currentCall->getAccountId() == accountId
-            && currentCall->getPeerNumber() == call.getPeerNumber()) {
-            auto w = currentCall->getAccount();
-            auto account = w.lock();
-            if (!account) {
-                JAMI_ERR("No account detected");
-                return;
-            }
-            auto device_uid = account->getUsername();
-            if (device_uid.find("ring:") == 0) {
-                // NOTE: in case of a SIP call it's already ready to compare
-                device_uid = device_uid.substr(5); // after ring:
-            }
-            auto answerToCall = false;
-            auto downgradeToAudioOnly = currentCall->isAudioOnly() != call.isAudioOnly();
-            if (downgradeToAudioOnly)
-                // Accept the incoming audio only
-                answerToCall = call.isAudioOnly();
-            else
-                // Accept the incoming call from the higher id number
-                answerToCall = (device_uid.compare(call.getPeerNumber()) < 0);
+void
+Manager::mediaChangeRequested(const std::string& callId,
+                              const std::string& accountId,
+                              const std::vector<DRing::MediaMap>& mediaList)
+{
+    JAMI_INFO("Media change request for call %s on account %s with %lu media",
+              callId.c_str(),
+              accountId.c_str(),
+              mediaList.size());
 
-            if (answerToCall) {
-                auto currentCallID = currentCall->getCallId();
-                runOnMainThread([this, currentCallID, callID] {
-                    answerCall(callID);
-                    hangupCall(currentCallID);
-                });
-            }
-        }
-    }
+    // Report the media change request.
+    emitSignal<DRing::CallSignal::MediaChangeRequested>(accountId, callId, mediaList);
 }
 
 void
@@ -2103,10 +2133,12 @@ Manager::sendCallTextMessage(const std::string& callID,
                 JAMI_ERR("no conference associated to call ID %s", callID.c_str());
             }
         } else {
-             try {
+            try {
                 call->sendTextMessage(messages, from);
             } catch (const im::InstantMessageException& e) {
-                JAMI_ERR("Failed to send message to call %s: %s", call->getCallId().c_str(), e.what());
+                JAMI_ERR("Failed to send message to call %s: %s",
+                         call->getCallId().c_str(),
+                         e.what());
             }
         }
     } else {
@@ -2693,6 +2725,118 @@ Manager::ManagerPimpl::initAudioDriver()
             audiodriver_->startStream(type);
 }
 
+// Internal helper method
+void
+Manager::ManagerPimpl::stripSipPrefix(Call& incomCall)
+{
+    // strip sip: which is not required and bring confusion with ip to ip calls
+    // when placing new call from history.
+    std::string peerNumber(incomCall.getPeerNumber());
+
+    const char SIP_PREFIX[] = "sip:";
+    size_t startIndex = peerNumber.find(SIP_PREFIX);
+
+    if (startIndex != std::string::npos)
+        incomCall.setPeerNumber(peerNumber.substr(startIndex + sizeof(SIP_PREFIX) - 1));
+}
+
+// Internal helper method
+void
+Manager::ManagerPimpl::processIncomingCall(Call& incomCall, const std::string& accountId)
+{
+    auto& mgr = Manager::instance();
+    mgr.stopTone();
+
+    auto incomCallId = incomCall.getCallId();
+    auto currentCall = mgr.getCurrentCall();
+
+    auto w = incomCall.getAccount();
+    auto account = w.lock();
+    if (!account) {
+        JAMI_ERR("No account detected");
+        return;
+    }
+
+    if (not mgr.hasCurrentCall()) {
+        incomCall.setState(Call::ConnectionState::RINGING);
+#if !defined(RING_UWP) && !(defined(TARGET_OS_IOS) && TARGET_OS_IOS)
+        if (not account->isRendezVous())
+            mgr.playRingtone(accountId);
+#endif
+    }
+
+    addWaitingCall(incomCallId);
+
+    if (account->isRendezVous()) {
+        dht::ThreadPool::io().run([this, incomCallId] {
+            auto& mgr = Manager::instance();
+            mgr.answerCall(incomCallId);
+            auto call = mgr.getCallFromCallID(incomCallId);
+            auto accountId = call->getAccountId();
+            for (const auto& cid : mgr.getCallList()) {
+                if (auto call = mgr.getCallFromCallID(cid)) {
+                    if (call->getState() != Call::CallState::ACTIVE)
+                        continue;
+                    if (call->getAccountId() == accountId) {
+                        if (cid != incomCallId) {
+                            if (call->getConfId().empty()) {
+                                mgr.joinParticipant(incomCallId, cid, false);
+                            } else {
+                                mgr.addParticipant(incomCallId, call->getConfId());
+                            }
+                            return;
+                        }
+                    }
+                }
+            }
+            // First call
+            auto conf = std::make_shared<Conference>();
+            conferenceMap_.emplace(conf->getConfID(), conf);
+
+            // Bind calls according to their state
+            bindCallToConference(*call, *conf);
+            conf->detach();
+
+            emitSignal<DRing::CallSignal::ConferenceCreated>(conf->getConfID());
+        });
+    } else if (autoAnswer_) {
+        dht::ThreadPool::io().run([incomCallId] { Manager::instance().answerCall(incomCallId); });
+    } else if (currentCall) {
+        // Test if already calling this person
+        if (currentCall->getAccountId() == account->getAccountID()
+            && currentCall->getPeerNumber() == incomCall.getPeerNumber()) {
+            auto w = currentCall->getAccount();
+            auto account = w.lock();
+            if (!account) {
+                JAMI_ERR("No account detected");
+                return;
+            }
+            auto device_uid = account->getUsername();
+            if (device_uid.find("ring:") == 0) {
+                // NOTE: in case of a SIP call it's already ready to compare
+                device_uid = device_uid.substr(5); // after ring:
+            }
+            auto answerToCall = false;
+            auto downgradeToAudioOnly = currentCall->isAudioOnly() != incomCall.isAudioOnly();
+            if (downgradeToAudioOnly)
+                // Accept the incoming audio only
+                answerToCall = incomCall.isAudioOnly();
+            else
+                // Accept the incoming call from the higher id number
+                answerToCall = (device_uid.compare(incomCall.getPeerNumber()) < 0);
+
+            if (answerToCall) {
+                auto currentCallID = currentCall->getCallId();
+                runOnMainThread([currentCallID, incomCallId] {
+                    auto& mgr = Manager::instance();
+                    mgr.answerCall(incomCallId);
+                    mgr.hangupCall(currentCallID);
+                });
+            }
+        }
+    }
+}
+
 AudioFormat
 Manager::hardwareAudioFormatChanged(AudioFormat format)
 {
diff --git a/src/manager.h b/src/manager.h
index 0548d499e0..e1a29bd9ae 100644
--- a/src/manager.h
+++ b/src/manager.h
@@ -174,15 +174,53 @@ public:
      * @param mediaList a list of the new media
      * @return true on success
      */
-    bool requestMediaChange(const std::string& callID,
+    bool requestMediaChange(const std::string& callId,
                             const std::vector<DRing::MediaMap>& mediaList);
 
     /**
      * Functions which occur with a user's action
      * Answer the call
-     * @param id  The call identifier
+     * @param callId
+     */
+    bool answerCall(const std::string& callId);
+
+    /**
+     * Answer a call with a list of media
+     * @param callId
+     * @param mediaList the list of media attributes. The client can
+     * control the media through the attributes. The list should have
+     * the same size as the list reported in the incoming call signal.
+     */
+    bool answerCallWithMedia(const std::string& callId,
+                             const std::vector<DRing::MediaMap>& mediaList);
+
+    /**
+     * Answer a media change request
+     * @param callId
+     * @param mediaList the list of media attributes. The client can
+     * control the media through the attributes. The list should have
+     * the same size as the list reported in the media change request.
+     * The client can ignore the media update request by not calling this
+     * method, or calling it with an empty media list.
+     */
+    bool answerMediaChangeRequest(const std::string& callId,
+                                  const std::vector<DRing::MediaMap>& mediaList = {});
+
+    /**
+     * Handle incoming call and notify user
+     * @param call A call pointer
+     * @param accountId an account id
+     */
+    void incomingCall(Call& call, const std::string& accountId);
+
+    /**
+     * Handle a media change request from the peer
+     * @param callId
+     * @param accountId
      */
-    bool answerCall(const std::string& id);
+    void mediaChangeRequested(const std::string& callId,
+                              const std::string& accountId,
+                              const std::vector<DRing::MediaMap>& mediaList);
 
     /**
      * Functions which occur with a user's action
@@ -382,13 +420,6 @@ public:
      */
     void stopTone();
 
-    /**
-     * Handle incoming call and notify user
-     * @param call A call pointer
-     * @param accountId an account id
-     */
-    void incomingCall(Call& call, const std::string& accountId);
-
     /**
      * Notify the user that the recipient of the call has answered and the put the
      * call in Current state
@@ -992,6 +1023,7 @@ private:
     ~Manager();
     friend class AudioDeviceGuard;
 
+    // Data members
     struct ManagerPimpl;
     std::unique_ptr<ManagerPimpl> pimpl_;
 };
diff --git a/src/sip/sdes_negotiator.cpp b/src/sip/sdes_negotiator.cpp
index fc13ebe20a..199069e3a2 100644
--- a/src/sip/sdes_negotiator.cpp
+++ b/src/sip/sdes_negotiator.cpp
@@ -33,10 +33,6 @@
 
 namespace jami {
 
-SdesNegotiator::SdesNegotiator(const std::vector<CryptoSuiteDefinition>& localCapabilites)
-    : localCapabilities_(localCapabilites)
-{}
-
 std::vector<CryptoAttribute>
 SdesNegotiator::parse(const std::vector<std::string>& attributes)
 {
@@ -144,12 +140,12 @@ SdesNegotiator::parse(const std::vector<std::string>& attributes)
 }
 
 CryptoAttribute
-SdesNegotiator::negotiate(const std::vector<std::string>& attributes) const
+SdesNegotiator::negotiate(const std::vector<std::string>& attributes)
 {
     try {
         auto cryptoAttributeVector(parse(attributes));
         for (const auto& iter_offer : cryptoAttributeVector) {
-            for (const auto& iter_local : localCapabilities_) {
+            for (const auto& iter_local : CryptoSuites) {
                 if (iter_offer.getCryptoSuite() == iter_local.name)
                     return iter_offer;
             }
diff --git a/src/sip/sdes_negotiator.h b/src/sip/sdes_negotiator.h
index 14786592a6..051c768840 100644
--- a/src/sip/sdes_negotiator.h
+++ b/src/sip/sdes_negotiator.h
@@ -84,33 +84,14 @@ static std::vector<CryptoSuiteDefinition> CryptoSuites = {
 class SdesNegotiator
 {
 public:
-    SdesNegotiator() {}
+    SdesNegotiator();
 
-    /**
-     * Constructor for an SDES crypto attributes
-     * negotiator.
-     *
-     * @param capabilites
-     *       A vector of crypto attributes as defined in
-     *       RFC4568. This string will be parsed
-     *       and a crypto context will be created
-     *       from it.
-     */
-    SdesNegotiator(const std::vector<CryptoSuiteDefinition>& capabilites);
+    static CryptoAttribute negotiate(const std::vector<std::string>& attributes);
 
-    CryptoAttribute negotiate(const std::vector<std::string>& attributes) const;
-
-    inline explicit operator bool() const { return not localCapabilities_.empty(); }
+    inline explicit operator bool() const { return not CryptoSuites.empty(); }
 
 private:
     static std::vector<CryptoAttribute> parse(const std::vector<std::string>& attributes);
-
-    /**
-     * A vector list containing the remote attributes.
-     * Multiple crypto lines can be sent, and the
-     * preferred method is then chosen from that list.
-     */
-    std::vector<CryptoSuiteDefinition> localCapabilities_;
 };
 
 } // namespace jami
diff --git a/src/sip/sdp.cpp b/src/sip/sdp.cpp
index 69841d41d5..ee58712677 100644
--- a/src/sip/sdp.cpp
+++ b/src/sip/sdp.cpp
@@ -64,7 +64,6 @@ Sdp::Sdp(const std::string& id)
                })
     , publishedIpAddr_()
     , publishedIpAddrType_()
-    , sdesNego_ {CryptoSuites}
     , telephoneEventPayload_(101) // same as asterisk
     , sessionName_("Call ID " + id)
 {
@@ -213,6 +212,30 @@ Sdp::getMediaDirection(pjmedia_sdp_media* media)
     return MediaDirection::UNKNOWN;
 }
 
+MediaTransport
+Sdp::getMediaTransport(pjmedia_sdp_media* media)
+{
+    if (pj_stricmp2(&media->desc.transport, "RTP/SAVP") == 0)
+        return MediaTransport::RTP_SAVP;
+    else if (pj_stricmp2(&media->desc.transport, "RTP/AVP") == 0)
+        return MediaTransport::RTP_AVP;
+
+    return MediaTransport::UNKNOWN;
+}
+
+std::vector<std::string>
+Sdp::getCrypto(pjmedia_sdp_media* media)
+{
+    std::vector<std::string> crypto;
+    for (unsigned j = 0; j < media->attr_count; j++) {
+        const auto attribute = media->attr[j];
+        if (pj_stricmp2(&attribute->name, "crypto") == 0)
+            crypto.emplace_back(attribute->value.ptr, attribute->value.slen);
+    }
+
+    return crypto;
+}
+
 pjmedia_sdp_media*
 Sdp::addMediaDescription(const MediaAttribute& mediaAttr, bool onHold)
 {
@@ -300,8 +323,8 @@ Sdp::addMediaDescription(const MediaAttribute& mediaAttr, bool onHold)
             const auto accountVideoCodec = std::static_pointer_cast<AccountVideoCodecInfo>(
                 video_codec_list_[i]);
             const auto& profileLevelID = accountVideoCodec->parameters.empty()
-                                            ? libav_utils::DEFAULT_H264_PROFILE_LEVEL_ID
-                                            : accountVideoCodec->parameters;
+                                             ? libav_utils::DEFAULT_H264_PROFILE_LEVEL_ID
+                                             : accountVideoCodec->parameters;
             auto value = fmt::format("fmtp:{} {}", payload, profileLevelID);
             med->attr[med->attr_count++] = pjmedia_sdp_attr_create(memPool_.get(),
                                                                    value.c_str(),
@@ -364,7 +387,9 @@ void
 Sdp::setTelephoneEventRtpmap(pjmedia_sdp_media* med)
 {
     ++med->desc.fmt_count;
-    pj_strdup2(memPool_.get(), &med->desc.fmt[med->desc.fmt_count - 1], std::to_string(telephoneEventPayload_).c_str());
+    pj_strdup2(memPool_.get(),
+               &med->desc.fmt[med->desc.fmt_count - 1],
+               std::to_string(telephoneEventPayload_).c_str());
 
     pjmedia_sdp_attr* attr_rtpmap = static_cast<pjmedia_sdp_attr*>(
         pj_pool_zalloc(memPool_.get(), sizeof(pjmedia_sdp_attr)));
@@ -413,8 +438,22 @@ Sdp::setLocalMediaCapabilities(MediaType type,
     }
 }
 
+const char*
+Sdp::getSdpDirectionStr(SdpDirection direction)
+{
+    if (direction == SdpDirection::LOCAL_OFFER)
+        return "LOCAL_OFFER";
+    if (direction == SdpDirection::LOCAL_ANSWER)
+        return "LOCAL_ANSWER";
+    if (direction == SdpDirection::REMOTE_OFFER)
+        return "REMOTE_OFFER";
+    if (direction == SdpDirection::REMOTE_ANSWER)
+        return "REMOTE_ANSWER";
+    return "NONE";
+}
+
 void
-Sdp::printSession(const pjmedia_sdp_session* session, const char* header, bool isOffer)
+Sdp::printSession(const pjmedia_sdp_session* session, const char* header, SdpDirection direction)
 {
     static constexpr size_t BUF_SZ = 4095;
     sip_utils::register_thread();
@@ -440,16 +479,17 @@ Sdp::printSession(const pjmedia_sdp_session* session, const char* header, bool i
     std::array<char, BUF_SZ + 1> buffer;
     auto size = pjmedia_sdp_print(cloned_session, buffer.data(), BUF_SZ);
     if (size < 0) {
-        JAMI_ERR("%sSDP too big for dump", header);
+        JAMI_ERR("%s SDP too big for dump", header);
         return;
     }
-    JAMI_DBG("[SDP %s] %s%.*s", isOffer ? "offer" : "answer", header, size, buffer.data());
+
+    JAMI_DBG("[SDP %s] %s\n%.*s", getSdpDirectionStr(direction), header, size, buffer.data());
 }
 
 void
-Sdp::createLocalSession(bool offer)
+Sdp::createLocalSession(SdpDirection direction)
 {
-    isOffer_ = offer;
+    sdpDirection_ = direction;
     localSession_ = PJ_POOL_ZALLOC_T(memPool_.get(), pjmedia_sdp_session);
     localSession_->conn = PJ_POOL_ZALLOC_T(memPool_.get(), pjmedia_sdp_conn);
 
@@ -498,7 +538,7 @@ Sdp::createOffer(const std::vector<MediaAttribute>& mediaList)
 
     JAMI_DBG("Creating SDP offer with %lu medias", mediaList.size());
 
-    createLocalSession(true);
+    createLocalSession(SdpDirection::LOCAL_OFFER);
 
     if (validateSession() != PJ_SUCCESS) {
         JAMI_ERR("Failed to create initial offer");
@@ -522,26 +562,35 @@ Sdp::createOffer(const std::vector<MediaAttribute>& mediaList)
         return false;
     }
 
+    Sdp::printSession(localSession_, "Local session (initial):", sdpDirection_);
+
     return true;
 }
 
 void
-Sdp::receiveOffer(const pjmedia_sdp_session* remote, const std::vector<MediaAttribute>& mediaList)
+Sdp::setReceivedOffer(const pjmedia_sdp_session* remote)
 {
     if (remote == nullptr) {
         JAMI_ERR("Remote session is NULL");
         return;
     }
+    remoteSession_ = pjmedia_sdp_session_clone(memPool_.get(), remote);
+}
 
-    JAMI_DBG("Creating SDP answer with %lu media", mediaList.size());
+bool
+Sdp::processIncomingOffer(const std::vector<MediaAttribute>& mediaList)
+{
+    assert(remoteSession_);
 
-    printSession(remote, "Remote session:\n", isOffer_);
+    JAMI_DBG("Processing received offer for [%s] with %lu", sessionName_.c_str(), mediaList.size());
+
+    printSession(remoteSession_, "Remote session:", SdpDirection::REMOTE_OFFER);
 
     if (not localSession_) {
-        createLocalSession(false);
+        createLocalSession(SdpDirection::LOCAL_ANSWER);
         if (validateSession() != PJ_SUCCESS) {
             JAMI_ERR("Failed to create initial offer");
-            return;
+            return false;
         }
     }
 
@@ -551,30 +600,33 @@ Sdp::receiveOffer(const pjmedia_sdp_session* remote, const std::vector<MediaAttr
         localSession_->media[localSession_->media_count++] = addMediaDescription(media);
     }
 
-    printSession(localSession_, "Local session:\n", not isOffer_);
+    printSession(localSession_, "Local session:\n", sdpDirection_);
 
     if (validateSession() != PJ_SUCCESS) {
         JAMI_ERR("Failed to add medias");
-        return;
+        return false;
     }
 
-    remoteSession_ = pjmedia_sdp_session_clone(memPool_.get(), remote);
-
     if (pjmedia_sdp_neg_create_w_remote_offer(memPool_.get(),
                                               localSession_,
                                               remoteSession_,
                                               &negotiator_)
         != PJ_SUCCESS) {
         JAMI_ERR("Failed to initialize media negotiation");
+        return false;
     }
+
+    return true;
 }
 
-void
+bool
 Sdp::startNegotiation()
 {
+    JAMI_DBG("Starting media negotiation for [%s]", sessionName_.c_str());
+
     if (negotiator_ == NULL) {
         JAMI_ERR("Can't start negotiation with invalid negotiator");
-        return;
+        return false;
     }
 
     const pjmedia_sdp_session* active_local;
@@ -582,29 +634,34 @@ Sdp::startNegotiation()
 
     if (pjmedia_sdp_neg_get_state(negotiator_) != PJMEDIA_SDP_NEG_STATE_WAIT_NEGO) {
         JAMI_WARN("Negotiator not in right state for negotiation");
-        return;
+        return false;
     }
 
-    if (pjmedia_sdp_neg_negotiate(memPool_.get(), negotiator_, 0) != PJ_SUCCESS)
-        return;
+    if (pjmedia_sdp_neg_negotiate(memPool_.get(), negotiator_, 0) != PJ_SUCCESS) {
+        JAMI_ERR("Failed to start media negotiation");
+        return false;
+    }
 
     if (pjmedia_sdp_neg_get_active_local(negotiator_, &active_local) != PJ_SUCCESS)
         JAMI_ERR("Could not retrieve local active session");
-    else {
-        setActiveLocalSdpSession(active_local);
-        if (active_local != nullptr) {
-            Sdp::printSession(active_local, "Local session:\n", isOffer_);
-        }
+
+    setActiveLocalSdpSession(active_local);
+
+    if (active_local != nullptr) {
+        Sdp::printSession(active_local, "Local active session:", sdpDirection_);
     }
 
-    if (pjmedia_sdp_neg_get_active_remote(negotiator_, &active_remote) != PJ_SUCCESS)
+    if (pjmedia_sdp_neg_get_active_remote(negotiator_, &active_remote) != PJ_SUCCESS
+        or active_remote == nullptr) {
         JAMI_ERR("Could not retrieve remote active session");
-    else {
-        setActiveRemoteSdpSession(active_remote);
-        if (active_remote != nullptr) {
-            Sdp::printSession(active_remote, "Remote active session:\n", not isOffer_);
-        }
+        return false;
     }
+
+    setActiveRemoteSdpSession(active_remote);
+
+    Sdp::printSession(active_remote, "Remote active session:", sdpDirection_);
+
+    return true;
 }
 
 std::string
@@ -799,7 +856,7 @@ Sdp::getMediaDescriptions(const pjmedia_sdp_session* session, bool remote) const
             if (pj_stricmp2(&attribute->name, "crypto") == 0)
                 crypto.emplace_back(attribute->value.ptr, attribute->value.slen);
         }
-        descr.crypto = sdesNego_.negotiate(crypto);
+        descr.crypto = SdesNegotiator::negotiate(crypto);
     }
     return ret;
 }
@@ -936,4 +993,51 @@ Sdp::clearIce(pjmedia_sdp_session* session)
     }
 }
 
+std::vector<MediaAttribute>
+Sdp::getMediaAttributeListFromSdp(const pjmedia_sdp_session* sdpSession)
+{
+    if (sdpSession == nullptr) {
+        return {};
+    }
+
+    std::vector<MediaAttribute> mediaList;
+    for (unsigned idx = 0; idx < sdpSession->media_count; idx++) {
+        mediaList.emplace_back(MediaAttribute {});
+        auto& mediaAttr = mediaList.back();
+
+        auto const& media = sdpSession->media[idx];
+
+        // Get media type.
+        if (!pj_stricmp2(&media->desc.media, "audio"))
+            mediaAttr.type_ = MediaType::MEDIA_AUDIO;
+        else if (!pj_stricmp2(&media->desc.media, "video"))
+            mediaAttr.type_ = MediaType::MEDIA_VIDEO;
+        else {
+            JAMI_WARN("Media#%u only 'audio' and 'video' types are supported!", idx);
+            // Keep the media in the list but dont bother parsing the attributes
+            continue;
+        }
+
+        // Get mute state.
+        auto direction = getMediaDirection(media);
+        mediaAttr.muted_ = direction != MediaDirection::SENDRECV
+                           and direction != MediaDirection::SENDONLY;
+
+        // Get transport.
+        auto transp = getMediaTransport(media);
+        if (transp == MediaTransport::UNKNOWN) {
+            JAMI_WARN("Media#%u could not determine transport type!", idx);
+        }
+
+        // A media is secure if the transport is of type RTP/SAVP
+        // and the crypto materials are present.
+        mediaAttr.secure_ = transp == MediaTransport::RTP_SAVP and not getCrypto(media).empty();
+
+        mediaAttr.label_ = mediaAttr.type_ == MediaType::MEDIA_AUDIO ? "audio_" : "video_";
+        mediaAttr.label_ += std::to_string(idx);
+    }
+
+    return mediaList;
+}
+
 } // namespace jami
diff --git a/src/sip/sdp.h b/src/sip/sdp.h
index 09bc16849d..788b14c15e 100644
--- a/src/sip/sdp.h
+++ b/src/sip/sdp.h
@@ -61,6 +61,8 @@ public:
     {}
 };
 
+enum class SdpDirection { LOCAL_OFFER, LOCAL_ANSWER, REMOTE_OFFER, REMOTE_ANSWER, NONE };
+
 class Sdp
 {
 public:
@@ -119,19 +121,19 @@ public:
      */
     bool createOffer(const std::vector<MediaAttribute>& mediaList);
 
-    /*
-     * On receiving an invite outside a dialog, build the local offer and create the
-     * SDP negotiator instance with the remote offer.
+    void setReceivedOffer(const pjmedia_sdp_session* remote);
+
+    /**
+     * Build a new SDP answer using mediaList.
      *
-     * @param remote    The remote offer
+     * @param mediaList The list of media attributes to build the answer
      */
-    void receiveOffer(const pjmedia_sdp_session* remote,
-                      const std::vector<MediaAttribute>& mediaList);
+    bool processIncomingOffer(const std::vector<MediaAttribute>& mediaList);
 
     /**
      * Start the sdp negotiation.
      */
-    void startNegotiation();
+    bool startNegotiation();
 
     /**
      * Remove all media in the session media vector.
@@ -180,6 +182,9 @@ public:
     std::vector<MediaDescription> getMediaDescriptions(const pjmedia_sdp_session* session,
                                                        bool remote) const;
 
+    static std::vector<MediaAttribute> getMediaAttributeListFromSdp(
+        const pjmedia_sdp_session* sdpSession);
+
     using MediaSlot = std::pair<MediaDescription, MediaDescription>;
     std::vector<MediaSlot> getMediaSlots() const;
 
@@ -194,12 +199,14 @@ public:
     std::vector<std::string> getIceCandidates(unsigned media_index) const;
 
     void clearIce();
-    // True if the session is an offer and false if it's an answer.
-    bool isOffer() const { return isOffer_; };
+    SdpDirection getSdpDirection() const { return sdpDirection_; };
+    static const char* getSdpDirectionStr(SdpDirection direction);
 
     /// \brief Log the given session
     /// \note crypto lines with are removed for security
-    static void printSession(const pjmedia_sdp_session* session, const char* header, bool offer);
+    static void printSession(const pjmedia_sdp_session* session,
+                             const char* header,
+                             SdpDirection direction);
 
 private:
     friend class test::SDPTest;
@@ -264,15 +271,13 @@ private:
     uint16_t localVideoDataPort_ {0};
     uint16_t localVideoControlPort_ {0};
 
-    SdesNegotiator sdesNego_;
-
     unsigned int telephoneEventPayload_;
 
     // The call Id of the SDP owner
     std::string sessionName_ {};
 
     // Offer/Answer flag.
-    bool isOffer_ {true};
+    SdpDirection sdpDirection_ {SdpDirection::NONE};
 
     /*
      * Build the sdp media section
@@ -281,7 +286,6 @@ private:
     pjmedia_sdp_media* addMediaDescription(const MediaAttribute& mediaAttr, bool onHold = false);
 
     // Determine media direction
-    char const* mediaDirection(bool enabled, bool onHold, bool muted);
     char const* mediaDirection(MediaType type, bool onHold);
     char const* mediaDirection(const MediaAttribute& localAttr, const MediaAttribute& peerAttr);
 
@@ -301,7 +305,7 @@ private:
     /*
      * Create a new SDP
      */
-    void createLocalSession(bool offer);
+    void createLocalSession(SdpDirection direction);
 
     /*
      * Validate SDP
diff --git a/src/sip/sipaccount.cpp b/src/sip/sipaccount.cpp
index 22cbed8dbd..543c1bfa96 100644
--- a/src/sip/sipaccount.cpp
+++ b/src/sip/sipaccount.cpp
@@ -183,6 +183,16 @@ SIPAccount::newIncomingCall(const std::string& from UNUSED,
     return manager.callFactory.newSipCall(shared(), Call::CallType::INCOMING, details);
 }
 
+std::shared_ptr<SIPCall>
+SIPAccount::newIncomingCall(const std::string& from UNUSED,
+                            const std::vector<MediaAttribute>& mediaAttrList,
+                            const std::shared_ptr<SipTransport>&)
+{
+    return Manager::instance().callFactory.newSipCall(shared(),
+                                                      Call::CallType::INCOMING,
+                                                      mediaAttrList);
+}
+
 template<>
 std::shared_ptr<SIPCall>
 SIPAccount::newOutgoingCall(std::string_view toUrl,
@@ -225,7 +235,6 @@ SIPAccount::newOutgoingCall(std::string_view toUrl,
 
     auto toUri = getToUri(to);
     call->initIceMediaTransport(true);
-    call->setIPToIP(isIP2IP());
     call->setPeerNumber(toUri);
     call->setPeerUri(toUri);
 
@@ -316,7 +325,6 @@ SIPAccount::newOutgoingCall(std::string_view toUrl, const std::vector<MediaAttri
 
     auto toUri = getToUri(to);
     call->initIceMediaTransport(true);
-    call->setIPToIP(isIP2IP());
     call->setPeerNumber(toUri);
     call->setPeerUri(toUri);
 
@@ -436,7 +444,7 @@ bool
 SIPAccount::SIPStartCall(std::shared_ptr<SIPCall>& call)
 {
     // Add Ice headers to local SDP if ice transport exist
-    call->setupLocalSDPFromIce();
+    call->addLocalIceAttributes();
 
     const std::string& toUri(call->getPeerNumber()); // expecting a fully well formed sip uri
     pj_str_t pjTo = sip_utils::CONST_PJ_STR(toUri);
@@ -1379,7 +1387,6 @@ SIPAccount::initTlsConfiguration()
     avail_ciphers.resize(cipherNum);
 
     ciphers_.clear();
-#if PJ_VERSION_NUM > (2 << 24 | 2 << 16)
     std::string_view stream(tlsCiphers_), item;
     while (jami::getline(stream, item, ' ')) {
         std::string cipher(item);
@@ -1390,7 +1397,7 @@ SIPAccount::initTlsConfiguration()
         } else
             JAMI_ERR("Invalid cipher: %s", cipher.c_str());
     }
-#endif
+
     ciphers_.erase(std::remove_if(ciphers_.begin(),
                                   ciphers_.end(),
                                   [&](pj_ssl_cipher c) {
diff --git a/src/sip/sipaccount.h b/src/sip/sipaccount.h
index 15009f1b7a..5e5484b523 100644
--- a/src/sip/sipaccount.h
+++ b/src/sip/sipaccount.h
@@ -373,13 +373,6 @@ public:
 
     virtual bool isTlsEnabled() const override { return tlsEnable_; }
 
-    virtual KeyExchangeProtocol getSrtpKeyExchange() const override
-    {
-        if (tlsEnable_ && srtpKeyExchange_ == KeyExchangeProtocol::NONE)
-            return KeyExchangeProtocol::SDES;
-        return srtpKeyExchange_;
-    }
-
     virtual bool getSrtpFallback() const override { return srtpFallback_; }
 
     void setReceivedParameter(const std::string& received)
@@ -491,6 +484,18 @@ public:
                                              const std::map<std::string, std::string>& details = {},
                                              const std::shared_ptr<SipTransport>& = nullptr) override;
 
+    /**
+     * Create incoming SIPCall.
+     * @param[in] from The origin of the call
+     * @param mediaList A list of media
+     * @param sipTr: SIP Transport
+     * @return A shared pointer on the created call.
+     */
+    std::shared_ptr<SIPCall> newIncomingCall(
+        const std::string& from,
+        const std::vector<MediaAttribute>& mediaList,
+        const std::shared_ptr<SipTransport>& sipTr = {}) override;
+
     void onRegister(pjsip_regc_cbparam* param);
 
     virtual void sendTextMessage(const std::string& to,
@@ -541,7 +546,7 @@ private:
     bool hostnameMatch(std::string_view hostname) const;
     bool proxyMatch(std::string_view hostname) const;
 
-    bool isSrtpEnabled() const { return srtpKeyExchange_ != KeyExchangeProtocol::NONE; }
+    bool isSrtpEnabled() const override { return srtpKeyExchange_ != KeyExchangeProtocol::NONE; }
 
     /**
      * Callback called by the transport layer when the registration
diff --git a/src/sip/sipaccountbase.cpp b/src/sip/sipaccountbase.cpp
index 92f5ce85ae..9f36efaa81 100644
--- a/src/sip/sipaccountbase.cpp
+++ b/src/sip/sipaccountbase.cpp
@@ -683,8 +683,7 @@ std::vector<MediaAttribute>
 SIPAccountBase::createDefaultMediaList(bool addVideo, bool onHold)
 {
     std::vector<MediaAttribute> mediaList;
-    bool secure = getSrtpKeyExchange() == KeyExchangeProtocol::SDES;
-
+    bool secure = isSrtpEnabled();
     // Add audio and DTMF events
     mediaList.emplace_back(
         MediaAttribute(MediaType::MEDIA_AUDIO, onHold, secure, false, "", "main audio"));
diff --git a/src/sip/sipaccountbase.h b/src/sip/sipaccountbase.h
index 62d734605d..359df70b96 100644
--- a/src/sip/sipaccountbase.h
+++ b/src/sip/sipaccountbase.h
@@ -145,6 +145,18 @@ public:
         const std::shared_ptr<SipTransport>& = nullptr)
         = 0;
 
+    /**
+     * Create incoming SIPCall.
+     * @param[in] from The origin of the call
+     * @param mediaList A list of media
+     * @param sipTr: SIP Transport
+     * @return A shared pointer on the created call.
+     */
+    virtual std::shared_ptr<SIPCall> newIncomingCall(const std::string& from,
+                                                     const std::vector<MediaAttribute>& mediaList,
+                                                     const std::shared_ptr<SipTransport>& sipTr = {})
+        = 0;
+
     virtual bool isStunEnabled() const { return false; }
 
     virtual pj_str_t getStunServerName() const { return pj_str_t {nullptr, 0}; };
@@ -210,7 +222,7 @@ public:
      */
     bool getPublishedSameasLocal() const { return publishedSameasLocal_; }
 
-    virtual KeyExchangeProtocol getSrtpKeyExchange() const = 0;
+    virtual bool isSrtpEnabled() const = 0;
 
     virtual bool getSrtpFallback() const = 0;
 
diff --git a/src/sip/sipcall.cpp b/src/sip/sipcall.cpp
index b7a18b90c3..fd0c5c6b27 100644
--- a/src/sip/sipcall.cpp
+++ b/src/sip/sipcall.cpp
@@ -124,7 +124,21 @@ SIPCall::SIPCall(const std::shared_ptr<SIPAccountBase>& account,
                                     account->getActiveAccountCodecInfoList(MEDIA_VIDEO));
 #endif
 
-    initMediaStreams(mediaAttrList);
+    std::vector<MediaAttribute> mediaList;
+    if (mediaAttrList.size() > 0) {
+        mediaList = mediaAttrList;
+    } else {
+        // Handle incoming call without media offer.
+        JAMI_WARN("[call:%s] No media offered in the incoming invite. Will answer with audio-only",
+                  getCallId().c_str());
+        mediaList = getSIPAccount()->createDefaultMediaList(false, getState() == CallState::HOLD);
+    }
+
+    JAMI_DBG("[call:%s] Create a new SIP call with %lu medias",
+             getCallId().c_str(),
+             mediaList.size());
+
+    initMediaStreams(mediaList);
 }
 
 SIPCall::~SIPCall()
@@ -418,19 +432,20 @@ SIPCall::setTransport(const std::shared_ptr<SipTransport>& t)
 void
 SIPCall::requestReinvite()
 {
-    JAMI_DBG("[call:%s] Requesting a SIP re-invite", getCallId().c_str());
+    JAMI_DBG("[call:%s] Sending a SIP re-invite to request media change", getCallId().c_str());
+
     if (isWaitingForIceAndMedia_) {
         remainingRequest_ = Request::SwitchInput;
     } else {
         auto mediaList = getMediaAttributeList();
         assert(not mediaList.empty());
 
-        // TODO.
-        // We should erase existing streams only after the new ones were
-        // successfully negotiated, and make a live switch. But for now,
-        // we reset all stream before creating new ones.
+        // TODO. We should erase existing streams only after the new
+        // ones were successfully negotiated, and make a live switch. But
+        // for now, we reset all streams before creating new ones.
         rtpStreams_.clear();
         initMediaStreams(mediaList);
+
         if (SIPSessionReinvite(mediaList) == PJ_SUCCESS) {
             isWaitingForIceAndMedia_ = true;
         }
@@ -452,7 +467,7 @@ SIPCall::SIPSessionReinvite(const std::vector<MediaAttribute>& mediaAttrList)
     if (not inviteSession_ or inviteSession_->invite_tsx)
         return PJ_SUCCESS;
 
-    JAMI_DBG("[call:%s] Processing reINVITE (state=%s)",
+    JAMI_DBG("[call:%s] Preparing and sending a re-invite (state=%s)",
              getCallId().c_str(),
              pjsip_inv_state_name(inviteSession_->state));
 
@@ -470,7 +485,7 @@ SIPCall::SIPSessionReinvite(const std::vector<MediaAttribute>& mediaAttrList)
         return !PJ_SUCCESS;
 
     if (initIceMediaTransport(true))
-        setupLocalSDPFromIce();
+        addLocalIceAttributes();
 
     pjsip_tx_data* tdata;
     auto local_sdp = sdp_->getLocalSdpSession();
@@ -605,6 +620,8 @@ SIPCall::setMute(bool state)
 void
 SIPCall::setInviteSession(pjsip_inv_session* inviteSession)
 {
+    std::lock_guard<std::recursive_mutex> lk {callMutex_};
+
     if (inviteSession == nullptr and inviteSession_) {
         JAMI_DBG("[call:%s] Delete current invite session", getCallId().c_str());
     } else if (inviteSession != nullptr) {
@@ -720,6 +737,171 @@ SIPCall::answer()
     setState(CallState::ACTIVE, ConnectionState::CONNECTED);
 }
 
+void
+SIPCall::answer(const std::vector<MediaAttribute>& mediaAttrList)
+{
+    std::lock_guard<std::recursive_mutex> lk {callMutex_};
+    auto account = getSIPAccount();
+    if (not account) {
+        JAMI_ERR("No account detected");
+        return;
+    }
+    if (mediaAttrList.size() != rtpStreams_.size()) {
+        JAMI_ERR("Media list size %lu in answer does not match. Expected %lu",
+                 mediaAttrList.size(),
+                 rtpStreams_.size());
+        return;
+    }
+
+    // Apply the media attributes provided by the user.
+    for (size_t idx = 0; idx < mediaAttrList.size(); idx++) {
+        rtpStreams_[idx].mediaAttribute_ = std::make_shared<MediaAttribute>(mediaAttrList[idx]);
+    }
+
+    if (not inviteSession_)
+        throw VoipLinkException("[call:" + getCallId()
+                                + "] answer: no invite session for this call");
+
+    if (not inviteSession_->neg) {
+        JAMI_WARN("[call:%s] Negotiator is NULL, we've received an INVITE without an SDP",
+                  getCallId().c_str());
+        pjmedia_sdp_session* dummy = 0;
+        Manager::instance().sipVoIPLink().createSDPOffer(inviteSession_.get(), &dummy);
+
+        if (account->isStunEnabled())
+            updateSDPFromSTUN();
+    }
+
+    pj_str_t contact(account->getContactHeader(transport_ ? transport_->get() : nullptr));
+    setContactHeader(&contact);
+
+    if (!inviteSession_->last_answer)
+        throw std::runtime_error("Should only be called for initial answer");
+
+    // TODO. We need a test for this scenario.
+    // How to Check if this use-case is not broken by the changes.
+
+    // Answer with an SDP offer if the initial invite was empty.
+    // SIP protocol allows a UA to send a call invite without SDP.
+    // In this case, if the callee wants to accept the call, it must
+    // provide an SDP offer in the answer. The caller will then send
+    // its SDP answer in the SIP OK (200) message.
+    pjsip_tx_data* tdata;
+    if (pjsip_inv_answer(inviteSession_.get(),
+                         PJSIP_SC_OK,
+                         NULL,
+                         not inviteSession_->neg ? sdp_->getLocalSdpSession() : NULL,
+                         &tdata)
+        != PJ_SUCCESS)
+        throw std::runtime_error("Could not init invite request answer (200 OK)");
+
+    if (contactHeader_.slen) {
+        JAMI_DBG("[call:%s] Answering with contact header: %.*s",
+                 getCallId().c_str(),
+                 (int) contactHeader_.slen,
+                 contactHeader_.ptr);
+        sip_utils::addContactHeader(&contactHeader_, tdata);
+    }
+
+    // Add user-agent header
+    sip_utils::addUserAgentHeader(account->getUserAgentName(), tdata);
+
+    if (pjsip_inv_send_msg(inviteSession_.get(), tdata) != PJ_SUCCESS) {
+        setInviteSession();
+        throw std::runtime_error("Could not send invite request answer (200 OK)");
+    }
+
+    setState(CallState::ACTIVE, ConnectionState::CONNECTED);
+}
+
+void
+SIPCall::answerMediaChangeRequest(const std::vector<MediaAttribute>& mediaAttrList)
+{
+    std::lock_guard<std::recursive_mutex> lk {callMutex_};
+
+    auto account = getSIPAccount();
+    if (not account) {
+        JAMI_ERR("[call:%s] No account detected", getCallId().c_str());
+        return;
+    }
+
+    if (mediaAttrList.empty()) {
+        JAMI_DBG("[call:%s] Media list size is empty. Ignoring the media change request",
+                 getCallId().c_str());
+        return;
+    }
+
+    if (not sdp_) {
+        JAMI_ERR("[call:%s] No valid SDP session", getCallId().c_str());
+        return;
+    }
+
+    JAMI_DBG("[call:%s] Current media", getCallId().c_str());
+    unsigned idx = 0;
+    for (auto const& rtp : rtpStreams_) {
+        JAMI_DBG("[call:%s] Media @%u: %s",
+                 getCallId().c_str(),
+                 idx++,
+                 rtp.mediaAttribute_->toString(true).c_str());
+    }
+
+    JAMI_DBG("[call:%s] Answering to media change request with new media", getCallId().c_str());
+    idx = 0;
+    for (auto const& newMediaAttr : mediaAttrList) {
+        JAMI_DBG("[call:%s] Media @%u: %s",
+                 getCallId().c_str(),
+                 idx++,
+                 newMediaAttr.toString(true).c_str());
+    }
+
+    updateAllMediaStreams(mediaAttrList);
+
+    if (not sdp_->processIncomingOffer(mediaAttrList)) {
+        JAMI_WARN("[call:%s] Could not process the new offer, ignoring", getCallId().c_str());
+        return;
+    }
+
+    if (not sdp_->getRemoteSdpSession()) {
+        JAMI_ERR("[call:%s] No valid remote SDP session", getCallId().c_str());
+        return;
+    }
+
+    setupLocalIce();
+
+    if (not sdp_->startNegotiation()) {
+        JAMI_ERR("[call:%s] Could not start media negotiation for a re-invite request",
+                 getCallId().c_str());
+        return;
+    }
+
+    if (pjsip_inv_set_sdp_answer(inviteSession_.get(), sdp_->getLocalSdpSession()) != PJ_SUCCESS) {
+        JAMI_ERR("[call:%s] Could not start media negotiation for a re-invite request",
+                 getCallId().c_str());
+        return;
+    }
+
+    pjsip_tx_data* tdata;
+    if (pjsip_inv_answer(inviteSession_.get(), PJSIP_SC_OK, NULL, NULL, &tdata) != PJ_SUCCESS) {
+        JAMI_ERR("[call:%s] Could not init answer to a re-invite request", getCallId().c_str());
+        return;
+    }
+
+    if (contactHeader_.slen) {
+        sip_utils::addContactHeader(&contactHeader_, tdata);
+    }
+
+    // Add user-agent header
+    sip_utils::addUserAgentHeader(account->getUserAgentName(), tdata);
+
+    if (pjsip_inv_send_msg(inviteSession_.get(), tdata) != PJ_SUCCESS) {
+        JAMI_ERR("[call:%s] Could not send answer to a re-invite request", getCallId().c_str());
+        setInviteSession();
+        return;
+    }
+
+    JAMI_DBG("[call:%s] Successfully answered the media change request", getCallId().c_str());
+}
+
 void
 SIPCall::hangup(int reason)
 {
@@ -1257,7 +1439,7 @@ SIPCall::onPeerRinging()
 }
 
 void
-SIPCall::setupLocalSDPFromIce()
+SIPCall::addLocalIceAttributes()
 {
     auto media_tr = getIceMediaTransport();
 
@@ -1288,9 +1470,9 @@ SIPCall::setupLocalSDPFromIce()
     unsigned idx = 0;
     unsigned compId = 1;
     for (auto const& stream : rtpStreams_) {
-        JAMI_DBG("[call:%s] add ICE local candidates for media [%s] at index %u",
+        JAMI_DBG("[call:%s] add ICE local candidates for media [%s] @ %u",
                  getCallId().c_str(),
-                 stream.mediaAttribute_->label_.c_str(),
+                 stream.mediaAttribute_->toString().c_str(),
                  idx);
         // RTP
         sdp_->addIceCandidates(idx, media_tr->getLocalCandidates(compId));
@@ -1391,6 +1573,50 @@ SIPCall::initMediaStreams(const std::vector<MediaAttribute>& mediaAttrList)
     return rtpStreams_.size();
 }
 
+bool
+SIPCall::isAudioMuted() const
+{
+    std::function<bool(const RtpStream& stream)> mutedCheck = [](auto const& stream) {
+        return (stream.mediaAttribute_->type_ == MediaType::MEDIA_AUDIO
+                and not stream.mediaAttribute_->muted_);
+    };
+
+    const auto iter = std::find_if(rtpStreams_.begin(), rtpStreams_.end(), mutedCheck);
+
+    return iter != rtpStreams_.end();
+}
+
+bool
+SIPCall::hasVideo() const
+{
+#ifdef ENABLE_VIDEO
+    std::function<bool(const RtpStream& stream)> videoCheck = [](auto const& stream) {
+        return stream.mediaAttribute_->type_ == MediaType::MEDIA_VIDEO;
+    };
+
+    const auto iter = std::find_if(rtpStreams_.begin(), rtpStreams_.end(), videoCheck);
+
+    return iter != rtpStreams_.end();
+#else
+    return false;
+#endif
+}
+
+bool
+SIPCall::isVideoMuted() const
+{
+#ifdef ENABLE_VIDEO
+    std::function<bool(const RtpStream& stream)> mutedCheck = [](auto const& stream) {
+        return (stream.mediaAttribute_->type_ == MediaType::MEDIA_VIDEO
+                and not stream.mediaAttribute_->muted_);
+    };
+    const auto iter = std::find_if(rtpStreams_.begin(), rtpStreams_.end(), mutedCheck);
+    return iter != rtpStreams_.end();
+#else
+    return true;
+#endif
+}
+
 void
 SIPCall::startAllMedia()
 {
@@ -1480,18 +1706,6 @@ SIPCall::startAllMedia()
     }
 
 #ifdef ENABLE_VIDEO
-    // Check if there is an un-muted video stream
-    auto const& iter = std::find_if(rtpStreams_.begin(),
-                                    rtpStreams_.end(),
-                                    [](const RtpStream& stream) {
-                                        return stream.mediaAttribute_->type_
-                                                   == MediaType::MEDIA_VIDEO
-                                               and not stream.mediaAttribute_->muted_;
-                                    });
-
-    // Video is muted if all video streams are muted.
-    isVideoMuted_ = iter == rtpStreams_.end();
-
     if (!isVideoEnabled && !getConfId().empty()) {
         auto conference = Manager::instance().getConferenceFromID(getConfId());
         conference->attachVideo(getReceiveVideoFrameActiveWriter().get(), getCallId());
@@ -1609,8 +1823,8 @@ SIPCall::muteMedia(const std::string& mediaType, bool mute)
     requestMediaChange(mediaList);
 }
 
-bool
-SIPCall::updateMediaStreamInternal(const MediaAttribute& newMediaAttr, size_t streamIdx)
+void
+SIPCall::updateMediaStream(const MediaAttribute& newMediaAttr, size_t streamIdx)
 {
     assert(streamIdx < rtpStreams_.size());
 
@@ -1620,8 +1834,6 @@ SIPCall::updateMediaStreamInternal(const MediaAttribute& newMediaAttr, size_t st
     auto const& mediaAttr = rtpStream.mediaAttribute_;
     assert(mediaAttr);
 
-    bool requireReinvite = false;
-
     if (newMediaAttr.muted_ == mediaAttr->muted_) {
         // Nothing to do. Already in the desired state.
         JAMI_DBG("[call:%s] [%s] already %s",
@@ -1629,7 +1841,7 @@ SIPCall::updateMediaStreamInternal(const MediaAttribute& newMediaAttr, size_t st
                  mediaAttr->label_.c_str(),
                  mediaAttr->muted_ ? "muted " : "un-muted ");
 
-        return requireReinvite;
+        return;
     }
 
     // Update
@@ -1646,24 +1858,19 @@ SIPCall::updateMediaStreamInternal(const MediaAttribute& newMediaAttr, size_t st
         if (not isSubcall())
             emitSignal<DRing::CallSignal::AudioMuted>(getCallId(), mediaAttr->muted_);
         setMute(mediaAttr->muted_);
-        return requireReinvite;
+        return;
     }
 
 #ifdef ENABLE_VIDEO
     if (mediaAttr->type_ == MediaType::MEDIA_VIDEO) {
         if (not isSubcall())
             emitSignal<DRing::CallSignal::VideoMuted>(getCallId(), mediaAttr->muted_);
-
-        // Changes in video attributes always trigger a re-invite.
-        requireReinvite = true;
     }
 #endif
-
-    return requireReinvite;
 }
 
-bool
-SIPCall::requestMediaChange(const std::vector<MediaAttribute>& mediaAttrList)
+void
+SIPCall::updateAllMediaStreams(const std::vector<MediaAttribute>& mediaAttrList)
 {
     JAMI_DBG("[call:%s] New local medias", getCallId().c_str());
 
@@ -1671,34 +1878,78 @@ SIPCall::requestMediaChange(const std::vector<MediaAttribute>& mediaAttrList)
     for (auto const& newMediaAttr : mediaAttrList) {
         JAMI_DBG("[call:%s] Media @%u: %s",
                  getCallId().c_str(),
-                 idx,
+                 idx++,
                  newMediaAttr.toString(true).c_str());
-        idx++;
     }
 
-    bool reinviteRequired = false;
+    JAMI_DBG("[call:%s] Updating local media streams", getCallId().c_str());
+
+    for (auto const& newAttr : mediaAttrList) {
+        auto streamIdx = findRtpStreamIndex(newAttr.label_);
 
-    for (auto const& newMediaAttr : mediaAttrList) {
-        auto streamIdx = findRtpStreamIndex(newMediaAttr.label_);
         if (streamIdx == rtpStreams_.size()) {
             // Media does not exist, add a new one.
-            addMediaStream(newMediaAttr);
+            addMediaStream(newAttr);
             auto& stream = rtpStreams_.back();
             createRtpSession(stream);
-            JAMI_DBG(
-                "[call:%s] Added a new media stream - type: %s - @ index %lu. Require a re-invite",
-                getCallId().c_str(),
-                stream.mediaAttribute_->type_ == MediaType::MEDIA_AUDIO ? "AUDIO" : "VIDEO",
-                streamIdx);
-            // Needs a new SDP and reinvite.
-            reinviteRequired = true;
+            JAMI_DBG("[call:%s] Added a new media stream [%s] @ index %lu",
+                     getCallId().c_str(),
+                     stream.mediaAttribute_->label_.c_str(),
+                     streamIdx);
         } else {
-            reinviteRequired |= updateMediaStreamInternal(newMediaAttr, streamIdx);
+            updateMediaStream(newAttr, streamIdx);
         }
     }
+}
+
+bool
+SIPCall::isReinviteRequired(const std::vector<MediaAttribute>& mediaAttrList)
+{
+    if (mediaAttrList.size() != rtpStreams_.size())
+        return true;
+
+    for (auto const& newAttr : mediaAttrList) {
+        auto streamIdx = findRtpStreamIndex(newAttr.label_);
 
-    if (reinviteRequired)
+        if (streamIdx == rtpStreams_.size()) {
+            // Always needs a reinvite when a new media is added.
+            return true;
+        }
+
+#ifdef ENABLE_VIDEO
+        if (newAttr.type_ == MediaType::MEDIA_VIDEO) {
+            assert(rtpStreams_[streamIdx].mediaAttribute_);
+            // Changes in video attributes always trigger a re-invite.
+            return newAttr.muted_ != rtpStreams_[streamIdx].mediaAttribute_->muted_;
+        }
+#endif
+    }
+
+    return false;
+}
+
+bool
+SIPCall::requestMediaChange(const std::vector<MediaAttribute>& mediaAttrList)
+{
+    JAMI_DBG("[call:%s] Requesting media change. List of new media:", getCallId().c_str());
+
+    unsigned idx = 0;
+    for (auto const& newMediaAttr : mediaAttrList) {
+        JAMI_DBG("[call:%s] Media @%u: %s",
+                 getCallId().c_str(),
+                 idx++,
+                 newMediaAttr.toString(true).c_str());
+    }
+
+    auto needReinvite = isReinviteRequired(mediaAttrList);
+
+    updateAllMediaStreams(mediaAttrList);
+
+    if (needReinvite) {
+        JAMI_DBG("[call:%s] Media change requires a new negotiation (re-invite)",
+                 getCallId().c_str());
         requestReinvite();
+    }
 
     return true;
 }
@@ -1720,14 +1971,19 @@ SIPCall::getMediaAttributeList() const
 /// In case of ICE transport used, the medias streams are launched asynchronously when
 /// the transport is negotiated.
 void
-SIPCall::onMediaUpdate()
+SIPCall::onMediaNegotiationComplete()
 {
+    JAMI_WARN("[call:%s] Media negotiation complete", getCallId().c_str());
+
     // Main call (no subcalls) must wait for ICE now, the rest of code needs to access
     // to a negotiated transport.
     runOnMainThread([w = weak()] {
         if (auto this_ = w.lock()) {
+            emitSignal<DRing::CallSignal::MediaNegotiationStatus>(
+                this_->getCallId(), MediaNegotiationStatusEvents::NEGOTIATION_SUCCESS);
+
             std::lock_guard<std::recursive_mutex> lk {this_->callMutex_};
-            JAMI_WARN("[call:%s] medias changed", this_->getCallId().c_str());
+            JAMI_WARN("[call:%s] media changed", this_->getCallId().c_str());
             // The call is already ended, so we don't need to restart medias
             if (not this_->inviteSession_
                 or this_->inviteSession_->state == PJSIP_INV_STATE_DISCONNECTED or not this_->sdp_)
@@ -1735,7 +1991,7 @@ SIPCall::onMediaUpdate()
             // If ICE is not used, start medias now
             auto rem_ice_attrs = this_->sdp_->getIceAttributes();
             if (rem_ice_attrs.ufrag.empty() or rem_ice_attrs.pwd.empty()) {
-                JAMI_WARN("[call:%s] no remote ICE for medias", this_->getCallId().c_str());
+                JAMI_WARN("[call:%s] no remote ICE for media", this_->getCallId().c_str());
                 this_->stopAllMedia();
                 this_->startAllMedia();
                 return;
@@ -1808,6 +2064,61 @@ SIPCall::onIceNegoSucceed()
     startAllMedia();
 }
 
+pj_status_t
+SIPCall::onReceiveReinvite(const pjmedia_sdp_session* offer, pjsip_rx_data* rdata)
+{
+    JAMI_DBG("[call:%s] Received a re-invite", getCallId().c_str());
+
+    pj_status_t res = PJ_SUCCESS;
+
+    if (not sdp_) {
+        JAMI_ERR("SDP session is invalid");
+        return res;
+    }
+
+    sdp_->clearIce();
+    auto acc = getSIPAccount();
+    if (not acc) {
+        JAMI_ERR("No account detected");
+        return res;
+    }
+
+    Sdp::printSession(offer, "Remote session (media change request)", SdpDirection::REMOTE_OFFER);
+
+    sdp_->setReceivedOffer(offer);
+
+    auto const& mediaAttrList = Sdp::getMediaAttributeListFromSdp(offer);
+    if (mediaAttrList.empty()) {
+        JAMI_WARN("[call:%s] Media list is empty, ignoring", getCallId().c_str());
+        return res;
+    }
+
+    if (upnp_) {
+        openPortsUPnP();
+    }
+
+    pjsip_tx_data* tdata = nullptr;
+    if (pjsip_inv_initial_answer(inviteSession_.get(), rdata, PJSIP_SC_TRYING, NULL, NULL, &tdata)
+        != PJ_SUCCESS) {
+        JAMI_ERR("Could not create answer TRYING");
+        return res;
+    }
+
+    // Report the change request.
+    auto const& remoteMediaList = MediaAttribute::mediaAttributesToMediaMaps(mediaAttrList);
+    // TODO_MC. Validate this assessment.
+    // Report re-invites only if the number of media changed, otherwise answer
+    // using the current local attributes.
+    if (acc->isMultiStreamEnabled() and remoteMediaList.size() != rtpStreams_.size()) {
+        Manager::instance().mediaChangeRequested(getCallId(), getAccountId(), remoteMediaList);
+    } else {
+        auto localMediaList = getMediaAttributeList();
+        answerMediaChangeRequest(localMediaList);
+    }
+
+    return res;
+}
+
 int
 SIPCall::onReceiveOffer(const pjmedia_sdp_session* offer, const pjsip_rx_data* rdata)
 {
@@ -1820,15 +2131,21 @@ SIPCall::onReceiveOffer(const pjmedia_sdp_session* offer, const pjsip_rx_data* r
         return !PJ_SUCCESS;
     }
 
-    // TODO. WARNING. If this is an ongoing audio-only call, and the peer added
-    // video in a subsequent offer, the local user MUST first accept the new media(s).
+    JAMI_DBG("[call:%s] Received a new offer (re-invite)", getCallId().c_str());
+
     // This list should be provided by the client. Kept for backward compatibility.
     auto mediaList = acc->createDefaultMediaList(acc->isVideoEnabled(),
                                                  getState() == CallState::HOLD);
 
-    sdp_->receiveOffer(offer, mediaList);
+    if (upnp_) {
+        openPortsUPnP();
+    }
+
+    sdp_->setReceivedOffer(offer);
+    sdp_->processIncomingOffer(mediaList);
 
-    setRemoteSdp(offer);
+    if (offer)
+        setupLocalIce();
     sdp_->startNegotiation();
 
     pjsip_tx_data* tdata = nullptr;
@@ -1862,7 +2179,9 @@ SIPCall::onReceiveOffer(const pjmedia_sdp_session* offer, const pjsip_rx_data* r
         return !PJ_SUCCESS;
     }
 
-    openPortsUPnP();
+    if (upnp_) {
+        openPortsUPnP();
+    }
 
     return PJ_SUCCESS;
 }
@@ -1870,43 +2189,48 @@ SIPCall::onReceiveOffer(const pjmedia_sdp_session* offer, const pjsip_rx_data* r
 void
 SIPCall::openPortsUPnP()
 {
-    if (upnp_ and sdp_) {
-        /**
-         * Try to open the desired ports with UPnP,
-         * if they are used, use the alternative port and update the SDP session with the newly
-         * chosen port(s)
-         *
-         * TODO:
-         * No need to request mappings for specfic port numbers. Set the port to '0' to
-         * request the first available port (faster and more likely to succeed).
-         */
-        JAMI_DBG("[call:%s] opening ports via UPNP for SDP session", getCallId().c_str());
-
-        upnp_->reserveMapping(sdp_->getLocalAudioPort(), upnp::PortType::UDP);
-        // RTCP port.
-        upnp_->reserveMapping(sdp_->getLocalAudioControlPort(), upnp::PortType::UDP);
+    if (not sdp_) {
+        JAMI_ERR("[call:%s] Current SDP instance is invalid", getCallId().c_str());
+        return;
+    }
+
+    /**
+     * Try to open the desired ports with UPnP,
+     * if they are used, use the alternative port and update the SDP session with the newly
+     * chosen port(s)
+     *
+     * TODO:
+     * No need to request mappings for specfic port numbers. Set the port to '0' to
+     * request the first available port (faster and more likely to succeed).
+     */
+    JAMI_DBG("[call:%s] opening ports via UPNP for SDP session", getCallId().c_str());
+
+    // RTP port.
+    upnp_->reserveMapping(sdp_->getLocalAudioPort(), upnp::PortType::UDP);
+    // RTCP port.
+    upnp_->reserveMapping(sdp_->getLocalAudioControlPort(), upnp::PortType::UDP);
 
 #ifdef ENABLE_VIDEO
-        // RTP port.
-        upnp_->reserveMapping(sdp_->getLocalVideoPort(), upnp::PortType::UDP);
-        // RTCP port.
-        upnp_->reserveMapping(sdp_->getLocalVideoControlPort(), upnp::PortType::UDP);
+    // RTP port.
+    upnp_->reserveMapping(sdp_->getLocalVideoPort(), upnp::PortType::UDP);
+    // RTCP port.
+    upnp_->reserveMapping(sdp_->getLocalVideoControlPort(), upnp::PortType::UDP);
 #endif
-    }
 }
 
 std::map<std::string, std::string>
 SIPCall::getDetails() const
 {
-    auto details = Call::getDetails();
-    details.emplace(DRing::Call::Details::PEER_HOLDING, peerHolding_ ? TRUE_STR : FALSE_STR);
-
     auto acc = getSIPAccount();
     if (!acc) {
         JAMI_ERR("No account detected");
         return {};
     }
 
+    auto details = Call::getDetails();
+
+    details.emplace(DRing::Call::Details::PEER_HOLDING, peerHolding_ ? TRUE_STR : FALSE_STR);
+
 #ifdef ENABLE_VIDEO
     for (auto const& stream : rtpStreams_) {
         if (stream.mediaAttribute_->type_ != MediaType::MEDIA_VIDEO)
@@ -2069,7 +2393,7 @@ SIPCall::toggleRecording()
         if (audioRtp)
             audioRtp->initRecorder(recorder_);
 #ifdef ENABLE_VIDEO
-        if (not isAudioOnly_) {
+        if (hasVideo()) {
             auto const& videoRtp = getVideoRtp();
             if (videoRtp)
                 videoRtp->initRecorder(recorder_);
@@ -2091,7 +2415,7 @@ SIPCall::deinitRecorder()
         if (audioRtp)
             audioRtp->deinitRecorder(recorder_);
 #ifdef ENABLE_VIDEO
-        if (not isAudioOnly_) {
+        if (hasVideo()) {
             auto const& videoRtp = getVideoRtp();
             if (videoRtp)
                 videoRtp->deinitRecorder(recorder_);
@@ -2124,15 +2448,14 @@ SIPCall::InvSessionDeleter::operator()(pjsip_inv_session* inv) const noexcept
 }
 
 bool
-SIPCall::initIceMediaTransport(bool master,
-                               std::optional<IceTransportOptions> options,
-                               unsigned channel_num)
+SIPCall::initIceMediaTransport(bool master, std::optional<IceTransportOptions> options)
 {
     auto acc = getSIPAccount();
     if (!acc) {
         JAMI_ERR("No account detected");
         return false;
     }
+
     JAMI_DBG("[call:%s] create media ICE transport", getCallId().c_str());
     auto iceOptions = options == std::nullopt ? acc->getIceOptions() : *options;
 
@@ -2172,9 +2495,11 @@ SIPCall::initIceMediaTransport(bool master,
         });
     };
 
+    // Each RTP stream requires a pair of ICE components (RTP + RTCP).
+    int compCount = static_cast<int>(rtpStreams_.size() * 2);
     auto& iceTransportFactory = Manager::instance().getIceTransportFactory();
     auto transport = iceTransportFactory.createUTransport(getCallId().c_str(),
-                                                          channel_num,
+                                                          compCount,
                                                           master,
                                                           iceOptions);
     std::lock_guard<std::mutex> lk(transportMtx_);
@@ -2194,7 +2519,7 @@ SIPCall::initIceMediaTransport(bool master,
 void
 SIPCall::merge(Call& call)
 {
-    JAMI_DBG("[sipcall:%s] merge subcall %s", getCallId().c_str(), call.getCallId().c_str());
+    JAMI_DBG("[call:%s] merge subcall %s", getCallId().c_str(), call.getCallId().c_str());
 
     // This static cast is safe as this method is private and overload Call::merge
     auto& subcall = static_cast<SIPCall&>(call);
@@ -2203,7 +2528,8 @@ SIPCall::merge(Call& call)
     std::lock_guard<std::recursive_mutex> lk1 {callMutex_, std::adopt_lock};
     std::lock_guard<std::recursive_mutex> lk2 {subcall.callMutex_, std::adopt_lock};
     inviteSession_ = std::move(subcall.inviteSession_);
-    inviteSession_->mod_data[Manager::instance().sipVoIPLink().getModId()] = this;
+    if (inviteSession_)
+        inviteSession_->mod_data[Manager::instance().sipVoIPLink().getModId()] = this;
     setTransport(std::move(subcall.transport_));
     sdp_ = std::move(subcall.sdp_);
     peerHolding_ = subcall.peerHolding_;
@@ -2222,20 +2548,29 @@ SIPCall::merge(Call& call)
     startIceMedia();
 }
 
-void
-SIPCall::setRemoteSdp(const pjmedia_sdp_session* sdp)
+bool
+SIPCall::remoteHasValidIceAttributes()
 {
-    if (!sdp)
-        return;
+    if (not sdp_) {
+        throw std::runtime_error("Must have a valid SDP Session");
+    }
 
-    // If ICE is not used, start medias now
     auto rem_ice_attrs = sdp_->getIceAttributes();
-    if (rem_ice_attrs.ufrag.empty() or rem_ice_attrs.pwd.empty()) {
+    return not rem_ice_attrs.ufrag.empty() and not rem_ice_attrs.pwd.empty();
+}
+
+void
+SIPCall::setupLocalIce()
+{
+    if (not remoteHasValidIceAttributes()) {
+        // If ICE attributes are not present, skip the ICE initialization
+        // step (most likely ICE is not used).
         JAMI_WARN("[call:%s] no ICE data in remote SDP", getCallId().c_str());
         return;
     }
 
     if (not initIceMediaTransport(false)) {
+        JAMI_ERR("[call:%s] ICE initialization failed", getCallId().c_str());
         // Fatal condition
         // TODO: what's SIP rfc says about that?
         // (same question in startIceMedia)
@@ -2244,7 +2579,7 @@ SIPCall::setRemoteSdp(const pjmedia_sdp_session* sdp)
     }
 
     // WARNING: This call blocks! (need ice init done)
-    setupLocalSDPFromIce();
+    addLocalIceAttributes();
 }
 
 bool
diff --git a/src/sip/sipcall.h b/src/sip/sipcall.h
index 81ea04ee80..a475a8a55c 100644
--- a/src/sip/sipcall.h
+++ b/src/sip/sipcall.h
@@ -126,6 +126,8 @@ private:
 
 public:
     void answer() override;
+    void answer(const std::vector<MediaAttribute>& mediaList) override;
+    void answerMediaChangeRequest(const std::vector<MediaAttribute>& mediaList) override;
     void hangup(int reason) override;
     void refuse() override;
     void transfer(const std::string& to) override;
@@ -157,6 +159,10 @@ public:
 
         return nullptr;
     }
+
+    bool hasVideo() const override;
+    bool isAudioMuted() const override;
+    bool isVideoMuted() const override;
     // End of override of Call class
 
     // Override of Recordable class
@@ -174,42 +180,39 @@ public:
 
     // Implementation of events reported by SipVoipLink.
     /**
-     * Tell the user that the call is ringing
+     * Call is in ringing state on peer's side
      */
     void onPeerRinging();
-
     /**
-     * Tell the user that the call was answered
+     * Peer answered the call
      */
     void onAnswered();
-
     /**
-     * To call in case of server/internal error
+     * Called to report server/internal errors
      * @param cause Optional error code
      */
     void onFailure(signed cause = 0);
-
     /**
      * Peer answered busy
      */
     void onBusyHere();
-
     /**
-     * Peer close the connection
+     * Peer closed the connection
      */
     void onClosed();
-
     /**
      * Report a new offer from peer on a existing invite session
      * (aka re-invite)
      */
-    int onReceiveOffer(const pjmedia_sdp_session* offer, const pjsip_rx_data* rdata);
+    [[deprecated("Replaced by onReceiveReinvite")]] int onReceiveOffer(
+        const pjmedia_sdp_session* offer, const pjsip_rx_data* rdata);
 
+    pj_status_t onReceiveReinvite(const pjmedia_sdp_session* offer, pjsip_rx_data* rdata);
     /**
      * Called when the media negotiation (SDP offer/answer) has
      * completed.
      */
-    void onMediaUpdate();
+    void onMediaNegotiationComplete();
     // End fo SiPVoipLink events
 
     void setContactHeader(pj_str_t* contact);
@@ -228,13 +231,14 @@ public:
 
     void updateSDPFromSTUN();
 
-    void setupLocalSDPFromIce();
+    bool remoteHasValidIceAttributes();
+    void addLocalIceAttributes();
 
     /**
      * Give peer SDP to the call for handling
      * @param sdp pointer on PJSIP sdp structure, could be nullptr (acts as no-op in such case)
      */
-    void setRemoteSdp(const pjmedia_sdp_session* sdp);
+    void setupLocalIce();
 
     void terminateSipSession(int status);
 
@@ -268,8 +272,7 @@ public:
     void setPeerUri(const std::string& peerUri) { peerUri_ = peerUri; }
 
     bool initIceMediaTransport(bool master,
-                               std::optional<IceTransportOptions> options = std::nullopt,
-                               unsigned channel_num = 4);
+                               std::optional<IceTransportOptions> options = std::nullopt);
 
     bool isIceRunning() const;
 
@@ -348,16 +351,27 @@ private:
     bool hold();
 
     bool unhold();
-    /**
-     * Update the attributes of a media stream
-     * @param newMediaAttr the new attributes
-     * @param streamIdx the index of the stream to update
-     * @return true if the update requires a new SDP and SIP re-invite.
-     */
-    bool updateMediaStreamInternal(const MediaAttribute& newMediaAttr, size_t streamIdx);
+    // Update the attributes of a media stream
+    void updateMediaStream(const MediaAttribute& newMediaAttr, size_t streamIdx);
+    void updateAllMediaStreams(const std::vector<MediaAttribute>& mediaAttrList);
+    // Check if a SIP re-invite must be sent to negotiate the new media
+    bool isReinviteRequired(const std::vector<MediaAttribute>& mediaAttrList);
     void requestReinvite();
     int SIPSessionReinvite(const std::vector<MediaAttribute>& mediaAttrList);
     int SIPSessionReinvite();
+    // Add a media stream to the call.
+    void addMediaStream(const MediaAttribute& mediaAttr);
+    // Init media streams
+    size_t initMediaStreams(const std::vector<MediaAttribute>& mediaAttrList);
+    // Create a new stream from SDP description.
+    void createRtpSession(RtpStream& rtpStream);
+    // Configure the RTP session from SDP description.
+    void configureRtpSession(const std::shared_ptr<RtpSession>& rtpSession,
+                             const std::shared_ptr<MediaAttribute>& mediaAttr,
+                             const MediaDescription& localMedia,
+                             const MediaDescription& remoteMedia);
+    // Find the stream index with the matching label
+    size_t findRtpStreamIndex(const std::string& label) const;
 
     std::vector<IceCandidate> getAllRemoteCandidates();
 
@@ -375,30 +389,6 @@ private:
     }
     inline std::weak_ptr<SIPCall> weak() { return std::weak_ptr<SIPCall>(shared()); }
 
-    // Add a media stream to the call.
-    void addMediaStream(const MediaAttribute& mediaAttr);
-
-    // Init media streams
-    size_t initMediaStreams(const std::vector<MediaAttribute>& mediaAttrList);
-
-    // Create a new stream from SDP description.
-    void createRtpSession(RtpStream& rtpStream);
-
-    // Configure the RTP session from SDP description.
-    void configureRtpSession(const std::shared_ptr<RtpSession>& rtpSession,
-                             const std::shared_ptr<MediaAttribute>& mediaAttr,
-                             const MediaDescription& localMedia,
-                             const MediaDescription& remoteMedia);
-
-    void dumpMediaAttribute(const MediaAttribute& mediaAttr, size_t idx) const;
-
-    /**
-     * Find the stream index of the matching label.
-     * @param label the stream label
-     * @return the stream index on success, the last index past one otherwise.
-     */
-    size_t findRtpStreamIndex(const std::string& label) const;
-
     // Vector holding the current RTP sessions.
     std::vector<RtpStream> rtpStreams_;
 
diff --git a/src/sip/siptransport.cpp b/src/sip/siptransport.cpp
index b4943392f4..87bf03c4a8 100644
--- a/src/sip/siptransport.cpp
+++ b/src/sip/siptransport.cpp
@@ -219,11 +219,7 @@ SipTransportBroker::transportStateChanged(pjsip_transport* tp,
 
         sipTransport = key->second.lock();
 
-#if PJ_VERSION_NUM > (2 << 24 | 1 << 16)
         bool destroyed = state == PJSIP_TP_STATE_DESTROY;
-#else
-        bool destroyed = tp->is_destroying;
-#endif
 
         // maps cleanup
         if (destroyed) {
diff --git a/src/sip/sipvoiplink.cpp b/src/sip/sipvoiplink.cpp
index 80522548bb..854186c6aa 100644
--- a/src/sip/sipvoiplink.cpp
+++ b/src/sip/sipvoiplink.cpp
@@ -61,6 +61,11 @@
 #include <pjsip-simple/presence.h>
 #include <pjsip-simple/publish.h>
 
+// Only PJSIP 2.10 is supported.
+#if PJ_VERSION_NUM < (2 << 24 | 10 << 16)
+#error "Unsupported PJSIP version (requires version 2.10+)"
+#endif
+
 #include <istream>
 #include <algorithm>
 #include <regex>
@@ -84,6 +89,7 @@ static void outgoing_request_forked_cb(pjsip_inv_session* inv, pjsip_event* e);
 static void transaction_state_changed_cb(pjsip_inv_session* inv,
                                          pjsip_transaction* tsx,
                                          pjsip_event* e);
+
 static std::shared_ptr<SIPCall> getCallFromInvite(pjsip_inv_session* inv);
 #ifdef DEBUG_SIP_REQUEST_MSG
 static void processInviteResponseHelper(pjsip_inv_session* inv, pjsip_event* e);
@@ -334,12 +340,15 @@ transaction_request_cb(pjsip_rx_data* rdata)
         sip_utils::logMessageHeaders(&rdata->msg_info.msg->hdr);
     }
 
-    pjmedia_sdp_session* r_sdp;
+    pjmedia_sdp_session* r_sdp {nullptr};
 
-    if (!body
-        || pjmedia_sdp_parse(rdata->tp_info.pool, (char*) body->data, body->len, &r_sdp)
-               != PJ_SUCCESS)
-        r_sdp = NULL;
+    if (body) {
+        if (pjmedia_sdp_parse(rdata->tp_info.pool, (char*) body->data, body->len, &r_sdp)
+            != PJ_SUCCESS) {
+            JAMI_WARN("Failed to parse the SDP in offer");
+            r_sdp = nullptr;
+        }
+    }
 
     if (not account->hasActiveCodec(MEDIA_AUDIO)) {
         try_respond_stateless(endpt_, rdata, PJSIP_SC_NOT_ACCEPTABLE_HERE, NULL, NULL, NULL);
@@ -355,17 +364,8 @@ transaction_request_cb(pjsip_rx_data* rdata)
         return PJ_FALSE;
     }
 
-    bool hasVideo = false;
-    if (r_sdp) {
-        auto pj_str_video = sip_utils::CONST_PJ_STR("video");
-        for (decltype(r_sdp->media_count) i = 0; i < r_sdp->media_count; i++) {
-            if (pj_strcmp(&r_sdp->media[i]->desc.media, &pj_str_video) == 0)
-                hasVideo = true;
-        }
-    }
-    auto call = account->newIncomingCall(std::string(remote_user),
-                                         {{"AUDIO_ONLY", (hasVideo ? "false" : "true")}},
-                                         transport);
+    auto const& remoteMediaList = Sdp::getMediaAttributeListFromSdp(r_sdp);
+    auto call = account->newIncomingCall(std::string(remote_user), remoteMediaList, transport);
 
     if (!call) {
         return PJ_FALSE;
@@ -413,12 +413,18 @@ transaction_request_cb(pjsip_rx_data* rdata)
     if (account->isStunEnabled())
         call->updateSDPFromSTUN();
 
-    // This list should be provided by the client. Kept for backward compatibility.
-    auto mediaList = account->createDefaultMediaList(account->isVideoEnabled() and hasVideo);
-
-    call->getSDP().receiveOffer(r_sdp, mediaList);
-
-    call->setRemoteSdp(r_sdp);
+    {
+        auto hasVideo = MediaAttribute::hasMediaType(remoteMediaList, MediaType::MEDIA_VIDEO);
+        // TODO.
+        // This list should be built using all the medias in the incoming offer.
+        // The local media should be set temporarily inactive (and possibly unconfigured) until
+        // we receive accept(mediaList) from the client.
+        auto mediaList = account->createDefaultMediaList(account->isVideoEnabled() and hasVideo);
+        call->getSDP().setReceivedOffer(r_sdp);
+        call->getSDP().processIncomingOffer(mediaList);
+    }
+    if (r_sdp)
+        call->setupLocalIce();
 
     pjsip_dialog* dialog = nullptr;
     if (pjsip_dlg_create_uas_and_inc_lock(pjsip_ua_instance(), rdata, nullptr, &dialog)
@@ -664,22 +670,18 @@ SIPVoIPLink::SIPVoIPLink()
     TRY(pjsip_pres_init_module(endpt_, pjsip_evsub_instance()));
     TRY(pjsip_endpt_register_module(endpt_, &PresSubServer::mod_presence_server));
 
-    static const pjsip_inv_callback inv_cb
-        = { invite_session_state_changed_cb,
-            outgoing_request_forked_cb,
-            transaction_state_changed_cb,
-            nullptr /* on_rx_offer */,
-#if PJ_VERSION_NUM >= (2 << 24 | 7 << 16)
-            nullptr /* on_rx_offer2 */,
-#endif
-#if PJ_VERSION_NUM > (2 << 24 | 1 << 16)
-            reinvite_received_cb,
-#endif
-            sdp_create_offer_cb,
-            sdp_media_update_cb,
-            nullptr /* on_send_ack */,
-            nullptr /* on_redirected */,
-          };
+    static const pjsip_inv_callback inv_cb = {
+        invite_session_state_changed_cb,
+        outgoing_request_forked_cb,
+        transaction_state_changed_cb,
+        nullptr /* on_rx_offer */,
+        nullptr /* on_rx_offer2 */,
+        reinvite_received_cb,
+        sdp_create_offer_cb,
+        sdp_media_update_cb,
+        nullptr /* on_send_ack */,
+        nullptr /* on_redirected */,
+    };
     TRY(pjsip_inv_usage_init(endpt_, &inv_cb));
 
     static constexpr pj_str_t allowed[] = {
@@ -930,9 +932,18 @@ reinvite_received_cb(pjsip_inv_session* inv, const pjmedia_sdp_session* offer, p
     if (!offer)
         return !PJ_SUCCESS;
     if (auto call = getCallFromInvite(inv)) {
-        return call->onReceiveOffer(offer, rdata);
+        if (auto const& account = call->getAccount().lock()) {
+            if (account->isMultiStreamEnabled()) {
+                return call->onReceiveReinvite(offer, rdata);
+            } else {
+                return call->onReceiveOffer(offer, rdata);
+            }
+        }
     }
-    return !PJ_SUCCESS;
+
+    // Return success if there is no matching call. The re-invite
+    // should be ignored.
+    return PJ_SUCCESS;
 }
 
 static void
@@ -1051,14 +1062,14 @@ sdp_media_update_cb(pjsip_inv_session* inv, pj_status_t status)
     auto& sdp = call->getSDP();
     sdp.setActiveLocalSdpSession(localSDP);
     if (localSDP != nullptr) {
-        Sdp::printSession(localSDP, "Local active session:\n", sdp.isOffer());
+        Sdp::printSession(localSDP, "Local active session:", sdp.getSdpDirection());
     }
 
     sdp.setActiveRemoteSdpSession(remoteSDP);
     if (remoteSDP != nullptr) {
-        Sdp::printSession(remoteSDP, "Remote active session:\n", not sdp.isOffer());
+        Sdp::printSession(remoteSDP, "Remote active session:", sdp.getSdpDirection());
     }
-    call->onMediaUpdate();
+    call->onMediaNegotiationComplete();
 }
 
 static void
@@ -1518,14 +1529,7 @@ SIPVoIPLink::findLocalAddressFromSTUN(pjsip_transport* transport,
     pj_sockaddr_in mapped_addr;
     pj_sock_t sipSocket = pjsip_udp_transport_get_socket(transport);
     const pjstun_setting stunOpt
-        = { PJ_TRUE,
-#if PJ_VERSION_NUM > (2 << 24 | 7 << 16)
-            localIp.getFamily(),
-#endif
-            *stunServerName,
-            stunPort,
-            *stunServerName,
-            stunPort };
+        = {PJ_TRUE, localIp.getFamily(), *stunServerName, stunPort, *stunServerName, stunPort};
     const pj_status_t stunStatus = pjstun_get_mapped_addr2(&cp_.factory,
                                                            &stunOpt,
                                                            1,
diff --git a/test/unitTest/Makefile.am b/test/unitTest/Makefile.am
index 9b60c286ee..a19f97bb23 100644
--- a/test/unitTest/Makefile.am
+++ b/test/unitTest/Makefile.am
@@ -146,10 +146,10 @@ ut_conversationRepository_SOURCES = conversationRepository/conversationRepositor
 check_PROGRAMS += ut_conversation
 ut_conversation_SOURCES = conversation/conversation.cpp
 #
-# media_control
+# media_negotiation
 #
-check_PROGRAMS += ut_media_control
-ut_media_control_SOURCES = media_control/media_control.cpp
+check_PROGRAMS += ut_media_negotiation
+ut_media_negotiation_SOURCES = media_negotiation/media_negotiation.cpp
 
 #
 # syncHistory
diff --git a/test/unitTest/media_negotiation/media_negotiation.cpp b/test/unitTest/media_negotiation/media_negotiation.cpp
new file mode 100644
index 0000000000..eb265a7a69
--- /dev/null
+++ b/test/unitTest/media_negotiation/media_negotiation.cpp
@@ -0,0 +1,849 @@
+/*
+ *  Copyright (C) 2021 Savoir-faire Linux Inc.
+ *
+ *  Author: Mohamed Chibani <mohamed.chibani@savoirfairelinux.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
+ *  the Free Software Foundation; either version 3 of the License, or
+ *  (at your option) any later version.
+ *
+ *  This program is distributed in the hope that it will be useful,
+ *  but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *  GNU General Public License for more details.
+ *
+ *  You should have received a copy of the GNU General Public License
+ *  along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <cppunit/TestAssert.h>
+#include <cppunit/TestFixture.h>
+#include <cppunit/extensions/HelperMacros.h>
+
+#include <condition_variable>
+#include <string>
+
+#include "manager.h"
+#include "jamidht/connectionmanager.h"
+#include "jamidht/jamiaccount.h"
+#include "../../test_runner.h"
+#include "dring.h"
+#include "call_const.h"
+#include "account_const.h"
+#include "sip/sipcall.h"
+#include "sip/sdp.h"
+
+using namespace DRing::Account;
+using namespace DRing::Call;
+
+namespace jami {
+namespace test {
+
+struct TestScenario
+{
+    TestScenario(const std::vector<MediaAttribute>& offer,
+                 const std::vector<MediaAttribute>& answer,
+                 const std::vector<MediaAttribute>& offerUpdate,
+                 const std::vector<MediaAttribute>& answerUpdate)
+        : offer_(std::move(offer))
+        , answer_(std::move(answer))
+        , offerUpdate_(std::move(offerUpdate))
+        , answerUpdate_(std::move(answerUpdate))
+    {}
+
+    TestScenario() {};
+
+    std::vector<MediaAttribute> offer_;
+    std::vector<MediaAttribute> answer_;
+    std::vector<MediaAttribute> offerUpdate_;
+    std::vector<MediaAttribute> answerUpdate_;
+    // Determine if we should expect the MediaNegotiationStatus signal.
+    bool expectMediaRenegotiation_ {false};
+    // Determine if we should expect the MediaChangeRequested signal.
+    bool expectMediaChangeRequest_ {false};
+};
+
+struct CallData
+{
+    struct Signal
+    {
+        Signal(const std::string& name, const std::string& event = {})
+            : name_(std::move(name))
+            , event_(std::move(event)) {};
+
+        std::string name_ {};
+        std::string event_ {};
+    };
+
+    std::string accountId_ {};
+    std::string userName_ {};
+    std::string alias_ {};
+    std::string callId_ {};
+    std::vector<Signal> signals_;
+    std::condition_variable cv_ {};
+    std::mutex mtx_;
+};
+
+/**
+ * Basic tests for media negotiation.
+ */
+class MediaNegotiationTest : public CppUnit::TestFixture
+{
+public:
+    MediaNegotiationTest()
+    {
+        // Init daemon
+        DRing::init(DRing::InitFlag(DRing::DRING_FLAG_DEBUG | DRing::DRING_FLAG_CONSOLE_LOG));
+        if (not Manager::instance().initialized)
+            CPPUNIT_ASSERT(DRing::start("dring-sample.yml"));
+    }
+    ~MediaNegotiationTest() { DRing::fini(); }
+
+    static std::string name() { return "MediaNegotiationTest"; }
+    void setUp();
+    void tearDown();
+
+private:
+    // Test cases.
+    void audio_and_video_then_mute_video();
+    void audio_only_then_add_video();
+    void audio_and_video_then_mute_audio();
+
+    CPPUNIT_TEST_SUITE(MediaNegotiationTest);
+    CPPUNIT_TEST(audio_and_video_then_mute_video);
+    CPPUNIT_TEST(audio_only_then_add_video);
+    CPPUNIT_TEST(audio_and_video_then_mute_audio);
+    CPPUNIT_TEST_SUITE_END();
+
+    // Event/Signal handlers
+    static void onCallStateChange(const std::string& callId,
+                                  const std::string& state,
+                                  CallData& callData);
+    static void onIncomingCallWithMedia(const std::string& accountId,
+                                        const std::string& callId,
+                                        const std::vector<DRing::MediaMap> mediaList,
+                                        CallData& callData);
+    static void onMediaChangeRequested(const std::string& accountId,
+                                       const std::string& callId,
+                                       const std::vector<DRing::MediaMap> mediaList,
+                                       CallData& callData);
+    static void onVideoMuted(const std::string& callId, bool muted, CallData& callData);
+    static void onMediaNegotiationStatus(const std::string& callId,
+                                         const std::string& event,
+                                         CallData& callData);
+
+    // Helpers
+    static void configureScenario(CallData& bob, CallData& alice);
+    void testWithScenario(CallData& aliceData, CallData& bobData, const TestScenario& scenario);
+    static std::string getUserAlias(const std::string& callId);
+    // Infer media direction from the mute state.
+    // Note that when processing caller side, local is the caller and
+    // remote is the callee, and when processing the callee, the local is
+    // callee and remote is the caller.
+    static MediaDirection inferDirection(bool localMute, bool remoteMute);
+    // Wait for a signal from the callbacks. Some signals also report the event that
+    // triggered the signal a like the StateChange signal.
+    static bool waitForSignal(CallData& callData,
+                              const std::string& signal,
+                              const std::string& expectedEvent = {});
+
+private:
+    CallData aliceData_;
+    CallData bobData_;
+};
+
+CPPUNIT_TEST_SUITE_NAMED_REGISTRATION(MediaNegotiationTest, MediaNegotiationTest::name());
+
+void
+MediaNegotiationTest::setUp()
+{
+    std::map<std::string, std::string> details = DRing::getAccountTemplate("RING");
+    details[ConfProperties::TYPE] = "RING";
+    details[ConfProperties::DISPLAYNAME] = "ALICE";
+    details[ConfProperties::ALIAS] = "ALICE";
+    details[ConfProperties::UPNP_ENABLED] = "false";
+    details[ConfProperties::ARCHIVE_PASSWORD] = "";
+    details[ConfProperties::ARCHIVE_PIN] = "";
+    details[ConfProperties::ARCHIVE_PATH] = "";
+    aliceData_.accountId_ = Manager::instance().addAccount(details);
+
+    details = DRing::getAccountTemplate("RING");
+    details[ConfProperties::TYPE] = "RING";
+    details[ConfProperties::DISPLAYNAME] = "BOB";
+    details[ConfProperties::ALIAS] = "BOB";
+    details[ConfProperties::UPNP_ENABLED] = "false";
+    details[ConfProperties::ARCHIVE_PASSWORD] = "";
+    details[ConfProperties::ARCHIVE_PIN] = "";
+    details[ConfProperties::ARCHIVE_PATH] = "";
+    bobData_.accountId_ = Manager::instance().addAccount(details);
+
+    JAMI_INFO("Initialize account...");
+    auto aliceAccount = Manager::instance().getAccount<JamiAccount>(aliceData_.accountId_);
+    aliceAccount->enableMultiStream(true);
+    auto bobAccount = Manager::instance().getAccount<JamiAccount>(bobData_.accountId_);
+    bobAccount->enableMultiStream(true);
+
+    std::map<std::string, std::shared_ptr<DRing::CallbackWrapperBase>> confHandlers;
+    std::mutex mtx;
+    std::unique_lock<std::mutex> lk {mtx};
+    std::condition_variable cv;
+    std::atomic_bool accountsReady {false};
+    confHandlers.insert(
+        DRing::exportable_callback<DRing::ConfigurationSignal::VolatileDetailsChanged>(
+            [&](const std::string&, const std::map<std::string, std::string>&) {
+                bool ready = false;
+                auto details = aliceAccount->getVolatileAccountDetails();
+                auto daemonStatus = details[DRing::Account::ConfProperties::Registration::STATUS];
+                ready = (daemonStatus == "REGISTERED");
+                details = bobAccount->getVolatileAccountDetails();
+                daemonStatus = details[DRing::Account::ConfProperties::Registration::STATUS];
+                ready &= (daemonStatus == "REGISTERED");
+                if (ready) {
+                    accountsReady = true;
+                    cv.notify_one();
+                }
+            }));
+    DRing::registerSignalHandlers(confHandlers);
+    CPPUNIT_ASSERT(cv.wait_for(lk, std::chrono::seconds(30), [&] { return accountsReady.load(); }));
+    DRing::unregisterSignalHandlers();
+}
+
+void
+MediaNegotiationTest::tearDown()
+{
+    JAMI_INFO("Remove created accounts...");
+
+    std::map<std::string, std::shared_ptr<DRing::CallbackWrapperBase>> confHandlers;
+    std::mutex mtx;
+    std::unique_lock<std::mutex> lk {mtx};
+    std::condition_variable cv;
+    auto currentAccSize = Manager::instance().getAccountList().size();
+    std::atomic_bool accountsRemoved {false};
+    confHandlers.insert(
+        DRing::exportable_callback<DRing::ConfigurationSignal::AccountsChanged>([&]() {
+            if (Manager::instance().getAccountList().size() <= currentAccSize - 2) {
+                accountsRemoved = true;
+                cv.notify_one();
+            }
+        }));
+    DRing::registerSignalHandlers(confHandlers);
+
+    Manager::instance().removeAccount(aliceData_.accountId_, true);
+    Manager::instance().removeAccount(bobData_.accountId_, true);
+    // Because cppunit is not linked with dbus, just poll if removed
+    CPPUNIT_ASSERT(
+        cv.wait_for(lk, std::chrono::seconds(30), [&] { return accountsRemoved.load(); }));
+
+    DRing::unregisterSignalHandlers();
+}
+
+std::string
+MediaNegotiationTest::getUserAlias(const std::string& callId)
+{
+    auto call = Manager::instance().getCallFromCallID(callId);
+
+    if (not call) {
+        JAMI_WARN("Call with ID [%s] does not exist anymore!", callId.c_str());
+        return {};
+    }
+
+    auto const& account = call->getAccount().lock();
+    if (not account) {
+        return {};
+    }
+
+    return account->getAccountDetails()[ConfProperties::ALIAS];
+}
+
+MediaDirection
+MediaNegotiationTest::inferDirection([[maybe_unused]] bool localMute,
+                                     [[maybe_unused]] bool remoteMute)
+{
+#if 1
+    return MediaDirection::SENDRECV;
+#else
+    // NOTE. The media direction inferred here should be the correct one
+    // according to the spec (RFC-3264 and RFC-6337). However, the current
+    // implementation always set 'sendrecv' regardless of the mute state.
+    if (not localMute and not remoteMute)
+        return MediaDirection::SENDRECV;
+
+    if (localMute and not remoteMute)
+        return MediaDirection::RECVONLY;
+
+    if (not localMute and remoteMute)
+        return MediaDirection::SENDONLY;
+
+    return MediaDirection::INACTIVE;
+#endif
+}
+
+void
+MediaNegotiationTest::onIncomingCallWithMedia(const std::string& accountId,
+                                              const std::string& callId,
+                                              const std::vector<DRing::MediaMap> mediaList,
+                                              CallData& callData)
+{
+    CPPUNIT_ASSERT_EQUAL(callData.accountId_, accountId);
+
+    JAMI_INFO("Signal [%s] - user [%s] - call [%s] - media count [%lu]",
+              DRing::CallSignal::IncomingCallWithMedia::name,
+              callData.alias_.c_str(),
+              callId.c_str(),
+              mediaList.size());
+
+    // NOTE.
+    // We shouldn't access shared_ptr<Call> as this event is supposed to mimic
+    // the client, and the client have no access to this type. But here, we only
+    // needed to check if the call exists. This is the most straightforward and
+    // reliable way to do it until we add a new API (like hasCall(id)).
+    if (not Manager::instance().getCallFromCallID(callId)) {
+        JAMI_WARN("Call with ID [%s] does not exist!", callId.c_str());
+        callData.callId_ = {};
+        return;
+    }
+
+    std::unique_lock<std::mutex> lock {callData.mtx_};
+    callData.callId_ = callId;
+    callData.signals_.emplace_back(CallData::Signal(DRing::CallSignal::IncomingCallWithMedia::name));
+
+    callData.cv_.notify_one();
+}
+
+void
+MediaNegotiationTest::onMediaChangeRequested(const std::string& accountId,
+                                             const std::string& callId,
+                                             const std::vector<DRing::MediaMap> mediaList,
+                                             CallData& callData)
+{
+    CPPUNIT_ASSERT_EQUAL(callData.accountId_, accountId);
+
+    JAMI_INFO("Signal [%s] - user [%s] - call [%s] - media count [%lu]",
+              DRing::CallSignal::MediaChangeRequested::name,
+              callData.alias_.c_str(),
+              callId.c_str(),
+              mediaList.size());
+
+    // TODO
+    // We shouldn't access shared_ptr<Call> as this event is supposed to mimic
+    // the client, and the client have no access to this type. But here, we only
+    // needed to check if the call exists. This is the most straightforward and
+    // reliable way to do it until we add a new API (like hasCall(id)).
+    if (not Manager::instance().getCallFromCallID(callId)) {
+        JAMI_WARN("Call with ID [%s] does not exist!", callId.c_str());
+        callData.callId_ = {};
+        return;
+    }
+
+    std::unique_lock<std::mutex> lock {callData.mtx_};
+    callData.callId_ = callId;
+    callData.signals_.emplace_back(CallData::Signal(DRing::CallSignal::MediaChangeRequested::name));
+
+    callData.cv_.notify_one();
+}
+
+void
+MediaNegotiationTest::onCallStateChange(const std::string& callId,
+                                        const std::string& state,
+                                        CallData& callData)
+{
+    auto call = Manager::instance().getCallFromCallID(callId);
+    if (not call) {
+        JAMI_WARN("Call with ID [%s] does not exist anymore!", callId.c_str());
+        return;
+    }
+
+    auto account = call->getAccount().lock();
+    if (not account) {
+        JAMI_WARN("Account owning the call [%s] does not exist!", callId.c_str());
+        return;
+    }
+
+    JAMI_INFO("Signal [%s] - user [%s] - call [%s] - state [%s]",
+              DRing::CallSignal::StateChange::name,
+              callData.alias_.c_str(),
+              callId.c_str(),
+              state.c_str());
+
+    if (account->getAccountID() != callData.accountId_)
+        return;
+
+    {
+        std::unique_lock<std::mutex> lock {callData.mtx_};
+        callData.signals_.emplace_back(
+            CallData::Signal(DRing::CallSignal::StateChange::name, state));
+    }
+
+    if (state == "CURRENT" or state == "OVER" or state == "HUNGUP") {
+        callData.cv_.notify_one();
+    }
+}
+
+void
+MediaNegotiationTest::onVideoMuted(const std::string& callId, bool muted, CallData& callData)
+{
+    auto call = Manager::instance().getCallFromCallID(callId);
+
+    if (not call) {
+        JAMI_WARN("Call with ID [%s] does not exist anymore!", callId.c_str());
+        return;
+    }
+
+    auto account = call->getAccount().lock();
+    if (not account) {
+        JAMI_WARN("Account owning the call [%s] does not exist!", callId.c_str());
+        return;
+    }
+
+    JAMI_INFO("Signal [%s] - user [%s] - call [%s] - state [%s]",
+              DRing::CallSignal::VideoMuted::name,
+              account->getAccountDetails()[ConfProperties::ALIAS].c_str(),
+              call->getCallId().c_str(),
+              muted ? "Mute" : "Un-mute");
+
+    if (account->getAccountID() != callData.accountId_)
+        return;
+
+    {
+        std::unique_lock<std::mutex> lock {callData.mtx_};
+        callData.signals_.emplace_back(
+            CallData::Signal(DRing::CallSignal::VideoMuted::name, muted ? "muted" : "un-muted"));
+    }
+
+    callData.cv_.notify_one();
+}
+
+void
+MediaNegotiationTest::onMediaNegotiationStatus(const std::string& callId,
+                                               const std::string& event,
+                                               CallData& callData)
+{
+    auto call = Manager::instance().getCallFromCallID(callId);
+    if (not call) {
+        JAMI_WARN("Call with ID [%s] does not exist!", callId.c_str());
+        return;
+    }
+
+    auto account = call->getAccount().lock();
+    if (not account) {
+        JAMI_WARN("Account owning the call [%s] does not exist!", callId.c_str());
+        return;
+    }
+
+    JAMI_INFO("Signal [%s] - user [%s] - call [%s] - state [%s]",
+              DRing::CallSignal::MediaNegotiationStatus::name,
+              account->getAccountDetails()[ConfProperties::ALIAS].c_str(),
+              call->getCallId().c_str(),
+              event.c_str());
+
+    if (account->getAccountID() != callData.accountId_)
+        return;
+
+    {
+        std::unique_lock<std::mutex> lock {callData.mtx_};
+        callData.signals_.emplace_back(
+            CallData::Signal(DRing::CallSignal::MediaNegotiationStatus::name, event));
+    }
+
+    callData.cv_.notify_one();
+}
+
+bool
+MediaNegotiationTest::waitForSignal(CallData& callData,
+                                    const std::string& expectedSignal,
+                                    const std::string& expectedEvent)
+{
+    const std::chrono::seconds TIME_OUT {30};
+    std::unique_lock<std::mutex> lock {callData.mtx_};
+
+    // Combined signal + event (if any).
+    std::string sigEvent(expectedSignal);
+    if (not expectedEvent.empty())
+        sigEvent += "::" + expectedEvent;
+
+    JAMI_INFO("[%s] is waiting for [%s] signal/event", callData.alias_.c_str(), sigEvent.c_str());
+
+    auto res = callData.cv_.wait_for(lock, TIME_OUT, [&] {
+        // Search for the expected signal in list of received signals.
+        bool pred = false;
+        for (auto it = callData.signals_.begin(); it != callData.signals_.end(); it++) {
+            // The predicate is true if the signal names match, and if the
+            // expectedEvent is not empty, the events must also match.
+            if (it->name_ == expectedSignal
+                and (expectedEvent.empty() or it->event_ == expectedEvent)) {
+                pred = true;
+                // Done with this signal.
+                callData.signals_.erase(it);
+                break;
+            }
+        }
+
+        return pred;
+    });
+
+    if (not res) {
+        JAMI_ERR("[%s] waiting for signal/event [%s] timed-out!",
+                 callData.alias_.c_str(),
+                 sigEvent.c_str());
+
+        JAMI_INFO("[%s] currently has the following signals:", callData.alias_.c_str());
+
+        for (auto const& sig : callData.signals_) {
+            JAMI_INFO() << "Signal [" << sig.name_
+                        << (sig.event_.empty() ? "" : ("::" + sig.event_)) << "]";
+        }
+    }
+
+    return res;
+}
+
+void
+MediaNegotiationTest::configureScenario(CallData& aliceData, CallData& bobData)
+{
+    {
+        CPPUNIT_ASSERT(not aliceData.accountId_.empty());
+        auto const& account = Manager::instance().getAccount<JamiAccount>(aliceData.accountId_);
+        aliceData.userName_ = account->getAccountDetails()[ConfProperties::USERNAME];
+        aliceData.alias_ = account->getAccountDetails()[ConfProperties::ALIAS];
+    }
+
+    {
+        CPPUNIT_ASSERT(not bobData.accountId_.empty());
+        auto const& account = Manager::instance().getAccount<JamiAccount>(bobData.accountId_);
+        bobData.userName_ = account->getAccountDetails()[ConfProperties::USERNAME];
+        bobData.alias_ = account->getAccountDetails()[ConfProperties::ALIAS];
+    }
+
+    std::map<std::string, std::shared_ptr<DRing::CallbackWrapperBase>> signalHandlers;
+
+    // Insert needed signal handlers.
+    signalHandlers.insert(DRing::exportable_callback<DRing::CallSignal::IncomingCallWithMedia>(
+        [&](const std::string& accountId,
+            const std::string& callId,
+            const std::string&,
+            const std::vector<DRing::MediaMap> mediaList) {
+            auto user = getUserAlias(callId);
+            if (not user.empty())
+                onIncomingCallWithMedia(accountId,
+                                        callId,
+                                        mediaList,
+                                        user == aliceData.alias_ ? aliceData : bobData);
+        }));
+
+    signalHandlers.insert(DRing::exportable_callback<DRing::CallSignal::MediaChangeRequested>(
+        [&](const std::string& accountId,
+            const std::string& callId,
+            const std::vector<DRing::MediaMap> mediaList) {
+            auto user = getUserAlias(callId);
+            if (not user.empty())
+                onMediaChangeRequested(accountId,
+                                       callId,
+                                       mediaList,
+                                       user == aliceData.alias_ ? aliceData : bobData);
+        }));
+
+    signalHandlers.insert(DRing::exportable_callback<DRing::CallSignal::StateChange>(
+        [&](const std::string& callId, const std::string& state, signed) {
+            auto user = getUserAlias(callId);
+            if (not user.empty())
+                onCallStateChange(callId, state, user == aliceData.alias_ ? aliceData : bobData);
+        }));
+
+    signalHandlers.insert(DRing::exportable_callback<DRing::CallSignal::VideoMuted>(
+        [&](const std::string& callId, bool muted) {
+            auto user = getUserAlias(callId);
+            if (not user.empty())
+                onVideoMuted(callId, muted, user == aliceData.alias_ ? aliceData : bobData);
+        }));
+
+    signalHandlers.insert(DRing::exportable_callback<DRing::CallSignal::MediaNegotiationStatus>(
+        [&](const std::string& callId, const std::string& event) {
+            auto user = getUserAlias(callId);
+            if (not user.empty())
+                onMediaNegotiationStatus(callId,
+                                         event,
+                                         user == aliceData.alias_ ? aliceData : bobData);
+        }));
+
+    DRing::registerSignalHandlers(signalHandlers);
+}
+
+void
+MediaNegotiationTest::testWithScenario(CallData& aliceData,
+                                       CallData& bobData,
+                                       const TestScenario& scenario)
+{
+    JAMI_INFO("=== Start a call and validate ===");
+
+    // The media count of the offer and answer must match (RFC-3264).
+    auto mediaCount = scenario.offer_.size();
+    CPPUNIT_ASSERT_EQUAL(mediaCount, scenario.answer_.size());
+
+    auto const& aliceCall = std::dynamic_pointer_cast<SIPCall>(
+        (Manager::instance().getAccount<JamiAccount>(aliceData.accountId_))
+            ->newOutgoingCall(bobData.userName_, scenario.offer_));
+    CPPUNIT_ASSERT(aliceCall);
+    aliceData.callId_ = aliceCall->getCallId();
+
+    JAMI_INFO("ALICE [%s] started a call with BOB [%s] and wait for answer",
+              aliceData.accountId_.c_str(),
+              bobData.accountId_.c_str());
+
+    // Wait for incoming call signal.
+    CPPUNIT_ASSERT_EQUAL(true,
+                         waitForSignal(bobData, DRing::CallSignal::IncomingCallWithMedia::name));
+    // Answer the call.
+    {
+        auto const& mediaList = MediaAttribute::mediaAttributesToMediaMaps(scenario.answer_);
+        Manager::instance().answerCallWithMedia(bobData.callId_, mediaList);
+    }
+
+    // Wait for media negotiation complete signal.
+    CPPUNIT_ASSERT_EQUAL(true,
+                         waitForSignal(bobData,
+                                       DRing::CallSignal::MediaNegotiationStatus::name,
+                                       MediaNegotiationStatusEvents::NEGOTIATION_SUCCESS));
+    // Wait for the StateChange signal.
+    CPPUNIT_ASSERT_EQUAL(true,
+                         waitForSignal(bobData,
+                                       DRing::CallSignal::StateChange::name,
+                                       StateEvent::CURRENT));
+
+    JAMI_INFO("BOB answered the call [%s]", bobData.callId_.c_str());
+
+    // Wait for media negotiation complete signal.
+    CPPUNIT_ASSERT_EQUAL(true,
+                         waitForSignal(aliceData,
+                                       DRing::CallSignal::MediaNegotiationStatus::name,
+                                       MediaNegotiationStatusEvents::NEGOTIATION_SUCCESS));
+
+    // Validate Alice's media
+    {
+        auto mediaAttr = aliceCall->getMediaAttributeList();
+        CPPUNIT_ASSERT_EQUAL(mediaCount, mediaAttr.size());
+        for (size_t idx = 0; idx < mediaCount; idx++) {
+            CPPUNIT_ASSERT_EQUAL(scenario.offer_[idx].muted_, mediaAttr[idx].muted_);
+        }
+    }
+
+    // Validate Bob's media
+    {
+        auto const& bobCall = std::dynamic_pointer_cast<SIPCall>(
+            Manager::instance().getCallFromCallID(bobData.callId_));
+        auto mediaAttr = bobCall->getMediaAttributeList();
+        CPPUNIT_ASSERT_EQUAL(mediaCount, mediaAttr.size());
+        for (size_t idx = 0; idx < mediaCount; idx++) {
+            CPPUNIT_ASSERT_EQUAL(scenario.answer_[idx].muted_, mediaAttr[idx].muted_);
+        }
+    }
+
+    std::this_thread::sleep_for(std::chrono::seconds(3));
+
+    JAMI_INFO("=== Request Media Change and validate ===");
+    {
+        auto const& mediaList = MediaAttribute::mediaAttributesToMediaMaps(scenario.offerUpdate_);
+        Manager::instance().requestMediaChange(aliceData.callId_, mediaList);
+    }
+
+    // Update and validate media count.
+    mediaCount = scenario.offerUpdate_.size();
+    CPPUNIT_ASSERT_EQUAL(mediaCount, scenario.answerUpdate_.size());
+
+    // Not all media change requests requires validation from client.
+    if (scenario.expectMediaChangeRequest_) {
+        // Wait for media change request signal.
+        CPPUNIT_ASSERT_EQUAL(true,
+                             waitForSignal(bobData, DRing::CallSignal::MediaChangeRequested::name));
+
+        // Answer the change request.
+        auto const& mediaList = MediaAttribute::mediaAttributesToMediaMaps(scenario.answerUpdate_);
+        Manager::instance().answerMediaChangeRequest(bobData.callId_, mediaList);
+    }
+
+    if (scenario.expectMediaRenegotiation_) {
+        // Wait for media negotiation complete signal.
+        CPPUNIT_ASSERT_EQUAL(true,
+                             waitForSignal(aliceData,
+                                           DRing::CallSignal::MediaNegotiationStatus::name,
+                                           MediaNegotiationStatusEvents::NEGOTIATION_SUCCESS));
+
+        // Validate Alice's media
+        {
+            auto mediaAttr = aliceCall->getMediaAttributeList();
+            CPPUNIT_ASSERT_EQUAL(mediaCount, mediaAttr.size());
+            for (size_t idx = 0; idx < mediaCount; idx++) {
+                CPPUNIT_ASSERT_EQUAL(scenario.offerUpdate_[idx].muted_, mediaAttr[idx].muted_);
+            }
+        }
+
+        // Validate Bob's media
+        {
+            auto const& bobCall = std::dynamic_pointer_cast<SIPCall>(
+                Manager::instance().getCallFromCallID(bobData.callId_));
+            auto mediaAttr = bobCall->getMediaAttributeList();
+            CPPUNIT_ASSERT_EQUAL(mediaCount, mediaAttr.size());
+            for (size_t idx = 0; idx < mediaCount; idx++) {
+                CPPUNIT_ASSERT_EQUAL(scenario.answerUpdate_[idx].muted_, mediaAttr[idx].muted_);
+            }
+        }
+    }
+
+    std::this_thread::sleep_for(std::chrono::seconds(3));
+
+    // Bob hang-up.
+    JAMI_INFO("Hang up BOB's call and wait for ALICE to hang up");
+    Manager::instance().hangupCall(bobData.callId_);
+
+    CPPUNIT_ASSERT_EQUAL(true,
+                         waitForSignal(aliceData,
+                                       DRing::CallSignal::StateChange::name,
+                                       StateEvent::HUNGUP));
+
+    JAMI_INFO("Call terminated on both sides");
+}
+
+void
+MediaNegotiationTest::audio_and_video_then_mute_video()
+{
+    JAMI_INFO("=== Begin test %s ===", __FUNCTION__);
+
+    JAMI_INFO("Waiting for accounts setup ...");
+    // TODO remove. This sleeps is because it take some time for the DHT to be connected
+    // and account announced
+    std::this_thread::sleep_for(std::chrono::seconds(10));
+
+    configureScenario(aliceData_, bobData_);
+
+    MediaAttribute defaultAudio(MediaType::MEDIA_AUDIO);
+    defaultAudio.label_ = "main audio";
+
+    MediaAttribute defaultVideo(MediaType::MEDIA_VIDEO);
+    defaultVideo.label_ = "main video";
+
+    {
+        MediaAttribute audio(defaultAudio);
+        MediaAttribute video(defaultVideo);
+
+        TestScenario scenario;
+        // First offer/answer
+        scenario.offer_.emplace_back(audio);
+        scenario.offer_.emplace_back(video);
+        scenario.answer_.emplace_back(audio);
+        scenario.answer_.emplace_back(video);
+
+        // Updated offer/answer
+        scenario.offerUpdate_.emplace_back(audio);
+        video.muted_ = true;
+        scenario.offerUpdate_.emplace_back(video);
+
+        scenario.answerUpdate_.emplace_back(audio);
+        video.muted_ = false;
+        scenario.answerUpdate_.emplace_back(video);
+        scenario.expectMediaRenegotiation_ = true;
+        scenario.expectMediaChangeRequest_ = false;
+
+        testWithScenario(aliceData_, bobData_, scenario);
+    }
+
+    DRing::unregisterSignalHandlers();
+
+    JAMI_INFO("=== End test %s ===", __FUNCTION__);
+}
+
+void
+MediaNegotiationTest::audio_only_then_add_video()
+{
+    JAMI_INFO("=== Begin test %s ===", __FUNCTION__);
+
+    JAMI_INFO("Waiting for accounts setup ...");
+    // TODO remove. This sleeps is because it take some time for the DHT to be connected
+    // and account announced
+    std::this_thread::sleep_for(std::chrono::seconds(10));
+
+    configureScenario(aliceData_, bobData_);
+
+    MediaAttribute defaultAudio(MediaType::MEDIA_AUDIO);
+    defaultAudio.label_ = "main audio";
+
+    MediaAttribute defaultVideo(MediaType::MEDIA_VIDEO);
+    defaultVideo.label_ = "main video";
+
+    {
+        MediaAttribute audio(defaultAudio);
+        MediaAttribute video(defaultVideo);
+
+        TestScenario scenario;
+        // First offer/answer
+        scenario.offer_.emplace_back(audio);
+        scenario.answer_.emplace_back(audio);
+
+        // Updated offer/answer
+        scenario.offerUpdate_.emplace_back(audio);
+        scenario.offerUpdate_.emplace_back(video);
+        scenario.answerUpdate_.emplace_back(audio);
+        scenario.answerUpdate_.emplace_back(video);
+        scenario.expectMediaRenegotiation_ = true;
+        scenario.expectMediaChangeRequest_ = true;
+
+        testWithScenario(aliceData_, bobData_, scenario);
+    }
+
+    DRing::unregisterSignalHandlers();
+
+    JAMI_INFO("=== End test %s ===", __FUNCTION__);
+}
+
+void
+MediaNegotiationTest::audio_and_video_then_mute_audio()
+{
+    JAMI_INFO("=== Begin test %s ===", __FUNCTION__);
+
+    JAMI_INFO("Waiting for accounts setup ...");
+    // TODO remove. This sleeps is because it take some time for the DHT to be connected
+    // and account announced
+    std::this_thread::sleep_for(std::chrono::seconds(10));
+
+    configureScenario(aliceData_, bobData_);
+
+    MediaAttribute defaultAudio(MediaType::MEDIA_AUDIO);
+    defaultAudio.label_ = "main audio";
+
+    MediaAttribute defaultVideo(MediaType::MEDIA_VIDEO);
+    defaultVideo.label_ = "main video";
+
+    {
+        MediaAttribute audio(defaultAudio);
+        MediaAttribute video(defaultVideo);
+
+        TestScenario scenario;
+        // First offer/answer
+        scenario.offer_.emplace_back(audio);
+        scenario.offer_.emplace_back(video);
+        scenario.answer_.emplace_back(audio);
+        scenario.answer_.emplace_back(video);
+
+        // Updated offer/answer
+        audio.muted_ = true;
+        scenario.offerUpdate_.emplace_back(audio);
+        scenario.offerUpdate_.emplace_back(video);
+
+        audio.muted_ = false;
+        scenario.answerUpdate_.emplace_back(audio);
+        scenario.answerUpdate_.emplace_back(video);
+
+        scenario.expectMediaRenegotiation_ = false;
+        scenario.expectMediaChangeRequest_ = false;
+
+        testWithScenario(aliceData_, bobData_, scenario);
+    }
+
+    DRing::unregisterSignalHandlers();
+
+    JAMI_INFO("=== End test %s ===", __FUNCTION__);
+}
+
+} // namespace test
+} // namespace jami
+
+RING_TEST_RUNNER(jami::test::MediaNegotiationTest::name())
-- 
GitLab