diff --git a/bin/dbus/cx.ring.Ring.CallManager.xml b/bin/dbus/cx.ring.Ring.CallManager.xml
index 0ae9e74ed4996664b6da3a1da1964c6f53c9cfac..83ffb8df0e9ad688365ce8edb4e3591e11e6a68d 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 2f47339411aeedf5db0d31522f0db73288afb4b3..baed40d6916613e7905cd8557619e3a053aa7d71 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 a213d12df576315f4941e856b16b54960107c741..84222523b5131953ea7e3912fbd403675da36e73 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 6724539a512173a3b370a76c602d4dc21eed0bdd..59a1f30312e2ed1fe770f23de65791b99d6bee6c 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 745dcaad85abd90dffc59dc14c5702978c75b2ec..a7ae0bd43c057eb3734cd04a6247c74ed5c28ecd 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 da3e951268cf47bd4933c5f87269043fe7aec770..8687220dd5d5aaf3b44bdcfebd022e4eac960879 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 dbcd364b5d3f0aa29755701568d121adbd85d57f..8767be2195b143cbcbe6727e7a8bdb7dc1cea551 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 ece8b9c0d808c79e680a2d87e288c849952a275b..589c5d6afdf495dc0334cbf5256dda69a2c8e41c 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 521e566a939cd85cf1bfc7756508640dbd5e6015..0916b1af5ed4fb1e305ab76edcc2b1e4a47ed9a5 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 0282713af431419c6cfac34a0709230f77fbd119..607a798d9a308d930dc55269dc2b1cf485571972 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 006a8f155cc84bc72c4faeda2bda7676a75a491a..c47c194d23c02b4dc5c14c92b5d7bb343442b5b7 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 f3374befc8d1d96d6304489748ba9ae57711d16a..9ecb37cb78203df64e8a10bd15cf7a93baa2e1d2 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 50e58032ff06c5a284ac31f15cdf5ec1e255593b..841327b131b2f92b500e5ecdc9c5882c09dd3a2d 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 4397e6d48280365677073f69d16f6c3b1bf7092a..a306c0765480833bec251157c4cda93543883f19 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 f68d94b2dbea4a578644b2f6efa9dbecdb17e52f..541f5e36351991e674b5c0024539b3be03710e00 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 613e8ecf75ff17a00702c1b380c17a14b15c2672..7a8e55c53c33d368299c3c7b458689c2d66f1c8b 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 28b3103767d3919e58dd5e6626e18f6639f20788..21bcf1769034d2a5c6c072a1b914ff93d2a5b752 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 4e30c5bdd21c55a2cedb95b19d94d8c580de4bf4..b0756201198a672da1ab6da9c30bf358a58f539f 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 cb30cc049179c6ca156f24649b6e8bd91b4f6a5c..ead8170ba8bf6d8b51a87b1e7efe277b7163c3c6 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 0548d499e0547f7cd86e436e13d4891748a9f7d7..e1a29bd9ae6a84a158f9d0e3f521799ee74e92d9 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 fc13ebe20a94a9c0b26c7a48d852cb015a0efcb1..199069e3a2d42ae06c82bfffba21feeee411dae7 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 14786592a6a4cad6564d095e086b5626869dbc08..051c7688402d38039494abd4650428d84a2ad064 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 69841d41d5bb12c5bb379bd1ccd064daf393bb01..ee587126774e1b1ca9b6101b31271b0e5caf030a 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 09bc16849d243e6b877ee84601d9709a505961de..788b14c15efa6bc5591f213f011e08b6ad23022a 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 22cbed8dbd81f05af7b2471f1910773afb3884e2..543c1bfa9612f8c59f00b42e6692d995f4725b75 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 15009f1b7afbee24539a13d10442601c8080d59e..5e5484b52363beac42dcfcd6fd88fb9ae60039c0 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 92f5ce85aede17b92bb8163695866c388ae48cbb..9f36efaa815208873d6caeec383a9b1193b09cd6 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 62d734605d65d496f5820ce5234f700e9c7eda69..359df70b9671e326fc843e521ea31192b15d501b 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 b7a18b90c38d79265dedcd7ee1be246549d7f6c0..fd0c5c6b27401550903093d3f46872a776f648df 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 81ea04ee80daef89742b689f024d341f06ddd60c..a475a8a55cc89657966797098051fa50531035a4 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 b4943392f4cbf855fffcca39e72df0b52b24ac58..87bf03c4a8eb69476edca7d36346ca4dd4349a15 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 80522548bbefa1b9eb722e4c2664143f3232662a..854186c6aab1a79bb3c62ddb6518f9e7e30f1992 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 9b60c286ee7b762231f24e45c6ca6d73240c49f0..a19f97bb23c5a21ee8e5e0bdca94f1efe24cc97d 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 0000000000000000000000000000000000000000..eb265a7a6990ede183d6e417cfaf83ebb98a4238
--- /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())