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 0c1c1ff0b..cfc286f84 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.java +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.java @@ -86,6 +86,7 @@ import com.nextcloud.talk.models.json.signaling.Signaling; import com.nextcloud.talk.models.json.signaling.SignalingOverall; import com.nextcloud.talk.models.json.signaling.settings.IceServer; import com.nextcloud.talk.models.json.signaling.settings.SignalingSettingsOverall; +import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel; import com.nextcloud.talk.signaling.SignalingMessageReceiver; import com.nextcloud.talk.signaling.SignalingMessageSender; import com.nextcloud.talk.ui.dialog.AudioOutputDialog; @@ -173,6 +174,7 @@ import static com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CALL_WITHOUT_NOTIFI import static com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_NAME; import static com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_PASSWORD; import static com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FROM_NOTIFICATION_START_CALL; +import static com.nextcloud.talk.utils.bundle.BundleKeys.KEY_IS_BREAKOUT_ROOM; import static com.nextcloud.talk.utils.bundle.BundleKeys.KEY_IS_MODERATOR; import static com.nextcloud.talk.utils.bundle.BundleKeys.KEY_MODIFIED_BASE_URL; import static com.nextcloud.talk.utils.bundle.BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_AUDIO; @@ -180,11 +182,14 @@ import static com.nextcloud.talk.utils.bundle.BundleKeys.KEY_PARTICIPANT_PERMISS import static com.nextcloud.talk.utils.bundle.BundleKeys.KEY_RECORDING_STATE; import static com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_ID; import static com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN; +import static com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SWITCH_TO_ROOM_AND_START_CALL; import static com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USER_ENTITY; @AutoInjector(NextcloudTalkApplication.class) public class CallActivity extends CallBaseActivity { + public static boolean active = false; + public static final String VIDEO_STREAM_TYPE_SCREEN = "screen"; public static final String VIDEO_STREAM_TYPE_VIDEO = "video"; @@ -207,6 +212,7 @@ public class CallActivity extends CallBaseActivity { public WebRtcAudioManager audioManager; public CallRecordingViewModel callRecordingViewModel; + public RaiseHandViewModel raiseHandViewModel; private static final String[] PERMISSIONS_CALL = { Manifest.permission.CAMERA, @@ -304,6 +310,18 @@ public class CallActivity extends CallBaseActivity { private CallParticipantList callParticipantList; + private String switchToRoomToken = ""; + private boolean isBreakoutRoom = false; + + private SignalingMessageReceiver.LocalParticipantMessageListener localParticipantMessageListener = + new SignalingMessageReceiver.LocalParticipantMessageListener() { + @Override + public void onSwitchTo(String token) { + switchToRoomToken = token; + hangup(true); + } + }; + private SignalingMessageReceiver.OfferMessageListener offerMessageListener = new SignalingMessageReceiver.OfferMessageListener() { @Override public void onOffer(String sessionId, String roomType, String sdp, String nick) { @@ -375,6 +393,10 @@ public class CallActivity extends CallBaseActivity { isIncomingCallFromNotification = extras.getBoolean(KEY_FROM_NOTIFICATION_START_CALL); } + if (extras.containsKey(KEY_IS_BREAKOUT_ROOM)) { + isBreakoutRoom = extras.getBoolean(KEY_IS_BREAKOUT_ROOM); + } + credentials = ApiUtils.getCredentials(conversationUser.getUsername(), conversationUser.getToken()); baseUrl = extras.getString(KEY_MODIFIED_BASE_URL, ""); @@ -390,6 +412,33 @@ public class CallActivity extends CallBaseActivity { setCallState(CallStatus.CONNECTING); } + raiseHandViewModel = new ViewModelProvider(this, viewModelFactory).get((RaiseHandViewModel.class)); + raiseHandViewModel.setData(roomToken, isBreakoutRoom); + + raiseHandViewModel.getViewState().observe(this, viewState -> { + if (viewState instanceof RaiseHandViewModel.RaisedHandState) { + binding.lowerHandButton.setVisibility(View.VISIBLE); + } else if (viewState instanceof RaiseHandViewModel.LoweredHandState) { + binding.lowerHandButton.setVisibility(View.GONE); + } + + // TODO: build&send raiseHand message (if not possible in RaiseHandViewModel, just do it here..) +// if (isConnectionEstablished() && peerConnectionWrapperList != null) { +// if (!hasMCU) { +// for (PeerConnectionWrapper peerConnectionWrapper : peerConnectionWrapperList) { +// peerConnectionWrapper.raiseHand(...); +// } +// } else { +// for (PeerConnectionWrapper peerConnectionWrapper : peerConnectionWrapperList) { +// if (peerConnectionWrapper.getSessionId().equals(webSocketClient.getSessionId())) { +// peerConnectionWrapper.raiseHand(...); +// break; +// } +// } +// } +// } + }); + callRecordingViewModel = new ViewModelProvider(this, viewModelFactory).get((CallRecordingViewModel.class)); callRecordingViewModel.setData(roomToken); callRecordingViewModel.setRecordingState(extras.getInt(KEY_RECORDING_STATE)); @@ -449,6 +498,7 @@ public class CallActivity extends CallBaseActivity { @Override public void onStart() { super.onStart(); + active = true; initFeaturesVisibility(); try { @@ -458,6 +508,12 @@ public class CallActivity extends CallBaseActivity { } } + @Override + public void onStop() { + super.onStop(); + active = false; + } + @RequiresApi(api = Build.VERSION_CODES.S) private void requestBluetoothPermission() { if (ContextCompat.checkSelfPermission( @@ -474,7 +530,7 @@ public class CallActivity extends CallBaseActivity { } private void initFeaturesVisibility() { - if (isAllowedToStartOrStopRecording()) { + if (isAllowedToStartOrStopRecording() || isAllowedToRaiseHand()) { binding.moreCallActions.setVisibility(View.VISIBLE); } else { binding.moreCallActions.setVisibility(View.GONE); @@ -552,6 +608,10 @@ public class CallActivity extends CallBaseActivity { Toast.makeText(context, context.getResources().getString(R.string.record_active_info), Toast.LENGTH_LONG).show(); } }); + + binding.lowerHandButton.setOnClickListener(l -> { + raiseHandViewModel.lowerHand(); + }); } private void createCameraEnumerator() { @@ -1202,6 +1262,10 @@ public class CallActivity extends CallBaseActivity { } } + public void clickRaiseOrLowerHandButton() { + raiseHandViewModel.clickHandButton(); + } + private void animateCallControls(boolean show, long startDelay) { if (isVoiceOnlyCall) { @@ -1319,6 +1383,7 @@ public class CallActivity extends CallBaseActivity { @Override public void onDestroy() { if (signalingMessageReceiver != null) { + signalingMessageReceiver.removeListener(localParticipantMessageListener); signalingMessageReceiver.removeListener(offerMessageListener); } @@ -1453,6 +1518,7 @@ public class CallActivity extends CallBaseActivity { setupAndInitiateWebSocketsConnection(); } else { signalingMessageReceiver = internalSignalingMessageReceiver; + signalingMessageReceiver.addListener(localParticipantMessageListener); signalingMessageReceiver.addListener(offerMessageListener); signalingMessageSender = internalSignalingMessageSender; joinRoomAndCall(); @@ -1662,6 +1728,7 @@ public class CallActivity extends CallBaseActivity { // Although setupAndInitiateWebSocketsConnection could be called several times the web socket is // initialized just once, so the message receiver is also initialized just once. signalingMessageReceiver = webSocketClient.getSignalingMessageReceiver(); + signalingMessageReceiver.addListener(localParticipantMessageListener); signalingMessageReceiver.addListener(offerMessageListener); signalingMessageSender = webSocketClient.getSignalingMessageSender(); } else { @@ -1860,7 +1927,29 @@ public class CallActivity extends CallBaseActivity { @Override public void onNext(@io.reactivex.annotations.NonNull GenericOverall genericOverall) { - if (shutDownView) { + if (!switchToRoomToken.isEmpty()) { + Intent intent = new Intent(context, MainActivity.class); + + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); + + Bundle bundle = new Bundle(); + bundle.putBoolean(KEY_SWITCH_TO_ROOM_AND_START_CALL, true); + bundle.putString(KEY_ROOM_TOKEN, switchToRoomToken); + bundle.putParcelable(KEY_USER_ENTITY, conversationUser); + bundle.putBoolean(KEY_CALL_VOICE_ONLY, isVoiceOnlyCall); + intent.putExtras(bundle); + startActivity(intent); + + if (isBreakoutRoom) { + Toast.makeText(context, context.getResources().getString(R.string.switch_to_main_room), + Toast.LENGTH_LONG).show(); + } else { + Toast.makeText(context, context.getResources().getString(R.string.switch_to_breakout_room), + Toast.LENGTH_LONG).show(); + } + + finish(); + } else if (shutDownView) { finish(); } else if (currentCallStatus == CallStatus.RECONNECTING || currentCallStatus == CallStatus.PUBLISHER_FAILED) { @@ -2996,6 +3085,11 @@ public class CallActivity extends CallBaseActivity { && isModerator; } + public boolean isAllowedToRaiseHand() { + return CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "raise-hand") || + isBreakoutRoom; + } + private class SelfVideoTouchListener implements View.OnTouchListener { @SuppressLint("ClickableViewAccessibility") diff --git a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt index 0baf80a4d..a95a9d8e1 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt @@ -354,6 +354,20 @@ class MainActivity : BaseActivity(), ActionBarProvider { private fun handleIntent(intent: Intent) { handleActionFromContact(intent) + + if (intent.getBooleanExtra(BundleKeys.KEY_SWITCH_TO_ROOM_AND_START_CALL, false)) { + logRouterBackStack(router!!) + remapChatController( + router!!, + intent.getParcelableExtra(KEY_USER_ENTITY)!!.id!!, + intent.getStringExtra(KEY_ROOM_TOKEN)!!, + intent.extras!!, + true, + true + ) + logRouterBackStack(router!!) + } + if (intent.hasExtra(BundleKeys.KEY_FROM_NOTIFICATION_START_CALL)) { if (intent.getBooleanExtra(BundleKeys.KEY_FROM_NOTIFICATION_START_CALL, false)) { if (!router!!.hasRootController()) { diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApi.java b/app/src/main/java/com/nextcloud/talk/api/NcApi.java index e2fb846ab..02c763aa8 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApi.java +++ b/app/src/main/java/com/nextcloud/talk/api/NcApi.java @@ -587,4 +587,12 @@ public interface NcApi { @DELETE Observable stopRecording(@Header("Authorization") String authorization, @Url String url); + + @POST + Observable requestAssistance(@Header("Authorization") String authorization, + @Url String url); + + @DELETE + Observable withdrawRequestAssistance(@Header("Authorization") String authorization, + @Url String url); } diff --git a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt index a147bf18d..01badf1ab 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt +++ b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt @@ -151,6 +151,7 @@ import com.nextcloud.talk.presenters.MentionAutocompletePresenter import com.nextcloud.talk.remotefilebrowser.activities.RemoteFileBrowserActivity import com.nextcloud.talk.repositories.reactions.ReactionsRepository import com.nextcloud.talk.shareditems.activities.SharedItemsActivity +import com.nextcloud.talk.signaling.SignalingMessageReceiver import com.nextcloud.talk.ui.bottom.sheet.ProfileBottomSheet import com.nextcloud.talk.ui.dialog.AttachmentDialog import com.nextcloud.talk.ui.dialog.MessageActionsDialog @@ -172,6 +173,7 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ACTIVE_CONVERSATION import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_NAME import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FILE_PATHS import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_IS_BREAKOUT_ROOM import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_IS_MODERATOR import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_RECORDING_STATE import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_ID @@ -265,6 +267,7 @@ class ChatController(args: Bundle) : private var lookingIntoFuture = false var newMessagesCount = 0 var startCallFromNotification: Boolean? = null + var startCallFromRoomSwitch: Boolean = false val roomId: String val voiceOnly: Boolean var isFirstMessagesProcessing = true @@ -299,16 +302,24 @@ class ChatController(args: Bundle) : private var videoURI: Uri? = null + private val localParticipantMessageListener = object : SignalingMessageReceiver.LocalParticipantMessageListener { + override fun onSwitchTo(token: String?) { + if (token != null) { + switchToRoom(token) + } + } + } + init { Log.d(TAG, "init ChatController: " + System.identityHashCode(this).toString()) setHasOptionsMenu(true) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - this.conversationUser = args.getParcelable(KEY_USER_ENTITY) - this.roomId = args.getString(KEY_ROOM_ID, "") - this.roomToken = args.getString(KEY_ROOM_TOKEN, "") - this.sharedText = args.getString(BundleKeys.KEY_SHARED_TEXT, "") + conversationUser = args.getParcelable(KEY_USER_ENTITY) + roomId = args.getString(KEY_ROOM_ID, "") + roomToken = args.getString(KEY_ROOM_TOKEN, "") + sharedText = args.getString(BundleKeys.KEY_SHARED_TEXT, "") Log.d(TAG, " roomToken = $roomToken") if (roomToken.isNullOrEmpty()) { @@ -316,11 +327,11 @@ class ChatController(args: Bundle) : } if (args.containsKey(KEY_ACTIVE_CONVERSATION)) { - this.currentConversation = Parcels.unwrap(args.getParcelable(KEY_ACTIVE_CONVERSATION)) - this.participantPermissions = ParticipantPermissions(conversationUser!!, currentConversation!!) + currentConversation = Parcels.unwrap(args.getParcelable(KEY_ACTIVE_CONVERSATION)) + participantPermissions = ParticipantPermissions(conversationUser!!, currentConversation!!) } - this.roomPassword = args.getString(BundleKeys.KEY_CONVERSATION_PASSWORD, "") + roomPassword = args.getString(BundleKeys.KEY_CONVERSATION_PASSWORD, "") credentials = if (conversationUser?.userId == "?") { null @@ -329,10 +340,14 @@ class ChatController(args: Bundle) : } if (args.containsKey(BundleKeys.KEY_FROM_NOTIFICATION_START_CALL)) { - this.startCallFromNotification = args.getBoolean(BundleKeys.KEY_FROM_NOTIFICATION_START_CALL) + startCallFromNotification = args.getBoolean(BundleKeys.KEY_FROM_NOTIFICATION_START_CALL) } - this.voiceOnly = args.getBoolean(BundleKeys.KEY_CALL_VOICE_ONLY, false) + if (args.containsKey(BundleKeys.KEY_SWITCH_TO_ROOM_AND_START_CALL)) { + startCallFromRoomSwitch = args.getBoolean(BundleKeys.KEY_SWITCH_TO_ROOM_AND_START_CALL) + } + + voiceOnly = args.getBoolean(BundleKeys.KEY_CALL_VOICE_ONLY, false) } private fun getRoomInfo() { @@ -914,6 +929,43 @@ class ChatController(args: Bundle) : super.onViewBound(view) } + private fun switchToRoom(token: String) { + if (CallActivity.active) { + Log.d(TAG, "CallActivity is running. Ignore to switch chat in ChatController...") + return + } + + if (conversationUser != null) { + activity?.runOnUiThread { + if (currentConversation?.objectType == BREAKOUT_ROOM_TYPE) { + Toast.makeText( + context, + context.resources.getString(R.string.switch_to_main_room), + Toast.LENGTH_LONG + ).show() + } else { + Toast.makeText( + context, + context.resources.getString(R.string.switch_to_breakout_room), + Toast.LENGTH_LONG + ).show() + } + } + + val bundle = Bundle() + bundle.putParcelable(KEY_USER_ENTITY, conversationUser) + bundle.putString(KEY_ROOM_TOKEN, token) + + ConductorRemapping.remapChatController( + router, + conversationUser.id!!, + token, + bundle, + true + ) + } + } + private fun showSendButtonMenu() { val popupMenu = PopupMenu( ContextThemeWrapper(view?.context, R.style.ChatSendButtonMenu), @@ -1747,6 +1799,8 @@ class ChatController(args: Bundle) : eventBus.register(this) + webSocketInstance?.getSignalingMessageReceiver()?.addListener(localParticipantMessageListener) + if (conversationUser?.userId != "?" && CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "mention-flag") && activity != null @@ -1833,6 +1887,8 @@ class ChatController(args: Bundle) : eventBus.unregister(this) + webSocketInstance?.getSignalingMessageReceiver()?.removeListener(localParticipantMessageListener) + if (activity != null) { activity?.findViewById(R.id.toolbar)?.setOnClickListener(null) } @@ -1935,8 +1991,15 @@ class ChatController(args: Bundle) : ApplicationWideCurrentRoomHolder.getInstance().session = currentConversation?.sessionId + // FIXME The web socket should be set up in onAttach(). It is currently setup after joining the + // room to "ensure" (rather, increase the chances) that the WebsocketConnectionsWorker job + // was able to finish and, therefore, that the web socket instance can be got. setupWebsocket() + // Ensure that the listener is added if the web socket instance was not set up yet when + // onAttach() was called. + webSocketInstance?.getSignalingMessageReceiver()?.addListener(localParticipantMessageListener) + checkLobbyState() if (isFirstMessagesProcessing) { @@ -1955,6 +2018,10 @@ class ChatController(args: Bundle) : startCallFromNotification = false startACall(voiceOnly, false) } + + if (startCallFromRoomSwitch) { + startACall(voiceOnly, true) + } } override fun onError(e: Throwable) { @@ -2147,14 +2214,14 @@ class ChatController(args: Bundle) : } private fun setupWebsocket() { - if (conversationUser != null) { - webSocketInstance = - if (WebSocketConnectionHelper.getMagicWebSocketInstanceForUserId(conversationUser.id!!) != null) { - WebSocketConnectionHelper.getMagicWebSocketInstanceForUserId(conversationUser.id!!) - } else { - Log.d(TAG, "magicWebSocketInstance became null") - null - } + if (conversationUser == null) { + return + } + + webSocketInstance = WebSocketConnectionHelper.getMagicWebSocketInstanceForUserId(conversationUser.id!!) + + if (webSocketInstance == null) { + Log.d(TAG, "magicWebSocketInstance became null") } } @@ -2381,9 +2448,11 @@ class ChatController(args: Bundle) : } private fun updateReadStatusOfAllMessages(xChatLastCommonRead: Int?) { - for (message in adapter!!.items) { - xChatLastCommonRead?.let { - updateReadStatusOfMessage(message, it) + if (adapter != null) { + for (message in adapter!!.items) { + xChatLastCommonRead?.let { + updateReadStatusOfMessage(message, it) + } } } } @@ -2786,6 +2855,10 @@ class ChatController(args: Bundle) : bundle.putBoolean(BundleKeys.KEY_CALL_WITHOUT_NOTIFICATION, true) } + if (it.objectType == BREAKOUT_ROOM_TYPE) { + bundle.putBoolean(KEY_IS_BREAKOUT_ROOM, true) + } + return if (activity != null) { val callIntent = Intent(activity, CallActivity::class.java) callIntent.putExtras(bundle) @@ -3476,5 +3549,6 @@ class ChatController(args: Bundle) : private const val LOOKING_INTO_FUTURE_TIMEOUT = 30 private const val CHUNK_SIZE: Int = 10 private const val ONE_SECOND_IN_MILLIS = 1000 + private const val BREAKOUT_ROOM_TYPE = "room" } } diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt index fb8c8fa0c..5fce4b3b2 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt @@ -33,6 +33,8 @@ import com.nextcloud.talk.data.user.UsersRepository import com.nextcloud.talk.data.user.UsersRepositoryImpl import com.nextcloud.talk.polls.repositories.PollRepository import com.nextcloud.talk.polls.repositories.PollRepositoryImpl +import com.nextcloud.talk.raisehand.RequestAssistanceRepository +import com.nextcloud.talk.raisehand.RequestAssistanceRepositoryImpl import com.nextcloud.talk.remotefilebrowser.repositories.RemoteFileBrowserItemsRepository import com.nextcloud.talk.remotefilebrowser.repositories.RemoteFileBrowserItemsRepositoryImpl import com.nextcloud.talk.repositories.callrecording.CallRecordingRepository @@ -99,4 +101,10 @@ class RepositoryModule { fun provideCallRecordingRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): CallRecordingRepository { return CallRecordingRepositoryImpl(ncApi, userProvider) } + + @Provides + fun provideRequestAssistanceRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): + RequestAssistanceRepository { + return RequestAssistanceRepositoryImpl(ncApi, userProvider) + } } diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index 990bdc32d..48228f412 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -28,6 +28,7 @@ import com.nextcloud.talk.polls.viewmodels.PollCreateViewModel import com.nextcloud.talk.polls.viewmodels.PollMainViewModel import com.nextcloud.talk.polls.viewmodels.PollResultsViewModel import com.nextcloud.talk.polls.viewmodels.PollVoteViewModel +import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel import com.nextcloud.talk.remotefilebrowser.viewmodels.RemoteFileBrowserItemsViewModel import com.nextcloud.talk.shareditems.viewmodels.SharedItemsViewModel import com.nextcloud.talk.viewmodels.CallRecordingViewModel @@ -95,4 +96,9 @@ abstract class ViewModelModule { @IntoMap @ViewModelKey(CallRecordingViewModel::class) abstract fun callRecordingViewModel(viewModel: CallRecordingViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(RaiseHandViewModel::class) + abstract fun raiseHandViewModel(viewModel: RaiseHandViewModel): ViewModel } diff --git a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.kt b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.kt index 9cebbf32e..e60e3314d 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.kt @@ -529,7 +529,9 @@ data class ChatMessage( RECORDING_STARTED, RECORDING_STOPPED, AUDIO_RECORDING_STARTED, - AUDIO_RECORDING_STOPPED + AUDIO_RECORDING_STOPPED, + BREAKOUT_ROOMS_STARTED, + BREAKOUT_ROOMS_STOPPED } companion object { diff --git a/app/src/main/java/com/nextcloud/talk/models/json/converters/EnumSystemMessageTypeConverter.kt b/app/src/main/java/com/nextcloud/talk/models/json/converters/EnumSystemMessageTypeConverter.kt index 8e276a703..dded74d64 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/converters/EnumSystemMessageTypeConverter.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/converters/EnumSystemMessageTypeConverter.kt @@ -28,6 +28,8 @@ import com.bluelinelabs.logansquare.typeconverters.StringBasedTypeConverter import com.nextcloud.talk.models.json.chat.ChatMessage import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.AUDIO_RECORDING_STARTED import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.AUDIO_RECORDING_STOPPED +import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.BREAKOUT_ROOMS_STARTED +import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.BREAKOUT_ROOMS_STOPPED import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.CALL_ENDED import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.CALL_JOINED @@ -141,6 +143,8 @@ class EnumSystemMessageTypeConverter : StringBasedTypeConverter RECORDING_STOPPED "audio_recording_started" -> AUDIO_RECORDING_STARTED "audio_recording_stopped" -> AUDIO_RECORDING_STOPPED + "breakout_rooms_started" -> BREAKOUT_ROOMS_STARTED + "breakout_rooms_stopped" -> BREAKOUT_ROOMS_STOPPED else -> DUMMY } } @@ -202,6 +206,8 @@ class EnumSystemMessageTypeConverter : StringBasedTypeConverter "recording_stopped" AUDIO_RECORDING_STARTED -> "audio_recording_started" AUDIO_RECORDING_STOPPED -> "audio_recording_stopped" + BREAKOUT_ROOMS_STARTED -> "breakout_rooms_started" + BREAKOUT_ROOMS_STOPPED -> "breakout_rooms_stopped" else -> "" } } diff --git a/app/src/main/java/com/nextcloud/talk/raisehand/RequestAssistanceModel.kt b/app/src/main/java/com/nextcloud/talk/raisehand/RequestAssistanceModel.kt new file mode 100644 index 000000000..471838284 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/raisehand/RequestAssistanceModel.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud Talk application + * + * @author Marcel Hibbe + * Copyright (C) 2023 Marcel Hibbe + * + * 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.raisehand + +data class RequestAssistanceModel( + var success: Boolean +) diff --git a/app/src/main/java/com/nextcloud/talk/raisehand/RequestAssistanceRepository.kt b/app/src/main/java/com/nextcloud/talk/raisehand/RequestAssistanceRepository.kt new file mode 100644 index 000000000..826dc72e1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/raisehand/RequestAssistanceRepository.kt @@ -0,0 +1,34 @@ +/* + * Nextcloud Talk application + * + * @author Marcel Hibbe + * Copyright (C) 2023 Marcel Hibbe + * + * 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.raisehand + +import io.reactivex.Observable + +interface RequestAssistanceRepository { + + fun requestAssistance( + roomToken: String + ): Observable + + fun withdrawRequestAssistance( + roomToken: String + ): Observable +} diff --git a/app/src/main/java/com/nextcloud/talk/raisehand/RequestAssistanceRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/raisehand/RequestAssistanceRepositoryImpl.kt new file mode 100644 index 000000000..aa42daa7d --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/raisehand/RequestAssistanceRepositoryImpl.kt @@ -0,0 +1,81 @@ +/* + * Nextcloud Talk application + * + * @author Marcel Hibbe + * Copyright (C) 2023 Marcel Hibbe + * + * 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.raisehand + +import com.nextcloud.talk.api.NcApi +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.json.generic.GenericMeta +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew +import io.reactivex.Observable + +class RequestAssistanceRepositoryImpl(private val ncApi: NcApi, currentUserProvider: CurrentUserProviderNew) : + RequestAssistanceRepository { + + val currentUser: User = currentUserProvider.currentUser.blockingGet() + val credentials: String = ApiUtils.getCredentials(currentUser.username, currentUser.token) + + var apiVersion = 1 + + override fun requestAssistance(roomToken: String): Observable { + return ncApi.requestAssistance( + credentials, + ApiUtils.getUrlForRequestAssistance( + apiVersion, + currentUser.baseUrl, + roomToken + ) + ).map { mapToRequestAssistanceModel(it.ocs?.meta!!) } + } + + override fun withdrawRequestAssistance(roomToken: String): Observable { + return ncApi.withdrawRequestAssistance( + credentials, + ApiUtils.getUrlForRequestAssistance( + apiVersion, + currentUser.baseUrl, + roomToken + ) + ).map { mapToWithdrawRequestAssistanceModel(it.ocs?.meta!!) } + } + + private fun mapToRequestAssistanceModel( + response: GenericMeta + ): RequestAssistanceModel { + val success = response.statusCode == HTTP_OK + return RequestAssistanceModel( + success + ) + } + + private fun mapToWithdrawRequestAssistanceModel( + response: GenericMeta + ): WithdrawRequestAssistanceModel { + val success = response.statusCode == HTTP_OK + return WithdrawRequestAssistanceModel( + success + ) + } + + companion object { + private const val HTTP_OK: Int = 200 + } +} diff --git a/app/src/main/java/com/nextcloud/talk/raisehand/WithdrawRequestAssistanceModel.kt b/app/src/main/java/com/nextcloud/talk/raisehand/WithdrawRequestAssistanceModel.kt new file mode 100644 index 000000000..f8bba900d --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/raisehand/WithdrawRequestAssistanceModel.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud Talk application + * + * @author Marcel Hibbe + * Copyright (C) 2023 Marcel Hibbe + * + * 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.raisehand + +data class WithdrawRequestAssistanceModel( + var success: Boolean +) diff --git a/app/src/main/java/com/nextcloud/talk/raisehand/viewmodel/RaiseHandViewModel.kt b/app/src/main/java/com/nextcloud/talk/raisehand/viewmodel/RaiseHandViewModel.kt new file mode 100644 index 000000000..2d8a8a36a --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/raisehand/viewmodel/RaiseHandViewModel.kt @@ -0,0 +1,135 @@ +/* + * Nextcloud Talk application + * + * @author Marcel Hibbe + * Copyright (C) 2023 Marcel Hibbe + * + * 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.raisehand.viewmodel + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.nextcloud.talk.raisehand.RequestAssistanceModel +import com.nextcloud.talk.raisehand.RequestAssistanceRepository +import com.nextcloud.talk.raisehand.WithdrawRequestAssistanceModel +import com.nextcloud.talk.users.UserManager +import io.reactivex.Observer +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import javax.inject.Inject + +class RaiseHandViewModel @Inject constructor(private val repository: RequestAssistanceRepository) : ViewModel() { + + @Inject + lateinit var userManager: UserManager + + lateinit var roomToken: String + private var isBreakoutRoom: Boolean = false + + sealed interface ViewState + + object RaisedHandState : ViewState + object LoweredHandState : ViewState + object ErrorState : ViewState + + private val _viewState: MutableLiveData = MutableLiveData(LoweredHandState) + val viewState: LiveData + get() = _viewState + + fun clickHandButton() { + when (viewState.value) { + LoweredHandState -> { + raiseHand() + } + RaisedHandState -> { + lowerHand() + } + else -> {} + } + } + + private fun raiseHand() { + _viewState.value = RaisedHandState + if (isBreakoutRoom) { + repository.requestAssistance(roomToken) + .subscribeOn(Schedulers.io()) + ?.observeOn(AndroidSchedulers.mainThread()) + ?.subscribe(RequestAssistanceObserver()) + } + } + + fun lowerHand() { + _viewState.value = LoweredHandState + if (isBreakoutRoom) { + repository.withdrawRequestAssistance(roomToken) + .subscribeOn(Schedulers.io()) + ?.observeOn(AndroidSchedulers.mainThread()) + ?.subscribe(WithdrawRequestAssistanceObserver()) + } + } + + fun setData(roomToken: String, isBreakoutRoom: Boolean) { + this.roomToken = roomToken + this.isBreakoutRoom = isBreakoutRoom + } + + inner class RequestAssistanceObserver : Observer { + override fun onSubscribe(d: Disposable) { + // unused atm + } + + override fun onNext(requestAssistanceModel: RequestAssistanceModel) { + // RaisedHandState was already set because it's also used for signaling message + Log.d(TAG, "requestAssistance successful") + } + + override fun onError(e: Throwable) { + Log.e(TAG, "failure in RequestAssistanceObserver", e) + _viewState.value = ErrorState + } + + override fun onComplete() { + // dismiss() + } + } + + inner class WithdrawRequestAssistanceObserver : Observer { + override fun onSubscribe(d: Disposable) { + // unused atm + } + + override fun onNext(withdrawRequestAssistanceModel: WithdrawRequestAssistanceModel) { + // LoweredHandState was already set because it's also used for signaling message + Log.d(TAG, "withdrawRequestAssistance successful") + } + + override fun onError(e: Throwable) { + Log.e(TAG, "failure in WithdrawRequestAssistanceObserver", e) + _viewState.value = ErrorState + } + + override fun onComplete() { + // dismiss() + } + } + + companion object { + private val TAG = RaiseHandViewModel::class.java.simpleName + } +} diff --git a/app/src/main/java/com/nextcloud/talk/signaling/LocalParticipantMessageNotifier.java b/app/src/main/java/com/nextcloud/talk/signaling/LocalParticipantMessageNotifier.java new file mode 100644 index 000000000..b22ed5195 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/signaling/LocalParticipantMessageNotifier.java @@ -0,0 +1,53 @@ +/* + * 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.signaling; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Helper class to register and notify LocalParticipantMessageListeners. + * + * This class is only meant for internal use by SignalingMessageReceiver; listeners must register themselves against + * a SignalingMessageReceiver rather than against a LocalParticipantMessageNotifier. + */ +class LocalParticipantMessageNotifier { + + private final Set localParticipantMessageListeners = new LinkedHashSet<>(); + + public synchronized void addListener(SignalingMessageReceiver.LocalParticipantMessageListener listener) { + if (listener == null) { + throw new IllegalArgumentException("localParticipantMessageListener can not be null"); + } + + localParticipantMessageListeners.add(listener); + } + + public synchronized void removeListener(SignalingMessageReceiver.LocalParticipantMessageListener listener) { + localParticipantMessageListeners.remove(listener); + } + + public synchronized void notifySwitchTo(String token) { + for (SignalingMessageReceiver.LocalParticipantMessageListener listener : new ArrayList<>(localParticipantMessageListeners)) { + listener.onSwitchTo(token); + } + } +} 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 47dd83ca9..a8e201817 100644 --- a/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java +++ b/app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.java @@ -118,6 +118,26 @@ public abstract class SignalingMessageReceiver { void onAllParticipantsUpdate(long inCall); } + /** + * Listener for local participant messages. + * + * The messages are implicitly bound to the local participant (or, rather, its session); listeners are expected + * to know the local participant. + * + * The messages are related to the conversation, so the local participant may or may not be in a call when they + * are received. + */ + public interface LocalParticipantMessageListener { + /** + * Request for the client to switch to the given conversation. + * + * This message is received only when the external signaling server is used. + * + * @param token the token of the conversation to switch to. + */ + void onSwitchTo(String token); + } + /** * Listener for call participant messages. * @@ -160,6 +180,8 @@ public abstract class SignalingMessageReceiver { private final ParticipantListMessageNotifier participantListMessageNotifier = new ParticipantListMessageNotifier(); + private final LocalParticipantMessageNotifier localParticipantMessageNotifier = new LocalParticipantMessageNotifier(); + private final CallParticipantMessageNotifier callParticipantMessageNotifier = new CallParticipantMessageNotifier(); private final OfferMessageNotifier offerMessageNotifier = new OfferMessageNotifier(); @@ -181,6 +203,21 @@ public abstract class SignalingMessageReceiver { participantListMessageNotifier.removeListener(listener); } + /** + * Adds a listener for local participant messages. + * + * A listener is expected to be added only once. If the same listener is added again it will be notified just once. + * + * @param listener the LocalParticipantMessageListener + */ + public void addListener(LocalParticipantMessageListener listener) { + localParticipantMessageNotifier.addListener(listener); + } + + public void removeListener(LocalParticipantMessageListener listener) { + localParticipantMessageNotifier.removeListener(listener); + } + /** * Adds a listener for call participant messages. * @@ -232,10 +269,57 @@ public abstract class SignalingMessageReceiver { } protected void processEvent(Map eventMap) { - if (!"update".equals(eventMap.get("type")) || !"participants".equals(eventMap.get("target"))) { + if ("room".equals(eventMap.get("target")) && "switchto".equals(eventMap.get("type"))) { + processSwitchToEvent(eventMap); + return; } + if ("participants".equals(eventMap.get("target")) && "update".equals(eventMap.get("type"))) { + processUpdateEvent(eventMap); + + return; + } + } + + private void processSwitchToEvent(Map eventMap) { + // Message schema: + // { + // "type": "event", + // "event": { + // "target": "room", + // "type": "switchto", + // "switchto": { + // "roomid": #STRING#, + // }, + // }, + // } + + Map switchToMap; + try { + switchToMap = (Map) eventMap.get("switchto"); + } catch (RuntimeException e) { + // Broken message, this should not happen. + return; + } + + if (switchToMap == null) { + // Broken message, this should not happen. + return; + } + + String token; + try { + token = switchToMap.get("roomid").toString(); + } catch (RuntimeException e) { + // Broken message, this should not happen. + return; + } + + localParticipantMessageNotifier.notifySwitchTo(token); + } + + private void processUpdateEvent(Map eventMap) { Map updateMap; try { updateMap = (Map) eventMap.get("update"); diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/MoreCallActionsDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/MoreCallActionsDialog.kt index 875cc3f4c..717fec352 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/MoreCallActionsDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/MoreCallActionsDialog.kt @@ -32,6 +32,7 @@ import com.nextcloud.talk.R import com.nextcloud.talk.activities.CallActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.databinding.DialogMoreCallActionsBinding +import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.viewmodels.CallRecordingViewModel import javax.inject.Inject @@ -72,12 +73,22 @@ class MoreCallActionsDialog(private val callActivity: CallActivity) : BottomShee } else { binding.recordCall.visibility = View.GONE } + + if (callActivity.isAllowedToRaiseHand) { + binding.raiseHand.visibility = View.VISIBLE + } else { + binding.raiseHand.visibility = View.GONE + } } private fun initClickListeners() { binding.recordCall.setOnClickListener { callActivity.callRecordingViewModel.clickRecordButton() } + + binding.raiseHand.setOnClickListener { + callActivity.clickRaiseOrLowerHandButton() + } } private fun initObservers() { @@ -111,6 +122,26 @@ class MoreCallActionsDialog(private val callActivity: CallActivity) : BottomShee } } } + + callActivity.raiseHandViewModel.viewState.observe(this) { state -> + when (state) { + is RaiseHandViewModel.RaisedHandState -> { + binding.raiseHandText.text = context.getText(R.string.lower_hand) + binding.raiseHandIcon.setImageDrawable( + ContextCompat.getDrawable(context, R.drawable.ic_baseline_do_not_touch_24) + ) + dismiss() + } + is RaiseHandViewModel.LoweredHandState -> { + binding.raiseHandText.text = context.getText(R.string.raise_hand) + binding.raiseHandIcon.setImageDrawable( + ContextCompat.getDrawable(context, R.drawable.ic_hand_back_left) + ) + dismiss() + } + else -> {} + } + } } companion object { diff --git a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java index fe40815d9..6c2dd557b 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java +++ b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java @@ -498,4 +498,8 @@ public class ApiUtils { public static String getUrlForRecording(int version, String baseUrl, String token) { return getUrlForApi(version, baseUrl) + "/recording/" + token; } + + public static String getUrlForRequestAssistance(int version, String baseUrl, String token) { + return getUrlForApi(version, baseUrl) + "/breakout-rooms/" + token + "/request-assistance"; + } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt b/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt index 5f2c4aa3a..e89647e3c 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt @@ -80,4 +80,6 @@ object BundleKeys { const val KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_AUDIO = "KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_AUDIO" const val KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_VIDEO = "KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_VIDEO" const val KEY_IS_MODERATOR = "KEY_IS_MODERATOR" + const val KEY_SWITCH_TO_ROOM_AND_START_CALL = "KEY_SWITCH_TO_ROOM_AND_START_CALL" + const val KEY_IS_BREAKOUT_ROOM = "KEY_IS_BREAKOUT_ROOM" } diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java b/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java index 2676c5746..13f26a83c 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java +++ b/app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java @@ -190,6 +190,24 @@ public class PeerConnectionWrapper { } } + public void raiseHand(Boolean raise) { + // TODO: build&send raiseHand message (either here or via RaiseHandViewModel) +// NCMessagePayload ncMessagePayload = new NCMessagePayload(); +// ncMessagePayload.setState(raise); +// ncMessagePayload.setTimestamp(System.currentTimeMillis()); +// +// +// NCSignalingMessage ncSignalingMessage = new NCSignalingMessage(); +//// ncSignalingMessage.setFrom(); +// ncSignalingMessage.setTo(sessionId); +//// ncSignalingMessage.setSid(); +// ncSignalingMessage.setType("raiseHand"); +// ncSignalingMessage.setPayload(ncMessagePayload); +// ncSignalingMessage.setRoomType(videoStreamType); +// +// signalingMessageSender.send(ncSignalingMessage); + } + /** * Adds a listener for data channel messages. * @@ -273,7 +291,7 @@ public class PeerConnectionWrapper { try { buffer = ByteBuffer.wrap(LoganSquare.serialize(dataChannelMessage).getBytes()); dataChannel.send(new DataChannel.Buffer(buffer, false)); - } catch (IOException e) { + } catch (Exception e) { Log.d(TAG, "Failed to send channel data, attempting regular " + dataChannelMessage); } } diff --git a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt index 46076f29d..4a3d630db 100644 --- a/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt +++ b/app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt @@ -201,12 +201,14 @@ class WebSocketInstance internal constructor( val target = eventOverallWebSocketMessage.eventMap!!["target"] as String? if (target != null) { when (target) { - Globals.TARGET_ROOM -> + Globals.TARGET_ROOM -> { if ("message" == eventOverallWebSocketMessage.eventMap!!["type"]) { processRoomMessageMessage(eventOverallWebSocketMessage) } else if ("join" == eventOverallWebSocketMessage.eventMap!!["type"]) { processRoomJoinMessage(eventOverallWebSocketMessage) } + signalingMessageReceiver.process(eventOverallWebSocketMessage.eventMap) + } Globals.TARGET_PARTICIPANTS -> signalingMessageReceiver.process(eventOverallWebSocketMessage.eventMap) else -> diff --git a/app/src/main/res/drawable/ic_baseline_do_not_touch_24.xml b/app/src/main/res/drawable/ic_baseline_do_not_touch_24.xml new file mode 100644 index 000000000..a6ba60c39 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_do_not_touch_24.xml @@ -0,0 +1,23 @@ + + + + diff --git a/app/src/main/res/layout/call_activity.xml b/app/src/main/res/layout/call_activity.xml index 2d3a5bb75..a409b68e9 100644 --- a/app/src/main/res/layout/call_activity.xml +++ b/app/src/main/res/layout/call_activity.xml @@ -159,6 +159,22 @@ android:layout_height="wrap_content" android:layout_alignTop="@id/verticalCenter" android:layout_marginTop="-50dp" /> + + + diff --git a/app/src/main/res/layout/call_item.xml b/app/src/main/res/layout/call_item.xml index 9d5e52547..8901e054b 100644 --- a/app/src/main/res/layout/call_item.xml +++ b/app/src/main/res/layout/call_item.xml @@ -79,7 +79,7 @@ android:layout_height="16dp" android:layout_marginStart="10dp" android:layout_marginBottom="6dp" - android:contentDescription="@string/nc_remote_audio_off" + android:contentDescription="@string/raise_hand" android:src="@drawable/ic_hand_back_left" android:visibility="invisible" tools:visibility="visible" /> diff --git a/app/src/main/res/layout/dialog_more_call_actions.xml b/app/src/main/res/layout/dialog_more_call_actions.xml index a44fdf80a..9bb0bac78 100644 --- a/app/src/main/res/layout/dialog_more_call_actions.xml +++ b/app/src/main/res/layout/dialog_more_call_actions.xml @@ -37,6 +37,39 @@ android:textColor="@color/medium_emphasis_text_dark_background" android:textSize="@dimen/bottom_sheet_text_size" /> + + + + + + + + Answer as video call Switch to self video %1$s raised the hand + Raise hand + Lower hand Mute microphone @@ -628,6 +630,10 @@ 1 hour Chat messages can be expired after a certain time. Note: Files shared in chat will not be deleted for the owner, but will no longer be shared in the conversation. + + Switch to main room + Switch to breakout room + You are not allowed to activate audio! You are not allowed to activate video! Scroll to bottom diff --git a/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverLocalParticipantTest.java b/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverLocalParticipantTest.java new file mode 100644 index 000000000..4c6acee79 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverLocalParticipantTest.java @@ -0,0 +1,193 @@ +/* + * 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.signaling; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InOrder; + +import java.util.HashMap; +import java.util.Map; + +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.only; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +public class SignalingMessageReceiverLocalParticipantTest { + + private SignalingMessageReceiver signalingMessageReceiver; + + @Before + public void setUp() { + // SignalingMessageReceiver is abstract to prevent direct instantiation without calling the appropriate + // protected methods. + signalingMessageReceiver = new SignalingMessageReceiver() { + }; + } + + @Test + public void testAddLocalParticipantMessageListenerWithNullListener() { + Assert.assertThrows(IllegalArgumentException.class, () -> { + signalingMessageReceiver.addListener((SignalingMessageReceiver.LocalParticipantMessageListener) null); + }); + } + + @Test + public void testExternalSignalingLocalParticipantMessageSwitchTo() { + SignalingMessageReceiver.LocalParticipantMessageListener mockedLocalParticipantMessageListener = + mock(SignalingMessageReceiver.LocalParticipantMessageListener.class); + + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener); + + Map eventMap = new HashMap<>(); + eventMap.put("type", "switchto"); + eventMap.put("target", "room"); + Map switchToMap = new HashMap<>(); + switchToMap.put("roomid", "theToken"); + eventMap.put("switchto", switchToMap); + signalingMessageReceiver.processEvent(eventMap); + + verify(mockedLocalParticipantMessageListener, only()).onSwitchTo("theToken"); + } + + @Test + public void testExternalSignalingLocalParticipantMessageAfterRemovingListener() { + SignalingMessageReceiver.LocalParticipantMessageListener mockedLocalParticipantMessageListener = + mock(SignalingMessageReceiver.LocalParticipantMessageListener.class); + + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener); + signalingMessageReceiver.removeListener(mockedLocalParticipantMessageListener); + + Map eventMap = new HashMap<>(); + eventMap.put("type", "switchto"); + eventMap.put("target", "room"); + HashMap switchToMap = new HashMap<>(); + switchToMap.put("roomid", "theToken"); + eventMap.put("switchto", switchToMap); + signalingMessageReceiver.processEvent(eventMap); + + verifyNoInteractions(mockedLocalParticipantMessageListener); + } + + @Test + public void testExternalSignalingLocalParticipantMessageAfterRemovingSingleListenerOfSeveral() { + SignalingMessageReceiver.LocalParticipantMessageListener mockedLocalParticipantMessageListener1 = + mock(SignalingMessageReceiver.LocalParticipantMessageListener.class); + SignalingMessageReceiver.LocalParticipantMessageListener mockedLocalParticipantMessageListener2 = + mock(SignalingMessageReceiver.LocalParticipantMessageListener.class); + SignalingMessageReceiver.LocalParticipantMessageListener mockedLocalParticipantMessageListener3 = + mock(SignalingMessageReceiver.LocalParticipantMessageListener.class); + + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener1); + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener2); + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener3); + signalingMessageReceiver.removeListener(mockedLocalParticipantMessageListener2); + + Map eventMap = new HashMap<>(); + eventMap.put("type", "switchto"); + eventMap.put("target", "room"); + HashMap switchToMap = new HashMap<>(); + switchToMap.put("roomid", "theToken"); + eventMap.put("switchto", switchToMap); + signalingMessageReceiver.processEvent(eventMap); + + verify(mockedLocalParticipantMessageListener1, only()).onSwitchTo("theToken"); + verify(mockedLocalParticipantMessageListener3, only()).onSwitchTo("theToken"); + verifyNoInteractions(mockedLocalParticipantMessageListener2); + } + + @Test + public void testExternalSignalingLocalParticipantMessageAfterAddingListenerAgain() { + SignalingMessageReceiver.LocalParticipantMessageListener mockedLocalParticipantMessageListener = + mock(SignalingMessageReceiver.LocalParticipantMessageListener.class); + + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener); + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener); + + Map eventMap = new HashMap<>(); + eventMap.put("type", "switchto"); + eventMap.put("target", "room"); + HashMap switchToMap = new HashMap<>(); + switchToMap.put("roomid", "theToken"); + eventMap.put("switchto", switchToMap); + signalingMessageReceiver.processEvent(eventMap); + + verify(mockedLocalParticipantMessageListener, only()).onSwitchTo("theToken"); + } + + @Test + public void testAddLocalParticipantMessageListenerWhenHandlingExternalSignalingLocalParticipantMessage() { + SignalingMessageReceiver.LocalParticipantMessageListener mockedLocalParticipantMessageListener1 = + mock(SignalingMessageReceiver.LocalParticipantMessageListener.class); + SignalingMessageReceiver.LocalParticipantMessageListener mockedLocalParticipantMessageListener2 = + mock(SignalingMessageReceiver.LocalParticipantMessageListener.class); + + doAnswer((invocation) -> { + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener2); + return null; + }).when(mockedLocalParticipantMessageListener1).onSwitchTo("theToken"); + + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener1); + + Map eventMap = new HashMap<>(); + eventMap.put("type", "switchto"); + eventMap.put("target", "room"); + HashMap switchToMap = new HashMap<>(); + switchToMap.put("roomid", "theToken"); + eventMap.put("switchto", switchToMap); + signalingMessageReceiver.processEvent(eventMap); + + verify(mockedLocalParticipantMessageListener1, only()).onSwitchTo("theToken"); + verifyNoInteractions(mockedLocalParticipantMessageListener2); + } + + @Test + public void testRemoveLocalParticipantMessageListenerWhenHandlingExternalSignalingLocalParticipantMessage() { + SignalingMessageReceiver.LocalParticipantMessageListener mockedLocalParticipantMessageListener1 = + mock(SignalingMessageReceiver.LocalParticipantMessageListener.class); + SignalingMessageReceiver.LocalParticipantMessageListener mockedLocalParticipantMessageListener2 = + mock(SignalingMessageReceiver.LocalParticipantMessageListener.class); + + doAnswer((invocation) -> { + signalingMessageReceiver.removeListener(mockedLocalParticipantMessageListener2); + return null; + }).when(mockedLocalParticipantMessageListener1).onSwitchTo("theToken"); + + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener1); + signalingMessageReceiver.addListener(mockedLocalParticipantMessageListener2); + + Map eventMap = new HashMap<>(); + eventMap.put("type", "switchto"); + eventMap.put("target", "room"); + HashMap switchToMap = new HashMap<>(); + switchToMap.put("roomid", "theToken"); + eventMap.put("switchto", switchToMap); + signalingMessageReceiver.processEvent(eventMap); + + InOrder inOrder = inOrder(mockedLocalParticipantMessageListener1, mockedLocalParticipantMessageListener2); + + inOrder.verify(mockedLocalParticipantMessageListener1).onSwitchTo("theToken"); + inOrder.verify(mockedLocalParticipantMessageListener2).onSwitchTo("theToken"); + } +}