Эх сурвалжийг харах

Merge pull request #2614 from nextcloud/feature/2554/callRecordingSupport

Feature/2554/call recording support
Marcel Hibbe 2 жил өмнө
parent
commit
fc0e3dbe0a
35 өөрчлөгдсөн 1538 нэмэгдсэн , 619 устгасан
  1. 1 0
      app/build.gradle
  2. 0 1
      app/src/main/AndroidManifest.xml
  3. 137 35
      app/src/main/java/com/nextcloud/talk/activities/CallActivity.java
  4. 19 0
      app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java
  5. 4 0
      app/src/main/java/com/nextcloud/talk/activities/CallNotificationActivity.kt
  6. 10 0
      app/src/main/java/com/nextcloud/talk/api/NcApi.java
  7. 20 27
      app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt
  8. 7 0
      app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt
  9. 7 1
      app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt
  10. 25 0
      app/src/main/java/com/nextcloud/talk/models/domain/StartCallRecordingModel.kt
  11. 25 0
      app/src/main/java/com/nextcloud/talk/models/domain/StopCallRecordingModel.kt
  12. 4 0
      app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.kt
  13. 4 1
      app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.kt
  14. 12 42
      app/src/main/java/com/nextcloud/talk/models/json/converters/EnumSystemMessageTypeConverter.kt
  15. 36 0
      app/src/main/java/com/nextcloud/talk/repositories/callrecording/CallRecordingRepository.kt
  16. 88 0
      app/src/main/java/com/nextcloud/talk/repositories/callrecording/CallRecordingRepositoryImpl.kt
  17. 119 0
      app/src/main/java/com/nextcloud/talk/ui/dialog/MoreCallActionsDialog.kt
  18. 4 0
      app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java
  19. 38 0
      app/src/main/java/com/nextcloud/talk/utils/VibrationUtils.kt
  20. 2 0
      app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt
  21. 13 0
      app/src/main/java/com/nextcloud/talk/utils/database/user/CapabilitiesUtilNew.kt
  22. 164 0
      app/src/main/java/com/nextcloud/talk/viewmodels/CallRecordingViewModel.kt
  23. 0 494
      app/src/main/java/com/nextcloud/talk/webrtc/MagicWebSocketInstance.java
  24. 16 16
      app/src/main/java/com/nextcloud/talk/webrtc/WebSocketConnectionHelper.java
  25. 456 0
      app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt
  26. 25 0
      app/src/main/res/drawable/ic_dots_horizontal_white.xml
  27. 9 0
      app/src/main/res/drawable/record_start.xml
  28. 9 0
      app/src/main/res/drawable/record_stop.xml
  29. 34 1
      app/src/main/res/layout/call_activity.xml
  30. 0 1
      app/src/main/res/layout/dialog_audio_output.xml
  31. 73 0
      app/src/main/res/layout/dialog_more_call_actions.xml
  32. 13 0
      app/src/main/res/values/strings.xml
  33. 42 0
      app/src/test/java/com/nextcloud/talk/test/fakes/FakeCallRecordingRepository.kt
  34. 33 0
      app/src/test/java/com/nextcloud/talk/viewmodels/AbstractViewModelTest.kt
  35. 89 0
      app/src/test/java/com/nextcloud/talk/viewmodels/CallRecordingViewModelTest.kt

+ 1 - 0
app/build.gradle

@@ -290,6 +290,7 @@ dependencies {
 
     testImplementation 'junit:junit:4.13.2'
     testImplementation 'org.mockito:mockito-core:5.1.1'
+    testImplementation 'androidx.arch.core:core-testing:2.1.0'
 
     androidTestImplementation "androidx.test:core:1.5.0"
 

+ 0 - 1
app/src/main/AndroidManifest.xml

@@ -106,7 +106,6 @@
 
         <activity
             android:name=".activities.MainActivity"
-            android:label="@string/nc_app_name"
             android:exported="true"
             android:windowSoftInputMode="adjustResize">
             <intent-filter>

+ 137 - 35
app/src/main/java/com/nextcloud/talk/activities/CallActivity.java

@@ -3,6 +3,8 @@
  *
  * @author Mario Danic
  * @author Tim Krüger
+ * @author Marcel Hibbe
+ * Copyright (C) 2022 Marcel Hibbe <dev@mhibbe.de>
  * Copyright (C) 2022 Tim Krüger <t@timkrueger.me>
  * Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
  *
@@ -55,6 +57,7 @@ import android.widget.RelativeLayout;
 import android.widget.Toast;
 
 import com.bluelinelabs.logansquare.LoganSquare;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 import com.nextcloud.talk.R;
 import com.nextcloud.talk.adapters.ParticipantDisplayItem;
 import com.nextcloud.talk.adapters.ParticipantsAdapter;
@@ -86,19 +89,23 @@ import com.nextcloud.talk.models.json.signaling.settings.SignalingSettingsOveral
 import com.nextcloud.talk.signaling.SignalingMessageReceiver;
 import com.nextcloud.talk.signaling.SignalingMessageSender;
 import com.nextcloud.talk.ui.dialog.AudioOutputDialog;
+import com.nextcloud.talk.ui.dialog.MoreCallActionsDialog;
 import com.nextcloud.talk.users.UserManager;
 import com.nextcloud.talk.utils.ApiUtils;
 import com.nextcloud.talk.utils.DisplayUtils;
 import com.nextcloud.talk.utils.NotificationUtils;
+import com.nextcloud.talk.utils.VibrationUtils;
 import com.nextcloud.talk.utils.animations.PulseAnimation;
+import com.nextcloud.talk.utils.database.user.CapabilitiesUtilNew;
 import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil;
 import com.nextcloud.talk.utils.power.PowerManagerUtils;
 import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder;
+import com.nextcloud.talk.viewmodels.CallRecordingViewModel;
 import com.nextcloud.talk.webrtc.MagicWebRTCUtils;
-import com.nextcloud.talk.webrtc.MagicWebSocketInstance;
 import com.nextcloud.talk.webrtc.PeerConnectionWrapper;
 import com.nextcloud.talk.webrtc.WebRtcAudioManager;
 import com.nextcloud.talk.webrtc.WebSocketConnectionHelper;
+import com.nextcloud.talk.webrtc.WebSocketInstance;
 import com.wooplr.spotlight.SpotlightView;
 
 import org.apache.commons.lang3.StringEscapeUtils;
@@ -143,9 +150,11 @@ import androidx.annotation.DrawableRes;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
+import androidx.appcompat.app.AlertDialog;
 import androidx.appcompat.app.AppCompatActivity;
 import androidx.core.content.ContextCompat;
 import androidx.core.graphics.drawable.DrawableCompat;
+import androidx.lifecycle.ViewModelProvider;
 import autodagger.AutoInjector;
 import io.reactivex.Observable;
 import io.reactivex.Observer;
@@ -164,9 +173,11 @@ 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_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;
 import static com.nextcloud.talk.utils.bundle.BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_VIDEO;
+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_USER_ENTITY;
@@ -188,11 +199,15 @@ public class CallActivity extends CallBaseActivity {
 
     @Inject
     PlatformPermissionUtil permissionUtil;
+    @Inject
+    ViewModelProvider.Factory viewModelFactory;
 
     public static final String TAG = "CallActivity";
 
     public WebRtcAudioManager audioManager;
 
+    public CallRecordingViewModel callRecordingViewModel;
+
     private static final String[] PERMISSIONS_CALL = {
         Manifest.permission.CAMERA,
         Manifest.permission.RECORD_AUDIO
@@ -297,7 +312,7 @@ public class CallActivity extends CallBaseActivity {
     };
 
     private ExternalSignalingServer externalSignalingServer;
-    private MagicWebSocketInstance webSocketClient;
+    private WebSocketInstance webSocketClient;
     private WebSocketConnectionHelper webSocketConnectionHelper;
     private boolean hasMCU;
     private boolean hasExternalSignalingServer;
@@ -317,6 +332,7 @@ public class CallActivity extends CallBaseActivity {
     private CallActivityBinding binding;
 
     private AudioOutputDialog audioOutputDialog;
+    private MoreCallActionsDialog moreCallActionsDialog;
 
     private final ActivityResultLauncher<String> requestBluetoothPermissionLauncher =
         registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
@@ -328,6 +344,8 @@ public class CallActivity extends CallBaseActivity {
     private boolean canPublishAudioStream;
     private boolean canPublishVideoStream;
 
+    private boolean isModerator;
+
     @SuppressLint("ClickableViewAccessibility")
     @Override
     public void onCreate(Bundle savedInstanceState) {
@@ -351,6 +369,7 @@ public class CallActivity extends CallBaseActivity {
         isCallWithoutNotification = extras.getBoolean(KEY_CALL_WITHOUT_NOTIFICATION, false);
         canPublishAudioStream = extras.getBoolean(KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_AUDIO);
         canPublishVideoStream = extras.getBoolean(KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_VIDEO);
+        isModerator = extras.getBoolean(KEY_IS_MODERATOR, false);
 
         if (extras.containsKey(KEY_FROM_NOTIFICATION_START_CALL)) {
             isIncomingCallFromNotification = extras.getBoolean(KEY_FROM_NOTIFICATION_START_CALL);
@@ -371,6 +390,41 @@ public class CallActivity extends CallBaseActivity {
             setCallState(CallStatus.CONNECTING);
         }
 
+        callRecordingViewModel = new ViewModelProvider(this, viewModelFactory).get((CallRecordingViewModel.class));
+        callRecordingViewModel.setData(roomToken);
+        callRecordingViewModel.setRecordingState(extras.getInt(KEY_RECORDING_STATE));
+
+        callRecordingViewModel.getViewState().observe(this, viewState -> {
+            if (viewState instanceof CallRecordingViewModel.RecordingStartedState) {
+                binding.callRecordingIndicator.setVisibility(View.VISIBLE);
+                if (((CallRecordingViewModel.RecordingStartedState) viewState).getShowStartedInfo()) {
+                    VibrationUtils.INSTANCE.vibrateShort(context);
+                    Toast.makeText(context, context.getResources().getString(R.string.record_active_info), Toast.LENGTH_LONG).show();
+                }
+            } else if (viewState instanceof CallRecordingViewModel.RecordingConfirmStopState) {
+                MaterialAlertDialogBuilder dialogBuilder = new MaterialAlertDialogBuilder(this)
+                    .setTitle(R.string.record_stop_confirm_title)
+                    .setMessage(R.string.record_stop_confirm_message)
+                    .setPositiveButton(R.string.record_stop_description,
+                                       (dialog, which) -> callRecordingViewModel.stopRecording())
+                    .setNegativeButton(R.string.nc_common_dismiss,
+                                       (dialog, which) -> callRecordingViewModel.dismissStopRecording());
+                viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, dialogBuilder);
+                AlertDialog dialog = dialogBuilder.show();
+
+                viewThemeUtils.platform.colorTextButtons(
+                    dialog.getButton(AlertDialog.BUTTON_POSITIVE),
+                    dialog.getButton(AlertDialog.BUTTON_NEGATIVE)
+                                                        );
+
+            } else if (viewState instanceof CallRecordingViewModel.RecordingErrorState) {
+                Toast.makeText(context, context.getResources().getString(R.string.nc_common_error_sorry),
+                               Toast.LENGTH_LONG).show();
+            } else {
+                binding.callRecordingIndicator.setVisibility(View.GONE);
+            }
+        });
+
         initClickListeners();
         binding.microphoneButton.setOnTouchListener(new MicrophoneButtonTouchListener());
 
@@ -392,6 +446,18 @@ public class CallActivity extends CallBaseActivity {
         updateSelfVideoViewPosition();
     }
 
+    @Override
+    public void onStart() {
+        super.onStart();
+        initFeaturesVisibility();
+
+        try {
+            cache.evictAll();
+        } catch (IOException e) {
+            Log.e(TAG, "Failed to evict cache");
+        }
+    }
+
     @RequiresApi(api = Build.VERSION_CODES.S)
     private void requestBluetoothPermission() {
         if (ContextCompat.checkSelfPermission(
@@ -407,13 +473,11 @@ public class CallActivity extends CallBaseActivity {
         }
     }
 
-    @Override
-    public void onStart() {
-        super.onStart();
-        try {
-            cache.evictAll();
-        } catch (IOException e) {
-            Log.e(TAG, "Failed to evict cache");
+    private void initFeaturesVisibility() {
+        if (isAllowedToStartOrStopRecording()) {
+            binding.moreCallActions.setVisibility(View.VISIBLE);
+        } else {
+            binding.moreCallActions.setVisibility(View.GONE);
         }
     }
 
@@ -425,6 +489,11 @@ public class CallActivity extends CallBaseActivity {
             audioOutputDialog.show();
         });
 
+        binding.moreCallActions.setOnClickListener(v -> {
+            moreCallActionsDialog = new MoreCallActionsDialog(this);
+            moreCallActionsDialog.show();
+        });
+
         if (canPublishAudioStream) {
             binding.microphoneButton.setOnClickListener(l -> onMicrophoneClick());
             binding.microphoneButton.setOnLongClickListener(l -> {
@@ -475,6 +544,14 @@ public class CallActivity extends CallBaseActivity {
                 hangupNetworkCalls(false);
             }
         });
+
+        binding.callRecordingIndicator.setOnClickListener(l -> {
+            if (isAllowedToStartOrStopRecording()) {
+                callRecordingViewModel.clickRecordButton();
+            } else {
+                Toast.makeText(context, context.getResources().getString(R.string.record_active_info), Toast.LENGTH_LONG).show();
+            }
+        });
     }
 
     private void createCameraEnumerator() {
@@ -570,7 +647,7 @@ public class CallActivity extends CallBaseActivity {
     private void updateAudioOutputButton(WebRtcAudioManager.AudioDevice activeAudioDevice) {
         switch (activeAudioDevice) {
             case BLUETOOTH:
-                binding.audioOutputButton.setImageResource ( R.drawable.ic_baseline_bluetooth_audio_24);
+                binding.audioOutputButton.setImageResource(R.drawable.ic_baseline_bluetooth_audio_24);
                 break;
             case SPEAKER_PHONE:
                 binding.audioOutputButton.setImageResource(R.drawable.ic_volume_up_white_24dp);
@@ -1418,7 +1495,10 @@ public class CallActivity extends CallBaseActivity {
 
                     @Override
                     public void onNext(@io.reactivex.annotations.NonNull RoomOverall roomOverall) {
-                        callSession = roomOverall.getOcs().getData().getSessionId();
+                        Conversation conversation = roomOverall.getOcs().getData();
+                        callRecordingViewModel.setRecordingState(conversation.getCallRecording());
+
+                        callSession = conversation.getSessionId();
                         Log.d(TAG, " new callSession by joinRoom= " + callSession);
 
                         ApplicationWideCurrentRoomHolder.getInstance().setSession(callSession);
@@ -1537,7 +1617,7 @@ public class CallActivity extends CallBaseActivity {
                                     @Override
                                     public void onNext(
                                         @io.reactivex.annotations.NonNull
-                                            SignalingOverall signalingOverall) {
+                                        SignalingOverall signalingOverall) {
                                         receivedSignalingMessages(signalingOverall.getOcs().getSignalings());
                                     }
 
@@ -1605,26 +1685,42 @@ public class CallActivity extends CallBaseActivity {
             return;
         }
 
-        switch (webSocketCommunicationEvent.getType()) {
-            case "hello":
-                Log.d(TAG, "onMessageEvent 'hello'");
-                if (!webSocketCommunicationEvent.getHashMap().containsKey("oldResumeId")) {
-                    if (currentCallStatus == CallStatus.RECONNECTING) {
-                        hangup(false);
-                    } else {
-                        setCallState(CallStatus.RECONNECTING);
-                        runOnUiThread(this::initiateCall);
+        if (webSocketCommunicationEvent.getHashMap() != null) {
+            switch (webSocketCommunicationEvent.getType()) {
+                case "hello":
+                    Log.d(TAG, "onMessageEvent 'hello'");
+                    if (!webSocketCommunicationEvent.getHashMap().containsKey("oldResumeId")) {
+                        if (currentCallStatus == CallStatus.RECONNECTING) {
+                            hangup(false);
+                        } else {
+                            setCallState(CallStatus.RECONNECTING);
+                            runOnUiThread(this::initiateCall);
+                        }
                     }
-                }
-                break;
-            case "roomJoined":
-                Log.d(TAG, "onMessageEvent 'roomJoined'");
-                startSendingNick();
+                    break;
+                case "roomJoined":
+                    Log.d(TAG, "onMessageEvent 'roomJoined'");
+                    startSendingNick();
 
-                if (webSocketCommunicationEvent.getHashMap().get("roomToken").equals(roomToken)) {
-                    performCall();
-                }
-                break;
+                    if (webSocketCommunicationEvent.getHashMap().get("roomToken").equals(roomToken)) {
+                        performCall();
+                    }
+                    break;
+                case "recordingStatus":
+                    Log.d(TAG, "onMessageEvent 'recordingStatus'");
+
+                    if (webSocketCommunicationEvent.getHashMap().containsKey(KEY_RECORDING_STATE)) {
+                        String recordingStateString =
+                            webSocketCommunicationEvent.getHashMap().get(KEY_RECORDING_STATE);
+
+                        if (recordingStateString != null) {
+                            runOnUiThread(() -> {
+                                callRecordingViewModel.setRecordingState(Integer.parseInt(recordingStateString));
+                            });
+                        }
+                    }
+                    break;
+            }
         }
     }
 
@@ -1895,7 +1991,7 @@ public class CallActivity extends CallBaseActivity {
             // will not send an offer, so no connection is actually established when the remote participant has a
             // higher session ID but is not publishing media.
             if ((hasMCU && participantHasAudioOrVideo) ||
-                    (!hasMCU && selfParticipantHasAudioOrVideo && (!participantHasAudioOrVideo || sessionId.compareTo(currentSessionId) < 0))) {
+                (!hasMCU && selfParticipantHasAudioOrVideo && (!participantHasAudioOrVideo || sessionId.compareTo(currentSessionId) < 0))) {
                 getOrCreatePeerConnectionWrapperForSessionIdAndType(sessionId, VIDEO_STREAM_TYPE_VIDEO, false);
             }
         }
@@ -2127,7 +2223,7 @@ public class CallActivity extends CallBaseActivity {
 
     private void updateSelfVideoViewIceConnectionState(PeerConnection.IceConnectionState iceConnectionState) {
         boolean connected = iceConnectionState == PeerConnection.IceConnectionState.CONNECTED ||
-                                iceConnectionState == PeerConnection.IceConnectionState.COMPLETED;
+            iceConnectionState == PeerConnection.IceConnectionState.COMPLETED;
 
         // FIXME In voice only calls there is no video view, so the progress bar would appear floating in the middle of
         // nowhere. However, a way to signal that the local participant is not connected to the HPB is still need in
@@ -2505,8 +2601,9 @@ public class CallActivity extends CallBaseActivity {
     }
 
     /**
-     * Temporary implementation of SignalingMessageReceiver until signaling related code is extracted from CallActivity.
-     *
+     * Temporary implementation of SignalingMessageReceiver until signaling related code is extracted from
+     * CallActivity.
+     * <p>
      * All listeners are called in the main thread.
      */
     private static class InternalSignalingMessageReceiver extends SignalingMessageReceiver {
@@ -2701,7 +2798,7 @@ public class CallActivity extends CallBaseActivity {
 
         /**
          * Adds the local participant nick to offers and answers.
-         *
+         * <p>
          * For legacy reasons the offers and answers sent when the internal signaling server is used are expected to
          * provide the nick of the local participant.
          *
@@ -2872,6 +2969,11 @@ public class CallActivity extends CallBaseActivity {
         eventBus.post(new ConfigurationChangeEvent());
     }
 
+    public boolean isAllowedToStartOrStopRecording() {
+        return CapabilitiesUtilNew.isCallRecordingAvailable(conversationUser)
+            && isModerator;
+    }
+
     private class SelfVideoTouchListener implements View.OnTouchListener {
 
         @SuppressLint("ClickableViewAccessibility")

+ 19 - 0
app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java

@@ -1,3 +1,22 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Marcel Hibbe
+ * Copyright (C) 2022 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.activities;
 
 import android.annotation.SuppressLint;

+ 4 - 0
app/src/main/java/com/nextcloud/talk/activities/CallNotificationActivity.kt

@@ -169,6 +169,10 @@ class CallNotificationActivity : CallBaseActivity() {
                 BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_VIDEO,
                 participantPermission.canPublishVideo()
             )
+            originalBundle!!.putBoolean(
+                BundleKeys.KEY_IS_MODERATOR,
+                currentConversation!!.isParticipantOwnerOrModerator
+            )
 
             val intent = Intent(this, CallActivity::class.java)
             intent.putExtras(originalBundle!!)

+ 10 - 0
app/src/main/java/com/nextcloud/talk/api/NcApi.java

@@ -573,4 +573,14 @@ public interface NcApi {
     Observable<OpenGraphOverall> getOpenGraph(@Header("Authorization") String authorization,
                                               @Url String url,
                                               @Query("reference") String urlToFindPreviewFor);
+
+    @FormUrlEncoded
+    @POST
+    Observable<GenericOverall> startRecording(@Header("Authorization") String authorization,
+                                                    @Url String url,
+                                                    @Field("status") Integer status);
+
+    @DELETE
+    Observable<GenericOverall> stopRecording(@Header("Authorization") String authorization,
+                                              @Url String url);
 }

+ 20 - 27
app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt

@@ -44,13 +44,10 @@ import android.media.MediaPlayer
 import android.media.MediaRecorder
 import android.net.Uri
 import android.os.Build
-import android.os.Build.VERSION_CODES.O
 import android.os.Bundle
 import android.os.Handler
 import android.os.Parcelable
 import android.os.SystemClock
-import android.os.VibrationEffect
-import android.os.Vibrator
 import android.provider.ContactsContract
 import android.provider.MediaStore
 import android.text.Editable
@@ -169,11 +166,14 @@ import com.nextcloud.talk.utils.ImageEmojiEditText
 import com.nextcloud.talk.utils.MagicCharPolicy
 import com.nextcloud.talk.utils.NotificationUtils
 import com.nextcloud.talk.utils.ParticipantPermissions
+import com.nextcloud.talk.utils.VibrationUtils
 import com.nextcloud.talk.utils.bundle.BundleKeys
 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_MODERATOR
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_RECORDING_STATE
 import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_ID
 import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
 import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USER_ENTITY
@@ -184,8 +184,8 @@ import com.nextcloud.talk.utils.remapchat.RemapChatModel
 import com.nextcloud.talk.utils.rx.DisposableSet
 import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder
 import com.nextcloud.talk.utils.text.Spans
-import com.nextcloud.talk.webrtc.MagicWebSocketInstance
 import com.nextcloud.talk.webrtc.WebSocketConnectionHelper
+import com.nextcloud.talk.webrtc.WebSocketInstance
 import com.otaliastudios.autocomplete.Autocomplete
 import com.stfalcon.chatkit.commons.ImageLoader
 import com.stfalcon.chatkit.commons.models.IMessage
@@ -278,7 +278,7 @@ class ChatController(args: Bundle) :
     private var conversationVideoMenuItem: MenuItem? = null
     private var conversationSharedItemsItem: MenuItem? = null
 
-    var magicWebSocketInstance: MagicWebSocketInstance? = null
+    var webSocketInstance: WebSocketInstance? = null
 
     var lobbyTimerHandler: Handler? = null
     var pastPreconditionFailed = false
@@ -1173,7 +1173,7 @@ class ChatController(args: Bundle) :
                 Log.e(TAG, "start for audio recording failed")
             }
 
-            vibrate()
+            VibrationUtils.vibrateShort(context)
         }
     }
 
@@ -1206,7 +1206,7 @@ class ChatController(args: Bundle) :
                     Log.w(TAG, "error while stopping recorder!")
                 }
 
-                vibrate()
+                VibrationUtils.vibrateShort(context)
             }
             recorder = null
         } else {
@@ -1214,15 +1214,6 @@ class ChatController(args: Bundle) :
         }
     }
 
-    private fun vibrate() {
-        val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
-        if (Build.VERSION.SDK_INT >= O) {
-            vibrator.vibrate(VibrationEffect.createOneShot(SHORT_VIBRATE, VibrationEffect.DEFAULT_AMPLITUDE))
-        } else {
-            vibrator.vibrate(SHORT_VIBRATE)
-        }
-    }
-
     private fun requestRecordAudioPermissions() {
         requestPermissions(
             arrayOf(
@@ -1924,9 +1915,9 @@ class ChatController(args: Bundle) :
                             pullChatMessages(1, 0)
                         }
 
-                        if (magicWebSocketInstance != null) {
-                            magicWebSocketInstance?.joinRoomWithRoomTokenAndSession(
-                                roomToken,
+                        if (webSocketInstance != null) {
+                            webSocketInstance?.joinRoomWithRoomTokenAndSession(
+                                roomToken!!,
                                 currentConversation?.sessionId
                             )
                         }
@@ -1949,9 +1940,9 @@ class ChatController(args: Bundle) :
 
             inConversation = true
             ApplicationWideCurrentRoomHolder.getInstance().session = currentConversation?.sessionId
-            if (magicWebSocketInstance != null) {
-                magicWebSocketInstance?.joinRoomWithRoomTokenAndSession(
-                    roomToken,
+            if (webSocketInstance != null) {
+                webSocketInstance?.joinRoomWithRoomTokenAndSession(
+                    roomToken!!,
                     currentConversation?.sessionId
                 )
             }
@@ -2003,8 +1994,8 @@ class ChatController(args: Bundle) :
                         lobbyTimerHandler?.removeCallbacksAndMessages(null)
                     }
 
-                    if (magicWebSocketInstance != null && currentConversation != null) {
-                        magicWebSocketInstance?.joinRoomWithRoomTokenAndSession(
+                    if (webSocketInstance != null && currentConversation != null) {
+                        webSocketInstance?.joinRoomWithRoomTokenAndSession(
                             "",
                             currentConversation?.sessionId
                         )
@@ -2127,7 +2118,7 @@ class ChatController(args: Bundle) :
 
     private fun setupWebsocket() {
         if (conversationUser != null) {
-            magicWebSocketInstance =
+            webSocketInstance =
                 if (WebSocketConnectionHelper.getMagicWebSocketInstanceForUserId(conversationUser.id!!) != null) {
                     WebSocketConnectionHelper.getMagicWebSocketInstanceForUserId(conversationUser.id!!)
                 } else {
@@ -2738,6 +2729,8 @@ class ChatController(args: Bundle) :
             bundle.putString(BundleKeys.KEY_CONVERSATION_PASSWORD, roomPassword)
             bundle.putString(BundleKeys.KEY_MODIFIED_BASE_URL, conversationUser?.baseUrl)
             bundle.putString(KEY_CONVERSATION_NAME, it.displayName)
+            bundle.putInt(KEY_RECORDING_STATE, it.callRecording)
+            bundle.putBoolean(KEY_IS_MODERATOR, it.isParticipantOwnerOrModerator)
             bundle.putBoolean(
                 BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_AUDIO,
                 participantPermissions.canPublishAudio()
@@ -2767,7 +2760,7 @@ class ChatController(args: Bundle) :
     }
 
     override fun onClickReaction(chatMessage: ChatMessage, emoji: String) {
-        vibrate()
+        VibrationUtils.vibrateShort(context)
         if (chatMessage.reactionsSelf?.contains(emoji) == true) {
             reactionsRepository.deleteReaction(currentConversation!!, chatMessage, emoji)
                 .subscribeOn(Schedulers.io())
@@ -3289,6 +3282,7 @@ class ChatController(args: Bundle) :
                         bundle.putParcelable(KEY_USER_ENTITY, conversationUser)
                         bundle.putString(KEY_ROOM_TOKEN, roomOverall.ocs!!.data!!.token)
                         bundle.putString(KEY_ROOM_ID, roomOverall.ocs!!.data!!.roomId)
+                        bundle.putBoolean(KEY_IS_MODERATOR, roomOverall.ocs!!.data!!.isParticipantOwnerOrModerator)
 
                         if (conversationUser != null) {
                             bundle.putParcelable(
@@ -3422,7 +3416,6 @@ class ChatController(args: Bundle) :
         private const val VOICE_MESSAGE_CHANNELS = 1
         private const val FILE_DATE_PATTERN = "yyyy-MM-dd HH-mm-ss"
         private const val VIDEO_SUFFIX = ".mp4"
-        private const val SHORT_VIBRATE: Long = 20
         private const val FULLY_OPAQUE_INT: Int = 255
         private const val SEMI_TRANSPARENT_INT: Int = 99
         private const val VOICE_MESSAGE_SEEKBAR_BASE: Int = 1000

+ 7 - 0
app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt

@@ -35,6 +35,8 @@ import com.nextcloud.talk.polls.repositories.PollRepository
 import com.nextcloud.talk.polls.repositories.PollRepositoryImpl
 import com.nextcloud.talk.remotefilebrowser.repositories.RemoteFileBrowserItemsRepository
 import com.nextcloud.talk.remotefilebrowser.repositories.RemoteFileBrowserItemsRepositoryImpl
+import com.nextcloud.talk.repositories.callrecording.CallRecordingRepository
+import com.nextcloud.talk.repositories.callrecording.CallRecordingRepositoryImpl
 import com.nextcloud.talk.repositories.conversations.ConversationsRepository
 import com.nextcloud.talk.repositories.conversations.ConversationsRepositoryImpl
 import com.nextcloud.talk.repositories.reactions.ReactionsRepository
@@ -92,4 +94,9 @@ class RepositoryModule {
     fun provideReactionsRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): ReactionsRepository {
         return ReactionsRepositoryImpl(ncApi, userProvider)
     }
+
+    @Provides
+    fun provideCallRecordingRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): CallRecordingRepository {
+        return CallRecordingRepositoryImpl(ncApi, userProvider)
+    }
 }

+ 7 - 1
app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt

@@ -23,13 +23,14 @@ package com.nextcloud.talk.dagger.modules
 
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.ViewModelProvider
-import com.nextcloud.talk.remotefilebrowser.viewmodels.RemoteFileBrowserItemsViewModel
 import com.nextcloud.talk.messagesearch.MessageSearchViewModel
 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.remotefilebrowser.viewmodels.RemoteFileBrowserItemsViewModel
 import com.nextcloud.talk.shareditems.viewmodels.SharedItemsViewModel
+import com.nextcloud.talk.viewmodels.CallRecordingViewModel
 import dagger.Binds
 import dagger.MapKey
 import dagger.Module
@@ -89,4 +90,9 @@ abstract class ViewModelModule {
     @IntoMap
     @ViewModelKey(RemoteFileBrowserItemsViewModel::class)
     abstract fun remoteFileBrowserItemsViewModel(viewModel: RemoteFileBrowserItemsViewModel): ViewModel
+
+    @Binds
+    @IntoMap
+    @ViewModelKey(CallRecordingViewModel::class)
+    abstract fun callRecordingViewModel(viewModel: CallRecordingViewModel): ViewModel
 }

+ 25 - 0
app/src/main/java/com/nextcloud/talk/models/domain/StartCallRecordingModel.kt

@@ -0,0 +1,25 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Marcel Hibbe
+ * Copyright (C) 2022 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.models.domain
+
+data class StartCallRecordingModel(
+    var success: Boolean
+)

+ 25 - 0
app/src/main/java/com/nextcloud/talk/models/domain/StopCallRecordingModel.kt

@@ -0,0 +1,25 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Marcel Hibbe
+ * Copyright (C) 2022 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.models.domain
+
+data class StopCallRecordingModel(
+    var success: Boolean
+)

+ 4 - 0
app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.kt

@@ -518,6 +518,10 @@ data class ChatMessage(
         POLL_CLOSED,
         MESSAGE_EXPIRATION_ENABLED,
         MESSAGE_EXPIRATION_DISABLED,
+        RECORDING_STARTED,
+        RECORDING_STOPPED,
+        AUDIO_RECORDING_STARTED,
+        AUDIO_RECORDING_STOPPED,
     }
 
     companion object {

+ 4 - 1
app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.kt

@@ -139,7 +139,10 @@ data class Conversation(
     var statusMessage: String? = null,
 
     @JsonField(name = ["statusClearAt"])
-    var statusClearAt: Long? = 0
+    var statusClearAt: Long? = 0,
+
+    @JsonField(name = ["callRecording"])
+    var callRecording: Int = 0
 
 ) : Parcelable {
     // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'

+ 12 - 42
app/src/main/java/com/nextcloud/talk/models/json/converters/EnumSystemMessageTypeConverter.kt

@@ -26,6 +26,8 @@ package com.nextcloud.talk.models.json.converters
 
 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.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
@@ -74,54 +76,14 @@ import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.REACTIO
 import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.REACTION_REVOKED
 import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.READ_ONLY
 import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.READ_ONLY_OFF
+import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.RECORDING_STARTED
+import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.RECORDING_STOPPED
 import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.USER_ADDED
 import com.nextcloud.talk.models.json.chat.ChatMessage.SystemMessageType.USER_REMOVED
 
 /*
 * see https://nextcloud-talk.readthedocs.io/en/latest/chat/#system-messages
 *
-* `conversation_created` - {actor} created the conversation
-* `conversation_renamed` - {actor} renamed the conversation from "foo" to "bar"
-* `description_set` - {actor} set the description to "Hello world"
-* `description_removed` - {actor} removed the description
-* `call_started` - {actor} started a call
-* `call_joined` - {actor} joined the call
-* `call_left` - {actor} left the call
-* `call_ended` - Call with {user1}, {user2}, {user3}, {user4} and {user5} (Duration 30:23)
-* `call_ended_everyone` - {user1} ended the call with {user2}, {user3}, {user4} and {user5} (Duration 30:23)
-* `call_missed` - You missed a call from {user}
-* `call_tried` - You tried to call {user}
-* `read_only_off` - {actor} unlocked the conversation
-* `read_only` - {actor} locked the conversation
-* `listable_none` - {actor} limited the conversation to the current participants
-* `listable_users` - {actor} opened the conversation accessible to registered users
-* `listable_all` - {actor} opened the conversation accessible to registered and guest app users
-* `lobby_timer_reached` - The conversation is now open to everyone
-* `lobby_none` - {actor} opened the conversation to everyone
-* `lobby_non_moderators` - {actor} restricted the conversation to moderators
-* `guests_allowed` - {actor} allowed guests in the conversation
-* `guests_disallowed` - {actor} disallowed guests in the conversation
-* `password_set` - {actor} set a password for the conversation
-* `password_removed` - {actor} removed the password for the conversation
-* `user_added` - {actor} added {user} to the conversation
-* `user_removed` - {actor} removed {user} from the conversation
-* `group_added` - {actor} added group {group} to the conversation
-* `group_removed` - {actor} removed group {group} from the conversation
-* `circle_added` - {actor} added circle {circle} to the conversation
-* `circle_removed` - {actor} removed circle {circle} from the conversation
-* `moderator_promoted` - {actor} promoted {user} to moderator
-* `moderator_demoted` - {actor} demoted {user} from moderator
-* `guest_moderator_promoted` - {actor} promoted {user} to moderator
-* `guest_moderator_demoted` - {actor} demoted {user} from moderator
-* `message_deleted` - Message deleted by {actor} (Should not be shown to the user)
-* `history_cleared` - {actor} cleared the history of the conversation
-* `file_shared` - {file}
-* `object_shared` - {object}
-* `matterbridge_config_added` - {actor} set up Matterbridge to synchronize this conversation with other chats
-* `matterbridge_config_edited` - {actor} updated the Matterbridge configuration
-* `matterbridge_config_removed` - {actor} removed the Matterbridge configuration
-* `matterbridge_config_enabled` - {actor} started Matterbridge
-* `matterbridge_config_disabled` - {actor} stopped Matterbridge
 */
 class EnumSystemMessageTypeConverter : StringBasedTypeConverter<ChatMessage.SystemMessageType>() {
     override fun getFromString(string: String): ChatMessage.SystemMessageType {
@@ -175,6 +137,10 @@ class EnumSystemMessageTypeConverter : StringBasedTypeConverter<ChatMessage.Syst
             "poll_closed" -> POLL_CLOSED
             "message_expiration_enabled" -> MESSAGE_EXPIRATION_ENABLED
             "message_expiration_disabled" -> MESSAGE_EXPIRATION_DISABLED
+            "recording_started" -> RECORDING_STARTED
+            "recording_stopped" -> RECORDING_STOPPED
+            "audio_recording_started" -> AUDIO_RECORDING_STARTED
+            "audio_recording_stopped" -> AUDIO_RECORDING_STOPPED
             else -> DUMMY
         }
     }
@@ -232,6 +198,10 @@ class EnumSystemMessageTypeConverter : StringBasedTypeConverter<ChatMessage.Syst
             POLL_CLOSED -> "poll_closed"
             MESSAGE_EXPIRATION_ENABLED -> "message_expiration_enabled"
             MESSAGE_EXPIRATION_DISABLED -> "message_expiration_disabled"
+            RECORDING_STARTED -> "recording_started"
+            RECORDING_STOPPED -> "recording_stopped"
+            AUDIO_RECORDING_STARTED -> "audio_recording_started"
+            AUDIO_RECORDING_STOPPED -> "audio_recording_stopped"
             else -> ""
         }
     }

+ 36 - 0
app/src/main/java/com/nextcloud/talk/repositories/callrecording/CallRecordingRepository.kt

@@ -0,0 +1,36 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Marcel Hibbe
+ * Copyright (C) 2022 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.repositories.callrecording
+
+import com.nextcloud.talk.models.domain.StartCallRecordingModel
+import com.nextcloud.talk.models.domain.StopCallRecordingModel
+import io.reactivex.Observable
+
+interface CallRecordingRepository {
+
+    fun startRecording(
+        roomToken: String
+    ): Observable<StartCallRecordingModel>
+
+    fun stopRecording(
+        roomToken: String
+    ): Observable<StopCallRecordingModel>
+}

+ 88 - 0
app/src/main/java/com/nextcloud/talk/repositories/callrecording/CallRecordingRepositoryImpl.kt

@@ -0,0 +1,88 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Marcel Hibbe
+ * Copyright (C) 2022 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.repositories.callrecording
+
+import com.nextcloud.talk.api.NcApi
+import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.models.domain.StartCallRecordingModel
+import com.nextcloud.talk.models.domain.StopCallRecordingModel
+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 CallRecordingRepositoryImpl(private val ncApi: NcApi, currentUserProvider: CurrentUserProviderNew) :
+    CallRecordingRepository {
+
+    val currentUser: User = currentUserProvider.currentUser.blockingGet()
+    val credentials: String = ApiUtils.getCredentials(currentUser.username, currentUser.token)
+
+    var apiVersion = 1
+
+    override fun startRecording(
+        roomToken: String
+    ): Observable<StartCallRecordingModel> {
+        return ncApi.startRecording(
+            credentials,
+            ApiUtils.getUrlForRecording(
+                apiVersion,
+                currentUser.baseUrl,
+                roomToken
+            ),
+            1
+        ).map { mapToStartCallRecordingModel(it.ocs?.meta!!) }
+    }
+
+    override fun stopRecording(
+        roomToken: String
+    ): Observable<StopCallRecordingModel> {
+        return ncApi.stopRecording(
+            credentials,
+            ApiUtils.getUrlForRecording(
+                apiVersion,
+                currentUser.baseUrl,
+                roomToken
+            )
+        ).map { mapToStopCallRecordingModel(it.ocs?.meta!!) }
+    }
+
+    private fun mapToStartCallRecordingModel(
+        response: GenericMeta
+    ): StartCallRecordingModel {
+        val success = response.statusCode == HTTP_OK
+        return StartCallRecordingModel(
+            success
+        )
+    }
+
+    private fun mapToStopCallRecordingModel(
+        response: GenericMeta
+    ): StopCallRecordingModel {
+        val success = response.statusCode == HTTP_OK
+        return StopCallRecordingModel(
+            success
+        )
+    }
+
+    companion object {
+        private const val HTTP_OK: Int = 200
+    }
+}

+ 119 - 0
app/src/main/java/com/nextcloud/talk/ui/dialog/MoreCallActionsDialog.kt

@@ -0,0 +1,119 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Marcel Hibbe
+ * Copyright (C) 2022 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.ui.dialog
+
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import autodagger.AutoInjector
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+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.ui.theme.ViewThemeUtils
+import com.nextcloud.talk.viewmodels.CallRecordingViewModel
+import javax.inject.Inject
+
+@AutoInjector(NextcloudTalkApplication::class)
+class MoreCallActionsDialog(private val callActivity: CallActivity) : BottomSheetDialog(callActivity) {
+
+    @Inject
+    lateinit var viewThemeUtils: ViewThemeUtils
+
+    private lateinit var binding: DialogMoreCallActionsBinding
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        NextcloudTalkApplication.sharedApplication?.componentApplication?.inject(this)
+
+        binding = DialogMoreCallActionsBinding.inflate(layoutInflater)
+        setContentView(binding.root)
+        window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
+
+        viewThemeUtils.platform.themeDialogDark(binding.root)
+
+        initItemsVisibility()
+        initClickListeners()
+        initObservers()
+    }
+
+    override fun onStart() {
+        super.onStart()
+        val bottomSheet = findViewById<View>(R.id.design_bottom_sheet)
+        val behavior = BottomSheetBehavior.from(bottomSheet as View)
+        behavior.state = BottomSheetBehavior.STATE_COLLAPSED
+    }
+
+    private fun initItemsVisibility() {
+        if (callActivity.isAllowedToStartOrStopRecording) {
+            binding.recordCall.visibility = View.VISIBLE
+        } else {
+            binding.recordCall.visibility = View.GONE
+        }
+    }
+
+    private fun initClickListeners() {
+        binding.recordCall.setOnClickListener {
+            callActivity.callRecordingViewModel.clickRecordButton()
+        }
+    }
+
+    private fun initObservers() {
+        callActivity.callRecordingViewModel.viewState.observe(this) { state ->
+            when (state) {
+                is CallRecordingViewModel.RecordingStartedState -> {
+                    binding.recordCallText.text = context.getText(R.string.record_stop_description)
+                    binding.recordCallIcon.setImageDrawable(
+                        ContextCompat.getDrawable(context, R.drawable.record_stop)
+                    )
+                    dismiss()
+                }
+                is CallRecordingViewModel.RecordingStoppedState -> {
+                    binding.recordCallText.text = context.getText(R.string.record_start_description)
+                    binding.recordCallIcon.setImageDrawable(
+                        ContextCompat.getDrawable(context, R.drawable.record_start)
+                    )
+                    dismiss()
+                }
+                is CallRecordingViewModel.RecordingStartLoadingState -> {
+                    binding.recordCallText.text = context.getText(R.string.record_start_loading)
+                }
+                is CallRecordingViewModel.RecordingStopLoadingState -> {
+                    binding.recordCallText.text = context.getText(R.string.record_stop_loading)
+                }
+                is CallRecordingViewModel.RecordingConfirmStopState -> {
+                    binding.recordCallText.text = context.getText(R.string.record_stop_description)
+                }
+                else -> {
+                    Log.e(TAG, "unknown viewState for callRecordingViewModel")
+                }
+            }
+        }
+    }
+
+    companion object {
+        private const val TAG = "MoreCallActionsDialog"
+    }
+}

+ 4 - 0
app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java

@@ -494,4 +494,8 @@ public class ApiUtils {
     public static String getUrlForOpenGraph(String baseUrl) {
         return baseUrl + ocsApiVersion + "/references/resolve";
     }
+
+    public static String getUrlForRecording(int version, String baseUrl, String token) {
+        return getUrlForApi(version, baseUrl) + "/recording/" + token;
+    }
 }

+ 38 - 0
app/src/main/java/com/nextcloud/talk/utils/VibrationUtils.kt

@@ -0,0 +1,38 @@
+/*
+ * 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.utils
+
+import android.content.Context
+import android.os.Build
+import android.os.VibrationEffect
+import android.os.Vibrator
+
+object VibrationUtils {
+    private const val SHORT_VIBRATE: Long = 20
+
+    fun vibrateShort(context: Context) {
+        val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            vibrator.vibrate(VibrationEffect.createOneShot(SHORT_VIBRATE, VibrationEffect.DEFAULT_AMPLITUDE))
+        } else {
+            vibrator.vibrate(SHORT_VIBRATE)
+        }
+    }
+}

+ 2 - 0
app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt

@@ -55,6 +55,7 @@ object BundleKeys {
     const val KEY_INVITED_GROUP = "KEY_INVITED_GROUP"
     const val KEY_INVITED_EMAIL = "KEY_INVITED_EMAIL"
     const val KEY_CONVERSATION_NAME = "KEY_CONVERSATION_NAME"
+    const val KEY_RECORDING_STATE = "KEY_RECORDING_STATE"
     const val KEY_CALL_VOICE_ONLY = "KEY_CALL_VOICE_ONLY"
     const val KEY_CALL_WITHOUT_NOTIFICATION = "KEY_CALL_WITHOUT_NOTIFICATION"
     const val KEY_ACTIVE_CONVERSATION = "KEY_ACTIVE_CONVERSATION"
@@ -78,4 +79,5 @@ object BundleKeys {
     const val KEY_MIME_TYPE_FILTER = "KEY_MIME_TYPE_FILTER"
     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"
 }

+ 13 - 0
app/src/main/java/com/nextcloud/talk/utils/database/user/CapabilitiesUtilNew.kt

@@ -98,6 +98,19 @@ object CapabilitiesUtilNew {
         return false
     }
 
+    @JvmStatic
+    fun isCallRecordingAvailable(user: User): Boolean {
+        if (hasSpreedFeatureCapability(user, "recording-v1") &&
+            user.capabilities?.spreedCapability?.config?.containsKey("call") == true
+        ) {
+            val map: Map<String, String>? = user.capabilities!!.spreedCapability!!.config!!["call"]
+            if (map != null && map.containsKey("recording")) {
+                return map["recording"].toBoolean()
+            }
+        }
+        return false
+    }
+
     @JvmStatic
     fun isUserStatusAvailable(user: User): Boolean {
         return user.capabilities?.userStatusCapability?.enabled == true &&

+ 164 - 0
app/src/main/java/com/nextcloud/talk/viewmodels/CallRecordingViewModel.kt

@@ -0,0 +1,164 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Marcel Hibbe
+ * Copyright (C) 2022 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.viewmodels
+
+import android.util.Log
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import com.nextcloud.talk.models.domain.StartCallRecordingModel
+import com.nextcloud.talk.models.domain.StopCallRecordingModel
+import com.nextcloud.talk.repositories.callrecording.CallRecordingRepository
+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 CallRecordingViewModel @Inject constructor(private val repository: CallRecordingRepository) : ViewModel() {
+
+    @Inject
+    lateinit var userManager: UserManager
+
+    lateinit var roomToken: String
+
+    sealed interface ViewState
+    open class RecordingStartedState(val showStartedInfo: Boolean) : ViewState
+
+    object RecordingStoppedState : ViewState
+    object RecordingStartLoadingState : ViewState
+    object RecordingStopLoadingState : ViewState
+    object RecordingConfirmStopState : ViewState
+    object RecordingErrorState : ViewState
+
+    private val _viewState: MutableLiveData<ViewState> = MutableLiveData(RecordingStoppedState)
+    val viewState: LiveData<ViewState>
+        get() = _viewState
+
+    private var disposable: Disposable? = null
+
+    fun clickRecordButton() {
+        when (viewState.value) {
+            is RecordingStartedState -> {
+                _viewState.value = RecordingConfirmStopState
+            }
+            RecordingStoppedState -> {
+                startRecording()
+            }
+            RecordingConfirmStopState -> {
+                // confirm dialog to stop recording might have been dismissed without to click an action.
+                // just show it again.
+                _viewState.value = RecordingConfirmStopState
+            }
+            RecordingErrorState -> {
+                stopRecording()
+            }
+            else -> {}
+        }
+    }
+
+    private fun startRecording() {
+        _viewState.value = RecordingStartLoadingState
+        repository.startRecording(roomToken)
+            .subscribeOn(Schedulers.io())
+            ?.observeOn(AndroidSchedulers.mainThread())
+            ?.subscribe(CallStartRecordingObserver())
+    }
+
+    fun stopRecording() {
+        _viewState.value = RecordingStopLoadingState
+        repository.stopRecording(roomToken)
+            .subscribeOn(Schedulers.io())
+            ?.observeOn(AndroidSchedulers.mainThread())
+            ?.subscribe(CallStopRecordingObserver())
+    }
+
+    fun dismissStopRecording() {
+        _viewState.value = RecordingStartedState(false)
+    }
+
+    override fun onCleared() {
+        super.onCleared()
+        disposable?.dispose()
+    }
+
+    fun setData(roomToken: String) {
+        this.roomToken = roomToken
+    }
+
+    // https://nextcloud-talk.readthedocs.io/en/latest/constants/#call-recording-status
+    fun setRecordingState(state: Int) {
+        when (state) {
+            RECORDING_STOPPED_CODE -> _viewState.value = RecordingStoppedState
+            RECORDING_STARTED_VIDEO_CODE -> _viewState.value = RecordingStartedState(true)
+            RECORDING_STARTED_AUDIO_CODE -> _viewState.value = RecordingStartedState(true)
+            else -> {}
+        }
+    }
+
+    inner class CallStartRecordingObserver : Observer<StartCallRecordingModel> {
+        override fun onSubscribe(d: Disposable) {
+            // unused atm
+        }
+
+        override fun onNext(startCallRecordingModel: StartCallRecordingModel) {
+            // unused atm. RecordingStartedState is set via setRecordingState which is triggered by signaling message.
+        }
+
+        override fun onError(e: Throwable) {
+            Log.e(TAG, "failure in CallStartRecordingObserver", e)
+            _viewState.value = RecordingErrorState
+        }
+
+        override fun onComplete() {
+            // dismiss()
+        }
+    }
+
+    inner class CallStopRecordingObserver : Observer<StopCallRecordingModel> {
+        override fun onSubscribe(d: Disposable) {
+            // unused atm
+        }
+
+        override fun onNext(stopCallRecordingModel: StopCallRecordingModel) {
+            if (stopCallRecordingModel.success) {
+                _viewState.value = RecordingStoppedState
+            }
+        }
+
+        override fun onError(e: Throwable) {
+            Log.e(TAG, "failure in CallStopRecordingObserver", e)
+            _viewState.value = RecordingErrorState
+        }
+
+        override fun onComplete() {
+            // dismiss()
+        }
+    }
+
+    companion object {
+        private val TAG = CallRecordingViewModel::class.java.simpleName
+        const val RECORDING_STOPPED_CODE = 0
+        const val RECORDING_STARTED_VIDEO_CODE = 1
+        const val RECORDING_STARTED_AUDIO_CODE = 2
+    }
+}

+ 0 - 494
app/src/main/java/com/nextcloud/talk/webrtc/MagicWebSocketInstance.java

@@ -1,494 +0,0 @@
-/*
- * Nextcloud Talk application
- *
- * @author Mario Danic
- * Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.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.webrtc;
-
-import android.content.Context;
-import android.text.TextUtils;
-import android.util.Log;
-
-import com.bluelinelabs.logansquare.LoganSquare;
-import com.nextcloud.talk.application.NextcloudTalkApplication;
-import com.nextcloud.talk.data.user.model.User;
-import com.nextcloud.talk.events.NetworkEvent;
-import com.nextcloud.talk.events.WebSocketCommunicationEvent;
-import com.nextcloud.talk.models.json.participants.Participant;
-import com.nextcloud.talk.models.json.signaling.NCSignalingMessage;
-import com.nextcloud.talk.models.json.websocket.BaseWebSocketMessage;
-import com.nextcloud.talk.models.json.websocket.ByeWebSocketMessage;
-import com.nextcloud.talk.models.json.websocket.CallOverallWebSocketMessage;
-import com.nextcloud.talk.models.json.websocket.ErrorOverallWebSocketMessage;
-import com.nextcloud.talk.models.json.websocket.ErrorWebSocketMessage;
-import com.nextcloud.talk.models.json.websocket.EventOverallWebSocketMessage;
-import com.nextcloud.talk.models.json.websocket.HelloResponseOverallWebSocketMessage;
-import com.nextcloud.talk.models.json.websocket.JoinedRoomOverallWebSocketMessage;
-import com.nextcloud.talk.signaling.SignalingMessageReceiver;
-import com.nextcloud.talk.signaling.SignalingMessageSender;
-import com.nextcloud.talk.utils.bundle.BundleKeys;
-
-import org.greenrobot.eventbus.EventBus;
-import org.greenrobot.eventbus.Subscribe;
-import org.greenrobot.eventbus.ThreadMode;
-
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-import javax.inject.Inject;
-
-import androidx.annotation.NonNull;
-import autodagger.AutoInjector;
-import okhttp3.OkHttpClient;
-import okhttp3.Request;
-import okhttp3.Response;
-import okhttp3.WebSocket;
-import okhttp3.WebSocketListener;
-import okio.ByteString;
-
-import static com.nextcloud.talk.models.json.participants.Participant.ActorType.GUESTS;
-import static com.nextcloud.talk.models.json.participants.Participant.ActorType.USERS;
-import static com.nextcloud.talk.webrtc.Globals.ROOM_TOKEN;
-import static com.nextcloud.talk.webrtc.Globals.TARGET_PARTICIPANTS;
-import static com.nextcloud.talk.webrtc.Globals.TARGET_ROOM;
-
-@AutoInjector(NextcloudTalkApplication.class)
-public class MagicWebSocketInstance extends WebSocketListener {
-    private static final String TAG = "MagicWebSocketInstance";
-
-    @Inject
-    OkHttpClient okHttpClient;
-
-    @Inject
-    EventBus eventBus;
-
-    @Inject
-    Context context;
-
-    private final User conversationUser;
-    private final String webSocketTicket;
-    private String resumeId;
-    private String sessionId;
-    private boolean hasMCU;
-    private boolean connected;
-    private final WebSocketConnectionHelper webSocketConnectionHelper;
-    private WebSocket internalWebSocket;
-    private final String connectionUrl;
-
-    private String currentRoomToken;
-    private boolean reconnecting = false;
-
-    private HashMap<String, Participant> usersHashMap;
-
-    private List<String> messagesQueue = new ArrayList<>();
-
-    private final ExternalSignalingMessageReceiver signalingMessageReceiver = new ExternalSignalingMessageReceiver();
-
-    private final ExternalSignalingMessageSender signalingMessageSender = new ExternalSignalingMessageSender();
-
-    MagicWebSocketInstance(User conversationUser, String connectionUrl, String webSocketTicket) {
-        NextcloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this);
-
-        this.connectionUrl = connectionUrl;
-        this.conversationUser = conversationUser;
-        this.webSocketTicket = webSocketTicket;
-        this.webSocketConnectionHelper = new WebSocketConnectionHelper();
-        this.usersHashMap = new HashMap<>();
-
-        connected = false;
-        eventBus.register(this);
-
-        restartWebSocket();
-    }
-
-    private void sendHello() {
-        try {
-            if (TextUtils.isEmpty(resumeId)) {
-                internalWebSocket.send(
-                    LoganSquare.serialize(webSocketConnectionHelper
-                                              .getAssembledHelloModel(conversationUser, webSocketTicket)));
-            } else {
-                internalWebSocket.send(
-                    LoganSquare.serialize(webSocketConnectionHelper
-                                              .getAssembledHelloModelForResume(resumeId)));
-            }
-        } catch (IOException e) {
-            Log.e(TAG, "Failed to serialize hello model");
-        }
-    }
-
-    @Override
-    public void onOpen(WebSocket webSocket, Response response) {
-        internalWebSocket = webSocket;
-        sendHello();
-    }
-
-    private void closeWebSocket(WebSocket webSocket) {
-        webSocket.close(1000, null);
-        webSocket.cancel();
-        if (webSocket == internalWebSocket) {
-            connected = false;
-            messagesQueue = new ArrayList<>();
-        }
-
-        restartWebSocket();
-    }
-
-
-    public void clearResumeId() {
-        resumeId = "";
-    }
-
-    public final void restartWebSocket() {
-        reconnecting = true;
-
-        // TODO when improving logging, keep in mind this issue: https://github.com/nextcloud/talk-android/issues/1013
-        Log.d(TAG, "restartWebSocket: " + connectionUrl);
-        Request request = new Request.Builder().url(connectionUrl).build();
-        okHttpClient.newWebSocket(request, this);
-    }
-
-    @Override
-    public void onMessage(@NonNull WebSocket webSocket, @NonNull String text) {
-        if (webSocket == internalWebSocket) {
-            Log.d(TAG, "Receiving : " + webSocket + " " + text);
-
-            try {
-                BaseWebSocketMessage baseWebSocketMessage = LoganSquare.parse(text, BaseWebSocketMessage.class);
-                String messageType = baseWebSocketMessage.getType();
-                if (messageType != null) {
-                    switch (messageType) {
-                        case "hello":
-                            processHelloMessage(webSocket, text);
-                            break;
-                        case "error":
-                            processErrorMessage(webSocket, text);
-                            break;
-                        case "room":
-                            processJoinedRoomMessage(text);
-                            break;
-                        case "event":
-                            processEventMessage(text);
-                            break;
-                        case "message":
-                            processMessage(text);
-                            break;
-                        case "bye":
-                            connected = false;
-                            resumeId = "";
-                            break;
-                        default:
-                            break;
-                    }
-                } else {
-                    Log.e(TAG, "Received message with type: null");
-                }
-            } catch (IOException e) {
-                Log.e(TAG, "Failed to recognize WebSocket message", e);
-            }
-        }
-    }
-
-    private void processMessage(String text) throws IOException {
-        CallOverallWebSocketMessage callOverallWebSocketMessage =
-            LoganSquare.parse(text, CallOverallWebSocketMessage.class);
-
-        if (callOverallWebSocketMessage.getCallWebSocketMessage() != null) {
-            NCSignalingMessage ncSignalingMessage = callOverallWebSocketMessage
-                .getCallWebSocketMessage()
-                .getNcSignalingMessage();
-            if (ncSignalingMessage != null && TextUtils.isEmpty(ncSignalingMessage.getFrom()) &&
-                callOverallWebSocketMessage.getCallWebSocketMessage().getSenderWebSocketMessage() != null) {
-                ncSignalingMessage.setFrom(
-                    callOverallWebSocketMessage.getCallWebSocketMessage().getSenderWebSocketMessage().getSessionId());
-            }
-
-            signalingMessageReceiver.process(ncSignalingMessage);
-        }
-    }
-
-    private void processEventMessage(String text) throws IOException {
-        EventOverallWebSocketMessage eventOverallWebSocketMessage =
-            LoganSquare.parse(text, EventOverallWebSocketMessage.class);
-        if (eventOverallWebSocketMessage.getEventMap() != null) {
-            String target = (String) eventOverallWebSocketMessage.getEventMap().get("target");
-            if (target != null) {
-                switch (target) {
-                    case TARGET_ROOM:
-                        if ("message".equals(eventOverallWebSocketMessage.getEventMap().get("type"))) {
-                            processRoomMessageMessage(eventOverallWebSocketMessage);
-                        } else if ("join".equals(eventOverallWebSocketMessage.getEventMap().get("type"))) {
-                            processRoomJoinMessage(eventOverallWebSocketMessage);
-                        }
-                        break;
-                    case TARGET_PARTICIPANTS:
-                        signalingMessageReceiver.process(eventOverallWebSocketMessage.getEventMap());
-                        break;
-                    default:
-                        Log.i(TAG, "Received unknown/ignored event target: " + target);
-                        break;
-                }
-            } else {
-                Log.w(TAG, "Received message with event target: null");
-            }
-        }
-    }
-
-    private void processRoomMessageMessage(EventOverallWebSocketMessage eventOverallWebSocketMessage) {
-        Map<String, Object> messageHashMap = (Map<String, Object>) eventOverallWebSocketMessage
-            .getEventMap()
-            .get("message");
-        if (messageHashMap != null && messageHashMap.containsKey("data")) {
-            Map<String, Object> dataHashMap = (Map<String, Object>) messageHashMap.get("data");
-            if (dataHashMap != null && dataHashMap.containsKey("chat")) {
-                Map<String, Object> chatMap = (Map<String, Object>) dataHashMap.get("chat");
-                if (chatMap != null && chatMap.containsKey("refresh") && (boolean) chatMap.get("refresh")) {
-                    HashMap<String, String> refreshChatHashMap = new HashMap<>();
-                    refreshChatHashMap.put(BundleKeys.KEY_ROOM_TOKEN, (String) messageHashMap.get("roomid"));
-                    refreshChatHashMap.put(BundleKeys.KEY_INTERNAL_USER_ID, Long.toString(conversationUser.getId()));
-                    eventBus.post(new WebSocketCommunicationEvent("refreshChat", refreshChatHashMap));
-                }
-            }
-        }
-    }
-
-    private void processRoomJoinMessage(EventOverallWebSocketMessage eventOverallWebSocketMessage) {
-        List<HashMap<String, Object>> joinEventList = (List<HashMap<String, Object>>) eventOverallWebSocketMessage
-            .getEventMap()
-            .get("join");
-        HashMap<String, Object> internalHashMap;
-        Participant participant;
-        for (int i = 0; i < joinEventList.size(); i++) {
-            internalHashMap = joinEventList.get(i);
-            HashMap<String, Object> userMap = (HashMap<String, Object>) internalHashMap.get("user");
-            participant = new Participant();
-            String userId = (String) internalHashMap.get("userid");
-            if (userId != null) {
-                participant.setActorType(USERS);
-                participant.setActorId(userId);
-            } else {
-                participant.setActorType(GUESTS);
-                // FIXME seems to be not given by the HPB: participant.setActorId();
-            }
-            if (userMap != null) {
-                // There is no "user" attribute for guest participants.
-                participant.setDisplayName((String) userMap.get("displayname"));
-            }
-            usersHashMap.put((String) internalHashMap.get("sessionid"), participant);
-        }
-    }
-
-    private void processJoinedRoomMessage(String text) throws IOException {
-        JoinedRoomOverallWebSocketMessage joinedRoomOverallWebSocketMessage =
-            LoganSquare.parse(text, JoinedRoomOverallWebSocketMessage.class);
-        if (joinedRoomOverallWebSocketMessage.getRoomWebSocketMessage() != null) {
-            currentRoomToken = joinedRoomOverallWebSocketMessage.getRoomWebSocketMessage().getRoomId();
-
-            if (joinedRoomOverallWebSocketMessage
-                .getRoomWebSocketMessage()
-                .getRoomPropertiesWebSocketMessage() != null &&
-                !TextUtils.isEmpty(currentRoomToken)) {
-                sendRoomJoinedEvent();
-            }
-        }
-    }
-
-    private void processErrorMessage(WebSocket webSocket, String text) throws IOException {
-        Log.e(TAG, "Received error: " + text);
-        ErrorOverallWebSocketMessage errorOverallWebSocketMessage =
-            LoganSquare.parse(text, ErrorOverallWebSocketMessage.class);
-        ErrorWebSocketMessage message = errorOverallWebSocketMessage.getErrorWebSocketMessage();
-
-        if(message != null) {
-            if ("no_such_session".equals(message.getCode())) {
-                Log.d(TAG, "WebSocket " + webSocket.hashCode() + " resumeID " + resumeId + " expired");
-                resumeId = "";
-                currentRoomToken = "";
-                restartWebSocket();
-            } else if ("hello_expected".equals(message.getCode())) {
-                restartWebSocket();
-            }
-        }
-    }
-
-    private void processHelloMessage(WebSocket webSocket, String text) throws IOException {
-        connected = true;
-        reconnecting = false;
-        String oldResumeId = resumeId;
-        HelloResponseOverallWebSocketMessage helloResponseWebSocketMessage =
-            LoganSquare.parse(text, HelloResponseOverallWebSocketMessage.class);
-        if (helloResponseWebSocketMessage.getHelloResponseWebSocketMessage() != null) {
-            resumeId = helloResponseWebSocketMessage.getHelloResponseWebSocketMessage().getResumeId();
-            sessionId = helloResponseWebSocketMessage.getHelloResponseWebSocketMessage().getSessionId();
-            hasMCU = helloResponseWebSocketMessage.getHelloResponseWebSocketMessage().serverHasMCUSupport();
-        }
-
-        for (int i = 0; i < messagesQueue.size(); i++) {
-            webSocket.send(messagesQueue.get(i));
-        }
-
-        messagesQueue = new ArrayList<>();
-        HashMap<String, String> helloHasHap = new HashMap<>();
-        if (!TextUtils.isEmpty(oldResumeId)) {
-            helloHasHap.put("oldResumeId", oldResumeId);
-        } else {
-            currentRoomToken = "";
-        }
-
-        if (!TextUtils.isEmpty(currentRoomToken)) {
-            helloHasHap.put(ROOM_TOKEN, currentRoomToken);
-        }
-        eventBus.post(new WebSocketCommunicationEvent("hello", helloHasHap));
-    }
-
-    private void sendRoomJoinedEvent() {
-        HashMap<String, String> joinRoomHashMap = new HashMap<>();
-        joinRoomHashMap.put(ROOM_TOKEN, currentRoomToken);
-        eventBus.post(new WebSocketCommunicationEvent("roomJoined", joinRoomHashMap));
-    }
-
-    @Override
-    public void onMessage(@NonNull WebSocket webSocket, ByteString bytes) {
-        Log.d(TAG, "Receiving bytes : " + bytes.hex());
-    }
-
-    @Override
-    public void onClosing(@NonNull WebSocket webSocket, int code, @NonNull String reason) {
-        Log.d(TAG, "Closing : " + code + " / " + reason);
-    }
-
-    @Override
-    public void onFailure(WebSocket webSocket, Throwable t, Response response) {
-        Log.d(TAG, "Error : WebSocket " + webSocket.hashCode() + " onFailure: " + t.getMessage());
-        closeWebSocket(webSocket);
-    }
-
-    public String getSessionId() {
-        return sessionId;
-    }
-
-    public boolean hasMCU() {
-        return hasMCU;
-    }
-
-    public void joinRoomWithRoomTokenAndSession(String roomToken, String normalBackendSession) {
-        Log.d(TAG, "joinRoomWithRoomTokenAndSession");
-        Log.d(TAG, "   roomToken: " + roomToken);
-        Log.d(TAG, "   session: " + normalBackendSession);
-        try {
-            String message = LoganSquare.serialize(
-                webSocketConnectionHelper.getAssembledJoinOrLeaveRoomModel(roomToken, normalBackendSession));
-            if (!connected || reconnecting) {
-                messagesQueue.add(message);
-            } else {
-                if (roomToken.equals(currentRoomToken)) {
-                    sendRoomJoinedEvent();
-                } else {
-                    internalWebSocket.send(message);
-                }
-            }
-        } catch (IOException e) {
-            Log.e(TAG, e.getMessage(), e);
-        }
-    }
-
-    private void sendCallMessage(NCSignalingMessage ncSignalingMessage) {
-        try {
-            String message = LoganSquare.serialize(
-                webSocketConnectionHelper.getAssembledCallMessageModel(ncSignalingMessage));
-            if (!connected || reconnecting) {
-                messagesQueue.add(message);
-            } else {
-                internalWebSocket.send(message);
-            }
-        } catch (IOException e) {
-            Log.e(TAG, "Failed to serialize signaling message", e);
-        }
-    }
-
-    void sendBye() {
-        if (connected) {
-            try {
-                ByeWebSocketMessage byeWebSocketMessage = new ByeWebSocketMessage();
-                byeWebSocketMessage.setType("bye");
-                byeWebSocketMessage.setBye(new HashMap<>());
-                internalWebSocket.send(LoganSquare.serialize(byeWebSocketMessage));
-            } catch (IOException e) {
-                Log.e(TAG, "Failed to serialize bye message");
-            }
-        }
-    }
-
-    public boolean isConnected() {
-        return connected;
-    }
-
-    public String getDisplayNameForSession(String session) {
-        Participant participant = usersHashMap.get(session);
-        if (participant != null) {
-            if (participant.getDisplayName() != null) {
-                return participant.getDisplayName();
-            }
-        }
-
-        return "";
-    }
-
-    @Subscribe(threadMode = ThreadMode.BACKGROUND)
-    public void onMessageEvent(NetworkEvent networkEvent) {
-        if (networkEvent.getNetworkConnectionEvent() == NetworkEvent.NetworkConnectionEvent.NETWORK_CONNECTED &&
-            !isConnected()) {
-            restartWebSocket();
-        }
-    }
-
-    public SignalingMessageReceiver getSignalingMessageReceiver() {
-        return signalingMessageReceiver;
-    }
-
-    public SignalingMessageSender getSignalingMessageSender() {
-        return signalingMessageSender;
-    }
-
-    /**
-     * Temporary implementation of SignalingMessageReceiver until signaling related code is extracted to a Signaling
-     * class.
-     * <p>
-     * All listeners are called in the WebSocket reader thread. This thread should be the same as long as the WebSocket
-     * stays connected, but it may change whenever it is connected again.
-     */
-    private static class ExternalSignalingMessageReceiver extends SignalingMessageReceiver {
-        public void process(Map<String, Object> eventMap) {
-            processEvent(eventMap);
-        }
-
-        public void process(NCSignalingMessage message) {
-            processSignalingMessage(message);
-        }
-    }
-
-    private class ExternalSignalingMessageSender implements SignalingMessageSender {
-        @Override
-        public void send(NCSignalingMessage ncSignalingMessage) {
-            sendCallMessage(ncSignalingMessage);
-        }
-    }
-}

+ 16 - 16
app/src/main/java/com/nextcloud/talk/webrtc/WebSocketConnectionHelper.java

@@ -48,7 +48,7 @@ import okhttp3.OkHttpClient;
 @AutoInjector(NextcloudTalkApplication.class)
 public class WebSocketConnectionHelper {
     public static final String TAG = "WebSocketConnectionHelper";
-    private static Map<Long, MagicWebSocketInstance> magicWebSocketInstanceMap = new HashMap<>();
+    private static Map<Long, WebSocketInstance> magicWebSocketInstanceMap = new HashMap<>();
 
     @Inject
     OkHttpClient okHttpClient;
@@ -59,8 +59,8 @@ public class WebSocketConnectionHelper {
     }
 
     @SuppressLint("LongLogTag")
-    public static synchronized MagicWebSocketInstance getMagicWebSocketInstanceForUserId(long userId) {
-        MagicWebSocketInstance webSocketInstance = magicWebSocketInstanceMap.get(userId);
+    public static synchronized WebSocketInstance getMagicWebSocketInstanceForUserId(long userId) {
+        WebSocketInstance webSocketInstance = magicWebSocketInstanceMap.get(userId);
 
         if (webSocketInstance == null) {
             Log.d(TAG, "No magicWebSocketInstance found for user " + userId);
@@ -69,9 +69,9 @@ public class WebSocketConnectionHelper {
         return webSocketInstance;
     }
 
-    public static synchronized MagicWebSocketInstance getExternalSignalingInstanceForServer(String url,
-                                                                                            User user,
-                                                                                            String webSocketTicket, boolean isGuest) {
+    public static synchronized WebSocketInstance getExternalSignalingInstanceForServer(String url,
+                                                                                       User user,
+                                                                                       String webSocketTicket, boolean isGuest) {
         String generatedURL = url.replace("https://", "wss://").replace("http://", "ws://");
 
         if (generatedURL.endsWith("/")) {
@@ -82,24 +82,24 @@ public class WebSocketConnectionHelper {
 
         long userId = isGuest ? -1 : user.getId();
 
-        MagicWebSocketInstance magicWebSocketInstance;
-        if (userId != -1 && magicWebSocketInstanceMap.containsKey(user.getId()) && (magicWebSocketInstance = magicWebSocketInstanceMap.get(user.getId())) != null) {
-            return magicWebSocketInstance;
+        WebSocketInstance webSocketInstance;
+        if (userId != -1 && magicWebSocketInstanceMap.containsKey(user.getId()) && (webSocketInstance = magicWebSocketInstanceMap.get(user.getId())) != null) {
+            return webSocketInstance;
         } else {
             if (userId == -1) {
                 deleteExternalSignalingInstanceForUserEntity(userId);
             }
-            magicWebSocketInstance = new MagicWebSocketInstance(user, generatedURL, webSocketTicket);
-            magicWebSocketInstanceMap.put(user.getId(), magicWebSocketInstance);
-            return magicWebSocketInstance;
+            webSocketInstance = new WebSocketInstance(user, generatedURL, webSocketTicket);
+            magicWebSocketInstanceMap.put(user.getId(), webSocketInstance);
+            return webSocketInstance;
         }
     }
 
     public static synchronized void deleteExternalSignalingInstanceForUserEntity(long id) {
-        MagicWebSocketInstance magicWebSocketInstance;
-        if ((magicWebSocketInstance = magicWebSocketInstanceMap.get(id)) != null) {
-            if (magicWebSocketInstance.isConnected()) {
-                magicWebSocketInstance.sendBye();
+        WebSocketInstance webSocketInstance;
+        if ((webSocketInstance = magicWebSocketInstanceMap.get(id)) != null) {
+            if (webSocketInstance.isConnected()) {
+                webSocketInstance.sendBye();
                 magicWebSocketInstanceMap.remove(id);
             }
         }

+ 456 - 0
app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt

@@ -0,0 +1,456 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Mario Danic
+ * Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.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.webrtc
+
+import android.content.Context
+import android.text.TextUtils
+import android.util.Log
+import autodagger.AutoInjector
+import com.bluelinelabs.logansquare.LoganSquare
+import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
+import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.events.NetworkEvent
+import com.nextcloud.talk.events.WebSocketCommunicationEvent
+import com.nextcloud.talk.models.json.participants.Participant
+import com.nextcloud.talk.models.json.participants.Participant.ActorType
+import com.nextcloud.talk.models.json.signaling.NCSignalingMessage
+import com.nextcloud.talk.models.json.websocket.BaseWebSocketMessage
+import com.nextcloud.talk.models.json.websocket.ByeWebSocketMessage
+import com.nextcloud.talk.models.json.websocket.CallOverallWebSocketMessage
+import com.nextcloud.talk.models.json.websocket.ErrorOverallWebSocketMessage
+import com.nextcloud.talk.models.json.websocket.EventOverallWebSocketMessage
+import com.nextcloud.talk.models.json.websocket.HelloResponseOverallWebSocketMessage
+import com.nextcloud.talk.models.json.websocket.JoinedRoomOverallWebSocketMessage
+import com.nextcloud.talk.signaling.SignalingMessageReceiver
+import com.nextcloud.talk.signaling.SignalingMessageSender
+import com.nextcloud.talk.utils.bundle.BundleKeys
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.WebSocket
+import okhttp3.WebSocketListener
+import okio.ByteString
+import org.greenrobot.eventbus.EventBus
+import org.greenrobot.eventbus.Subscribe
+import org.greenrobot.eventbus.ThreadMode
+import java.io.IOException
+import javax.inject.Inject
+
+@AutoInjector(NextcloudTalkApplication::class)
+class WebSocketInstance internal constructor(
+    conversationUser: User,
+    connectionUrl: String,
+    webSocketTicket: String
+) : WebSocketListener() {
+    @JvmField
+    @Inject
+    var okHttpClient: OkHttpClient? = null
+
+    @JvmField
+    @Inject
+    var eventBus: EventBus? = null
+
+    @JvmField
+    @Inject
+    var context: Context? = null
+    private val conversationUser: User
+    private val webSocketTicket: String
+    private var resumeId: String? = null
+    var sessionId: String? = null
+        private set
+    private var hasMCU = false
+    var isConnected: Boolean
+        private set
+    private val webSocketConnectionHelper: WebSocketConnectionHelper
+    private var internalWebSocket: WebSocket? = null
+    private val connectionUrl: String
+    private var currentRoomToken: String? = null
+    private var reconnecting = false
+    private val usersHashMap: HashMap<String?, Participant>
+    private var messagesQueue: MutableList<String> = ArrayList()
+    private val signalingMessageReceiver = ExternalSignalingMessageReceiver()
+    val signalingMessageSender = ExternalSignalingMessageSender()
+
+    init {
+        sharedApplication!!.componentApplication.inject(this)
+        this.connectionUrl = connectionUrl
+        this.conversationUser = conversationUser
+        this.webSocketTicket = webSocketTicket
+        webSocketConnectionHelper = WebSocketConnectionHelper()
+        usersHashMap = HashMap()
+        isConnected = false
+        eventBus!!.register(this)
+        restartWebSocket()
+    }
+
+    private fun sendHello() {
+        try {
+            if (TextUtils.isEmpty(resumeId)) {
+                internalWebSocket!!.send(
+                    LoganSquare.serialize(
+                        webSocketConnectionHelper
+                            .getAssembledHelloModel(conversationUser, webSocketTicket)
+                    )
+                )
+            } else {
+                internalWebSocket!!.send(
+                    LoganSquare.serialize(
+                        webSocketConnectionHelper
+                            .getAssembledHelloModelForResume(resumeId)
+                    )
+                )
+            }
+        } catch (e: IOException) {
+            Log.e(TAG, "Failed to serialize hello model")
+        }
+    }
+
+    override fun onOpen(webSocket: WebSocket, response: Response) {
+        internalWebSocket = webSocket
+        sendHello()
+    }
+
+    private fun closeWebSocket(webSocket: WebSocket) {
+        webSocket.close(NORMAL_CLOSURE, null)
+        webSocket.cancel()
+        if (webSocket === internalWebSocket) {
+            isConnected = false
+            messagesQueue = ArrayList()
+        }
+        restartWebSocket()
+    }
+
+    fun clearResumeId() {
+        resumeId = ""
+    }
+
+    fun restartWebSocket() {
+        reconnecting = true
+
+        // TODO when improving logging, keep in mind this issue: https://github.com/nextcloud/talk-android/issues/1013
+        Log.d(TAG, "restartWebSocket: $connectionUrl")
+        val request = Request.Builder().url(connectionUrl).build()
+        okHttpClient!!.newWebSocket(request, this)
+    }
+
+    override fun onMessage(webSocket: WebSocket, text: String) {
+        if (webSocket === internalWebSocket) {
+            Log.d(TAG, "Receiving : $webSocket $text")
+            try {
+                val (messageType) = LoganSquare.parse(text, BaseWebSocketMessage::class.java)
+                if (messageType != null) {
+                    when (messageType) {
+                        "hello" -> processHelloMessage(webSocket, text)
+                        "error" -> processErrorMessage(webSocket, text)
+                        "room" -> processJoinedRoomMessage(text)
+                        "event" -> processEventMessage(text)
+                        "message" -> processMessage(text)
+                        "bye" -> {
+                            isConnected = false
+                            resumeId = ""
+                        }
+                        else -> {}
+                    }
+                } else {
+                    Log.e(TAG, "Received message with type: null")
+                }
+            } catch (e: IOException) {
+                Log.e(TAG, "Failed to recognize WebSocket message", e)
+            }
+        }
+    }
+
+    @Throws(IOException::class)
+    private fun processMessage(text: String) {
+        val (_, callWebSocketMessage) = LoganSquare.parse(text, CallOverallWebSocketMessage::class.java)
+        if (callWebSocketMessage != null) {
+            val ncSignalingMessage = callWebSocketMessage
+                .ncSignalingMessage
+            if (ncSignalingMessage != null &&
+                TextUtils.isEmpty(ncSignalingMessage.from) &&
+                callWebSocketMessage.senderWebSocketMessage != null
+            ) {
+                ncSignalingMessage.from = callWebSocketMessage.senderWebSocketMessage!!.sessionId
+            }
+            signalingMessageReceiver.process(ncSignalingMessage)
+        }
+    }
+
+    @Throws(IOException::class)
+    private fun processEventMessage(text: String) {
+        val eventOverallWebSocketMessage = LoganSquare.parse(text, EventOverallWebSocketMessage::class.java)
+        if (eventOverallWebSocketMessage.eventMap != null) {
+            val target = eventOverallWebSocketMessage.eventMap!!["target"] as String?
+            if (target != null) {
+                when (target) {
+                    Globals.TARGET_ROOM ->
+                        if ("message" == eventOverallWebSocketMessage.eventMap!!["type"]) {
+                            processRoomMessageMessage(eventOverallWebSocketMessage)
+                        } else if ("join" == eventOverallWebSocketMessage.eventMap!!["type"]) {
+                            processRoomJoinMessage(eventOverallWebSocketMessage)
+                        }
+                    Globals.TARGET_PARTICIPANTS ->
+                        signalingMessageReceiver.process(eventOverallWebSocketMessage.eventMap)
+                    else ->
+                        Log.i(TAG, "Received unknown/ignored event target: $target")
+                }
+            } else {
+                Log.w(TAG, "Received message with event target: null")
+            }
+        }
+    }
+
+    private fun processRoomMessageMessage(eventOverallWebSocketMessage: EventOverallWebSocketMessage) {
+        val messageHashMap = eventOverallWebSocketMessage.eventMap?.get("message") as Map<*, *>?
+
+        if (messageHashMap != null && messageHashMap.containsKey("data")) {
+            val dataHashMap = messageHashMap["data"] as Map<*, *>?
+
+            if (dataHashMap != null && dataHashMap.containsKey("chat")) {
+                val chatMap = dataHashMap["chat"] as Map<*, *>?
+                if (chatMap != null && chatMap.containsKey("refresh") && chatMap["refresh"] as Boolean) {
+                    val refreshChatHashMap = HashMap<String, String?>()
+                    refreshChatHashMap[BundleKeys.KEY_ROOM_TOKEN] = messageHashMap["roomid"] as String?
+                    refreshChatHashMap[BundleKeys.KEY_INTERNAL_USER_ID] = (conversationUser.id!!).toString()
+                    eventBus!!.post(WebSocketCommunicationEvent("refreshChat", refreshChatHashMap))
+                }
+            } else if (dataHashMap != null && dataHashMap.containsKey("recording")) {
+                val recordingMap = dataHashMap["recording"] as Map<*, *>?
+                if (recordingMap != null && recordingMap.containsKey("status")) {
+                    val status = (recordingMap["status"] as Long?)!!.toInt()
+                    Log.d(TAG, "status is $status")
+                    val recordingHashMap = HashMap<String, String>()
+                    recordingHashMap[BundleKeys.KEY_RECORDING_STATE] = status.toString()
+                    eventBus!!.post(WebSocketCommunicationEvent("recordingStatus", recordingHashMap))
+                }
+            }
+        }
+    }
+
+    private fun processRoomJoinMessage(eventOverallWebSocketMessage: EventOverallWebSocketMessage) {
+        val joinEventList = eventOverallWebSocketMessage.eventMap?.get("join") as List<HashMap<String, Any>>?
+        var internalHashMap: HashMap<String, Any>
+        var participant: Participant
+        for (i in joinEventList!!.indices) {
+            internalHashMap = joinEventList[i]
+            val userMap = internalHashMap["user"] as HashMap<String, Any>?
+            participant = Participant()
+            val userId = internalHashMap["userid"] as String?
+            if (userId != null) {
+                participant.actorType = ActorType.USERS
+                participant.actorId = userId
+            } else {
+                participant.actorType = ActorType.GUESTS
+                // FIXME seems to be not given by the HPB: participant.setActorId();
+            }
+            if (userMap != null) {
+                // There is no "user" attribute for guest participants.
+                participant.displayName = userMap["displayname"] as String?
+            }
+            usersHashMap[internalHashMap["sessionid"] as String?] = participant
+        }
+    }
+
+    @Throws(IOException::class)
+    private fun processJoinedRoomMessage(text: String) {
+        val (_, roomWebSocketMessage) = LoganSquare.parse(text, JoinedRoomOverallWebSocketMessage::class.java)
+        if (roomWebSocketMessage != null) {
+            currentRoomToken = roomWebSocketMessage.roomId
+            if (roomWebSocketMessage
+                .roomPropertiesWebSocketMessage != null &&
+                !TextUtils.isEmpty(currentRoomToken)
+            ) {
+                sendRoomJoinedEvent()
+            }
+        }
+    }
+
+    @Throws(IOException::class)
+    private fun processErrorMessage(webSocket: WebSocket, text: String) {
+        Log.e(TAG, "Received error: $text")
+        val (_, message) = LoganSquare.parse(text, ErrorOverallWebSocketMessage::class.java)
+        if (message != null) {
+            if ("no_such_session" == message.code) {
+                Log.d(TAG, "WebSocket " + webSocket.hashCode() + " resumeID " + resumeId + " expired")
+                resumeId = ""
+                currentRoomToken = ""
+                restartWebSocket()
+            } else if ("hello_expected" == message.code) {
+                restartWebSocket()
+            }
+        }
+    }
+
+    @Throws(IOException::class)
+    private fun processHelloMessage(webSocket: WebSocket, text: String) {
+        isConnected = true
+        reconnecting = false
+        val oldResumeId = resumeId
+        val (_, helloResponseWebSocketMessage1) = LoganSquare.parse(
+            text,
+            HelloResponseOverallWebSocketMessage::class.java
+        )
+        if (helloResponseWebSocketMessage1 != null) {
+            resumeId = helloResponseWebSocketMessage1.resumeId
+            sessionId = helloResponseWebSocketMessage1.sessionId
+            hasMCU = helloResponseWebSocketMessage1.serverHasMCUSupport()
+        }
+        for (i in messagesQueue.indices) {
+            webSocket.send(messagesQueue[i])
+        }
+        messagesQueue = ArrayList()
+        val helloHasHap = HashMap<String, String?>()
+        if (!TextUtils.isEmpty(oldResumeId)) {
+            helloHasHap["oldResumeId"] = oldResumeId
+        } else {
+            currentRoomToken = ""
+        }
+        if (!TextUtils.isEmpty(currentRoomToken)) {
+            helloHasHap[Globals.ROOM_TOKEN] = currentRoomToken
+        }
+        eventBus!!.post(WebSocketCommunicationEvent("hello", helloHasHap))
+    }
+
+    private fun sendRoomJoinedEvent() {
+        val joinRoomHashMap = HashMap<String, String?>()
+        joinRoomHashMap[Globals.ROOM_TOKEN] = currentRoomToken
+        eventBus!!.post(WebSocketCommunicationEvent("roomJoined", joinRoomHashMap))
+    }
+
+    override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
+        Log.d(TAG, "Receiving bytes : " + bytes.hex())
+    }
+
+    override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
+        Log.d(TAG, "Closing : $code / $reason")
+    }
+
+    override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
+        Log.d(TAG, "Error : WebSocket " + webSocket.hashCode() + " onFailure: " + t.message)
+        closeWebSocket(webSocket)
+    }
+
+    fun hasMCU(): Boolean {
+        return hasMCU
+    }
+
+    fun joinRoomWithRoomTokenAndSession(roomToken: String, normalBackendSession: String?) {
+        Log.d(TAG, "joinRoomWithRoomTokenAndSession")
+        Log.d(TAG, "   roomToken: $roomToken")
+        Log.d(TAG, "   session: $normalBackendSession")
+        try {
+            val message = LoganSquare.serialize(
+                webSocketConnectionHelper.getAssembledJoinOrLeaveRoomModel(roomToken, normalBackendSession)
+            )
+            if (!isConnected || reconnecting) {
+                messagesQueue.add(message)
+            } else {
+                if (roomToken == currentRoomToken) {
+                    sendRoomJoinedEvent()
+                } else {
+                    internalWebSocket!!.send(message)
+                }
+            }
+        } catch (e: IOException) {
+            Log.e(TAG, e.message, e)
+        }
+    }
+
+    private fun sendCallMessage(ncSignalingMessage: NCSignalingMessage) {
+        try {
+            val message = LoganSquare.serialize(
+                webSocketConnectionHelper.getAssembledCallMessageModel(ncSignalingMessage)
+            )
+            if (!isConnected || reconnecting) {
+                messagesQueue.add(message)
+            } else {
+                internalWebSocket!!.send(message)
+            }
+        } catch (e: IOException) {
+            Log.e(TAG, "Failed to serialize signaling message", e)
+        }
+    }
+
+    fun sendBye() {
+        if (isConnected) {
+            try {
+                val byeWebSocketMessage = ByeWebSocketMessage()
+                byeWebSocketMessage.type = "bye"
+                byeWebSocketMessage.bye = HashMap()
+                internalWebSocket!!.send(LoganSquare.serialize(byeWebSocketMessage))
+            } catch (e: IOException) {
+                Log.e(TAG, "Failed to serialize bye message")
+            }
+        }
+    }
+
+    fun getDisplayNameForSession(session: String?): String? {
+        val participant = usersHashMap[session]
+        if (participant != null) {
+            if (participant.displayName != null) {
+                return participant.displayName
+            }
+        }
+        return ""
+    }
+
+    @Subscribe(threadMode = ThreadMode.BACKGROUND)
+    fun onMessageEvent(networkEvent: NetworkEvent) {
+        if (networkEvent.networkConnectionEvent == NetworkEvent.NetworkConnectionEvent.NETWORK_CONNECTED &&
+            !isConnected
+        ) {
+            restartWebSocket()
+        }
+    }
+
+    fun getSignalingMessageReceiver(): SignalingMessageReceiver {
+        return signalingMessageReceiver
+    }
+
+    /**
+     * Temporary implementation of SignalingMessageReceiver until signaling related code is extracted to a Signaling
+     * class.
+     *
+     *
+     * All listeners are called in the WebSocket reader thread. This thread should be the same as long as the WebSocket
+     * stays connected, but it may change whenever it is connected again.
+     */
+    private class ExternalSignalingMessageReceiver : SignalingMessageReceiver() {
+        fun process(eventMap: Map<String, Any?>?) {
+            processEvent(eventMap)
+        }
+
+        fun process(message: NCSignalingMessage?) {
+            processSignalingMessage(message)
+        }
+    }
+
+    inner class ExternalSignalingMessageSender : SignalingMessageSender {
+        override fun send(ncSignalingMessage: NCSignalingMessage) {
+            sendCallMessage(ncSignalingMessage)
+        }
+    }
+
+    companion object {
+        private const val TAG = "MagicWebSocketInstance"
+        private const val NORMAL_CLOSURE = 1000
+    }
+}

+ 25 - 0
app/src/main/res/drawable/ic_dots_horizontal_white.xml

@@ -0,0 +1,25 @@
+<!--
+    @author Google LLC
+    Copyright (C) 2021 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="#FFFFFF"
+        android:pathData="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z" />
+</vector>

+ 9 - 0
app/src/main/res/drawable/record_start.xml

@@ -0,0 +1,9 @@
+<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="@color/nc_darkRed"
+      android:pathData="M12,2A10,10 0,0 0,2 12A10,10 0,0 0,12 22A10,10 0,0 0,22 12A10,10 0,0 0,12 2M12,9A3,3 0,0 1,15 12A3,3 0,0 1,12 15A3,3 0,0 1,9 12A3,3 0,0 1,12 9Z"/>
+</vector>

+ 9 - 0
app/src/main/res/drawable/record_stop.xml

@@ -0,0 +1,9 @@
+<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="@color/nc_darkRed"
+      android:pathData="M12,2A10,10 0,0 0,2 12A10,10 0,0 0,12 22A10,10 0,0 0,22 12A10,10 0,0 0,12 2M9,9H15V15H9"/>
+</vector>

+ 34 - 1
app/src/main/res/layout/call_activity.xml

@@ -89,6 +89,27 @@
                     android:visibility="gone" />
             </FrameLayout>
 
+            <LinearLayout
+                android:id="@+id/call_indicator_controls"
+                android:layout_width="match_parent"
+                android:layout_height="20dp"
+                android:layout_margin="20dp"
+                android:animateLayoutChanges="true"
+                android:background="@android:color/transparent"
+                android:orientation="horizontal"
+                android:weightSum="1">
+                <ImageView
+                    android:id="@+id/call_recording_indicator"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:src="@drawable/record_stop"
+                    android:contentDescription="@null"
+                    android:visibility="gone"
+                    android:translationZ="2dp"
+                    tools:visibility="visible">
+                </ImageView>
+            </LinearLayout>
+
             <LinearLayout
                 android:id="@+id/callInfosLinearLayout"
                 android:layout_width="match_parent"
@@ -152,7 +173,7 @@
         android:background="@android:color/transparent"
         android:gravity="center"
         android:orientation="horizontal"
-        android:weightSum="5">
+        android:weightSum="6">
 
         <ImageButton
             android:id="@+id/pictureInPictureButton"
@@ -204,6 +225,18 @@
             app:srcCompat="@drawable/ic_mic_off_white_24px"
             android:contentDescription="@string/nc_call_button_content_description_microphone" />
 
+        <ImageButton
+            android:id="@+id/more_call_actions"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:adjustViewBounds="true"
+            android:layout_marginHorizontal="@dimen/call_controls_margin_horizontal"
+            android:layout_weight="1"
+            android:background="@drawable/shape_oval"
+            android:backgroundTint="@color/call_buttons_background"
+            app:srcCompat="@drawable/ic_dots_horizontal_white"
+            android:contentDescription="@string/nc_call_button_content_description_microphone" />
+
         <ImageButton
             android:id="@+id/hangupButton"
             android:layout_width="0dp"

+ 0 - 1
app/src/main/res/layout/dialog_audio_output.xml

@@ -28,7 +28,6 @@
     android:paddingBottom="@dimen/standard_half_padding">
 
     <TextView
-        android:id="@+id/upload"
         android:layout_width="wrap_content"
         android:layout_height="@dimen/bottom_sheet_item_height"
         android:gravity="start|center_vertical"

+ 73 - 0
app/src/main/res/layout/dialog_more_call_actions.xml

@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Nextcloud Talk application
+  ~
+  ~ @author Marcel Hibbe
+  ~ Copyright (C) 2022 Marcel Hibbe <marcel.hibbe@nextcloud.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/>.
+  -->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:background="@color/bg_call_screen_dialog"
+    android:orientation="vertical"
+    android:paddingBottom="@dimen/standard_half_padding">
+
+    <TextView
+        android:layout_width="wrap_content"
+        android:layout_height="@dimen/bottom_sheet_item_height"
+        android:gravity="start|center_vertical"
+        android:paddingStart="@dimen/standard_padding"
+        android:paddingEnd="@dimen/standard_padding"
+        android:text="@string/call_more_actions_dialog_headline"
+        android:textColor="@color/medium_emphasis_text_dark_background"
+        android:textSize="@dimen/bottom_sheet_text_size" />
+
+    <LinearLayout
+        android:id="@+id/record_call"
+        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/record_call_icon"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:contentDescription="@null"
+            android:src="@drawable/record_start"
+            app:tint="@color/high_emphasis_menu_icon_inverse" />
+
+        <androidx.appcompat.widget.AppCompatTextView
+            android:id="@+id/record_call_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/record_start_description"
+            android:textAlignment="viewStart"
+            android:textColor="@color/high_emphasis_text_dark_background"
+            android:textSize="@dimen/bottom_sheet_text_size" />
+
+    </LinearLayout>
+
+</LinearLayout>

+ 13 - 0
app/src/main/res/values/strings.xml

@@ -217,6 +217,7 @@
     <string name="nc_call_button_content_description_audio_output">Change audio output</string>
     <string name="nc_call_button_content_description_camera">Toggle camera</string>
     <string name="nc_call_button_content_description_microphone">Toggle microphone</string>
+    <string name="nc_call_button_content_description_advanced">Advanced call options</string>
     <string name="nc_call_button_content_description_hangup">Hangup</string>
     <string name="nc_call_button_content_description_answer_voice_only">Answer as voice call only</string>
     <string name="nc_call_button_content_description_answer_video_call">Answer as video call</string>
@@ -557,6 +558,18 @@
     <string name="audio_output_dialog_headline">Audio output</string>
     <string name="audio_output_wired_headset">Wired headset</string>
 
+    <!-- Advanced call options -->
+    <string name="call_more_actions_dialog_headline">Advanced call options</string>
+
+    <!-- Call recording -->
+    <string name="record_start_description">Start recording</string>
+    <string name="record_start_loading">Starting …</string>
+    <string name="record_stop_description">Stop recording</string>
+    <string name="record_stop_loading">Stopping …</string>
+    <string name="record_stop_confirm_title">Stop Call recording</string>
+    <string name="record_stop_confirm_message">Do you really want to stop the recording?</string>
+    <string name="record_active_info">The call is being recorded</string>
+
     <!-- Shared items -->
     <string name="shared_items_media">Media</string>
     <string name="shared_items_file">File</string>

+ 42 - 0
app/src/test/java/com/nextcloud/talk/test/fakes/FakeCallRecordingRepository.kt

@@ -0,0 +1,42 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Álvaro Brey
+ * Copyright (C) 2022 Álvaro Brey
+ * Copyright (C) 2022 Nextcloud GmbH
+ *
+ * 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.test.fakes
+
+import com.nextcloud.talk.models.domain.StartCallRecordingModel
+import com.nextcloud.talk.models.domain.StopCallRecordingModel
+import com.nextcloud.talk.repositories.callrecording.CallRecordingRepository
+import io.reactivex.Observable
+
+class FakeCallRecordingRepository : CallRecordingRepository {
+
+    override fun startRecording(
+        roomToken: String
+    ): Observable<StartCallRecordingModel> {
+        return Observable.just(StartCallRecordingModel(true))
+    }
+
+    override fun stopRecording(
+        roomToken: String
+    ): Observable<StopCallRecordingModel> {
+        return Observable.just(StopCallRecordingModel(true))
+    }
+}

+ 33 - 0
app/src/test/java/com/nextcloud/talk/viewmodels/AbstractViewModelTest.kt

@@ -0,0 +1,33 @@
+package com.nextcloud.talk.viewmodels
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import io.reactivex.android.plugins.RxAndroidPlugins
+import io.reactivex.plugins.RxJavaPlugins
+import io.reactivex.schedulers.Schedulers
+import org.junit.BeforeClass
+import org.junit.Rule
+
+open class AbstractViewModelTest {
+    @get:Rule
+    val instantExecutorRule = InstantTaskExecutorRule()
+
+    companion object {
+        @JvmStatic
+        @BeforeClass
+        fun setUpClass() {
+            RxJavaPlugins.setIoSchedulerHandler {
+                Schedulers.trampoline()
+            }
+            RxJavaPlugins.setComputationSchedulerHandler {
+                Schedulers.trampoline()
+            }
+            RxJavaPlugins.setNewThreadSchedulerHandler {
+                Schedulers.trampoline()
+            }
+
+            RxAndroidPlugins.setInitMainThreadSchedulerHandler {
+                Schedulers.trampoline()
+            }
+        }
+    }
+}

+ 89 - 0
app/src/test/java/com/nextcloud/talk/viewmodels/CallRecordingViewModelTest.kt

@@ -0,0 +1,89 @@
+package com.nextcloud.talk.viewmodels
+
+import com.nextcloud.talk.test.fakes.FakeCallRecordingRepository
+import com.vividsolutions.jts.util.Assert
+import org.junit.Before
+import org.junit.Test
+import org.mockito.MockitoAnnotations
+
+class CallRecordingViewModelTest : AbstractViewModelTest() {
+
+    private val repository = FakeCallRecordingRepository()
+
+    @Before
+    fun setUp() {
+        MockitoAnnotations.openMocks(this)
+    }
+
+    @Test
+    fun testCallRecordingViewModel_clickStartRecord() {
+        val viewModel = CallRecordingViewModel(repository)
+        viewModel.setData("foo")
+        viewModel.clickRecordButton()
+
+        Assert.equals(CallRecordingViewModel.RecordingStartLoadingState, viewModel.viewState.value)
+
+        // fake to execute setRecordingState which would be triggered by signaling message
+        viewModel.setRecordingState(CallRecordingViewModel.RECORDING_STARTED_VIDEO_CODE)
+
+        Assert.equals(
+            CallRecordingViewModel.RecordingStartedState(true).javaClass,
+            viewModel.viewState.value?.javaClass
+        )
+    }
+
+    @Test
+    fun testCallRecordingViewModel_clickStopRecord() {
+        val viewModel = CallRecordingViewModel(repository)
+        viewModel.setData("foo")
+        viewModel.setRecordingState(CallRecordingViewModel.RECORDING_STARTED_VIDEO_CODE)
+
+        Assert.equals(true, (viewModel.viewState.value as CallRecordingViewModel.RecordingStartedState).showStartedInfo)
+
+        viewModel.clickRecordButton()
+
+        Assert.equals(CallRecordingViewModel.RecordingConfirmStopState, viewModel.viewState.value)
+
+        viewModel.stopRecording()
+
+        Assert.equals(CallRecordingViewModel.RecordingStoppedState, viewModel.viewState.value)
+    }
+
+    @Test
+    fun testCallRecordingViewModel_keepConfirmState() {
+        val viewModel = CallRecordingViewModel(repository)
+        viewModel.setData("foo")
+        viewModel.setRecordingState(CallRecordingViewModel.RECORDING_STARTED_VIDEO_CODE)
+
+        Assert.equals(true, (viewModel.viewState.value as CallRecordingViewModel.RecordingStartedState).showStartedInfo)
+
+        viewModel.clickRecordButton()
+
+        Assert.equals(CallRecordingViewModel.RecordingConfirmStopState, viewModel.viewState.value)
+
+        viewModel.clickRecordButton()
+
+        Assert.equals(CallRecordingViewModel.RecordingConfirmStopState, viewModel.viewState.value)
+    }
+
+    @Test
+    fun testCallRecordingViewModel_continueRecordingWhenDismissStopDialog() {
+        val viewModel = CallRecordingViewModel(repository)
+        viewModel.setData("foo")
+        viewModel.setRecordingState(CallRecordingViewModel.RECORDING_STARTED_VIDEO_CODE)
+        viewModel.clickRecordButton()
+
+        Assert.equals(CallRecordingViewModel.RecordingConfirmStopState, viewModel.viewState.value)
+
+        viewModel.dismissStopRecording()
+
+        Assert.equals(
+            CallRecordingViewModel.RecordingStartedState(false).javaClass,
+            viewModel.viewState.value?.javaClass
+        )
+        Assert.equals(
+            false,
+            (viewModel.viewState.value as CallRecordingViewModel.RecordingStartedState).showStartedInfo
+        )
+    }
+}