diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index a586de632..3b382fef4 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -66,6 +66,7 @@ import com.nextcloud.talk.call.CallParticipantModel import com.nextcloud.talk.call.MessageSender import com.nextcloud.talk.call.MessageSenderMcu import com.nextcloud.talk.call.MessageSenderNoMcu +import com.nextcloud.talk.call.MutableLocalCallParticipantModel import com.nextcloud.talk.call.ReactionAnimator import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.data.user.model.User @@ -246,6 +247,7 @@ class CallActivity : CallBaseActivity() { private val internalSignalingMessageSender = InternalSignalingMessageSender() private var signalingMessageSender: SignalingMessageSender? = null private var messageSender: MessageSender? = null + private val localCallParticipantModel: MutableLocalCallParticipantModel = MutableLocalCallParticipantModel() private val offerAnswerNickProviders: MutableMap = HashMap() private val callParticipantMessageListeners: MutableMap = HashMap() private val selfPeerConnectionObserver: PeerConnectionObserver = CallActivitySelfPeerConnectionObserver() @@ -1123,6 +1125,7 @@ class CallActivity : CallBaseActivity() { localStream!!.addTrack(localVideoTrack) localVideoTrack!!.setEnabled(false) localVideoTrack!!.addSink(binding!!.selfVideoRenderer) + localCallParticipantModel.isVideoEnabled = false } private fun microphoneInitialization() { @@ -1133,6 +1136,7 @@ class CallActivity : CallBaseActivity() { localAudioTrack = peerConnectionFactory!!.createAudioTrack("NCa0", audioSource) localAudioTrack!!.setEnabled(false) localStream!!.addTrack(localAudioTrack) + localCallParticipantModel.isAudioEnabled = false } @SuppressLint("MissingPermission") @@ -1157,9 +1161,11 @@ class CallActivity : CallBaseActivity() { if (microphoneOn && isCurrentlySpeaking && !isSpeakingLongTerm) { isSpeakingLongTerm = true + localCallParticipantModel.isSpeaking = true sendIsSpeakingMessage(true) } else if (!isCurrentlySpeaking && isSpeakingLongTerm) { isSpeakingLongTerm = false + localCallParticipantModel.isSpeaking = false sendIsSpeakingMessage(false) } Thread.sleep(MICROPHONE_VALUE_SLEEP) @@ -1342,6 +1348,7 @@ class CallActivity : CallBaseActivity() { } if (localStream != null && localStream!!.videoTracks.size > 0) { localStream!!.videoTracks[0].setEnabled(enable) + localCallParticipantModel.isVideoEnabled = enable } if (enable) { binding!!.selfVideoRenderer.visibility = View.VISIBLE @@ -1358,6 +1365,7 @@ class CallActivity : CallBaseActivity() { } if (localStream != null && localStream!!.audioTracks.size > 0) { localStream!!.audioTracks[0].setEnabled(enable) + localCallParticipantModel.isAudioEnabled = enable } } if (isConnectionEstablished) { diff --git a/app/src/main/java/com/nextcloud/talk/call/LocalCallParticipantModel.java b/app/src/main/java/com/nextcloud/talk/call/LocalCallParticipantModel.java new file mode 100644 index 000000000..b1dcececc --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/LocalCallParticipantModel.java @@ -0,0 +1,114 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call; + +import android.os.Handler; + +import java.util.Objects; + +/** + * Read-only data model for local call participants. + *

+ * Clients of the model can observe it with LocalCallParticipantModel.Observer to be notified when any value changes. + * Getters called after receiving a notification are guaranteed to provide at least the value that triggered the + * notification, but it may return even a more up to date one (so getting the value again on the following notification + * may return the same value as before). + */ +public class LocalCallParticipantModel { + + protected final LocalCallParticipantModelNotifier localCallParticipantModelNotifier = + new LocalCallParticipantModelNotifier(); + + protected Data audioEnabled; + protected Data speaking; + protected Data speakingWhileMuted; + protected Data videoEnabled; + + public interface Observer { + void onChange(); + } + + protected class Data { + + private T value; + + public Data() { + } + + public Data(T value) { + this.value = value; + } + + public T getValue() { + return value; + } + + public void setValue(T value) { + if (Objects.equals(this.value, value)) { + return; + } + + this.value = value; + + localCallParticipantModelNotifier.notifyChange(); + } + } + + public LocalCallParticipantModel() { + this.audioEnabled = new Data<>(Boolean.FALSE); + this.speaking = new Data<>(Boolean.FALSE); + this.speakingWhileMuted = new Data<>(Boolean.FALSE); + this.videoEnabled = new Data<>(Boolean.FALSE); + } + + public Boolean isAudioEnabled() { + return audioEnabled.getValue(); + } + + public Boolean isSpeaking() { + return speaking.getValue(); + } + + public Boolean isSpeakingWhileMuted() { + return speakingWhileMuted.getValue(); + } + + public Boolean isVideoEnabled() { + return videoEnabled.getValue(); + } + + /** + * Adds an Observer to be notified when any value changes. + * + * @param observer the Observer + * @see LocalCallParticipantModel#addObserver(Observer, Handler) + */ + public void addObserver(Observer observer) { + addObserver(observer, null); + } + + /** + * Adds an observer to be notified when any value changes. + *

+ * The observer will be notified on the thread associated to the given handler. If no handler is given the + * observer will be immediately notified on the same thread that changed the value; the observer will be + * immediately notified too if the thread of the handler is the same thread that changed the value. + *

+ * An observer is expected to be added only once. If the same observer is added again it will be notified just + * once on the thread of the last handler. + * + * @param observer the Observer + * @param handler a Handler for the thread to be notified on + */ + public void addObserver(Observer observer, Handler handler) { + localCallParticipantModelNotifier.addObserver(observer, handler); + } + + public void removeObserver(Observer observer) { + localCallParticipantModelNotifier.removeObserver(observer); + } +} diff --git a/app/src/main/java/com/nextcloud/talk/call/LocalCallParticipantModelNotifier.java b/app/src/main/java/com/nextcloud/talk/call/LocalCallParticipantModelNotifier.java new file mode 100644 index 000000000..b46f1f0a7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/LocalCallParticipantModelNotifier.java @@ -0,0 +1,73 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call; + +import android.os.Handler; +import android.os.Looper; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** + * Helper class to register and notify LocalCallParticipantModel.Observers. + *

+ * This class is only meant for internal use by LocalCallParticipantModel; observers must register themselves against a + * LocalCallParticipantModel rather than against a LocalCallParticipantModelNotifier. + */ +class LocalCallParticipantModelNotifier { + + private final List localCallParticipantModelObserversOn = new ArrayList<>(); + + /** + * Helper class to associate a LocalCallParticipantModel.Observer with a Handler. + */ + private static class LocalCallParticipantModelObserverOn { + public final LocalCallParticipantModel.Observer observer; + public final Handler handler; + + private LocalCallParticipantModelObserverOn(LocalCallParticipantModel.Observer observer, Handler handler) { + this.observer = observer; + this.handler = handler; + } + } + + public synchronized void addObserver(LocalCallParticipantModel.Observer observer, Handler handler) { + if (observer == null) { + throw new IllegalArgumentException("LocalCallParticipantModel.Observer can not be null"); + } + + removeObserver(observer); + + localCallParticipantModelObserversOn.add(new LocalCallParticipantModelObserverOn(observer, handler)); + } + + public synchronized void removeObserver(LocalCallParticipantModel.Observer observer) { + Iterator it = localCallParticipantModelObserversOn.iterator(); + while (it.hasNext()) { + LocalCallParticipantModelObserverOn observerOn = it.next(); + + if (observerOn.observer == observer) { + it.remove(); + + return; + } + } + } + + public synchronized void notifyChange() { + for (LocalCallParticipantModelObserverOn observerOn : new ArrayList<>(localCallParticipantModelObserversOn)) { + if (observerOn.handler == null || observerOn.handler.getLooper() == Looper.myLooper()) { + observerOn.observer.onChange(); + } else { + observerOn.handler.post(() -> { + observerOn.observer.onChange(); + }); + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/call/MutableLocalCallParticipantModel.java b/app/src/main/java/com/nextcloud/talk/call/MutableLocalCallParticipantModel.java new file mode 100644 index 000000000..91bbbfc9f --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/MutableLocalCallParticipantModel.java @@ -0,0 +1,51 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call; + +import java.util.Objects; + +/** + * Mutable data model for local call participants. + *

+ * Setting "speaking" will automatically set "speaking" or "speakingWhileMuted" as needed, depending on whether audio is + * enabled or not. Similarly, setting whether the audio is enabled or disabled will automatically switch between + * "speaking" and "speakingWhileMuted" as needed. + *

+ * There is no synchronization when setting the values; if needed, it should be handled by the clients of the model. + */ +public class MutableLocalCallParticipantModel extends LocalCallParticipantModel { + + public void setAudioEnabled(Boolean audioEnabled) { + if (Objects.equals(this.audioEnabled.getValue(), audioEnabled)) { + return; + } + + if (audioEnabled == null || !audioEnabled) { + this.speakingWhileMuted.setValue(this.speaking.getValue()); + this.speaking.setValue(Boolean.FALSE); + } + + this.audioEnabled.setValue(audioEnabled); + + if (audioEnabled != null && audioEnabled) { + this.speaking.setValue(this.speakingWhileMuted.getValue()); + this.speakingWhileMuted.setValue(Boolean.FALSE); + } + } + + public void setSpeaking(Boolean speaking) { + if (this.audioEnabled.getValue() != null && this.audioEnabled.getValue()) { + this.speaking.setValue(speaking); + } else { + this.speakingWhileMuted.setValue(speaking); + } + } + + public void setVideoEnabled(Boolean videoEnabled) { + this.videoEnabled.setValue(videoEnabled); + } +} diff --git a/app/src/test/java/com/nextcloud/talk/call/LocalCallParticipantModelTest.kt b/app/src/test/java/com/nextcloud/talk/call/LocalCallParticipantModelTest.kt new file mode 100644 index 000000000..2440fd0c1 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/call/LocalCallParticipantModelTest.kt @@ -0,0 +1,168 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.call + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito + +class LocalCallParticipantModelTest { + private var localCallParticipantModel: MutableLocalCallParticipantModel? = null + private var mockedLocalCallParticipantModelObserver: LocalCallParticipantModel.Observer? = null + + @Before + fun setUp() { + localCallParticipantModel = MutableLocalCallParticipantModel() + mockedLocalCallParticipantModelObserver = Mockito.mock(LocalCallParticipantModel.Observer::class.java) + } + + @Test + fun testSetAudioEnabled() { + localCallParticipantModel!!.addObserver(mockedLocalCallParticipantModelObserver) + + localCallParticipantModel!!.isAudioEnabled = true + + assertTrue(localCallParticipantModel!!.isAudioEnabled) + assertFalse(localCallParticipantModel!!.isSpeaking) + assertFalse(localCallParticipantModel!!.isSpeakingWhileMuted) + Mockito.verify(mockedLocalCallParticipantModelObserver, Mockito.only())?.onChange() + } + + @Test + fun testSetAudioEnabledWhileSpeakingWhileMuted() { + localCallParticipantModel!!.isSpeaking = true + + localCallParticipantModel!!.addObserver(mockedLocalCallParticipantModelObserver) + + localCallParticipantModel!!.isAudioEnabled = true + + assertTrue(localCallParticipantModel!!.isAudioEnabled) + assertTrue(localCallParticipantModel!!.isSpeaking) + assertFalse(localCallParticipantModel!!.isSpeakingWhileMuted) + Mockito.verify(mockedLocalCallParticipantModelObserver, Mockito.times(3))?.onChange() + } + + @Test + fun testSetAudioEnabledTwiceWhileSpeakingWhileMuted() { + localCallParticipantModel!!.isSpeaking = true + + localCallParticipantModel!!.addObserver(mockedLocalCallParticipantModelObserver) + + localCallParticipantModel!!.isAudioEnabled = true + localCallParticipantModel!!.isAudioEnabled = true + + assertTrue(localCallParticipantModel!!.isAudioEnabled) + assertTrue(localCallParticipantModel!!.isSpeaking) + assertFalse(localCallParticipantModel!!.isSpeakingWhileMuted) + Mockito.verify(mockedLocalCallParticipantModelObserver, Mockito.times(3))?.onChange() + } + + @Test + fun testSetAudioDisabled() { + localCallParticipantModel!!.isAudioEnabled = true + + localCallParticipantModel!!.addObserver(mockedLocalCallParticipantModelObserver) + + localCallParticipantModel!!.isAudioEnabled = false + + assertFalse(localCallParticipantModel!!.isAudioEnabled) + assertFalse(localCallParticipantModel!!.isSpeaking) + assertFalse(localCallParticipantModel!!.isSpeakingWhileMuted) + Mockito.verify(mockedLocalCallParticipantModelObserver, Mockito.only())?.onChange() + } + + @Test + fun testSetAudioDisabledWhileSpeaking() { + localCallParticipantModel!!.isAudioEnabled = true + localCallParticipantModel!!.isSpeaking = true + + localCallParticipantModel!!.addObserver(mockedLocalCallParticipantModelObserver) + + localCallParticipantModel!!.isAudioEnabled = false + + assertFalse(localCallParticipantModel!!.isAudioEnabled) + assertFalse(localCallParticipantModel!!.isSpeaking) + assertTrue(localCallParticipantModel!!.isSpeakingWhileMuted) + Mockito.verify(mockedLocalCallParticipantModelObserver, Mockito.times(3))?.onChange() + } + + @Test + fun testSetAudioDisabledTwiceWhileSpeaking() { + localCallParticipantModel!!.isAudioEnabled = true + localCallParticipantModel!!.isSpeaking = true + + localCallParticipantModel!!.addObserver(mockedLocalCallParticipantModelObserver) + + localCallParticipantModel!!.isAudioEnabled = false + localCallParticipantModel!!.isAudioEnabled = false + + assertFalse(localCallParticipantModel!!.isAudioEnabled) + assertFalse(localCallParticipantModel!!.isSpeaking) + assertTrue(localCallParticipantModel!!.isSpeakingWhileMuted) + Mockito.verify(mockedLocalCallParticipantModelObserver, Mockito.times(3))?.onChange() + } + + @Test + fun testSetSpeakingWhileAudioEnabled() { + localCallParticipantModel!!.isAudioEnabled = true + + localCallParticipantModel!!.addObserver(mockedLocalCallParticipantModelObserver) + + localCallParticipantModel!!.isSpeaking = true + + assertTrue(localCallParticipantModel!!.isAudioEnabled) + assertTrue(localCallParticipantModel!!.isSpeaking) + assertFalse(localCallParticipantModel!!.isSpeakingWhileMuted) + Mockito.verify(mockedLocalCallParticipantModelObserver, Mockito.only())?.onChange() + } + + @Test + fun testSetNotSpeakingWhileAudioEnabled() { + localCallParticipantModel!!.isAudioEnabled = true + localCallParticipantModel!!.isSpeaking = true + + localCallParticipantModel!!.addObserver(mockedLocalCallParticipantModelObserver) + + localCallParticipantModel!!.isSpeaking = false + + assertTrue(localCallParticipantModel!!.isAudioEnabled) + assertFalse(localCallParticipantModel!!.isSpeaking) + assertFalse(localCallParticipantModel!!.isSpeakingWhileMuted) + Mockito.verify(mockedLocalCallParticipantModelObserver, Mockito.only())?.onChange() + } + + @Test + fun testSetSpeakingWhileAudioDisabled() { + localCallParticipantModel!!.isAudioEnabled = false + + localCallParticipantModel!!.addObserver(mockedLocalCallParticipantModelObserver) + + localCallParticipantModel!!.isSpeaking = true + + assertFalse(localCallParticipantModel!!.isAudioEnabled) + assertFalse(localCallParticipantModel!!.isSpeaking) + assertTrue(localCallParticipantModel!!.isSpeakingWhileMuted) + Mockito.verify(mockedLocalCallParticipantModelObserver, Mockito.only())?.onChange() + } + + @Test + fun testSetNotSpeakingWhileAudioDisabled() { + localCallParticipantModel!!.isAudioEnabled = false + localCallParticipantModel!!.isSpeaking = true + + localCallParticipantModel!!.addObserver(mockedLocalCallParticipantModelObserver) + + localCallParticipantModel!!.isSpeaking = false + + assertFalse(localCallParticipantModel!!.isAudioEnabled) + assertFalse(localCallParticipantModel!!.isSpeaking) + assertFalse(localCallParticipantModel!!.isSpeakingWhileMuted) + Mockito.verify(mockedLocalCallParticipantModelObserver, Mockito.only())?.onChange() + } +}