Fixed UI when merging calls into local conference

This commit is contained in:
Sylvain Berfini 2026-02-13 11:42:27 +01:00
parent e8601d8dab
commit 91f13fe407
8 changed files with 107 additions and 78 deletions

View file

@ -1040,6 +1040,11 @@ class CoreContext
@WorkerThread @WorkerThread
fun terminateCall(call: Call) { fun terminateCall(call: Call) {
val conference = call.conference
if (conference != null) {
Log.i("$TAG Terminating conference [${call.remoteAddress.asStringUriOnly()}]")
conference.terminate()
} else {
if (call.dir == Call.Dir.Incoming && LinphoneUtils.isCallIncoming(call.state)) { if (call.dir == Call.Dir.Incoming && LinphoneUtils.isCallIncoming(call.state)) {
val reason = if (call.core.callsNb > 1) Reason.Busy else Reason.Declined val reason = if (call.core.callsNb > 1) Reason.Busy else Reason.Declined
Log.i( Log.i(
@ -1051,6 +1056,7 @@ class CoreContext
call.terminate() call.terminate()
} }
} }
}
@UiThread @UiThread
fun showCallActivity() { fun showCallActivity() {

View file

@ -43,6 +43,7 @@ import androidx.databinding.DataBindingUtil
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.findNavController import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController
import androidx.window.layout.FoldingFeature import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker import androidx.window.layout.WindowInfoTracker
import androidx.window.layout.WindowLayoutInfo import androidx.window.layout.WindowLayoutInfo
@ -264,6 +265,18 @@ class CallActivity : GenericActivity() {
coreContext.enableProximitySensor(enabled) coreContext.enableProximitySensor(enabled)
} }
callViewModel.goToCallEvent.observe(this) {
it.consume {
navigateToActiveCall(true)
}
}
callViewModel.goToConferenceEvent.observe(this) {
it.consume {
navigateToActiveCall(false)
}
}
callsViewModel.showIncomingCallEvent.observe(this) { callsViewModel.showIncomingCallEvent.observe(this) {
it.consume { it.consume {
val action = IncomingCallFragmentDirections.actionGlobalIncomingCallFragment() val action = IncomingCallFragmentDirections.actionGlobalIncomingCallFragment()

View file

@ -237,17 +237,6 @@ class ActiveConferenceCallFragment : GenericCallFragment() {
} }
} }
callViewModel.goToCallEvent.observe(viewLifecycleOwner) {
it.consume {
if (findNavController().currentDestination?.id == R.id.activeConferenceCallFragment) {
Log.i("$TAG Going to active call fragment")
val action =
ActiveConferenceCallFragmentDirections.actionActiveConferenceCallFragmentToActiveCallFragment()
findNavController().navigate(action)
}
}
}
binding.setBackClickListener { binding.setBackClickListener {
(requireActivity() as CallActivity).goToMainActivity() (requireActivity() as CallActivity).goToMainActivity()
} }

View file

@ -98,6 +98,8 @@ class ConferenceViewModel
MutableLiveData() MutableLiveData()
} }
var conferenceConfigured = false
private lateinit var conference: Conference private lateinit var conference: Conference
private val conferenceListener = object : ConferenceListenerStub() { private val conferenceListener = object : ConferenceListenerStub() {
@ -289,6 +291,7 @@ class ConferenceViewModel
} }
init { init {
conferenceConfigured = false
isPaused.value = false isPaused.value = false
isConversationAvailable.value = false isConversationAvailable.value = false
isMeParticipantSendingVideo.value = false isMeParticipantSendingVideo.value = false
@ -297,6 +300,7 @@ class ConferenceViewModel
@WorkerThread @WorkerThread
fun destroy() { fun destroy() {
conferenceConfigured = false
isCurrentCallInConference.postValue(false) isCurrentCallInConference.postValue(false)
if (::conference.isInitialized) { if (::conference.isInitialized) {
conference.removeListener(conferenceListener) conference.removeListener(conferenceListener)
@ -314,6 +318,7 @@ class ConferenceViewModel
isCurrentCallInConference.postValue(true) isCurrentCallInConference.postValue(true)
conference = conf conference = conf
conference.addListener(conferenceListener) conference.addListener(conferenceListener)
conferenceConfigured = true
val isIn = conference.isIn val isIn = conference.isIn
val state = conf.state val state = conf.state
@ -351,15 +356,15 @@ class ConferenceViewModel
Log.w( Log.w(
"$TAG Conference has a participant sharing its screen, changing layout from mosaic to active speaker" "$TAG Conference has a participant sharing its screen, changing layout from mosaic to active speaker"
) )
setNewLayout(ACTIVE_SPEAKER_LAYOUT) setNewLayout(ACTIVE_SPEAKER_LAYOUT, call)
} else if (currentLayout == AUDIO_ONLY_LAYOUT) { } else if (currentLayout == AUDIO_ONLY_LAYOUT) {
val defaultLayout = call.core.defaultConferenceLayout.toInt() val defaultLayout = call.core.defaultConferenceLayout.toInt()
if (defaultLayout == Conference.Layout.ActiveSpeaker.toInt()) { if (defaultLayout == Conference.Layout.ActiveSpeaker.toInt()) {
Log.w("$TAG Joined conference in audio only layout, switching to active speaker layout") Log.w("$TAG Joined conference in audio only layout, switching to active speaker layout")
setNewLayout(ACTIVE_SPEAKER_LAYOUT) setNewLayout(ACTIVE_SPEAKER_LAYOUT, call)
} else { } else {
Log.w("$TAG Joined conference in audio only layout, switching to grid layout") Log.w("$TAG Joined conference in audio only layout, switching to grid layout")
setNewLayout(GRID_LAYOUT) setNewLayout(GRID_LAYOUT, call)
} }
} }
} }
@ -461,9 +466,9 @@ class ConferenceViewModel
} }
@WorkerThread @WorkerThread
fun setNewLayout(newLayout: Int) { fun setNewLayout(newLayout: Int, currentCall: Call? = null) {
if (::conference.isInitialized) { if (::conference.isInitialized) {
val call = conference.call val call = conference.call ?: currentCall
if (call != null) { if (call != null) {
val params = call.core.createCallParams(call) val params = call.core.createCallParams(call)
if (params != null) { if (params != null) {

View file

@ -331,17 +331,6 @@ class ActiveCallFragment : GenericCallFragment() {
} }
} }
callViewModel.goToConferenceEvent.observe(viewLifecycleOwner) {
it.consume {
if (findNavController().currentDestination?.id == R.id.activeCallFragment) {
Log.i("$TAG Going to conference fragment")
val action =
ActiveCallFragmentDirections.actionActiveCallFragmentToActiveConferenceCallFragment()
findNavController().navigate(action)
}
}
}
callViewModel.isReceivingVideo.observe(viewLifecycleOwner) { receiving -> callViewModel.isReceivingVideo.observe(viewLifecycleOwner) { receiving ->
if (!receiving && callViewModel.fullScreenMode.value == true) { if (!receiving && callViewModel.fullScreenMode.value == true) {
Log.i("$TAG We are no longer receiving video, leaving full screen mode") Log.i("$TAG We are no longer receiving video, leaving full screen mode")

View file

@ -47,6 +47,8 @@ class CallsViewModel
val callsCount = MutableLiveData<Int>() val callsCount = MutableLiveData<Int>()
val allCallsIntoConference = MutableLiveData<Boolean>()
val showTopBar = MutableLiveData<Boolean>() val showTopBar = MutableLiveData<Boolean>()
val goToActiveCallEvent = MutableLiveData<Event<Boolean>>() val goToActiveCallEvent = MutableLiveData<Event<Boolean>>()
@ -234,6 +236,7 @@ class CallsViewModel
showRedToast(R.string.conference_failed_to_merge_calls_into_conference_toast, R.drawable.warning_circle) showRedToast(R.string.conference_failed_to_merge_calls_into_conference_toast, R.drawable.warning_circle)
} else { } else {
conference.addParticipants(core.calls) conference.addParticipants(core.calls)
allCallsIntoConference.postValue(true)
} }
} }
} }
@ -251,9 +254,16 @@ class CallsViewModel
} }
callsExceptCurrentOne.postValue(list) callsExceptCurrentOne.postValue(list)
if (core.callsNb > 1) { val callsCount = core.callsNb
showTopBar.postValue(true) if (callsCount > 1) {
if (core.callsNb == 2) { val callsNotInConference = core.calls.filter {
it.conference == null
}
val callsNotInConferenceCount = callsNotInConference.count()
Log.i("$TAG Found [$callsNotInConferenceCount] calls not in conference over [$callsCount] calls")
allCallsIntoConference.postValue(callsNotInConferenceCount == 0)
if (callsNotInConferenceCount == 1) {
val found = core.calls.find { val found = core.calls.find {
it.state == Call.State.Paused it.state == Call.State.Paused
} }
@ -273,19 +283,25 @@ class CallsViewModel
} }
callsTopBarStatus.postValue(LinphoneUtils.callStateToString(found.state)) callsTopBarStatus.postValue(LinphoneUtils.callStateToString(found.state))
} else { } else {
Log.e("$TAG Failed to find a paused call") Log.w("$TAG Failed to find a paused call")
} }
} else { } else if (callsNotInConferenceCount > 1) {
callsTopBarLabel.postValue( callsTopBarLabel.postValue(
AppUtils.getFormattedString(R.string.calls_paused_count_label, core.callsNb - 1) AppUtils.getFormattedString(R.string.calls_paused_count_label, core.callsNb - 1)
) )
callsTopBarStatus.postValue("") // TODO: improve ? callsTopBarStatus.postValue("") // TODO: improve ?
}
} else { } else {
if (core.callsNb == 1) { configureTopBarForSingleCallOrConference()
}
} else if (core.callsNb == 1) {
configureTopBarForSingleCallOrConference()
}
}
private fun configureTopBarForSingleCallOrConference() {
callsTopBarIcon.postValue(R.drawable.phone) callsTopBarIcon.postValue(R.drawable.phone)
val call = core.calls.first() val call = coreContext.core.calls.first()
val conference = call.conference val conference = call.conference
if (conference != null) { if (conference != null) {
callsTopBarLabel.postValue(conference.subject) callsTopBarLabel.postValue(conference.subject)
@ -301,5 +317,3 @@ class CallsViewModel
callsTopBarStatus.postValue(LinphoneUtils.callStateToString(call.state)) callsTopBarStatus.postValue(LinphoneUtils.callStateToString(call.state))
} }
} }
}
}

View file

@ -392,6 +392,13 @@ class CurrentCallViewModel
} }
else -> {} else -> {}
} }
if (call.conference != null && !conferenceModel.conferenceConfigured) {
Log.i("$TAG Found conference on call but not conference model, initializing it now")
conferenceModel.configureFromCall(call)
updateMicrophoneMutedIcon()
goToConferenceEvent.postValue(Event(true))
}
} }
} }
@ -695,21 +702,7 @@ class CurrentCallViewModel
@UiThread @UiThread
fun refreshMicrophoneState() { fun refreshMicrophoneState() {
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
if (::currentCall.isInitialized) { updateMicrophoneMutedIcon()
val micMuted = if (currentCall.conference != null) {
currentCall.conference?.microphoneMuted == true
} else {
currentCall.microphoneMuted
}
if (micMuted != isMicrophoneMuted.value) {
if (micMuted) {
Log.w("$TAG Microphone is muted, updating button state accordingly")
} else {
Log.i("$TAG Microphone is not muted, updating button state accordingly")
}
isMicrophoneMuted.postValue(micMuted)
}
}
} }
} }
@ -1097,6 +1090,7 @@ class CurrentCallViewModel
conferenceModel.configureFromCall(call) conferenceModel.configureFromCall(call)
goToConferenceEvent.postValue(Event(true)) goToConferenceEvent.postValue(Event(true))
} else { } else {
Log.i("$TAG No conference attached to this call, going to call fragment")
conferenceModel.destroy() conferenceModel.destroy()
goToCallEvent.postValue(Event(true)) goToCallEvent.postValue(Event(true))
} }
@ -1262,6 +1256,25 @@ class CurrentCallViewModel
} }
} }
@WorkerThread
private fun updateMicrophoneMutedIcon() {
if (::currentCall.isInitialized) {
val micMuted = if (currentCall.conference != null) {
currentCall.conference?.microphoneMuted == true
} else {
currentCall.microphoneMuted
}
if (micMuted != isMicrophoneMuted.value) {
if (micMuted) {
Log.w("$TAG Microphone is muted, updating button state accordingly")
} else {
Log.i("$TAG Microphone is not muted, updating button state accordingly")
}
isMicrophoneMuted.postValue(micMuted)
}
}
}
@WorkerThread @WorkerThread
private fun updateOutputAudioDevice(audioDevice: AudioDevice?) { private fun updateOutputAudioDevice(audioDevice: AudioDevice?) {
Log.i("$TAG Output audio device updated to [${audioDevice?.deviceName} (${audioDevice?.type})]") Log.i("$TAG Output audio device updated to [${audioDevice?.deviceName} (${audioDevice?.type})]")

View file

@ -13,7 +13,7 @@
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@{viewModel.callsCount > 1 || viewModel.showTopBar ? @drawable/color_success_500 : @drawable/color_black, default=@drawable/color_black}" android:background="@{(viewModel.callsCount > 1 &amp;&amp; !viewModel.allCallsIntoConference) || viewModel.showTopBar ? @drawable/color_success_500 : @drawable/color_black, default=@drawable/color_black}"
android:onClick="@{() -> viewModel.topBarClicked()}"> android:onClick="@{() -> viewModel.topBarClicked()}">
<ImageView <ImageView
@ -24,7 +24,7 @@
android:layout_marginEnd="10dp" android:layout_marginEnd="10dp"
android:src="@{viewModel.callsTopBarIcon, default=@drawable/phone_pause}" android:src="@{viewModel.callsTopBarIcon, default=@drawable/phone_pause}"
android:contentDescription="@null" android:contentDescription="@null"
android:visibility="@{viewModel.callsCount > 1 || viewModel.showTopBar ? View.VISIBLE : View.GONE, default=gone}" android:visibility="@{(viewModel.callsCount > 1 &amp;&amp; !viewModel.allCallsIntoConference) || viewModel.showTopBar ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/call_display_name" app:layout_constraintTop_toTopOf="@id/call_display_name"
app:layout_constraintBottom_toBottomOf="@id/call_display_name" app:layout_constraintBottom_toBottomOf="@id/call_display_name"
@ -43,7 +43,7 @@
android:text="@{viewModel.callsTopBarLabel, default=`John Doe`}" android:text="@{viewModel.callsTopBarLabel, default=`John Doe`}"
android:textColor="@color/bc_white" android:textColor="@color/bc_white"
android:textSize="16sp" android:textSize="16sp"
android:visibility="@{viewModel.callsCount > 1 || viewModel.showTopBar ? View.VISIBLE : View.GONE, default=gone}" android:visibility="@{(viewModel.callsCount > 1 &amp;&amp; !viewModel.allCallsIntoConference) || viewModel.showTopBar ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintEnd_toStartOf="@id/call_time" app:layout_constraintEnd_toStartOf="@id/call_time"
app:layout_constraintStart_toEndOf="@id/call_icon" app:layout_constraintStart_toEndOf="@id/call_icon"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
@ -59,7 +59,7 @@
android:text="@{viewModel.callsTopBarStatus, default=`Paused`}" android:text="@{viewModel.callsTopBarStatus, default=`Paused`}"
android:textColor="@color/bc_white" android:textColor="@color/bc_white"
android:textSize="14sp" android:textSize="14sp"
android:visibility="@{viewModel.callsCount > 1 || viewModel.showTopBar ? View.VISIBLE : View.GONE, default=gone}" android:visibility="@{(viewModel.callsCount > 1 &amp;&amp; !viewModel.allCallsIntoConference) || viewModel.showTopBar ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/call_display_name" app:layout_constraintStart_toEndOf="@id/call_display_name"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"