add button to switch cameras during video call

Daniel Gultsch created

RIP symmetry :-(

fixes #3683

Change summary

art/flip_camera_android-black-24dp.svg                                    |  1 
art/render.rb                                                             |  1 
src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java           | 40 
src/main/java/eu/siacs/conversations/ui/util/MainThreadExecutor.java      | 37 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java | 10 
src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java       | 45 
src/main/res/drawable-hdpi/date_bubble_grey.9.png                         |  0 
src/main/res/drawable-hdpi/date_bubble_white.9.png                        |  0 
src/main/res/drawable-hdpi/ic_flip_camera_android_black_24dp.png          |  0 
src/main/res/drawable-hdpi/message_bubble_received.9.png                  |  0 
src/main/res/drawable-hdpi/message_bubble_received_dark.9.png             |  0 
src/main/res/drawable-hdpi/message_bubble_received_grey.9.png             |  0 
src/main/res/drawable-hdpi/message_bubble_received_warning.9.png          |  0 
src/main/res/drawable-hdpi/message_bubble_received_white.9.png            |  0 
src/main/res/drawable-hdpi/message_bubble_sent.9.png                      |  0 
src/main/res/drawable-hdpi/message_bubble_sent_grey.9.png                 |  0 
src/main/res/drawable-mdpi/date_bubble_grey.9.png                         |  0 
src/main/res/drawable-mdpi/date_bubble_white.9.png                        |  0 
src/main/res/drawable-mdpi/ic_flip_camera_android_black_24dp.png          |  0 
src/main/res/drawable-mdpi/message_bubble_received.9.png                  |  0 
src/main/res/drawable-mdpi/message_bubble_received_dark.9.png             |  0 
src/main/res/drawable-mdpi/message_bubble_received_grey.9.png             |  0 
src/main/res/drawable-mdpi/message_bubble_received_warning.9.png          |  0 
src/main/res/drawable-mdpi/message_bubble_received_white.9.png            |  0 
src/main/res/drawable-mdpi/message_bubble_sent.9.png                      |  0 
src/main/res/drawable-mdpi/message_bubble_sent_grey.9.png                 |  0 
src/main/res/drawable-xhdpi/date_bubble_grey.9.png                        |  0 
src/main/res/drawable-xhdpi/date_bubble_white.9.png                       |  0 
src/main/res/drawable-xhdpi/ic_flip_camera_android_black_24dp.png         |  0 
src/main/res/drawable-xhdpi/message_bubble_received.9.png                 |  0 
src/main/res/drawable-xhdpi/message_bubble_received_dark.9.png            |  0 
src/main/res/drawable-xhdpi/message_bubble_received_grey.9.png            |  0 
src/main/res/drawable-xhdpi/message_bubble_received_warning.9.png         |  0 
src/main/res/drawable-xhdpi/message_bubble_received_white.9.png           |  0 
src/main/res/drawable-xhdpi/message_bubble_sent.9.png                     |  0 
src/main/res/drawable-xhdpi/message_bubble_sent_grey.9.png                |  0 
src/main/res/drawable-xxhdpi/date_bubble_grey.9.png                       |  0 
src/main/res/drawable-xxhdpi/date_bubble_white.9.png                      |  0 
src/main/res/drawable-xxhdpi/ic_flip_camera_android_black_24dp.png        |  0 
src/main/res/drawable-xxhdpi/message_bubble_received.9.png                |  0 
src/main/res/drawable-xxhdpi/message_bubble_received_dark.9.png           |  0 
src/main/res/drawable-xxhdpi/message_bubble_received_grey.9.png           |  0 
src/main/res/drawable-xxhdpi/message_bubble_received_warning.9.png        |  0 
src/main/res/drawable-xxhdpi/message_bubble_received_white.9.png          |  0 
src/main/res/drawable-xxhdpi/message_bubble_sent.9.png                    |  0 
src/main/res/drawable-xxhdpi/message_bubble_sent_grey.9.png               |  0 
src/main/res/drawable-xxxhdpi/date_bubble_grey.9.png                      |  0 
src/main/res/drawable-xxxhdpi/date_bubble_white.9.png                     |  0 
src/main/res/drawable-xxxhdpi/ic_flip_camera_android_black_24dp.png       |  0 
src/main/res/drawable-xxxhdpi/message_bubble_received.9.png               |  0 
src/main/res/drawable-xxxhdpi/message_bubble_received_dark.9.png          |  0 
src/main/res/drawable-xxxhdpi/message_bubble_received_grey.9.png          |  0 
src/main/res/drawable-xxxhdpi/message_bubble_received_warning.9.png       |  0 
src/main/res/drawable-xxxhdpi/message_bubble_received_white.9.png         |  0 
src/main/res/drawable-xxxhdpi/message_bubble_sent.9.png                   |  0 
src/main/res/drawable-xxxhdpi/message_bubble_sent_grey.9.png              |  0 
src/main/res/layout/activity_rtp_session.xml                              | 73 
src/main/res/values/strings.xml                                           |  1 
58 files changed, 168 insertions(+), 40 deletions(-)

Detailed changes

art/flip_camera_android-black-24dp.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24" fill="black" width="24px" height="24px"><g><rect fill="none" height="24" width="24"/></g><g><g><path d="M9,12c0,1.66,1.34,3,3,3s3-1.34,3-3s-1.34-3-3-3S9,10.34,9,12z"/><path d="M8,10V8H5.09C6.47,5.61,9.05,4,12,4c3.72,0,6.85,2.56,7.74,6h2.06c-0.93-4.56-4.96-8-9.8-8C8.73,2,5.82,3.58,4,6.01V4H2v6 H8z"/><path d="M16,14v2h2.91c-1.38,2.39-3.96,4-6.91,4c-3.72,0-6.85-2.56-7.74-6H2.2c0.93,4.56,4.96,8,9.8,8c3.27,0,6.18-1.58,8-4.01V20 h2v-6H16z"/></g></g></svg>

art/render.rb 🔗

@@ -27,6 +27,7 @@ images = {
     'open_pdf_white.svg' => ['open_pdf_white', 128],
 	'conversations_mono.svg' => ['conversations/ic_notification', 24],
     'quicksy_mono.svg' => ['quicksy/ic_notification', 24],
+    'flip_camera_android-black-24dp.svg' => ['ic_flip_camera_android_black_24dp', 24],
 	'ic_send_text_offline.svg' => ['ic_send_text_offline', 36],
 	'ic_send_text_offline_white.svg' => ['ic_send_text_offline_white', 36],
 	'ic_send_text_online.svg' => ['ic_send_text_online', 36],

src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java 🔗

@@ -22,9 +22,14 @@ import android.widget.Toast;
 
 import com.google.common.base.Optional;
 import com.google.common.base.Preconditions;
+import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
 
+import org.checkerframework.checker.nullness.compatqual.NullableDecl;
 import org.webrtc.SurfaceViewRenderer;
 import org.webrtc.VideoTrack;
 
@@ -42,6 +47,7 @@ import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.services.AppRTCAudioManager;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.ui.util.AvatarWorkerTask;
+import eu.siacs.conversations.ui.util.MainThreadExecutor;
 import eu.siacs.conversations.utils.PermissionUtils;
 import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection;
 import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
@@ -83,7 +89,6 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
 
     @Override
     public void onCreate(Bundle savedInstanceState) {
-        Log.d(Config.LOGTAG, this.getClass().getName() + ".onCreate()");
         super.onCreate(savedInstanceState);
         getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
                 | WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
@@ -561,18 +566,21 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
         if (state == RtpEndUserState.CONNECTED && !isPictureInPicture()) {
             Preconditions.checkArgument(media.size() > 0, "Media must not be empty");
             if (media.contains(Media.VIDEO)) {
-                updateInCallButtonConfigurationVideo(requireRtpConnection().isVideoEnabled());
+                final JingleRtpConnection rtpConnection = requireRtpConnection();
+                updateInCallButtonConfigurationVideo(rtpConnection.isVideoEnabled(), rtpConnection.isCameraSwitchable());
             } else {
                 final AppRTCAudioManager audioManager = requireRtpConnection().getAudioManager();
                 updateInCallButtonConfigurationSpeaker(
                         audioManager.getSelectedAudioDevice(),
                         audioManager.getAudioDevices().size()
                 );
+                this.binding.inCallActionFarRight.setVisibility(View.GONE);
             }
             updateInCallButtonConfigurationMicrophone(requireRtpConnection().isMicrophoneEnabled());
         } else {
             this.binding.inCallActionLeft.setVisibility(View.GONE);
             this.binding.inCallActionRight.setVisibility(View.GONE);
+            this.binding.inCallActionFarRight.setVisibility(View.GONE);
         }
     }
 
@@ -612,8 +620,15 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
     }
 
     @SuppressLint("RestrictedApi")
-    private void updateInCallButtonConfigurationVideo(final boolean videoEnabled) {
+    private void updateInCallButtonConfigurationVideo(final boolean videoEnabled, final boolean isCameraSwitchable) {
         this.binding.inCallActionRight.setVisibility(View.VISIBLE);
+        if (isCameraSwitchable) {
+            this.binding.inCallActionFarRight.setImageResource(R.drawable.ic_flip_camera_android_black_24dp);
+            this.binding.inCallActionFarRight.setVisibility(View.VISIBLE);
+            this.binding.inCallActionFarRight.setOnClickListener(this::switchCamera);
+        } else {
+            this.binding.inCallActionFarRight.setVisibility(View.GONE);
+        }
         if (videoEnabled) {
             this.binding.inCallActionRight.setImageResource(R.drawable.ic_videocam_black_24dp);
             this.binding.inCallActionRight.setOnClickListener(this::disableVideo);
@@ -623,14 +638,29 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
         }
     }
 
+    private void switchCamera(final View view) {
+        Futures.addCallback(requireRtpConnection().switchCamera(), new FutureCallback<Void>() {
+            @Override
+            public void onSuccess(@NullableDecl Void result) {
+
+            }
+
+            @Override
+            public void onFailure(final Throwable throwable) {
+                Log.d(Config.LOGTAG,"could not switch camera", Throwables.getRootCause(throwable));
+                Toast.makeText(RtpSessionActivity.this, R.string.could_not_switch_camera, Toast.LENGTH_LONG).show();
+            }
+        }, MainThreadExecutor.getInstance());
+    }
+
     private void enableVideo(View view) {
         requireRtpConnection().setVideoEnabled(true);
-        updateInCallButtonConfigurationVideo(true);
+        updateInCallButtonConfigurationVideo(true, requireRtpConnection().isCameraSwitchable());
     }
 
     private void disableVideo(View view) {
         requireRtpConnection().setVideoEnabled(false);
-        updateInCallButtonConfigurationVideo(false);
+        updateInCallButtonConfigurationVideo(false, requireRtpConnection().isCameraSwitchable());
 
     }
 

src/main/java/eu/siacs/conversations/ui/util/MainThreadExecutor.java 🔗

@@ -0,0 +1,37 @@
+/*
+ * Copyright 2019 Daniel Gultsch
+ * 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.
+ */
+
+package eu.siacs.conversations.ui.util;
+
+import android.os.Handler;
+import android.os.Looper;
+
+import java.util.concurrent.Executor;
+
+public class MainThreadExecutor implements Executor {
+
+    private static final MainThreadExecutor INSTANCE = new MainThreadExecutor();
+
+    private final Handler handler = new Handler(Looper.myLooper());
+
+    @Override
+    public void execute(final Runnable command) {
+        handler.post(command);
+    }
+
+    public static MainThreadExecutor getInstance() {
+        return INSTANCE;
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java 🔗

@@ -12,6 +12,7 @@ import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Sets;
 import com.google.common.primitives.Ints;
+import com.google.common.util.concurrent.ListenableFuture;
 
 import org.webrtc.EglBase;
 import org.webrtc.IceCandidate;
@@ -1037,6 +1038,15 @@ public class JingleRtpConnection extends AbstractJingleConnection implements Web
         return webRTCWrapper.isVideoEnabled();
     }
 
+
+    public boolean isCameraSwitchable() {
+        return webRTCWrapper.isCameraSwitchable();
+    }
+
+    public ListenableFuture<Void> switchCamera() {
+        return webRTCWrapper.switchCamera();
+    }
+
     public void setVideoEnabled(final boolean enabled) {
         webRTCWrapper.setVideoEnabled(enabled);
     }

src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java 🔗

@@ -8,6 +8,8 @@ import android.util.Log;
 
 import com.google.common.base.Optional;
 import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.MoreExecutors;
@@ -255,7 +257,7 @@ public class WebRTCWrapper {
         try {
             peerConnection.dispose();
         } catch (final IllegalStateException e) {
-            Log.e(Config.LOGTAG,"unable to dispose of peer connection", e);
+            Log.e(Config.LOGTAG, "unable to dispose of peer connection", e);
         }
     }
 
@@ -270,6 +272,31 @@ public class WebRTCWrapper {
         }
     }
 
+    boolean isCameraSwitchable() {
+        final CapturerChoice capturerChoice = this.capturerChoice;
+        return capturerChoice != null && capturerChoice.availableCameras.size() > 1;
+    }
+
+    ListenableFuture<Void> switchCamera() {
+        final CapturerChoice capturerChoice = this.capturerChoice;
+        if (capturerChoice == null) {
+            return Futures.immediateFailedFuture(new IllegalStateException("CameraCapturer has not been initialized"));
+        }
+        final SettableFuture<Void> future = SettableFuture.create();
+        capturerChoice.cameraVideoCapturer.switchCamera(new CameraVideoCapturer.CameraSwitchHandler() {
+            @Override
+            public void onCameraSwitchDone(boolean isFrontCamera) {
+                future.set(null);
+            }
+
+            @Override
+            public void onCameraSwitchError(final String message) {
+                future.setException(new IllegalStateException(String.format("Unable to switch camera %s", message)));
+            }
+        });
+        return future;
+    }
+
     boolean isMicrophoneEnabled() {
         final AudioTrack audioTrack = this.localAudioTrack;
         if (audioTrack == null) {
@@ -408,21 +435,21 @@ public class WebRTCWrapper {
 
     private Optional<CapturerChoice> getVideoCapturer() {
         final CameraEnumerator enumerator = getCameraEnumerator();
-        final String[] deviceNames = enumerator.getDeviceNames();
+        final Set<String> deviceNames = ImmutableSet.copyOf(enumerator.getDeviceNames());
         for (final String deviceName : deviceNames) {
             if (enumerator.isFrontFacing(deviceName)) {
-                return Optional.fromNullable(of(enumerator, deviceName));
+                return Optional.fromNullable(of(enumerator, deviceName, deviceNames));
             }
         }
-        if (deviceNames.length == 0) {
+        if (deviceNames.size() == 0) {
             return Optional.absent();
         } else {
-            return Optional.fromNullable(of(enumerator, deviceNames[0]));
+            return Optional.fromNullable(of(enumerator, Iterables.get(deviceNames, 0), deviceNames));
         }
     }
 
     @Nullable
-    private static CapturerChoice of(CameraEnumerator enumerator, final String deviceName) {
+    private static CapturerChoice of(CameraEnumerator enumerator, final String deviceName, Set<String> availableCameras) {
         final CameraVideoCapturer capturer = enumerator.createCapturer(deviceName, null);
         if (capturer == null) {
             return null;
@@ -431,7 +458,7 @@ public class WebRTCWrapper {
         Collections.sort(choices, (a, b) -> b.width - a.width);
         for (final CameraEnumerationAndroid.CaptureFormat captureFormat : choices) {
             if (captureFormat.width <= CAPTURING_RESOLUTION) {
-                return new CapturerChoice(capturer, captureFormat);
+                return new CapturerChoice(capturer, captureFormat, availableCameras);
             }
         }
         return null;
@@ -520,10 +547,12 @@ public class WebRTCWrapper {
     private static class CapturerChoice {
         private final CameraVideoCapturer cameraVideoCapturer;
         private final CameraEnumerationAndroid.CaptureFormat captureFormat;
+        private final Set<String> availableCameras;
 
-        CapturerChoice(CameraVideoCapturer cameraVideoCapturer, CameraEnumerationAndroid.CaptureFormat captureFormat) {
+        CapturerChoice(CameraVideoCapturer cameraVideoCapturer, CameraEnumerationAndroid.CaptureFormat captureFormat, Set<String> cameras) {
             this.cameraVideoCapturer = cameraVideoCapturer;
             this.captureFormat = captureFormat;
+            this.availableCameras = cameras;
         }
 
         int getFrameRate() {

src/main/res/layout/activity_rtp_session.xml 🔗

@@ -117,34 +117,56 @@
 
         <RelativeLayout
             android:id="@+id/button_row"
-            android:layout_width="288dp"
+            android:layout_width="match_parent"
             android:layout_height="wrap_content"
             android:layout_alignParentBottom="true"
             android:layout_centerHorizontal="true"
             android:layout_marginBottom="24dp">
 
-            <android.support.design.widget.FloatingActionButton
-                android:id="@+id/reject_call"
-                android:layout_width="wrap_content"
+            <RelativeLayout
+                android:layout_width="288dp"
                 android:layout_height="wrap_content"
-                android:layout_alignParentStart="true"
-                android:layout_alignParentLeft="true"
-                android:layout_centerVertical="true"
-                android:layout_margin="16dp"
-                android:src="@drawable/ic_call_end_white_48dp"
-                android:visibility="gone"
-                app:backgroundTint="@color/red700"
-                app:elevation="4dp"
-                app:fabCustomSize="72dp"
-                app:maxImageSize="36dp"
-                tools:visibility="visible" />
+                android:layout_centerInParent="true">
+
+                <android.support.design.widget.FloatingActionButton
+                    android:id="@+id/reject_call"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:layout_alignParentStart="true"
+                    android:layout_alignParentLeft="true"
+                    android:layout_margin="16dp"
+                    android:src="@drawable/ic_call_end_white_48dp"
+                    android:visibility="gone"
+                    app:backgroundTint="@color/red700"
+                    app:elevation="4dp"
+                    app:fabCustomSize="72dp"
+                    app:maxImageSize="36dp"
+                    tools:visibility="visible" />
+
+                <android.support.design.widget.FloatingActionButton
+                    android:id="@+id/accept_call"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:layout_alignParentEnd="true"
+                    android:layout_alignParentRight="true"
+                    android:layout_centerVertical="true"
+                    android:layout_margin="16dp"
+                    android:src="@drawable/ic_call_white_48dp"
+                    android:visibility="gone"
+                    app:backgroundTint="@color/green700"
+                    app:elevation="4dp"
+                    app:fabCustomSize="72dp"
+                    app:maxImageSize="36dp"
+                    tools:visibility="visible" />
+
+            </RelativeLayout>
 
             <android.support.design.widget.FloatingActionButton
                 android:id="@+id/in_call_action_left"
                 android:layout_width="wrap_content"
                 android:layout_height="wrap_content"
                 android:layout_centerVertical="true"
-                android:layout_margin="16dp"
+                android:layout_margin="12dp"
                 android:layout_toStartOf="@+id/end_call"
                 android:layout_toLeftOf="@+id/end_call"
                 android:visibility="gone"
@@ -171,7 +193,7 @@
                 android:layout_width="wrap_content"
                 android:layout_height="wrap_content"
                 android:layout_centerVertical="true"
-                android:layout_margin="16dp"
+                android:layout_margin="12dp"
                 android:layout_toEndOf="@+id/end_call"
                 android:layout_toRightOf="@+id/end_call"
                 android:visibility="gone"
@@ -180,22 +202,19 @@
                 app:fabSize="mini"
                 app:tint="?attr/icon_tint" />
 
-
             <android.support.design.widget.FloatingActionButton
-                android:id="@+id/accept_call"
+                android:id="@+id/in_call_action_far_right"
                 android:layout_width="wrap_content"
                 android:layout_height="wrap_content"
-                android:layout_alignParentEnd="true"
-                android:layout_alignParentRight="true"
                 android:layout_centerVertical="true"
-                android:layout_margin="16dp"
-                android:src="@drawable/ic_call_white_48dp"
+                android:layout_margin="12dp"
+                android:layout_toEndOf="@+id/in_call_action_right"
+                android:layout_toRightOf="@+id/in_call_action_right"
                 android:visibility="gone"
-                app:backgroundTint="@color/green700"
+                app:backgroundTint="?color_background_primary"
                 app:elevation="4dp"
-                app:fabCustomSize="72dp"
-                app:maxImageSize="36dp"
-                tools:visibility="visible" />
+                app:fabSize="mini"
+                app:tint="?attr/icon_tint" />
         </RelativeLayout>
 
     </RelativeLayout>

src/main/res/values/strings.xml 🔗

@@ -920,6 +920,7 @@
     <string name="microphone_unavailable">Your microphone is unavailable</string>
     <string name="only_one_call_at_a_time">You can only have one call at a time.</string>
     <string name="return_to_ongoing_call">Return to ongoing call</string>
+    <string name="could_not_switch_camera">Could not switch camera</string>
     <plurals name="view_users">
         <item quantity="one">View %1$d Participant</item>
         <item quantity="other">View %1$d Participants</item>