diff --git a/app/src/main/java/org/linphone/ui/call/fragment/ActiveConferenceCallFragment.kt b/app/src/main/java/org/linphone/ui/call/fragment/ActiveConferenceCallFragment.kt index 1dc205f85..ed20d7baa 100644 --- a/app/src/main/java/org/linphone/ui/call/fragment/ActiveConferenceCallFragment.kt +++ b/app/src/main/java/org/linphone/ui/call/fragment/ActiveConferenceCallFragment.kt @@ -25,6 +25,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.lifecycle.ViewModelProvider +import com.google.android.material.bottomsheet.BottomSheetBehavior import org.linphone.databinding.CallActiveConferenceFragmentBinding import org.linphone.ui.call.viewmodel.CallsViewModel import org.linphone.ui.call.viewmodel.CurrentCallViewModel @@ -66,9 +67,23 @@ class ActiveConferenceCallFragment : GenericCallFragment() { binding.callsViewModel = callsViewModel binding.numpadModel = callViewModel.numpadModel + val actionsBottomSheetBehavior = BottomSheetBehavior.from(binding.bottomBar.root) + actionsBottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + callViewModel.callDuration.observe(viewLifecycleOwner) { duration -> binding.chronometer.base = SystemClock.elapsedRealtime() - (1000 * duration) binding.chronometer.start() } + + callViewModel.toggleExtraActionsBottomSheetEvent.observe(viewLifecycleOwner) { + it.consume { + val state = actionsBottomSheetBehavior.state + if (state == BottomSheetBehavior.STATE_COLLAPSED) { + actionsBottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED + } else if (state == BottomSheetBehavior.STATE_EXPANDED) { + actionsBottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + } + } + } } } diff --git a/app/src/main/java/org/linphone/ui/call/model/ConferenceModel.kt b/app/src/main/java/org/linphone/ui/call/model/ConferenceModel.kt index 145af8603..01bef7e8b 100644 --- a/app/src/main/java/org/linphone/ui/call/model/ConferenceModel.kt +++ b/app/src/main/java/org/linphone/ui/call/model/ConferenceModel.kt @@ -23,6 +23,9 @@ import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData import org.linphone.core.Call import org.linphone.core.Conference +import org.linphone.core.ConferenceListenerStub +import org.linphone.core.Participant +import org.linphone.core.ParticipantDevice import org.linphone.core.tools.Log class ConferenceModel { @@ -32,16 +35,152 @@ class ConferenceModel { val subject = MutableLiveData() + val participantDevices = MutableLiveData>() + private lateinit var conference: Conference + private val conferenceListener = object : ConferenceListenerStub() { + @WorkerThread + override fun onParticipantDeviceAdded( + conference: Conference, + participantDevice: ParticipantDevice + ) { + Log.i( + "$TAG Participant device added: ${participantDevice.address.asStringUriOnly()}" + ) + + val list = arrayListOf() + list.addAll(participantDevices.value.orEmpty()) + + val newModel = ConferenceParticipantDeviceModel(participantDevice) + list.add(newModel) + + participantDevices.postValue(sortParticipantDevicesList(list)) + } + + @WorkerThread + override fun onParticipantDeviceRemoved( + conference: Conference, + participantDevice: ParticipantDevice + ) { + Log.i( + "$TAG Participant device removed: ${participantDevice.address.asStringUriOnly()}" + ) + + val list = arrayListOf() + list.addAll(participantDevices.value.orEmpty()) + + val toRemove = list.find { + participantDevice.address.weakEqual(it.device.address) + } + if (toRemove != null) { + toRemove.destroy() + list.remove(toRemove) + } + + participantDevices.postValue(list) + } + + @WorkerThread + override fun onParticipantDeviceStateChanged( + conference: Conference, + device: ParticipantDevice, + state: ParticipantDevice.State + ) { + Log.i( + "$TAG Participant device [${device.address.asStringUriOnly()}] state changed [$state]" + ) + } + + @WorkerThread + override fun onStateChanged(conference: Conference, state: Conference.State) { + Log.i("$TAG State changed [$state]") + } + } + + @WorkerThread + fun destroy() { + if (::conference.isInitialized) { + conference.removeListener(conferenceListener) + participantDevices.value.orEmpty().forEach(ConferenceParticipantDeviceModel::destroy) + } + } + @WorkerThread fun configureFromCall(call: Call) { val conf = call.conference ?: return + if (::conference.isInitialized) { + conference.removeListener(conferenceListener) + } + conference = conf + conference.addListener(conferenceListener) Log.i( "$TAG Configuring conference with subject [${conference.subject}] from call [${call.callLog.callId}]" ) subject.postValue(conference.subject) + + computeParticipantsDevices() + } + + @WorkerThread + private fun computeParticipantsDevices() { + participantDevices.value.orEmpty().forEach(ConferenceParticipantDeviceModel::destroy) + val list = arrayListOf() + + val participants = conference.participantList + Log.i("$TAG [${participants.size}] participant in conference") + + for (participant in participants) { + val devices = participant.devices + val role = participant.role + + Log.i( + "$TAG Participant [${participant.address.asStringUriOnly()}] has [${devices.size}] devices and role [${role.name}]" + ) + if (role == Participant.Role.Listener) { + continue + } + + for (device in participant.devices) { + val model = ConferenceParticipantDeviceModel(device) + list.add(model) + } + } + Log.i( + "$TAG [${list.size}] participant devices will be displayed (not counting ourselves)" + ) + + val ourDevices = conference.me.devices + Log.i("$TAG We have [${ourDevices.size}] devices") + for (device in ourDevices) { + val model = ConferenceParticipantDeviceModel(device, true) + list.add(model) + } + + participantDevices.postValue(sortParticipantDevicesList(list)) + } + + private fun sortParticipantDevicesList(devices: List): ArrayList { + val sortedList = arrayListOf() + sortedList.addAll(devices) + + val meDeviceData = sortedList.find { + it.isMe + } + if (meDeviceData != null) { + val index = sortedList.indexOf(meDeviceData) + val expectedIndex = sortedList.size - 1 + if (index != expectedIndex) { + Log.i( + "$TAG Me device data is at index $index, moving it to index $expectedIndex" + ) + sortedList.removeAt(index) + sortedList.add(expectedIndex, meDeviceData) + } + } + + return sortedList } } diff --git a/app/src/main/java/org/linphone/ui/call/model/ConferenceParticipantDeviceModel.kt b/app/src/main/java/org/linphone/ui/call/model/ConferenceParticipantDeviceModel.kt new file mode 100644 index 000000000..b4ba8a624 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/call/model/ConferenceParticipantDeviceModel.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * 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 . + */ +package org.linphone.ui.call.model + +import androidx.annotation.WorkerThread +import androidx.lifecycle.MutableLiveData +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.core.ParticipantDevice +import org.linphone.core.ParticipantDeviceListenerStub +import org.linphone.core.tools.Log + +class ConferenceParticipantDeviceModel @WorkerThread constructor( + val device: ParticipantDevice, + val isMe: Boolean = false +) { + companion object { + private const val TAG = "[Conference Participant Device Model]" + } + + val avatarModel = coreContext.contactsManager.getContactAvatarModelForAddress(device.address) + + val isMuted = MutableLiveData() + + val isSpeaking = MutableLiveData() + + private val deviceListener = object : ParticipantDeviceListenerStub() { + override fun onStateChanged( + participantDevice: ParticipantDevice, + state: ParticipantDevice.State? + ) { + Log.i( + "$TAG Participant device [${participantDevice.address.asStringUriOnly()}] state changed [$state]" + ) + } + + override fun onIsMuted(participantDevice: ParticipantDevice, muted: Boolean) { + Log.i( + "$TAG Participant device [${participantDevice.address.asStringUriOnly()}] is ${if (participantDevice.isMuted) "muted" else "no longer muted"}" + ) + isMuted.postValue(participantDevice.isMuted) + } + + override fun onIsSpeakingChanged( + participantDevice: ParticipantDevice, + speaking: Boolean + ) { + Log.i( + "$TAG Participant device [${participantDevice.address.asStringUriOnly()}] is ${if (participantDevice.isSpeaking) "speaking" else "no longer speaking"}" + ) + isSpeaking.postValue(participantDevice.isSpeaking) + } + } + + init { + device.addListener(deviceListener) + + isMuted.postValue(device.isMuted) + isSpeaking.postValue(device.isSpeaking) + Log.i( + "$TAG Participant [${device.address.asStringUriOnly()}] is in state [${device.state}]" + ) + } + + @WorkerThread + fun destroy() { + device.removeListener(deviceListener) + } +} diff --git a/app/src/main/java/org/linphone/ui/call/view/GridBoxLayout.kt b/app/src/main/java/org/linphone/ui/call/view/GridBoxLayout.kt new file mode 100644 index 000000000..dec41081b --- /dev/null +++ b/app/src/main/java/org/linphone/ui/call/view/GridBoxLayout.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2010-2022 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * 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 . + */ +package org.linphone.ui.call.view + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.widget.GridLayout +import androidx.annotation.UiThread +import androidx.core.view.children +import org.linphone.core.tools.Log + +@UiThread +class GridBoxLayout : GridLayout { + companion object { + private const val TAG = "[Grid Box Layout]" + + private val placementMatrix = arrayOf( + intArrayOf(1, 2, 3, 4, 5, 6), + intArrayOf(1, 1, 2, 2, 3, 3), + intArrayOf(1, 1, 1, 2, 2, 2), + intArrayOf(1, 1, 1, 1, 2, 2), + intArrayOf(1, 1, 1, 1, 1, 2), + intArrayOf(1, 1, 1, 1, 1, 1) + ) + } + + constructor(context: Context) : this(context, null) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) + + private var centerContent: Boolean = true + private var previousChildCount = 0 + private var previousCellSize = 0 + + @SuppressLint("DrawAllocation") + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + if (childCount == 0 || (!changed && previousChildCount == childCount)) { + super.onLayout(changed, left, top, right, bottom) + // To prevent display issue the first time conference is locally paused + children.forEach { child -> + child.post { + child.layoutParams.width = previousCellSize + child.layoutParams.height = previousCellSize + child.requestLayout() + } + } + return + } + + // To prevent java.lang.IllegalArgumentException: columnCount must be greater than or equal + // to the maximum of all grid indices (and spans) defined in the LayoutParams of each child. + children.forEach { child -> + child.layoutParams = LayoutParams() + } + + val maxChild = placementMatrix[0].size + if (childCount > maxChild) { + val maxMosaicParticipants = 6 + Log.e( + "$TAG $childCount children but placementMatrix only knows how to display $maxChild (max allowed participants for grid layout in settings is $maxMosaicParticipants)" + ) + return + } + + val availableSize = Pair(right - left, bottom - top) + var cellSize = 0 + for (index in 1..childCount) { + val neededColumns = placementMatrix[index - 1][childCount - 1] + val candidateWidth = 1 * availableSize.first / neededColumns + val candidateHeight = 1 * availableSize.second / index + val candidateSize = if (candidateWidth < candidateHeight) candidateWidth else candidateHeight + if (candidateSize > cellSize) { + columnCount = neededColumns + rowCount = index + cellSize = candidateSize + } + } + previousCellSize = cellSize + previousChildCount = childCount + + super.onLayout(changed, left, top, right, bottom) + children.forEach { child -> + child.layoutParams.width = cellSize + child.layoutParams.height = cellSize + child.post { + child.requestLayout() + } + } + + if (centerContent) { + setPadding( + (availableSize.first - (columnCount * cellSize)) / 2, + (availableSize.second - (rowCount * cellSize)) / 2, + (availableSize.first - (columnCount * cellSize)) / 2, + (availableSize.second - (rowCount * cellSize)) / 2 + ) + } + Log.d( + "$TAG cellsize=$cellSize columns=$columnCount rows=$rowCount availablesize=$availableSize" + ) + } +} diff --git a/app/src/main/java/org/linphone/ui/call/viewmodel/CurrentCallViewModel.kt b/app/src/main/java/org/linphone/ui/call/viewmodel/CurrentCallViewModel.kt index 46429b508..78f5731c7 100644 --- a/app/src/main/java/org/linphone/ui/call/viewmodel/CurrentCallViewModel.kt +++ b/app/src/main/java/org/linphone/ui/call/viewmodel/CurrentCallViewModel.kt @@ -411,6 +411,7 @@ class CurrentCallViewModel @UiThread constructor() : ViewModel() { coreContext.postOnCoreThread { core -> core.removeListener(coreListener) + conferenceModel.destroy() contact.value?.destroy() if (::currentCall.isInitialized) { diff --git a/app/src/main/res/drawable/shape_squircle_main2_200_border.xml b/app/src/main/res/drawable/shape_squircle_main2_200_border.xml new file mode 100644 index 000000000..891993dc0 --- /dev/null +++ b/app/src/main/res/drawable/shape_squircle_main2_200_border.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_squircle_main2_400_background.xml b/app/src/main/res/drawable/shape_squircle_main2_400_border.xml similarity index 100% rename from app/src/main/res/drawable/shape_squircle_main2_400_background.xml rename to app/src/main/res/drawable/shape_squircle_main2_400_border.xml diff --git a/app/src/main/res/layout/call_active_conference_fragment.xml b/app/src/main/res/layout/call_active_conference_fragment.xml index a2c57facb..53342dec8 100644 --- a/app/src/main/res/layout/call_active_conference_fragment.xml +++ b/app/src/main/res/layout/call_active_conference_fragment.xml @@ -58,6 +58,7 @@ android:layout_marginStart="12dp" android:layout_marginEnd="12dp" android:layout_marginBottom="@dimen/call_main_actions_menu_height" + android:visibility="@{conferenceViewModel.participantDevices.size() > 1 ? View.GONE : View.VISIBLE}" app:layout_constraintTop_toBottomOf="@id/call_direction_label" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -88,7 +89,7 @@ android:paddingBottom="12dp" android:paddingStart="20dp" android:paddingEnd="20dp" - android:background="@drawable/shape_squircle_main2_400_background" + android:background="@drawable/shape_squircle_main2_400_border" android:text="@string/conference_share_link_title" android:textSize="18sp" android:textColor="@color/gray_main2_400" @@ -99,6 +100,22 @@ app:layout_constraintStart_toStartOf="@id/background" app:layout_constraintEnd_toEndOf="@id/background"/> + + - - \ No newline at end of file diff --git a/app/src/main/res/layout/call_conference_grid_cell.xml b/app/src/main/res/layout/call_conference_grid_cell.xml new file mode 100644 index 000000000..e5ec892f1 --- /dev/null +++ b/app/src/main/res/layout/call_conference_grid_cell.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file