diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.java b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.java index 642c5757e..40df89010 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.java +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.java @@ -2124,7 +2124,7 @@ public class CallActivity extends CallBaseActivity { } private CallParticipant addCallParticipant(String sessionId) { - CallParticipant callParticipant = new CallParticipant(sessionId); + CallParticipant callParticipant = new CallParticipant(sessionId, signalingMessageReceiver); callParticipants.put(sessionId, callParticipant); SignalingMessageReceiver.CallParticipantMessageListener callParticipantMessageListener = @@ -2679,6 +2679,20 @@ public class CallActivity extends CallBaseActivity { this.sessionId = sessionId; } + @Override + public void onRaiseHand(boolean state, long timestamp) { + if (state) { + CallParticipant participant = callParticipants.get(sessionId); + if (participant != null) { + String nick = participant.getCallParticipantModel().getNick(); + runOnUiThread(() -> Toast.makeText( + context, + String.format(context.getResources().getString(R.string.nc_call_raised_hand), nick), + Toast.LENGTH_LONG).show()); + } + } + } + @Override public void onUnshareScreen() { endPeerConnection(sessionId, "screen"); diff --git a/app/src/main/java/com/nextcloud/talk/adapters/ParticipantDisplayItem.java b/app/src/main/java/com/nextcloud/talk/adapters/ParticipantDisplayItem.java index 418afa1a5..e488b9384 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/ParticipantDisplayItem.java +++ b/app/src/main/java/com/nextcloud/talk/adapters/ParticipantDisplayItem.java @@ -5,6 +5,7 @@ import android.os.Looper; import android.text.TextUtils; import com.nextcloud.talk.call.CallParticipantModel; +import com.nextcloud.talk.call.RaisedHand; import com.nextcloud.talk.utils.ApiUtils; import org.webrtc.EglBase; @@ -42,6 +43,7 @@ public class ParticipantDisplayItem { private MediaStream mediaStream; private boolean streamEnabled; private boolean isAudioEnabled; + private RaisedHand raisedHand; public ParticipantDisplayItem(String baseUrl, String defaultGuestNick, EglBase rootEglBase, String streamType, CallParticipantModel callParticipantModel) { @@ -82,6 +84,8 @@ public class ParticipantDisplayItem { callParticipantModel.isVideoAvailable() : false; } + raisedHand = callParticipantModel.getRaisedHand(); + participantDisplayItemNotifier.notifyChange(); } @@ -129,6 +133,10 @@ public class ParticipantDisplayItem { return isAudioEnabled; } + public RaisedHand getRaisedHand() { + return raisedHand; + } + public void addObserver(Observer observer) { participantDisplayItemNotifier.addObserver(observer); } @@ -148,6 +156,7 @@ public class ParticipantDisplayItem { ", streamType='" + streamType + '\'' + ", streamEnabled=" + streamEnabled + ", rootEglBase=" + rootEglBase + + ", raisedHand=" + raisedHand + '}'; } } diff --git a/app/src/main/java/com/nextcloud/talk/adapters/ParticipantsAdapter.java b/app/src/main/java/com/nextcloud/talk/adapters/ParticipantsAdapter.java index 85e24d50d..cd1497cdc 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/ParticipantsAdapter.java +++ b/app/src/main/java/com/nextcloud/talk/adapters/ParticipantsAdapter.java @@ -143,11 +143,17 @@ public class ParticipantsAdapter extends BaseAdapter { if (!participantDisplayItem.isAudioEnabled()) { audioOffView.setVisibility(View.VISIBLE); } else { - audioOffView.setVisibility(View.INVISIBLE); + audioOffView.setVisibility(View.GONE); + } + + ImageView raisedHandView = convertView.findViewById(R.id.raised_hand); + if (participantDisplayItem.getRaisedHand() != null && participantDisplayItem.getRaisedHand().getState()) { + raisedHandView.setVisibility(View.VISIBLE); + } else { + raisedHandView.setVisibility(View.GONE); } return convertView; - } private boolean hasVideoStream(ParticipantDisplayItem participantDisplayItem, MediaStream mediaStream) { diff --git a/app/src/main/java/com/nextcloud/talk/call/CallParticipant.java b/app/src/main/java/com/nextcloud/talk/call/CallParticipant.java index 3b153f8cb..20d03fe05 100644 --- a/app/src/main/java/com/nextcloud/talk/call/CallParticipant.java +++ b/app/src/main/java/com/nextcloud/talk/call/CallParticipant.java @@ -19,6 +19,7 @@ */ package com.nextcloud.talk.call; +import com.nextcloud.talk.signaling.SignalingMessageReceiver; import com.nextcloud.talk.webrtc.PeerConnectionWrapper; import org.webrtc.MediaStream; @@ -32,6 +33,18 @@ import org.webrtc.PeerConnection; */ public class CallParticipant { + private final SignalingMessageReceiver.CallParticipantMessageListener callParticipantMessageListener = + new SignalingMessageReceiver.CallParticipantMessageListener() { + @Override + public void onRaiseHand(boolean state, long timestamp) { + callParticipantModel.setRaisedHand(state, timestamp); + } + + @Override + public void onUnshareScreen() { + } + }; + private final PeerConnectionWrapper.PeerConnectionObserver peerConnectionObserver = new PeerConnectionWrapper.PeerConnectionObserver() { @Override @@ -99,14 +112,21 @@ public class CallParticipant { private final MutableCallParticipantModel callParticipantModel; + private final SignalingMessageReceiver signalingMessageReceiver; + private PeerConnectionWrapper peerConnectionWrapper; private PeerConnectionWrapper screenPeerConnectionWrapper; - public CallParticipant(String sessionId) { + public CallParticipant(String sessionId, SignalingMessageReceiver signalingMessageReceiver) { callParticipantModel = new MutableCallParticipantModel(sessionId); + + this.signalingMessageReceiver = signalingMessageReceiver; + signalingMessageReceiver.addListener(callParticipantMessageListener, sessionId); } public void destroy() { + signalingMessageReceiver.removeListener(callParticipantMessageListener); + if (peerConnectionWrapper != null) { peerConnectionWrapper.removeObserver(peerConnectionObserver); peerConnectionWrapper.removeListener(dataChannelMessageListener); diff --git a/app/src/main/java/com/nextcloud/talk/call/CallParticipantModel.java b/app/src/main/java/com/nextcloud/talk/call/CallParticipantModel.java index 8c3947824..24d8b3ffd 100644 --- a/app/src/main/java/com/nextcloud/talk/call/CallParticipantModel.java +++ b/app/src/main/java/com/nextcloud/talk/call/CallParticipantModel.java @@ -29,6 +29,9 @@ import java.util.Objects; /** * Read-only data model for (remote) call participants. * + * If the hand was never raised null is returned by "getRaisedHand()". Otherwise a RaisedHand object is returned with + * the current state (raised or not) and the timestamp when the raised hand state last changed. + * * The received audio and video are available only if the participant is sending them and also has them enabled. * Before a connection is established it is not known whether audio and video are available or not, so null is returned * in that case (therefore it should not be autoboxed to a plain boolean without checking that). @@ -72,6 +75,8 @@ public class CallParticipantModel { protected Data userId; protected Data nick; + protected Data raisedHand; + protected Data iceConnectionState; protected Data mediaStream; protected Data audioAvailable; @@ -86,6 +91,8 @@ public class CallParticipantModel { this.userId = new Data<>(); this.nick = new Data<>(); + this.raisedHand = new Data<>(); + this.iceConnectionState = new Data<>(); this.mediaStream = new Data<>(); this.audioAvailable = new Data<>(); @@ -107,6 +114,10 @@ public class CallParticipantModel { return nick.getValue(); } + public RaisedHand getRaisedHand() { + return raisedHand.getValue(); + } + public PeerConnection.IceConnectionState getIceConnectionState() { return iceConnectionState.getValue(); } diff --git a/app/src/main/java/com/nextcloud/talk/call/MutableCallParticipantModel.java b/app/src/main/java/com/nextcloud/talk/call/MutableCallParticipantModel.java index 4023bd296..a70f76c85 100644 --- a/app/src/main/java/com/nextcloud/talk/call/MutableCallParticipantModel.java +++ b/app/src/main/java/com/nextcloud/talk/call/MutableCallParticipantModel.java @@ -41,6 +41,10 @@ public class MutableCallParticipantModel extends CallParticipantModel { this.nick.setValue(nick); } + public void setRaisedHand(boolean state, long timestamp) { + this.raisedHand.setValue(new RaisedHand(state, timestamp)); + } + public void setIceConnectionState(PeerConnection.IceConnectionState iceConnectionState) { this.iceConnectionState.setValue(iceConnectionState); } diff --git a/app/src/main/java/com/nextcloud/talk/call/RaisedHand.kt b/app/src/main/java/com/nextcloud/talk/call/RaisedHand.kt new file mode 100644 index 000000000..7a07fb337 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/RaisedHand.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud Talk application + * + * @author Daniel Calviño Sánchez + * Copyright (C) 2023 Daniel Calviño Sánchez + * + * 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 com.nextcloud.talk.call + +data class RaisedHand(val state: Boolean, val timestamp: Long) diff --git a/app/src/main/java/com/nextcloud/talk/models/json/signaling/NCMessagePayload.kt b/app/src/main/java/com/nextcloud/talk/models/json/signaling/NCMessagePayload.kt index 6387649f3..ce078f11e 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/signaling/NCMessagePayload.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/signaling/NCMessagePayload.kt @@ -38,8 +38,12 @@ data class NCMessagePayload( @JsonField(name = ["candidate"]) var iceCandidate: NCIceCandidate? = null, @JsonField(name = ["name"]) - var name: String? = null + var name: String? = null, + @JsonField(name = ["state"]) + var state: Boolean? = null, + @JsonField(name = ["timestamp"]) + var timestamp: Long? = null ) : Parcelable { // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' - constructor() : this(null, null, null, null, null) + constructor() : this(null, null, null, null, null, null, null) } diff --git a/app/src/main/java/com/nextcloud/talk/signaling/CallParticipantMessageNotifier.java b/app/src/main/java/com/nextcloud/talk/signaling/CallParticipantMessageNotifier.java index f06e72629..f1cf54e9b 100644 --- a/app/src/main/java/com/nextcloud/talk/signaling/CallParticipantMessageNotifier.java +++ b/app/src/main/java/com/nextcloud/talk/signaling/CallParticipantMessageNotifier.java @@ -87,6 +87,12 @@ class CallParticipantMessageNotifier { return callParticipantMessageListeners; } + public synchronized void notifyRaiseHand(String sessionId, boolean state, long timestamp) { + for (SignalingMessageReceiver.CallParticipantMessageListener listener : getListenersFor(sessionId)) { + listener.onRaiseHand(state, timestamp); + } + } + public synchronized void notifyUnshareScreen(String sessionId) { for (SignalingMessageReceiver.CallParticipantMessageListener listener : getListenersFor(sessionId)) { listener.onUnshareScreen(); diff --git a/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java b/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java index 161dae555..47dd83ca9 100644 --- a/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java +++ b/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java @@ -128,6 +128,7 @@ public abstract class SignalingMessageReceiver { * message on the call participant. */ public interface CallParticipantMessageListener { + void onRaiseHand(boolean state, long timestamp); void onUnshareScreen(); } @@ -415,6 +416,63 @@ public abstract class SignalingMessageReceiver { String sessionId = signalingMessage.getFrom(); String roomType = signalingMessage.getRoomType(); + if ("raiseHand".equals(type)) { + // Message schema (external signaling server): + // { + // "type": "message", + // "message": { + // "sender": { + // ... + // }, + // "data": { + // "to": #STRING#, + // "sid": #STRING#, + // "roomType": "video", + // "type": "raiseHand", + // "payload": { + // "state": #BOOLEAN#, + // "timestamp": #LONG#, + // }, + // "from": #STRING#, + // }, + // }, + // } + // + // Message schema (internal signaling server): + // { + // "type": "message", + // "data": { + // "to": #STRING#, + // "sid": #STRING#, + // "roomType": "video", + // "type": "raiseHand", + // "payload": { + // "state": #BOOLEAN#, + // "timestamp": #LONG#, + // }, + // "from": #STRING#, + // }, + // } + + NCMessagePayload payload = signalingMessage.getPayload(); + if (payload == null) { + // Broken message, this should not happen. + return; + } + + Boolean state = payload.getState(); + Long timestamp = payload.getTimestamp(); + + if (state == null || timestamp == null) { + // Broken message, this should not happen. + return; + } + + callParticipantMessageNotifier.notifyRaiseHand(sessionId, state, timestamp); + + return; + } + // "unshareScreen" messages are directly sent to the screen peer connection when the internal signaling // server is used, and to the room when the external signaling server is used. However, the (relevant) data // of the received message ("from" and "type") is the same in both cases. diff --git a/app/src/main/res/drawable/ic_hand_back_left.xml b/app/src/main/res/drawable/ic_hand_back_left.xml new file mode 100644 index 000000000..01aa6a40e --- /dev/null +++ b/app/src/main/res/drawable/ic_hand_back_left.xml @@ -0,0 +1,25 @@ + + + + diff --git a/app/src/main/res/layout/call_item.xml b/app/src/main/res/layout/call_item.xml index 8b87a7eac..9d5e52547 100644 --- a/app/src/main/res/layout/call_item.xml +++ b/app/src/main/res/layout/call_item.xml @@ -23,7 +23,6 @@ --> - + android:orientation="horizontal" + android:layout_alignParentBottom="true"> - + + + + + + Answer as voice call only Answer as video call Switch to self video + %1$s raised the hand Mute microphone diff --git a/app/src/test/java/com/nextcloud/talk/call/CallParticipantModelTest.kt b/app/src/test/java/com/nextcloud/talk/call/CallParticipantModelTest.kt new file mode 100644 index 000000000..efba5bd4e --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/call/CallParticipantModelTest.kt @@ -0,0 +1,58 @@ +/* + * Nextcloud Talk application + * + * @author Daniel Calviño Sánchez + * Copyright (C) 2022 Daniel Calviño Sánchez + * + * 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 com.nextcloud.talk.call + +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito + +class CallParticipantModelTest { + private var callParticipantModel: MutableCallParticipantModel? = null + private var mockedCallParticipantModelObserver: CallParticipantModel.Observer? = null + + @Before + fun setUp() { + callParticipantModel = MutableCallParticipantModel("theSessionId") + mockedCallParticipantModelObserver = Mockito.mock(CallParticipantModel.Observer::class.java) + } + + @Test + fun testSetRaisedHand() { + callParticipantModel!!.addObserver(mockedCallParticipantModelObserver) + callParticipantModel!!.setRaisedHand(true, 4815162342L) + Mockito.verify(mockedCallParticipantModelObserver, Mockito.only())?.onChange() + } + + @Test + fun testSetRaisedHandTwice() { + callParticipantModel!!.addObserver(mockedCallParticipantModelObserver) + callParticipantModel!!.setRaisedHand(true, 4815162342L) + callParticipantModel!!.setRaisedHand(false, 4815162342108L) + Mockito.verify(mockedCallParticipantModelObserver, Mockito.times(2))?.onChange() + } + + @Test + fun testSetRaisedHandTwiceWithSameValue() { + callParticipantModel!!.addObserver(mockedCallParticipantModelObserver) + callParticipantModel!!.setRaisedHand(true, 4815162342L) + callParticipantModel!!.setRaisedHand(true, 4815162342L) + Mockito.verify(mockedCallParticipantModelObserver, Mockito.only())?.onChange() + } +} diff --git a/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverCallParticipantTest.java b/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverCallParticipantTest.java index 01963682e..96d90b23d 100644 --- a/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverCallParticipantTest.java +++ b/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverCallParticipantTest.java @@ -19,6 +19,7 @@ */ package com.nextcloud.talk.signaling; +import com.nextcloud.talk.models.json.signaling.NCMessagePayload; import com.nextcloud.talk.models.json.signaling.NCSignalingMessage; import org.junit.Assert; @@ -62,6 +63,27 @@ public class SignalingMessageReceiverCallParticipantTest { }); } + @Test + public void testCallParticipantMessageRaiseHand() { + SignalingMessageReceiver.CallParticipantMessageListener mockedCallParticipantMessageListener = + mock(SignalingMessageReceiver.CallParticipantMessageListener.class); + + signalingMessageReceiver.addListener(mockedCallParticipantMessageListener, "theSessionId"); + + NCSignalingMessage signalingMessage = new NCSignalingMessage(); + signalingMessage.setFrom("theSessionId"); + signalingMessage.setType("raiseHand"); + signalingMessage.setRoomType("theRoomType"); + NCMessagePayload messagePayload = new NCMessagePayload(); + messagePayload.setType("raiseHand"); + messagePayload.setState(Boolean.TRUE); + messagePayload.setTimestamp(4815162342L); + signalingMessage.setPayload(messagePayload); + signalingMessageReceiver.processSignalingMessage(signalingMessage); + + verify(mockedCallParticipantMessageListener, only()).onRaiseHand(true, 4815162342L); + } + @Test public void testCallParticipantMessageUnshareScreen() { SignalingMessageReceiver.CallParticipantMessageListener mockedCallParticipantMessageListener =