Merge pull request #2715 from nextcloud/feature/2556/breakoutRooms

add handling for breakout rooms
This commit is contained in:
Marcel Hibbe 2023-02-20 13:15:40 +01:00 committed by GitHub
commit a0e275da3c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1004 additions and 27 deletions

View File

@ -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")

View File

@ -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<User>(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()) {

View File

@ -587,4 +587,12 @@ public interface NcApi {
@DELETE
Observable<GenericOverall> stopRecording(@Header("Authorization") String authorization,
@Url String url);
@POST
Observable<GenericOverall> requestAssistance(@Header("Authorization") String authorization,
@Url String url);
@DELETE
Observable<GenericOverall> withdrawRequestAssistance(@Header("Authorization") String authorization,
@Url String url);
}

View File

@ -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<Conversation>(args.getParcelable(KEY_ACTIVE_CONVERSATION))
this.participantPermissions = ParticipantPermissions(conversationUser!!, currentConversation!!)
currentConversation = Parcels.unwrap<Conversation>(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<View>(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"
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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<ChatMessage.Syst
"recording_stopped" -> 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<ChatMessage.Syst
RECORDING_STOPPED -> "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 -> ""
}
}

View File

@ -0,0 +1,25 @@
/*
* Nextcloud Talk application
*
* @author Marcel Hibbe
* Copyright (C) 2023 Marcel Hibbe <dev@mhibbe.de>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.talk.raisehand
data class RequestAssistanceModel(
var success: Boolean
)

View File

@ -0,0 +1,34 @@
/*
* Nextcloud Talk application
*
* @author Marcel Hibbe
* Copyright (C) 2023 Marcel Hibbe <dev@mhibbe.de>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.nextcloud.talk.raisehand
import io.reactivex.Observable
interface RequestAssistanceRepository {
fun requestAssistance(
roomToken: String
): Observable<RequestAssistanceModel>
fun withdrawRequestAssistance(
roomToken: String
): Observable<WithdrawRequestAssistanceModel>
}

View File

@ -0,0 +1,81 @@
/*
* Nextcloud Talk application
*
* @author Marcel Hibbe
* Copyright (C) 2023 Marcel Hibbe <dev@mhibbe.de>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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<RequestAssistanceModel> {
return ncApi.requestAssistance(
credentials,
ApiUtils.getUrlForRequestAssistance(
apiVersion,
currentUser.baseUrl,
roomToken
)
).map { mapToRequestAssistanceModel(it.ocs?.meta!!) }
}
override fun withdrawRequestAssistance(roomToken: String): Observable<WithdrawRequestAssistanceModel> {
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
}
}

View File

@ -0,0 +1,25 @@
/*
* Nextcloud Talk application
*
* @author Marcel Hibbe
* Copyright (C) 2023 Marcel Hibbe <dev@mhibbe.de>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.talk.raisehand
data class WithdrawRequestAssistanceModel(
var success: Boolean
)

View File

@ -0,0 +1,135 @@
/*
* Nextcloud Talk application
*
* @author Marcel Hibbe
* Copyright (C) 2023 Marcel Hibbe <dev@mhibbe.de>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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<ViewState> = MutableLiveData(LoweredHandState)
val viewState: LiveData<ViewState>
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<RequestAssistanceModel> {
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<WithdrawRequestAssistanceModel> {
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
}
}

View File

@ -0,0 +1,53 @@
/*
* Nextcloud Talk application
*
* @author Daniel Calviño Sánchez
* Copyright (C) 2023 Daniel Calviño Sánchez <danxuliu@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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<SignalingMessageReceiver.LocalParticipantMessageListener> 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);
}
}
}

View File

@ -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<String, Object> 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<String, Object> eventMap) {
// Message schema:
// {
// "type": "event",
// "event": {
// "target": "room",
// "type": "switchto",
// "switchto": {
// "roomid": #STRING#,
// },
// },
// }
Map<String, Object> switchToMap;
try {
switchToMap = (Map<String, Object>) 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<String, Object> eventMap) {
Map<String, Object> updateMap;
try {
updateMap = (Map<String, Object>) eventMap.get("update");

View File

@ -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 {

View File

@ -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";
}
}

View File

@ -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"
}

View File

@ -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);
}
}

View File

@ -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 ->

View File

@ -0,0 +1,23 @@
<!--
@author Google LLC
Copyright (C) 2023 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="@android:color/white" android:pathData="M13,10.17l-2.5,-2.5V2.25C10.5,1.56 11.06,1 11.75,1S13,1.56 13,2.25V10.17zM20,12.75V11V5.25C20,4.56 19.44,4 18.75,4S17.5,4.56 17.5,5.25V11h-1V3.25C16.5,2.56 15.94,2 15.25,2S14,2.56 14,3.25v7.92l6,6V12.75zM9.5,4.25C9.5,3.56 8.94,3 8.25,3c-0.67,0 -1.2,0.53 -1.24,1.18L9.5,6.67V4.25zM13,10.17l-2.5,-2.5V2.25C10.5,1.56 11.06,1 11.75,1S13,1.56 13,2.25V10.17zM20,12.75V11V5.25C20,4.56 19.44,4 18.75,4S17.5,4.56 17.5,5.25V11h-1V3.25C16.5,2.56 15.94,2 15.25,2S14,2.56 14,3.25v7.92l6,6V12.75zM9.5,4.25C9.5,3.56 8.94,3 8.25,3c-0.67,0 -1.2,0.53 -1.24,1.18L9.5,6.67V4.25zM21.19,21.19L2.81,2.81L1.39,4.22l5.63,5.63L7,9.83v4.3c-1.11,-0.64 -2.58,-1.47 -2.6,-1.48c-0.17,-0.09 -0.34,-0.14 -0.54,-0.14c-0.26,0 -0.5,0.09 -0.7,0.26C3.12,12.78 2,13.88 2,13.88l6.8,7.18c0.57,0.6 1.35,0.94 2.18,0.94H17c0.62,0 1.18,-0.19 1.65,-0.52l-0.02,-0.02l1.15,1.15L21.19,21.19z"/>
</vector>

View File

@ -159,6 +159,22 @@
android:layout_height="wrap_content"
android:layout_alignTop="@id/verticalCenter"
android:layout_marginTop="-50dp" />
<ImageButton
android:id="@+id/lower_hand_button"
android:layout_width="@dimen/min_size_clickable_area"
android:layout_height="@dimen/min_size_clickable_area"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:layout_marginBottom="150dp"
android:layout_marginEnd="20dp"
android:contentDescription="@string/lower_hand"
android:background="@drawable/shape_oval"
android:backgroundTint="@color/call_buttons_background"
android:visibility="gone"
tools:visibility="visible"
app:srcCompat="@drawable/ic_baseline_do_not_touch_24" />
</RelativeLayout>
</LinearLayout>

View File

@ -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" />

View File

@ -37,6 +37,39 @@
android:textColor="@color/medium_emphasis_text_dark_background"
android:textSize="@dimen/bottom_sheet_text_size" />
<LinearLayout
android:id="@+id/raise_hand"
android:layout_width="match_parent"
android:layout_height="@dimen/bottom_sheet_item_height"
android:background="?android:attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="@dimen/standard_padding"
android:paddingEnd="@dimen/standard_padding"
tools:ignore="UseCompoundDrawables">
<ImageView
android:id="@+id/raise_hand_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@null"
android:src="@drawable/ic_hand_back_left"
app:tint="@color/high_emphasis_menu_icon_inverse" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/raise_hand_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:paddingStart="@dimen/standard_double_padding"
android:paddingEnd="@dimen/zero"
android:text="@string/raise_hand"
android:textAlignment="viewStart"
android:textColor="@color/high_emphasis_text_dark_background"
android:textSize="@dimen/bottom_sheet_text_size" />
</LinearLayout>
<LinearLayout
android:id="@+id/record_call"
android:layout_width="match_parent"

View File

@ -223,6 +223,8 @@
<string name="nc_call_button_content_description_answer_video_call">Answer as video call</string>
<string name="nc_call_button_content_description_switch_to_self_vide">Switch to self video</string>
<string name="nc_call_raised_hand">%1$s raised the hand</string>
<string name="raise_hand">Raise hand</string>
<string name="lower_hand">Lower hand</string>
<!-- Picture in Picture -->
<string name="nc_pip_microphone_mute">Mute microphone</string>
@ -628,6 +630,10 @@
<string name="nc_expire_message_one_hour">1 hour</string>
<string name="nc_expire_messages_explanation">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.</string>
<!-- Breakout rooms -->
<string name="switch_to_main_room">Switch to main room</string>
<string name="switch_to_breakout_room">Switch to breakout room</string>
<string name="nc_not_allowed_to_activate_audio">You are not allowed to activate audio!</string>
<string name="nc_not_allowed_to_activate_video">You are not allowed to activate video!</string>
<string name="scroll_to_bottom">Scroll to bottom</string>

View File

@ -0,0 +1,193 @@
/*
* Nextcloud Talk application
*
* @author Daniel Calviño Sánchez
* Copyright (C) 2023 Daniel Calviño Sánchez <danxuliu@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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<String, Object> eventMap = new HashMap<>();
eventMap.put("type", "switchto");
eventMap.put("target", "room");
Map<String, Object> 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<String, Object> eventMap = new HashMap<>();
eventMap.put("type", "switchto");
eventMap.put("target", "room");
HashMap<String, Object> 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<String, Object> eventMap = new HashMap<>();
eventMap.put("type", "switchto");
eventMap.put("target", "room");
HashMap<String, Object> 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<String, Object> eventMap = new HashMap<>();
eventMap.put("type", "switchto");
eventMap.put("target", "room");
HashMap<String, Object> 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<String, Object> eventMap = new HashMap<>();
eventMap.put("type", "switchto");
eventMap.put("target", "room");
HashMap<String, Object> 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<String, Object> eventMap = new HashMap<>();
eventMap.put("type", "switchto");
eventMap.put("target", "room");
HashMap<String, Object> 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");
}
}