diff --git a/app/jni/tgvoip/tgvoip.cpp b/app/jni/tgvoip/tgvoip.cpp index afdde5731b..33342cf88c 100644 --- a/app/jni/tgvoip/tgvoip.cpp +++ b/app/jni/tgvoip/tgvoip.cpp @@ -34,6 +34,7 @@ #include #include +#include #include #include @@ -405,6 +406,11 @@ class JniWrapper { struct TgCallsContext { std::unique_ptr tgcalls; std::shared_ptr javaController; + std::shared_ptr videoCapture; + std::shared_ptr> localVideoSink; + std::shared_ptr> remoteVideoSink; + bool videoEnabled = false; + bool frontCamera = true; }; jbyteArray toJavaByteArray (JNIEnv *env, const std::vector &data) { @@ -464,6 +470,7 @@ JNI_OBJECT_FUNC(jlong, voip_TgCallsController, newInstance, env->ReleaseByteArrayElements(jEncryptionKey, (jbyte *) jEncryptionKeyData, JNI_ABORT); bool isOutgoingCall = configuration.getBoolean("isOutgoing") == JNI_TRUE; + bool isVideoCall = configuration.getBoolean("isVideo") == JNI_TRUE; // tgcalls::Endpoint @@ -579,6 +586,16 @@ JNI_OBJECT_FUNC(jlong, voip_TgCallsController, newInstance, std::shared_ptr javaController = std::make_shared(env, thiz, tgcalls::javaTgCallsController); + auto videoPlatformContext = std::make_shared(env); + std::shared_ptr videoCapture = + tgcalls::VideoCaptureInterface::Create( + tgcalls::StaticThreads::getThreads(), + "front", + false, + videoPlatformContext + ); + videoCapture->setState(isVideoCall ? tgcalls::VideoState::Active : tgcalls::VideoState::Inactive); + tgcalls::Descriptor descriptor = { .version = version, .config = tgcalls::Config { @@ -612,7 +629,7 @@ JNI_OBJECT_FUNC(jlong, voip_TgCallsController, newInstance, std::move(encryptionKey), isOutgoingCall ), - .videoCapture = nullptr, + .videoCapture = isVideoCall ? videoCapture : nullptr, .stateUpdated = [javaController](tgcalls::State state) { javaController->runSafely([javaController, state](JNIEnv *env) { jint javaState = toJavaCallState(env, state); @@ -667,6 +684,8 @@ JNI_OBJECT_FUNC(jlong, voip_TgCallsController, newInstance, auto *context = new TgCallsContext; context->javaController = javaController; + context->videoCapture = videoCapture; + context->videoEnabled = isVideoCall; context->tgcalls = tgcalls::Meta::Create(version, std::move(descriptor)); context->tgcalls->setNetworkType(networkType); context->tgcalls->setAudioOutputGainControlEnabled(audioOutputGainControlEnabled); @@ -749,6 +768,80 @@ JNI_OBJECT_FUNC(void, voip_TgCallsController, updateAudioOutputGainControlEnable } } +JNI_OBJECT_FUNC(jboolean, voip_TgCallsController, nativeSupportsVideo, jlong ptr) { + auto context = jni::jlong_to_ptr(ptr); + return context != nullptr && context->tgcalls != nullptr && context->tgcalls->supportsVideo() + ? JNI_TRUE + : JNI_FALSE; +} + +JNI_OBJECT_FUNC(void, voip_TgCallsController, nativeSetVideoEnabled, jlong ptr, jboolean jEnabled) { + auto context = jni::jlong_to_ptr(ptr); + if (context == nullptr || context->tgcalls == nullptr || context->videoCapture == nullptr) { + return; + } + bool enabled = jEnabled == JNI_TRUE; + if (context->videoEnabled == enabled) { + return; + } + context->videoEnabled = enabled; + if (enabled) { + context->videoCapture->setState(tgcalls::VideoState::Active); + context->tgcalls->setVideoCapture(context->videoCapture); + } else { + context->tgcalls->setVideoCapture(nullptr); + context->videoCapture->setState(tgcalls::VideoState::Inactive); + } +} + +JNI_OBJECT_FUNC(void, voip_TgCallsController, nativeSetVideoPaused, jlong ptr, jboolean jPaused) { + auto context = jni::jlong_to_ptr(ptr); + if (context == nullptr || context->videoCapture == nullptr || !context->videoEnabled) { + return; + } + context->videoCapture->setState( + jPaused == JNI_TRUE ? tgcalls::VideoState::Paused : tgcalls::VideoState::Active + ); +} + +JNI_OBJECT_FUNC(void, voip_TgCallsController, nativeSwitchCamera, jlong ptr) { + auto context = jni::jlong_to_ptr(ptr); + if (context == nullptr || context->videoCapture == nullptr) { + return; + } + context->frontCamera = !context->frontCamera; + context->videoCapture->switchToDevice(context->frontCamera ? "front" : "back", false); + context->videoCapture->setState( + context->videoEnabled ? tgcalls::VideoState::Active : tgcalls::VideoState::Inactive + ); +} + +JNI_OBJECT_FUNC(void, voip_TgCallsController, nativeSetLocalVideoOutput, jlong ptr, jobject jSink) { + auto context = jni::jlong_to_ptr(ptr); + if (context == nullptr || context->videoCapture == nullptr) { + return; + } + context->localVideoSink = jSink != nullptr + ? std::shared_ptr>( + webrtc::JavaToNativeVideoSink(env, jSink) + ) + : nullptr; + context->videoCapture->setOutput(context->localVideoSink); +} + +JNI_OBJECT_FUNC(void, voip_TgCallsController, nativeSetRemoteVideoOutput, jlong ptr, jobject jSink) { + auto context = jni::jlong_to_ptr(ptr); + if (context == nullptr || context->tgcalls == nullptr) { + return; + } + context->remoteVideoSink = jSink != nullptr + ? std::shared_ptr>( + webrtc::JavaToNativeVideoSink(env, jSink) + ) + : nullptr; + context->tgcalls->setIncomingVideoOutput(context->remoteVideoSink); +} + JNI_OBJECT_FUNC(void, voip_TgCallsController, fetchNetworkStats, jlong ptr, jobject jOutStats) { auto context = jni::jlong_to_ptr(ptr); if (context != nullptr && context->tgcalls != nullptr) { @@ -868,4 +961,4 @@ jint JNI_OnLoad (JavaVM *vm, void *reserved) { return JNI_VERSION_1_6; } -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fab8a54671..77225beecf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -67,6 +67,7 @@ + + android:foregroundServiceType="phoneCall|microphone|camera|mediaPlayback" /> @@ -352,6 +353,7 @@ android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|locale|layoutDirection|uiMode" android:launchMode="singleTask" + android:supportsPictureInPicture="true" android:windowSoftInputMode="stateHidden|adjustPan" android:exported="true"> @@ -457,4 +459,4 @@ android:name="com.google.android.gms.car.application" android:resource="@xml/automotive_app_desc"/> - \ No newline at end of file + diff --git a/app/src/main/java/org/telegram/messenger/voip/VideoCameraCapturer.java b/app/src/main/java/org/telegram/messenger/voip/VideoCameraCapturer.java new file mode 100644 index 0000000000..0c1bfbb929 --- /dev/null +++ b/app/src/main/java/org/telegram/messenger/voip/VideoCameraCapturer.java @@ -0,0 +1,212 @@ +/* + * This file is a part of Frogram X. + * + * It bridges Telegram's tgcalls Android video source to the WebRTC camera + * implementation bundled with the application. + */ +package org.telegram.messenger.voip; + +import android.content.Context; + +import androidx.annotation.Keep; + +import org.thunderdog.challegram.Log; +import org.webrtc.Camera1Enumerator; +import org.webrtc.Camera2Enumerator; +import org.webrtc.CameraEnumerator; +import org.webrtc.CameraVideoCapturer; +import org.webrtc.CapturerObserver; +import org.webrtc.ContextUtils; +import org.webrtc.SurfaceTextureHelper; + +@Keep +public final class VideoCameraCapturer { + private static final int VIDEO_STATE_INACTIVE = 0; + private static final int VIDEO_STATE_PAUSED = 1; + private static final int VIDEO_STATE_ACTIVE = 2; + + private static final int CAPTURE_WIDTH = 1280; + private static final int CAPTURE_HEIGHT = 720; + private static final int CAPTURE_FPS = 30; + + private long nativePtr; + private boolean useFrontCamera = true; + private boolean started; + private boolean destroyed; + private int requestedState = VIDEO_STATE_INACTIVE; + + private CameraVideoCapturer capturer; + private SurfaceTextureHelper surfaceTextureHelper; + + @Keep + public VideoCameraCapturer () { } + + @Keep + private synchronized void init (long nativePtr, boolean useFrontCamera) { + if (destroyed) { + return; + } + stopAndDisposeCapturer(); + this.nativePtr = nativePtr; + this.useFrontCamera = useFrontCamera; + + Context context = ContextUtils.getApplicationContext(); + if (context == null) { + Log.e(Log.TAG_VOIP, "Video camera initialization failed: application context is unavailable"); + return; + } + + CameraEnumerator enumerator = Camera2Enumerator.isSupported(context) + ? new Camera2Enumerator(context) + : new Camera1Enumerator(false); + String deviceName = findCamera(enumerator, useFrontCamera); + if (deviceName == null) { + Log.e(Log.TAG_VOIP, "Video camera initialization failed: no camera found"); + return; + } + + capturer = enumerator.createCapturer(deviceName, new CameraVideoCapturer.CameraEventsHandler() { + @Override + public void onCameraError (String errorDescription) { + Log.e(Log.TAG_VOIP, "Video camera error: %s", errorDescription); + } + + @Override + public void onCameraDisconnected () { + Log.w(Log.TAG_VOIP, "Video camera disconnected"); + } + + @Override + public void onCameraFreezed (String errorDescription) { + Log.e(Log.TAG_VOIP, "Video camera frozen: %s", errorDescription); + } + + @Override + public void onCameraOpening (String cameraName) { + Log.v(Log.TAG_VOIP, "Opening video camera: %s", cameraName); + } + + @Override + public void onFirstFrameAvailable () { + Log.v(Log.TAG_VOIP, "First local video frame is available"); + } + + @Override + public void onCameraClosed () { + Log.v(Log.TAG_VOIP, "Video camera closed"); + } + }); + if (capturer == null) { + Log.e(Log.TAG_VOIP, "Video camera initialization failed: capturer is unavailable"); + return; + } + + surfaceTextureHelper = SurfaceTextureHelper.create("FrogramVideoCamera", null); + if (surfaceTextureHelper == null) { + Log.e(Log.TAG_VOIP, "Video camera initialization failed: EGL surface is unavailable"); + capturer.dispose(); + capturer = null; + return; + } + + CapturerObserver observer = nativeGetJavaVideoCapturerObserver(nativePtr); + capturer.initialize(surfaceTextureHelper, context, observer); + if (requestedState == VIDEO_STATE_ACTIVE) { + startCapture(); + } + } + + @Keep + private synchronized void onStateChanged (long nativePtr, int state) { + if (destroyed || this.nativePtr != nativePtr) { + return; + } + requestedState = state; + if (state == VIDEO_STATE_ACTIVE) { + startCapture(); + } else if (state == VIDEO_STATE_PAUSED || state == VIDEO_STATE_INACTIVE) { + stopCapture(); + } + } + + @Keep + private synchronized void onAspectRatioRequested (float aspectRatio) { + if (capturer == null || !started || aspectRatio <= 0f) { + return; + } + if (aspectRatio < 1f) { + capturer.changeCaptureFormat(CAPTURE_HEIGHT, CAPTURE_WIDTH, CAPTURE_FPS); + } else { + capturer.changeCaptureFormat(CAPTURE_WIDTH, CAPTURE_HEIGHT, CAPTURE_FPS); + } + } + + @Keep + private synchronized void onDestroy () { + destroyed = true; + requestedState = VIDEO_STATE_INACTIVE; + stopAndDisposeCapturer(); + nativePtr = 0; + } + + private void startCapture () { + if (capturer == null || started) { + return; + } + try { + capturer.startCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT, CAPTURE_FPS); + started = true; + } catch (Throwable t) { + Log.e(Log.TAG_VOIP, "Unable to start video capture", t); + } + } + + private void stopCapture () { + if (capturer == null || !started) { + return; + } + try { + capturer.stopCapture(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Throwable t) { + Log.e(Log.TAG_VOIP, "Unable to stop video capture", t); + } + started = false; + } + + private void stopAndDisposeCapturer () { + stopCapture(); + if (capturer != null) { + try { + capturer.dispose(); + } catch (Throwable t) { + Log.e(Log.TAG_VOIP, "Unable to dispose video capturer", t); + } + capturer = null; + } + if (surfaceTextureHelper != null) { + try { + surfaceTextureHelper.dispose(); + } catch (Throwable t) { + Log.e(Log.TAG_VOIP, "Unable to dispose video surface", t); + } + surfaceTextureHelper = null; + } + } + + private static String findCamera (CameraEnumerator enumerator, boolean front) { + String fallback = null; + for (String deviceName : enumerator.getDeviceNames()) { + if (fallback == null) { + fallback = deviceName; + } + if ((front && enumerator.isFrontFacing(deviceName)) || (!front && enumerator.isBackFacing(deviceName))) { + return deviceName; + } + } + return fallback; + } + + private static native CapturerObserver nativeGetJavaVideoCapturerObserver (long nativePtr); +} diff --git a/app/src/main/java/org/thunderdog/challegram/MainActivity.java b/app/src/main/java/org/thunderdog/challegram/MainActivity.java index 8341c56598..461979b199 100644 --- a/app/src/main/java/org/thunderdog/challegram/MainActivity.java +++ b/app/src/main/java/org/thunderdog/challegram/MainActivity.java @@ -15,6 +15,7 @@ package org.thunderdog.challegram; import android.content.Intent; +import android.content.res.Configuration; import android.net.Uri; import android.os.Bundle; import android.os.Handler; @@ -1531,6 +1532,24 @@ public void onPause () { }*/ } + @Override + protected void onUserLeaveHint () { + ViewController current = navigation != null ? navigation.getCurrentStackItem() : null; + if (current instanceof CallController && ((CallController) current).enterPictureInPictureIfPossible()) { + return; + } + super.onUserLeaveHint(); + } + + @Override + public void onPictureInPictureModeChanged (boolean isInPictureInPictureMode, Configuration newConfig) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig); + ViewController current = navigation != null ? navigation.getCurrentStackItem() : null; + if (current instanceof CallController) { + ((CallController) current).onPictureInPictureModeChanged(isInPictureInPictureMode); + } + } + @Override public void onResume () { super.onResume(); diff --git a/app/src/main/java/org/thunderdog/challegram/U.java b/app/src/main/java/org/thunderdog/challegram/U.java index 441b910019..b9865d5937 100644 --- a/app/src/main/java/org/thunderdog/challegram/U.java +++ b/app/src/main/java/org/thunderdog/challegram/U.java @@ -457,6 +457,10 @@ public static String toHexString (String str) { } public static void startForeground (Service service, int notificationId, Notification notification) { + startForeground(service, notificationId, notification, false); + } + + public static void startForeground (Service service, int notificationId, Notification notification, boolean useCamera) { if (notification == null) throw new IllegalArgumentException(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -471,6 +475,9 @@ public static void startForeground (Service service, int notificationId, Notific if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { knownType |= android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE; } + if (useCamera) { + knownType |= android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA; + } knownType |= android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK; service.startForeground(notificationId, notification, knownType); return; diff --git a/app/src/main/java/org/thunderdog/challegram/service/TGCallService.java b/app/src/main/java/org/thunderdog/challegram/service/TGCallService.java index cf337e9272..f3079c7345 100644 --- a/app/src/main/java/org/thunderdog/challegram/service/TGCallService.java +++ b/app/src/main/java/org/thunderdog/challegram/service/TGCallService.java @@ -78,10 +78,13 @@ import org.thunderdog.challegram.voip.Socks5Proxy; import org.thunderdog.challegram.voip.VoIP; import org.thunderdog.challegram.voip.VoIPInstance; +import org.thunderdog.challegram.voip.annotation.AudioState; import org.thunderdog.challegram.voip.annotation.CallNetworkType; import org.thunderdog.challegram.voip.annotation.CallState; +import org.thunderdog.challegram.voip.annotation.VideoState; import org.thunderdog.challegram.voip.gui.CallSettings; import org.thunderdog.challegram.voip.gui.VoIPFeedbackActivity; +import org.webrtc.VideoSink; import java.lang.ref.WeakReference; import java.lang.reflect.Field; @@ -98,6 +101,10 @@ public class TGCallService extends Service implements TdlibCache.CallStateChangeListener, AudioManager.OnAudioFocusChangeListener, SensorEventListener, UI.StateListener { + public interface VideoStateListener { + void onVideoStateChanged (boolean supported, boolean localVideoEnabled, @VideoState int remoteVideoState, boolean frontCamera); + } + @Override public IBinder onBind (Intent intent) { return null; @@ -179,6 +186,8 @@ private void setCallId (Tdlib tdlib, int callId) { private SoundPoolMap soundPoolMap; private @Nullable VoIPInstance tgcalls; private @Nullable PrivateCallListener callListener; + private @Nullable VideoStateListener videoStateListener; + private @VideoState int remoteVideoState = VideoState.INACTIVE; private PowerManager.WakeLock cpuWakelock; private BluetoothAdapter btAdapter; @@ -271,6 +280,77 @@ public void updateOutputGainControlState () { } } + public void setVideoStateListener (@Nullable VideoStateListener listener) { + videoStateListener = listener; + dispatchVideoState(); + } + + private void dispatchVideoState () { + VideoStateListener listener = videoStateListener; + if (listener != null) { + listener.onVideoStateChanged( + tgcalls != null && tgcalls.supportsVideo(), + tgcalls != null && tgcalls.isVideoEnabled(), + remoteVideoState, + tgcalls == null || tgcalls.isFrontCamera() + ); + } + } + + public boolean supportsVideo () { + return tgcalls != null && tgcalls.supportsVideo(); + } + + public boolean isLocalVideoEnabled () { + return tgcalls != null && tgcalls.isVideoEnabled(); + } + + public boolean isFrontCamera () { + return tgcalls == null || tgcalls.isFrontCamera(); + } + + public @VideoState int getRemoteVideoState () { + return remoteVideoState; + } + + public boolean hasActiveVideo () { + return isLocalVideoEnabled() || remoteVideoState == VideoState.ACTIVE || remoteVideoState == VideoState.PAUSED; + } + + public void setVideoEnabled (boolean enabled) { + if (tgcalls == null || !tgcalls.supportsVideo()) { + return; + } + tgcalls.setVideoEnabled(enabled); + if (enabled) { + CallSettings settings = getCallSettings(); + if (settings != null && !settings.isSpeakerModeEnabled()) { + settings.setSpeakerMode(CallSettings.SPEAKER_MODE_SPEAKER); + } + } + updateVideoForegroundType(); + dispatchVideoState(); + } + + public void setVideoPaused (boolean paused) { + if (tgcalls != null && tgcalls.isVideoEnabled()) { + tgcalls.setVideoPaused(paused); + } + } + + public void switchCamera () { + if (tgcalls != null && tgcalls.isVideoEnabled()) { + tgcalls.switchCamera(); + dispatchVideoState(); + } + } + + public void setVideoSinks (@Nullable VideoSink localSink, @Nullable VideoSink remoteSink) { + if (tgcalls != null && tgcalls.supportsVideo()) { + tgcalls.setVideoSinks(localSink, remoteSink); + } + } + public boolean compareCall (Tdlib tdlib, int callId) { return call != null && callTdlib() == tdlib.id() && callId == call.id; } @@ -777,6 +857,17 @@ public void onAccuracyChanged (Sensor sensor, int accuracy) { private Notification ongoingCallNotification; + private void updateVideoForegroundType () { + if (ongoingCallNotification != null) { + U.startForeground( + this, + TdlibNotificationManager.ID_ONGOING_CALL_NOTIFICATION, + ongoingCallNotification, + isLocalVideoEnabled() + ); + } + } + private static final @DrawableRes int CALL_ICON_RES = R.drawable.baseline_phone_24_white; private void showNotification () { @@ -848,7 +939,7 @@ private void showNotification () { } else { ongoingCallNotification = builder.getNotification(); } - U.startForeground(this, TdlibNotificationManager.ID_ONGOING_CALL_NOTIFICATION, ongoingCallNotification); + U.startForeground(this, TdlibNotificationManager.ID_ONGOING_CALL_NOTIFICATION, ongoingCallNotification, isLocalVideoEnabled()); } // Sound @@ -1322,8 +1413,11 @@ private void releaseTgCalls (@Nullable Tdlib tdlib, @Nullable TdApi.Call call) { tdlib = tgcalls.tdlib(); } lastDebugLog = tgcalls.collectDebugLog(); + tgcalls.setVideoSinks(null, null); tgcalls.performDestroy(); tgcalls = null; + remoteVideoState = VideoState.INACTIVE; + dispatchVideoState(); } if (callListener != null && tdlib != null && call != null) { tdlib.listeners().unsubscribeFromCallUpdates(call.id, callListener); @@ -1389,6 +1483,12 @@ public void onSignalBarCountChanged (int newCount) { public void onSignallingDataEmitted (byte[] data) { tdlib.client().send(new TdApi.SendCallSignalingData(call.id, data), tdlib.silentHandler()); } + + @Override + public void onRemoteMediaStateChanged (VoIPInstance context, @AudioState int audioState, @VideoState int videoState) { + remoteVideoState = videoState; + UI.post(TGCallService.this::dispatchVideoState); + } }; VoIPInstance tgcallsTemp; @@ -1419,6 +1519,14 @@ public void onNewCallSignalingDataArrived (int callId, byte[] data) { }; tdlib.listeners().subscribeToCallUpdates(call.id, callListener); this.tgcalls = tgcalls; + if (tgcalls.isVideoEnabled()) { + CallSettings settings = getCallSettings(); + if (settings != null && !settings.isSpeakerModeEnabled()) { + settings.setSpeakerMode(CallSettings.SPEAKER_MODE_SPEAKER); + } + } + updateVideoForegroundType(); + UI.post(this::dispatchVideoState); } else { hangUp(); } diff --git a/app/src/main/java/org/thunderdog/challegram/telegram/CallManager.java b/app/src/main/java/org/thunderdog/challegram/telegram/CallManager.java index b6185ba609..bada4f232b 100644 --- a/app/src/main/java/org/thunderdog/challegram/telegram/CallManager.java +++ b/app/src/main/java/org/thunderdog/challegram/telegram/CallManager.java @@ -316,7 +316,11 @@ public boolean checkRecordPermissions (final Context context, final Tdlib tdlib, } public void makeCallDelayed (final ViewController context, final long userId, @Nullable final TdApi.UserFullInfo userFull, final boolean needPrompt) { - UI.post(() -> makeCall(context, userId, userFull, needPrompt), 180l); + makeCallDelayed(context, userId, userFull, needPrompt, false); + } + + private void makeCallDelayed (final ViewController context, final long userId, @Nullable final TdApi.UserFullInfo userFull, final boolean needPrompt, final boolean isVideo) { + UI.post(() -> makeCall(context, userId, userFull, needPrompt, isVideo), 180l); } public boolean hasActiveCall () { @@ -339,16 +343,24 @@ public boolean promptActiveCall () { } public void makeCall (final ViewController context,final long userId, @Nullable TdApi.UserFullInfo userFull) { - makeCall(context, userId, userFull, Settings.instance().needOutboundCallsPrompt()); + makeCall(context, userId, userFull, Settings.instance().needOutboundCallsPrompt(), false); } public void makeCall (final ViewController context, final long userId, @Nullable TdApi.UserFullInfo userFull, final boolean needPrompt) { + makeCall(context, userId, userFull, needPrompt, false); + } + + public void makeVideoCall (final ViewController context, final long userId, @Nullable TdApi.UserFullInfo userFull) { + makeCall(context, userId, userFull, false, true); + } + + private void makeCall (final ViewController context, final long userId, @Nullable TdApi.UserFullInfo userFull, final boolean needPrompt, final boolean isVideo) { if (userId == 0) { return; } if (Looper.myLooper() != Looper.getMainLooper()) { final TdApi.UserFullInfo userFullFinal = userFull; - UI.post(() -> makeCall(context, userId, userFullFinal, needPrompt)); + UI.post(() -> makeCall(context, userId, userFullFinal, needPrompt, isVideo)); return; } if (userFull == null) { @@ -382,7 +394,7 @@ public void makeCall (final ViewController context, final long userId, @Nulla hangUp(pendingCallTdlib, pendingCall.id, () -> { if (!signal[0]) { signal[0] = true; - makeCall(context, userId, userFullFinal, false); + makeCall(context, userId, userFullFinal, false, isVideo); } }); UI.post(() -> { @@ -426,27 +438,55 @@ public void makeCall (final ViewController context, final long userId, @Nulla if (error != null) { UI.showError(error); } else { - makeCall(context, userId, remoteUserFull, needPrompt); + makeCall(context, userId, remoteUserFull, needPrompt, isVideo); } }); return; } if (needPrompt) { final TdApi.UserFullInfo userFullFinal = userFull; - context.showOptions(Lang.getStringBold(R.string.CallX, context.tdlib().cache().userName(userId)), new int[]{R.id.btn_phone_call, R.id.btn_cancel}, new String[]{Lang.getString(R.string.Call), Lang.getString(R.string.Cancel)}, null, new int[]{R.drawable.baseline_call_24, R.drawable.baseline_cancel_24}, (itemView, id) -> { + final boolean canVideoCall = userFull.supportsVideoCalls; + context.showOptions( + Lang.getStringBold(R.string.CallX, context.tdlib().cache().userName(userId)), + canVideoCall ? new int[]{R.id.btn_phone_call, R.id.btn_video_call, R.id.btn_cancel} : new int[]{R.id.btn_phone_call, R.id.btn_cancel}, + canVideoCall ? new String[]{Lang.getString(R.string.AudioCall), Lang.getString(R.string.VideoCall), Lang.getString(R.string.Cancel)} : new String[]{Lang.getString(R.string.Call), Lang.getString(R.string.Cancel)}, + null, + canVideoCall ? new int[]{R.drawable.baseline_call_24, R.drawable.baseline_videocam_24, R.drawable.baseline_cancel_24} : new int[]{R.drawable.baseline_call_24, R.drawable.baseline_cancel_24}, + (itemView, id) -> { if (id == R.id.btn_phone_call) { - makeCallDelayed(context, userId, userFullFinal, false); + makeCallDelayed(context, userId, userFullFinal, false, false); + } else if (id == R.id.btn_video_call) { + makeCallDelayed(context, userId, userFullFinal, false, true); } return true; }); // UI.getCurrentStackItem(context); return; } + if (isVideo && !userFull.supportsVideoCalls) { + UI.showToast(R.string.VideoCallUnavailable, Toast.LENGTH_SHORT); + return; + } + if (isVideo) { + final TdApi.UserFullInfo resolvedUserFull = userFull; + BaseActivity activity = UI.getUiContext(); + if (activity != null && activity.permissions().requestRecordVideoPermissions(granted -> { + if (granted) { + makeCall(context, userId, resolvedUserFull, false, true); + } else if (activity.checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + showNeedMicAlert(false); + } else { + context.openMissingCameraPermissionAlert(); + } + })) { + return; + } + } if (!checkRecordPermissions(context.context(), context.tdlib(), null, userId, null)) { return; } context.context().closeAllMedia(false); - context.tdlib().send(new TdApi.CreateCall(userId, VoIP.getProtocol(), false), (callId, error) -> { + context.tdlib().send(new TdApi.CreateCall(userId, VoIP.getProtocol(), isVideo), (callId, error) -> { if (error != null) { Log.e(Log.TAG_VOIP, "Failed to create call: %s", TD.toErrorString(error)); UI.showError(error); @@ -487,14 +527,30 @@ private boolean checkConnection (final Context context, final Tdlib tdlib) { public void acceptCall (Context context, Tdlib tdlib, final int callId) { if (checkConnection(context, tdlib)) { - if (!checkRecordPermissions(context, tdlib, tdlib.cache().getCall(callId), 0, null)) { + TdApi.Call pendingCall = tdlib.cache().getCall(callId); + if (!checkRecordPermissions(context, tdlib, pendingCall, 0, null)) { return; } - Log.v(Log.TAG_VOIP, "#%d: AcceptCall requested", callId); - tdlib.client().send(new TdApi.AcceptCall(callId, VoIP.getProtocol()), object -> Log.v(Log.TAG_VOIP, "#%d: AcceptCall completed: %s", callId, object)); + BaseActivity activity = UI.getUiContext(); + if (pendingCall != null && pendingCall.isVideo && activity != null && + activity.permissions().requestAccessCameraPermission(granted -> { + if (granted) { + acceptCall(context, tdlib, callId); + } else { + sendAcceptCall(tdlib, callId); + } + })) { + return; + } + sendAcceptCall(tdlib, callId); } } + private void sendAcceptCall (Tdlib tdlib, int callId) { + Log.v(Log.TAG_VOIP, "#%d: AcceptCall requested", callId); + tdlib.client().send(new TdApi.AcceptCall(callId, VoIP.getProtocol()), object -> Log.v(Log.TAG_VOIP, "#%d: AcceptCall completed: %s", callId, object)); + } + public void hangUpCurrentCall () { int currentCall = getCurrentCallId(); if (currentCall != 0) { @@ -529,7 +585,7 @@ public void hangUp (final Tdlib tdlib, final int callId, final boolean isDisconn } int duration = getCallDuration(tdlib, callId); Log.v(Log.TAG_VOIP, "#%d: DiscardCall, isDisconnect: %b, connectionId: %d, duration: %d", callId, isDisconnect, connectionId, duration); - tdlib.client().send(new TdApi.DiscardCall(callId, isDisconnect, null, Math.max(0, duration), false, connectionId), object -> { + tdlib.client().send(new TdApi.DiscardCall(callId, isDisconnect, null, Math.max(0, duration), call.isVideo, connectionId), object -> { Log.v(Log.TAG_VOIP, "#%d: DiscardCall completed: %s", callId, object); }); } diff --git a/app/src/main/java/org/thunderdog/challegram/ui/CallController.java b/app/src/main/java/org/thunderdog/challegram/ui/CallController.java index a83b221b03..a481963e46 100644 --- a/app/src/main/java/org/thunderdog/challegram/ui/CallController.java +++ b/app/src/main/java/org/thunderdog/challegram/ui/CallController.java @@ -14,18 +14,22 @@ */ package org.thunderdog.challegram.ui; +import android.app.PictureInPictureParams; import android.content.Context; import android.content.pm.ActivityInfo; import android.graphics.Canvas; import android.graphics.RectF; import android.graphics.Typeface; import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.os.Build; import android.os.SystemClock; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextPaint; import android.text.TextUtils; import android.util.TypedValue; +import android.util.Rational; import android.view.Gravity; import android.view.MotionEvent; import android.view.View; @@ -66,10 +70,13 @@ import org.thunderdog.challegram.util.text.TextColorSetOverride; import org.thunderdog.challegram.util.text.TextColorSets; import org.thunderdog.challegram.voip.gui.CallSettings; +import org.thunderdog.challegram.voip.annotation.VideoState; import org.thunderdog.challegram.widget.AvatarView; import org.thunderdog.challegram.widget.EmojiTextView; import org.thunderdog.challegram.widget.TextView; import org.thunderdog.challegram.widget.voip.CallControlsLayout; +import org.webrtc.RendererCommon; +import org.webrtc.SurfaceViewRenderer; import me.vkryl.android.AnimatorUtils; import me.vkryl.android.ScrimUtil; @@ -82,7 +89,7 @@ import me.vkryl.core.MathUtils; import me.vkryl.core.StringUtils; -public class CallController extends ViewController implements TdlibCache.UserDataChangeListener, TdlibCache.CallStateChangeListener, View.OnClickListener, FactorAnimator.Target, Runnable, CallControlsLayout.CallControlCallback, Screen.StatusBarHeightChangeListener { +public class CallController extends ViewController implements TdlibCache.UserDataChangeListener, TdlibCache.CallStateChangeListener, View.OnClickListener, FactorAnimator.Target, Runnable, CallControlsLayout.CallControlCallback, Screen.StatusBarHeightChangeListener, TGCallService.VideoStateListener { private static final boolean DEBUG_FADE_BRANDING = true; private static class ButtonView extends View implements FactorAnimator.Target { @@ -212,6 +219,10 @@ public int getId () { } private AvatarView avatarView; + private FrameLayoutFix contentView; + private SurfaceViewRenderer remoteVideoView, localVideoView; + private FrameLayoutFix localVideoWrap; + private TextView remoteVideoStatusView; private TextView nameView, stateView; private EmojiStatusHelper emojiStatusHelper; private float nameTextWidth; @@ -272,7 +283,13 @@ protected void onDraw (Canvas c) { private CallControlsLayout callControlsLayout; private FrameLayoutFix buttonWrap; - private ButtonView muteButtonView, speakerButtonView; + private ButtonView muteButtonView, speakerButtonView, videoButtonView, switchCameraButtonView; + private @Nullable TGCallService boundVideoService; + private boolean videoSupported; + private boolean localVideoEnabled; + private boolean frontCamera = true; + private @VideoState int remoteVideoState = VideoState.INACTIVE; + private boolean inPictureInPicture; private float lastHeaderFactor; @@ -336,7 +353,7 @@ public void onStatusBarHeightChanged (int newHeight) { @Override protected View onCreateView (final Context context) { - final FrameLayoutFix contentView = new FrameLayoutFix(context) { + this.contentView = new FrameLayoutFix(context) { @Override protected void onMeasure (int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); @@ -349,6 +366,7 @@ protected void onLayout (boolean changed, int left, int top, int right, int bott updateEmojiPosition(); } }; + final FrameLayoutFix contentView = this.contentView; ViewSupport.setThemedBackground(contentView, ColorId.headerBackground, this); avatarView = new AvatarView(context) { @@ -394,6 +412,49 @@ protected void onDraw(Canvas c){ avatarView.setLayoutParams(FrameLayoutFix.newParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); contentView.addView(avatarView); + remoteVideoView = new SurfaceViewRenderer(context); + remoteVideoView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL); + remoteVideoView.setEnableHardwareScaler(true); + remoteVideoView.init(null, null); + remoteVideoView.setVisibility(View.GONE); + remoteVideoView.setLayoutParams(FrameLayoutFix.newParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + contentView.addView(remoteVideoView); + + remoteVideoStatusView = new TextView(context); + remoteVideoStatusView.setText(Lang.getString(R.string.RemoteCameraOff)); + remoteVideoStatusView.setTextColor(0xffffffff); + remoteVideoStatusView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14f); + remoteVideoStatusView.setGravity(Gravity.CENTER); + remoteVideoStatusView.setPadding(Screen.dp(16f), Screen.dp(9f), Screen.dp(16f), Screen.dp(9f)); + GradientDrawable remoteVideoStatusBackground = new GradientDrawable(); + remoteVideoStatusBackground.setColor(0x99000000); + remoteVideoStatusBackground.setCornerRadius(Screen.dp(20f)); + ViewUtils.setBackground(remoteVideoStatusView, remoteVideoStatusBackground); + remoteVideoStatusView.setVisibility(View.GONE); + remoteVideoStatusView.setLayoutParams(FrameLayoutFix.newParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER)); + contentView.addView(remoteVideoStatusView); + + localVideoWrap = new FrameLayoutFix(context); + GradientDrawable localVideoBackground = new GradientDrawable(); + localVideoBackground.setColor(0xff101715); + localVideoBackground.setCornerRadius(Screen.dp(14f)); + ViewUtils.setBackground(localVideoWrap, localVideoBackground); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + localVideoWrap.setClipToOutline(true); + localVideoWrap.setElevation(Screen.dp(8f)); + } + localVideoWrap.setVisibility(View.GONE); + + localVideoView = new SurfaceViewRenderer(context); + localVideoView.setZOrderMediaOverlay(true); + localVideoView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL); + localVideoView.setEnableHardwareScaler(true); + localVideoView.init(null, null); + localVideoView.setMirror(true); + localVideoView.setLayoutParams(FrameLayoutFix.newParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + localVideoWrap.addView(localVideoView); + contentView.addView(localVideoWrap); + FrameLayoutFix.LayoutParams params = FrameLayoutFix.newParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); // Top-left corner @@ -607,24 +668,47 @@ public boolean onTouchEvent (MotionEvent event) { muteButtonView.setOnClickListener(this); muteButtonView.setIcon(R.drawable.baseline_mic_24); muteButtonView.setNeedCross(true); - muteButtonView.setLayoutParams(FrameLayoutFix.newParams(Screen.dp(72f), Screen.dp(72f), Gravity.LEFT | Gravity.BOTTOM)); + muteButtonView.setLayoutParams(FrameLayoutFix.newParams(Screen.dp(64f), Screen.dp(72f), Gravity.LEFT | Gravity.BOTTOM)); + + FrameLayoutFix.LayoutParams videoButtonParams = FrameLayoutFix.newParams(Screen.dp(64f), Screen.dp(72f), Gravity.LEFT | Gravity.BOTTOM); + videoButtonParams.leftMargin = Screen.dp(64f); + videoButtonView = new ButtonView(context); + videoButtonView.setId(R.id.btn_call_video); + videoButtonView.setOnClickListener(this); + videoButtonView.setIcon(R.drawable.baseline_videocam_24); + videoButtonView.setNeedCross(true); + videoButtonView.setContentDescription(Lang.getString(R.string.TurnCameraOn)); + videoButtonView.setVisibility(View.GONE); + videoButtonView.setLayoutParams(videoButtonParams); ButtonView messageButtonView = new ButtonView(context); messageButtonView.setId(R.id.btn_openChat); messageButtonView.setOnClickListener(this); messageButtonView.setIcon(R.drawable.baseline_chat_bubble_24); - messageButtonView.setLayoutParams(FrameLayoutFix.newParams(Screen.dp(72f), Screen.dp(72f), Gravity.CENTER_HORIZONTAL | Gravity.BOTTOM)); + messageButtonView.setLayoutParams(FrameLayoutFix.newParams(Screen.dp(64f), Screen.dp(72f), Gravity.CENTER_HORIZONTAL | Gravity.BOTTOM)); + + FrameLayoutFix.LayoutParams switchCameraParams = FrameLayoutFix.newParams(Screen.dp(64f), Screen.dp(72f), Gravity.RIGHT | Gravity.BOTTOM); + switchCameraParams.rightMargin = Screen.dp(64f); + switchCameraButtonView = new ButtonView(context); + switchCameraButtonView.setId(R.id.btn_call_switch_camera); + switchCameraButtonView.setOnClickListener(this); + switchCameraButtonView.setIcon(R.drawable.baseline_camera_front_24); + switchCameraButtonView.setContentDescription(Lang.getString(R.string.SwitchCamera)); + switchCameraButtonView.setVisibility(View.GONE); + switchCameraButtonView.setLayoutParams(switchCameraParams); speakerButtonView = new ButtonView(context); speakerButtonView.setId(R.id.btn_speaker); speakerButtonView.setOnClickListener(this); speakerButtonView.setIcon(R.drawable.baseline_volume_up_24); - speakerButtonView.setLayoutParams(FrameLayoutFix.newParams(Screen.dp(72f), Screen.dp(72f), Gravity.RIGHT | Gravity.BOTTOM)); + speakerButtonView.setLayoutParams(FrameLayoutFix.newParams(Screen.dp(64f), Screen.dp(72f), Gravity.RIGHT | Gravity.BOTTOM)); buttonWrap = new FrameLayoutFix(context); buttonWrap.setLayoutParams(FrameLayoutFix.newParams(ViewGroup.LayoutParams.MATCH_PARENT, Screen.dp(76f), Gravity.BOTTOM)); buttonWrap.addView(muteButtonView); + buttonWrap.addView(videoButtonView); buttonWrap.addView(messageButtonView); + buttonWrap.addView(switchCameraButtonView); buttonWrap.addView(speakerButtonView); Views.setPaddingBottom(buttonWrap, extraBottomInset); Drawable drawable = ScrimUtil.makeCubicGradientScrimDrawable(0xff000000, 2, Gravity.BOTTOM, false); @@ -656,6 +740,8 @@ public boolean onTouchEvent (MotionEvent event) { speakerButtonView.setIsActive(callSettings.isSpeakerModeEnabled(), false); } + bindVideoService(); + return contentView; } @@ -678,6 +764,145 @@ public long mediaTextComplexColor () { this.emojiViewHint.setText(Lang.getString(R.string.CallEmojiHint, TD.getUserSingleName(call.userId, user))); } + private void bindVideoService () { + TGCallService service = TGCallService.currentInstance(); + if (service != null && !service.compareCall(tdlib, call.id)) { + service = null; + } + if (boundVideoService != service) { + if (boundVideoService != null) { + boundVideoService.setVideoStateListener(null); + boundVideoService.setVideoSinks(null, null); + } + boundVideoService = service; + if (service != null) { + service.setVideoStateListener(this); + } + } + if (service != null && localVideoView != null && remoteVideoView != null) { + service.setVideoSinks(localVideoView, remoteVideoView); + onVideoStateChanged(service.supportsVideo(), service.isLocalVideoEnabled(), service.getRemoteVideoState(), service.isFrontCamera()); + } else { + updateVideoUi(); + } + } + + @Override + public void onVideoStateChanged (boolean supported, boolean localVideoEnabled, @VideoState int remoteVideoState, boolean frontCamera) { + tdlib.ui().post(() -> { + if (isDestroyed()) { + return; + } + this.videoSupported = supported; + this.localVideoEnabled = localVideoEnabled; + this.remoteVideoState = remoteVideoState; + this.frontCamera = frontCamera; + updateVideoUi(); + }); + } + + private void updateVideoUi () { + if (remoteVideoView == null || localVideoWrap == null || videoButtonView == null) { + return; + } + boolean remoteVideoVisible = remoteVideoState != VideoState.INACTIVE; + boolean anyVideoVisible = remoteVideoVisible || localVideoEnabled; + + remoteVideoView.setVisibility(remoteVideoVisible ? View.VISIBLE : View.GONE); + remoteVideoStatusView.setVisibility(remoteVideoState == VideoState.PAUSED ? View.VISIBLE : View.GONE); + localVideoWrap.setVisibility(localVideoEnabled ? View.VISIBLE : View.GONE); + avatarView.setVisibility(anyVideoVisible ? View.GONE : View.VISIBLE); + localVideoView.setMirror(frontCamera); + + if (localVideoEnabled) { + FrameLayoutFix.LayoutParams localParams; + if (remoteVideoVisible) { + localParams = FrameLayoutFix.newParams(Screen.dp(112f), Screen.dp(168f), Gravity.RIGHT | Gravity.TOP); + localParams.topMargin = Math.max(Screen.getStatusBarHeight() + Screen.dp(12f), Screen.dp(36f)); + localParams.rightMargin = Screen.dp(12f); + } else { + localParams = FrameLayoutFix.newParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + } + localVideoWrap.setLayoutParams(localParams); + } + + boolean showVideoControls = videoSupported || call.isVideo; + videoButtonView.setVisibility(showVideoControls && !inPictureInPicture ? View.VISIBLE : View.GONE); + videoButtonView.setIsActive(!localVideoEnabled, isFocused()); + videoButtonView.setContentDescription(Lang.getString(localVideoEnabled ? R.string.TurnCameraOff : R.string.TurnCameraOn)); + switchCameraButtonView.setVisibility(localVideoEnabled && !inPictureInPicture ? View.VISIBLE : View.GONE); + applyPictureInPictureUi(); + } + + private void applyPictureInPictureUi () { + if (buttonWrap == null) { + return; + } + int visibility = inPictureInPicture ? View.GONE : View.VISIBLE; + buttonWrap.setVisibility(visibility); + callControlsLayout.setVisibility(visibility); + nameView.setVisibility(visibility); + stateView.setVisibility(visibility); + brandWrap.setVisibility(visibility); + emojiViewSmall.setVisibility(visibility); + emojiViewBig.setVisibility(visibility); + emojiViewHint.setVisibility(visibility); + if (!inPictureInPicture) { + updateControlsAlpha(); + } + } + + public boolean enterPictureInPictureIfPossible () { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return false; + } + bindVideoService(); + if (boundVideoService == null || !boundVideoService.hasActiveVideo() || context().isInPictureInPictureMode()) { + return false; + } + try { + PictureInPictureParams params = new PictureInPictureParams.Builder() + .setAspectRatio(new Rational(9, 16)) + .build(); + return context().enterPictureInPictureMode(params); + } catch (Throwable t) { + Log.w(Log.TAG_VOIP, "Unable to enter picture-in-picture mode", t); + return false; + } + } + + public void onPictureInPictureModeChanged (boolean inPictureInPicture) { + this.inPictureInPicture = inPictureInPicture; + if (boundVideoService != null) { + boundVideoService.setVideoPaused(false); + } + updateVideoUi(); + } + + private void setVideoEnabledWithPermission () { + bindVideoService(); + if (boundVideoService == null) { + return; + } + if (boundVideoService.isLocalVideoEnabled()) { + boundVideoService.setVideoEnabled(false); + return; + } + boolean requested = context().permissions().requestAccessCameraPermission(granted -> { + if (granted) { + bindVideoService(); + if (boundVideoService != null) { + boundVideoService.setVideoEnabled(true); + } + } else { + openMissingCameraPermissionAlert(); + } + }); + if (!requested) { + boundVideoService.setVideoEnabled(true); + } + } + @Override public void onCallAccept (TdApi.Call call) { tdlib.context().calls().acceptCall(context(), tdlib, call.id); @@ -690,7 +915,11 @@ public void onCallDecline (TdApi.Call call, boolean isHangUp) { @Override public void onCallRestart (TdApi.Call call) { - tdlib.context().calls().makeCall(this, call.userId, null); + if (call.isVideo) { + tdlib.context().calls().makeVideoCall(this, call.userId, null); + } else { + tdlib.context().calls().makeCall(this, call.userId, null); + } } public boolean compareUserId (long userId) { @@ -705,6 +934,10 @@ public void onCallClose (TdApi.Call call) { @Override public void onPrepareToShow () { super.onPrepareToShow(); + bindVideoService(); + if (boundVideoService != null) { + boundVideoService.setVideoPaused(false); + } if (!UI.isTablet()) { context().setOrientation(BaseActivity.getAndroidOrientationPortrait()); } @@ -713,6 +946,9 @@ public void onPrepareToShow () { @Override public void onCleanAfterHide () { super.onCleanAfterHide(); + if (boundVideoService != null && !inPictureInPicture) { + boundVideoService.setVideoPaused(true); + } if (!UI.isTablet()) { context().setOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); } @@ -738,6 +974,7 @@ private void setIsLooping (boolean isLooping) { @Override public void run () { if (!isDestroyed()) { + bindVideoService(); updateCallState(); if (isLooping) { UI.post(this, tdlib.context().calls().getTimeTillNextCallDurationUpdate(tdlib, call.id)); @@ -828,6 +1065,17 @@ public void onClick (View v) { } } else if (viewId == R.id.btn_openChat) { tdlib.ui().openPrivateChat(this, call.userId, null); + } else if (viewId == R.id.btn_call_video) { + if (!TD.isFinished(call)) { + setVideoEnabledWithPermission(); + } + } else if (viewId == R.id.btn_call_switch_camera) { + if (!TD.isFinished(call)) { + bindVideoService(); + if (boundVideoService != null) { + boundVideoService.switchCamera(); + } + } } else if (viewId == R.id.btn_speaker) { if (!TD.isFinished(call)) { if (callSettings == null) { @@ -949,6 +1197,7 @@ private void setFlashing (boolean isFlashing) { private long callDuration; private void updateCallState () { + bindVideoService(); updateLoop(); String str; callDuration = tdlib.context().calls().getCallDuration(tdlib, call.id); @@ -979,6 +1228,10 @@ private void updateFlashing () { @Override protected void onFocusStateChanged () { + bindVideoService(); + if (boundVideoService != null) { + boundVideoService.setVideoPaused(!isFocused() && !inPictureInPicture); + } updateFlashing(); } @@ -1141,6 +1394,10 @@ private void updateEmojiPosition () { @Override public void onFocus () { super.onFocus(); + bindVideoService(); + if (boundVideoService != null) { + boundVideoService.setVideoPaused(false); + } if (!oneShot) { destroyStackItemByIdExcludingLast(R.id.controller_call); ViewController c = previousStackItem(); @@ -1169,11 +1426,23 @@ public void replaceCall (TdApi.Call call) { updateCall(call); tdlib.cache().subscribeToCallUpdates(call.id, this); tdlib.context().calls().acknowledgeCurrentCall(call.id); + bindVideoService(); updateCallState(); } @Override public void destroy () { + if (boundVideoService != null) { + boundVideoService.setVideoStateListener(null); + boundVideoService.setVideoSinks(null, null); + boundVideoService = null; + } + if (localVideoView != null) { + localVideoView.release(); + } + if (remoteVideoView != null) { + remoteVideoView.release(); + } super.destroy(); Screen.removeStatusBarHeightListener(this); tdlib.cache().unsubscribeFromCallUpdates(call.id, this); diff --git a/app/src/main/java/org/thunderdog/challegram/ui/ProfileController.java b/app/src/main/java/org/thunderdog/challegram/ui/ProfileController.java index 2d18fa1d9c..756c87794c 100644 --- a/app/src/main/java/org/thunderdog/challegram/ui/ProfileController.java +++ b/app/src/main/java/org/thunderdog/challegram/ui/ProfileController.java @@ -486,7 +486,7 @@ public void onMenuItemPressed (int id, View view) { manageChat(); } else if (id == R.id.menu_btn_call) { if (userFull != null) { - tdlib.context().calls().makeCall(this, user.id, userFull); + tdlib.context().calls().makeCall(this, user.id, userFull, true); } /*case R.id.menu_btn_edit: { if (supergroupFull != null) { diff --git a/app/src/main/java/org/thunderdog/challegram/voip/CallConfiguration.java b/app/src/main/java/org/thunderdog/challegram/voip/CallConfiguration.java index a163ac108f..e7f301341a 100644 --- a/app/src/main/java/org/thunderdog/challegram/voip/CallConfiguration.java +++ b/app/src/main/java/org/thunderdog/challegram/voip/CallConfiguration.java @@ -25,6 +25,7 @@ public class CallConfiguration { public final TdApi.CallStateReady state; public final boolean isOutgoing; + public final boolean isVideo; public final @NonNull String persistentStateFilePath; public final @Nullable String logFilePath; @@ -48,6 +49,7 @@ public class CallConfiguration { public CallConfiguration ( @NonNull TdApi.CallStateReady state, boolean isOutgoing, + boolean isVideo, @NonNull File persistentStateFile, @Nullable File logFile, @Nullable File statsLogFile, @@ -78,6 +80,7 @@ public CallConfiguration ( this.state = state; } this.isOutgoing = isOutgoing; + this.isVideo = isVideo; this.persistentStateFilePath = persistentStateFile.getAbsolutePath(); this.logFilePath = logFile != null ? logFile.getAbsolutePath() : null; diff --git a/app/src/main/java/org/thunderdog/challegram/voip/TgCallsController.java b/app/src/main/java/org/thunderdog/challegram/voip/TgCallsController.java index f762a00d51..56af64a785 100644 --- a/app/src/main/java/org/thunderdog/challegram/voip/TgCallsController.java +++ b/app/src/main/java/org/thunderdog/challegram/voip/TgCallsController.java @@ -23,16 +23,20 @@ import org.thunderdog.challegram.voip.annotation.AudioState; import org.thunderdog.challegram.voip.annotation.CallNetworkType; import org.thunderdog.challegram.voip.annotation.VideoState; +import org.webrtc.VideoSink; @SuppressWarnings("JavaJniMissingFunction") public class TgCallsController extends VoIPInstance { private final String version; private long nativePtr; + private boolean videoEnabled; + private boolean frontCamera = true; public TgCallsController (@NonNull Tdlib tdlib, @NonNull TdApi.Call call, @NonNull CallConfiguration configuration, @NonNull CallOptions options, @NonNull ConnectionStateListener stateListener, String version) { super(tdlib, call, configuration, options, stateListener); if (configuration.state.encryptionKey.length != 256) throw new IllegalArgumentException(Integer.toString(configuration.state.encryptionKey.length)); this.version = version; + this.videoEnabled = configuration.isVideo; this.nativePtr = newInstance(version, configuration, options); } @@ -60,6 +64,12 @@ private native long newInstance ( private native void updateMicrophoneDisabled (long ptr, boolean isDisabled); private native void updateEchoCancellationStrength (long ptr, int strength); private native void updateAudioOutputGainControlEnabled (long ptr, boolean isEnabled); + private native boolean nativeSupportsVideo (long ptr); + private native void nativeSetVideoEnabled (long ptr, boolean enabled); + private native void nativeSetVideoPaused (long ptr, boolean paused); + private native void nativeSwitchCamera (long ptr); + private native void nativeSetLocalVideoOutput (long ptr, @Nullable VideoSink sink); + private native void nativeSetRemoteVideoOutput (long ptr, @Nullable VideoSink sink); private native void destroyInstance (long ptr); @Override @@ -97,6 +107,46 @@ protected void handleNetworkTypeChange (@CallNetworkType int type) { updateNetworkType(nativePtr(), type); } + @Override + public boolean supportsVideo () { + return nativePtr != 0 && nativeSupportsVideo(nativePtr()); + } + + @Override + public boolean isVideoEnabled () { + return videoEnabled; + } + + @Override + public boolean isFrontCamera () { + return frontCamera; + } + + @Override + public void setVideoEnabled (boolean enabled) { + if (videoEnabled != enabled) { + videoEnabled = enabled; + nativeSetVideoEnabled(nativePtr(), enabled); + } + } + + @Override + public void setVideoPaused (boolean paused) { + nativeSetVideoPaused(nativePtr(), paused); + } + + @Override + public void switchCamera () { + frontCamera = !frontCamera; + nativeSwitchCamera(nativePtr()); + } + + @Override + public void setVideoSinks (@Nullable VideoSink localSink, @Nullable VideoSink remoteSink) { + nativeSetLocalVideoOutput(nativePtr(), localSink); + nativeSetRemoteVideoOutput(nativePtr(), remoteSink); + } + @Override public long getConnectionId () { return preferredConnectionId(nativePtr()); diff --git a/app/src/main/java/org/thunderdog/challegram/voip/VoIP.java b/app/src/main/java/org/thunderdog/challegram/voip/VoIP.java index e84224274f..014e8cca1f 100644 --- a/app/src/main/java/org/thunderdog/challegram/voip/VoIP.java +++ b/app/src/main/java/org/thunderdog/challegram/voip/VoIP.java @@ -14,7 +14,9 @@ */ package org.thunderdog.challegram.voip; +import android.Manifest; import android.content.Context; +import android.content.pm.PackageManager; import android.media.AudioFormat; import android.media.AudioManager; import android.media.AudioTrack; @@ -382,9 +384,14 @@ public static VoIPInstance instantiateAndConnect ( final boolean preferSystemNoiseSuppressor = VoIPServerConfig.getBoolean("use_system_ns", true); // These do not change during the call + final boolean startWithVideo = call.isVideo && ( + Build.VERSION.SDK_INT < Build.VERSION_CODES.M || + ContextUtils.getApplicationContext().checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + ); final CallConfiguration configuration = new CallConfiguration( stateReady, call.isOutgoing, + startWithVideo, persistentStateFile, logFiles != null ? logFiles.logFile : null, diff --git a/app/src/main/java/org/thunderdog/challegram/voip/VoIPInstance.java b/app/src/main/java/org/thunderdog/challegram/voip/VoIPInstance.java index a7102ce937..42a1dae457 100644 --- a/app/src/main/java/org/thunderdog/challegram/voip/VoIPInstance.java +++ b/app/src/main/java/org/thunderdog/challegram/voip/VoIPInstance.java @@ -23,6 +23,7 @@ import org.thunderdog.challegram.telegram.Tdlib; import org.thunderdog.challegram.voip.annotation.CallNetworkType; import org.thunderdog.challegram.voip.annotation.CallState; +import org.webrtc.VideoSink; import me.vkryl.core.lambda.Destroyable; @@ -110,6 +111,26 @@ public void setNetworkType (@CallNetworkType int type) { protected abstract void handleNetworkTypeChange (@CallNetworkType int type); + public boolean supportsVideo () { + return false; + } + + public boolean isVideoEnabled () { + return false; + } + + public boolean isFrontCamera () { + return true; + } + + public void setVideoEnabled (boolean enabled) { } + + public void setVideoPaused (boolean paused) { } + + public void switchCamera () { } + + public void setVideoSinks (VideoSink localSink, VideoSink remoteSink) { } + // Getters public abstract CharSequence collectDebugLog (); diff --git a/app/src/main/res/values-ru/frogram_strings.xml b/app/src/main/res/values-ru/frogram_strings.xml index f41861f7f9..1fb24554a1 100644 --- a/app/src/main/res/values-ru/frogram_strings.xml +++ b/app/src/main/res/values-ru/frogram_strings.xml @@ -43,4 +43,11 @@ Открыть публикацию полностью Реакция по двойному нажатию Дважды нажмите на сообщение, чтобы поставить первую выбранную ниже реакцию. Если выбрана одна реакция, реакции по свайпу отключаются. В некоторых группах и каналах отдельные реакции могут быть недоступны. + Аудиозвонок + Видеозвонок + Видеозвонки недоступны для этого пользователя или устройства + Включить камеру + Выключить камеру + Переключить камеру + Камера выключена diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 193e449468..9787fd7a2e 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -1191,6 +1191,7 @@ + @@ -1361,6 +1362,8 @@ --> + + diff --git a/app/src/main/res/values/local_strings.xml b/app/src/main/res/values/local_strings.xml index a61dd082e0..02c68544a0 100644 --- a/app/src/main/res/values/local_strings.xml +++ b/app/src/main/res/values/local_strings.xml @@ -11,4 +11,12 @@ False-positive emulator detection warning on %1$s \`%2$s\` Hello. I use **Frogram X** on a real device, but it says I am running on an emulator.\n\n**My real device details:**\n\nName: %1$s \`%2$s\`\nProduct: \`%3$s\`\nDevice: \`%4$s\`\nHardware: \`%5$s\`\n\n**The app version I am using:**\n\n%6$s\n\n**Emulator detection result:** `%7$s`\n\nPlease take a look if this warning can be resolved for my device.\nThank you! - \ No newline at end of file + + Audio call + Video call + Video calls aren\'t supported by this user or device + Turn camera on + Turn camera off + Switch camera + Camera is off + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 63c65d129e..412b1b05fa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1801,7 +1801,7 @@ Call answered Line Busy Disconnected - Telegram Call + Frogram X Call Incoming Telegram Call On Mobile Network While Roaming @@ -2643,7 +2643,7 @@ The verification code has been resent to your email. We\'ve sent you a 6-digit recovery code. Please check your email and enter it here. Having trouble accessing your email? - Telegram Call + Frogram X Call Phone Call Set as current Archive sticker set %1$s? You can restore it later in Settings > Stickers > Archived.