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,15 +1040,21 @@ class CoreContext
@WorkerThread
fun terminateCall(call: Call) {
if (call.dir == Call.Dir.Incoming && LinphoneUtils.isCallIncoming(call.state)) {
val reason = if (call.core.callsNb > 1) Reason.Busy else Reason.Declined
Log.i(
"$TAG Declining call [${call.remoteAddress.asStringUriOnly()}] with reason [$reason]"
)
call.decline(reason)
val conference = call.conference
if (conference != null) {
Log.i("$TAG Terminating conference [${call.remoteAddress.asStringUriOnly()}]")
conference.terminate()
} else {
Log.i("$TAG Terminating call [${call.remoteAddress.asStringUriOnly()}]")
call.terminate()
if (call.dir == Call.Dir.Incoming && LinphoneUtils.isCallIncoming(call.state)) {
val reason = if (call.core.callsNb > 1) Reason.Busy else Reason.Declined
Log.i(
"$TAG Declining call [${call.remoteAddress.asStringUriOnly()}] with reason [$reason]"
)
call.decline(reason)
} else {
Log.i("$TAG Terminating call [${call.remoteAddress.asStringUriOnly()}]")
call.terminate()
}
}
}

View file

@ -43,6 +43,7 @@ import androidx.databinding.DataBindingUtil
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import androidx.window.layout.WindowLayoutInfo
@ -264,6 +265,18 @@ class CallActivity : GenericActivity() {
coreContext.enableProximitySensor(enabled)
}
callViewModel.goToCallEvent.observe(this) {
it.consume {
navigateToActiveCall(true)
}
}
callViewModel.goToConferenceEvent.observe(this) {
it.consume {
navigateToActiveCall(false)
}
}
callsViewModel.showIncomingCallEvent.observe(this) {
it.consume {
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 {
(requireActivity() as CallActivity).goToMainActivity()
}

View file

@ -98,6 +98,8 @@ class ConferenceViewModel
MutableLiveData()
}
var conferenceConfigured = false
private lateinit var conference: Conference
private val conferenceListener = object : ConferenceListenerStub() {
@ -289,6 +291,7 @@ class ConferenceViewModel
}
init {
conferenceConfigured = false
isPaused.value = false
isConversationAvailable.value = false
isMeParticipantSendingVideo.value = false
@ -297,6 +300,7 @@ class ConferenceViewModel
@WorkerThread
fun destroy() {
conferenceConfigured = false
isCurrentCallInConference.postValue(false)
if (::conference.isInitialized) {
conference.removeListener(conferenceListener)
@ -314,6 +318,7 @@ class ConferenceViewModel
isCurrentCallInConference.postValue(true)
conference = conf
conference.addListener(conferenceListener)
conferenceConfigured = true
val isIn = conference.isIn
val state = conf.state
@ -351,15 +356,15 @@ class ConferenceViewModel
Log.w(
"$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) {
val defaultLayout = call.core.defaultConferenceLayout.toInt()
if (defaultLayout == Conference.Layout.ActiveSpeaker.toInt()) {
Log.w("$TAG Joined conference in audio only layout, switching to active speaker layout")
setNewLayout(ACTIVE_SPEAKER_LAYOUT)
setNewLayout(ACTIVE_SPEAKER_LAYOUT, call)
} else {
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
fun setNewLayout(newLayout: Int) {
fun setNewLayout(newLayout: Int, currentCall: Call? = null) {
if (::conference.isInitialized) {
val call = conference.call
val call = conference.call ?: currentCall
if (call != null) {
val params = call.core.createCallParams(call)
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 ->
if (!receiving && callViewModel.fullScreenMode.value == true) {
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 allCallsIntoConference = MutableLiveData<Boolean>()
val showTopBar = MutableLiveData<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)
} else {
conference.addParticipants(core.calls)
allCallsIntoConference.postValue(true)
}
}
}
@ -251,9 +254,16 @@ class CallsViewModel
}
callsExceptCurrentOne.postValue(list)
if (core.callsNb > 1) {
showTopBar.postValue(true)
if (core.callsNb == 2) {
val callsCount = core.callsNb
if (callsCount > 1) {
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 {
it.state == Call.State.Paused
}
@ -273,33 +283,37 @@ class CallsViewModel
}
callsTopBarStatus.postValue(LinphoneUtils.callStateToString(found.state))
} 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(
AppUtils.getFormattedString(R.string.calls_paused_count_label, core.callsNb - 1)
)
callsTopBarStatus.postValue("") // TODO: improve ?
} else {
configureTopBarForSingleCallOrConference()
}
} else {
if (core.callsNb == 1) {
callsTopBarIcon.postValue(R.drawable.phone)
val call = core.calls.first()
val conference = call.conference
if (conference != null) {
callsTopBarLabel.postValue(conference.subject)
} else {
val remoteAddress = call.callLog.remoteAddress
val contact = coreContext.contactsManager.findContactByAddress(
remoteAddress
)
callsTopBarLabel.postValue(
contact?.name ?: LinphoneUtils.getDisplayName(remoteAddress)
)
}
callsTopBarStatus.postValue(LinphoneUtils.callStateToString(call.state))
}
} else if (core.callsNb == 1) {
configureTopBarForSingleCallOrConference()
}
}
private fun configureTopBarForSingleCallOrConference() {
callsTopBarIcon.postValue(R.drawable.phone)
val call = coreContext.core.calls.first()
val conference = call.conference
if (conference != null) {
callsTopBarLabel.postValue(conference.subject)
} else {
val remoteAddress = call.callLog.remoteAddress
val contact = coreContext.contactsManager.findContactByAddress(
remoteAddress
)
callsTopBarLabel.postValue(
contact?.name ?: LinphoneUtils.getDisplayName(remoteAddress)
)
}
callsTopBarStatus.postValue(LinphoneUtils.callStateToString(call.state))
}
}

View file

@ -392,6 +392,13 @@ class CurrentCallViewModel
}
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
fun refreshMicrophoneState() {
coreContext.postOnCoreThread {
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)
}
}
updateMicrophoneMutedIcon()
}
}
@ -1097,6 +1090,7 @@ class CurrentCallViewModel
conferenceModel.configureFromCall(call)
goToConferenceEvent.postValue(Event(true))
} else {
Log.i("$TAG No conference attached to this call, going to call fragment")
conferenceModel.destroy()
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
private fun updateOutputAudioDevice(audioDevice: AudioDevice?) {
Log.i("$TAG Output audio device updated to [${audioDevice?.deviceName} (${audioDevice?.type})]")

View file

@ -13,7 +13,7 @@
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
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()}">
<ImageView
@ -24,7 +24,7 @@
android:layout_marginEnd="10dp"
android:src="@{viewModel.callsTopBarIcon, default=@drawable/phone_pause}"
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_constraintTop_toTopOf="@id/call_display_name"
app:layout_constraintBottom_toBottomOf="@id/call_display_name"
@ -43,7 +43,7 @@
android:text="@{viewModel.callsTopBarLabel, default=`John Doe`}"
android:textColor="@color/bc_white"
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_constraintStart_toEndOf="@id/call_icon"
app:layout_constraintTop_toTopOf="parent"
@ -59,7 +59,7 @@
android:text="@{viewModel.callsTopBarStatus, default=`Paused`}"
android:textColor="@color/bc_white"
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_constraintStart_toEndOf="@id/call_display_name"
app:layout_constraintTop_toTopOf="parent"