From e67d5b9a37ad264921d6b5331983b4504056f8cc Mon Sep 17 00:00:00 2001 From: AbdulKus <73951988+AbdulKus@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:17:21 +0300 Subject: [PATCH 1/5] Fix movable video preview and PiP sizing --- .../challegram/ui/CallController.java | 150 +++++++++++++++--- 1 file changed, 127 insertions(+), 23 deletions(-) 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 9855412121..39f595eaf3 100644 --- a/app/src/main/java/org/thunderdog/challegram/ui/CallController.java +++ b/app/src/main/java/org/thunderdog/challegram/ui/CallController.java @@ -292,6 +292,13 @@ protected void onDraw (Canvas c) { private @VideoState int remoteVideoState = VideoState.INACTIVE; private boolean inPictureInPicture; private boolean localPreviewVisible; + private float localPreviewTouchX, localPreviewTouchY; + private float localPreviewStartTranslationX, localPreviewStartTranslationY; + private boolean localPreviewDragging; + + private static final float LOCAL_PREVIEW_WIDTH_DP = 108f; + private static final float LOCAL_PREVIEW_HEIGHT_DP = 162f; + private static final float LOCAL_PREVIEW_EDGE_MARGIN_DP = 12f; private float lastHeaderFactor; @@ -353,6 +360,9 @@ protected void onBottomInsetChanged (int extraBottomInset, int extraBottomInsetW if (callControlsLayout != null) { Views.setPaddingBottom(callControlsLayout, extraBottomInset); } + if (localVideoWrap != null && localPreviewVisible) { + localVideoWrap.post(this::clampLocalPreviewPosition); + } } @Override @@ -376,6 +386,7 @@ protected void onMeasure (int widthMeasureSpec, int heightMeasureSpec) { protected void onLayout (boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); updateEmojiPosition(); + clampLocalPreviewPosition(); } }; final FrameLayoutFix contentView = this.contentView; @@ -465,6 +476,9 @@ protected void onDraw(Canvas c){ localVideoView.setMirror(true); localVideoView.setLayoutParams(FrameLayoutFix.newParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); localVideoWrap.addView(localVideoView); + localVideoWrap.setClickable(true); + localVideoWrap.setOnTouchListener(this::onLocalPreviewTouch); + localVideoView.setOnTouchListener(this::onLocalPreviewTouch); contentView.addView(localVideoWrap); FrameLayoutFix.LayoutParams params = FrameLayoutFix.newParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); @@ -736,6 +750,7 @@ public boolean onTouchEvent (MotionEvent event) { callControlsLayout.setLayoutParams(FrameLayoutFix.newParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); contentView.addView(callControlsLayout); callControlsLayout.setCall(tdlib, call, false); + localVideoWrap.bringToFront(); // Data @@ -819,23 +834,21 @@ private void updateVideoUi () { } boolean remoteVideoVisible = remoteVideoState != VideoState.INACTIVE; boolean showLocalPreview = localVideoEnabled && !inPictureInPicture; - boolean anyVideoVisible = remoteVideoVisible || showLocalPreview; remoteVideoView.setVisibility(remoteVideoVisible ? View.VISIBLE : View.GONE); remoteVideoStatusView.setVisibility(remoteVideoState == VideoState.PAUSED ? View.VISIBLE : View.GONE); setLocalPreviewVisible(showLocalPreview); - avatarView.setVisibility(anyVideoVisible ? View.GONE : View.VISIBLE); + avatarView.setVisibility(remoteVideoVisible ? 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); - } + FrameLayoutFix.LayoutParams localParams = FrameLayoutFix.newParams( + Screen.dp(LOCAL_PREVIEW_WIDTH_DP), + Screen.dp(LOCAL_PREVIEW_HEIGHT_DP), + Gravity.RIGHT | Gravity.BOTTOM + ); + localParams.rightMargin = Screen.dp(LOCAL_PREVIEW_EDGE_MARGIN_DP); + localParams.bottomMargin = Screen.dp(88f) + extraBottomInset; localVideoWrap.setLayoutParams(localParams); } @@ -847,6 +860,101 @@ private void updateVideoUi () { applyPictureInPictureUi(); } + private boolean onLocalPreviewTouch (View view, MotionEvent event) { + if (inPictureInPicture || !localVideoEnabled) { + return false; + } + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: { + localPreviewTouchX = event.getRawX(); + localPreviewTouchY = event.getRawY(); + localPreviewStartTranslationX = localVideoWrap.getTranslationX(); + localPreviewStartTranslationY = localVideoWrap.getTranslationY(); + localPreviewDragging = false; + localVideoWrap.animate().cancel(); + localVideoWrap.setAlpha(1f); + localVideoWrap.setScaleX(1f); + localVideoWrap.setScaleY(1f); + return true; + } + case MotionEvent.ACTION_MOVE: { + float deltaX = event.getRawX() - localPreviewTouchX; + float deltaY = event.getRawY() - localPreviewTouchY; + if (!localPreviewDragging && Math.hypot(deltaX, deltaY) >= Screen.dp(4f)) { + localPreviewDragging = true; + } + if (localPreviewDragging) { + setLocalPreviewTranslation(localPreviewStartTranslationX + deltaX, localPreviewStartTranslationY + deltaY); + } + return true; + } + case MotionEvent.ACTION_UP: { + if (localPreviewDragging) { + snapLocalPreviewToEdge(); + } else { + view.performClick(); + } + localPreviewDragging = false; + return true; + } + case MotionEvent.ACTION_CANCEL: { + snapLocalPreviewToEdge(); + localPreviewDragging = false; + return true; + } + } + return false; + } + + private void setLocalPreviewTranslation (float translationX, float translationY) { + if (contentView == null || localVideoWrap == null || contentView.getWidth() == 0 || contentView.getHeight() == 0) { + return; + } + int edgeMargin = Screen.dp(LOCAL_PREVIEW_EDGE_MARGIN_DP); + int topMargin = Math.max(Screen.getStatusBarHeight() + edgeMargin, edgeMargin); + int bottomMargin = extraBottomInset + edgeMargin; + float minX = edgeMargin - localVideoWrap.getLeft(); + float maxX = contentView.getWidth() - edgeMargin - localVideoWrap.getRight(); + float minY = topMargin - localVideoWrap.getTop(); + float maxY = contentView.getHeight() - bottomMargin - localVideoWrap.getBottom(); + localVideoWrap.setTranslationX(clampPreviewTranslation(translationX, minX, maxX)); + localVideoWrap.setTranslationY(clampPreviewTranslation(translationY, minY, maxY)); + } + + private static float clampPreviewTranslation (float value, float min, float max) { + if (max < min) { + return (min + max) * .5f; + } + return Math.max(min, Math.min(max, value)); + } + + private void clampLocalPreviewPosition () { + if (localVideoWrap == null || !localPreviewVisible) { + return; + } + setLocalPreviewTranslation(localVideoWrap.getTranslationX(), localVideoWrap.getTranslationY()); + } + + private void snapLocalPreviewToEdge () { + if (contentView == null || localVideoWrap == null || contentView.getWidth() == 0) { + return; + } + int edgeMargin = Screen.dp(LOCAL_PREVIEW_EDGE_MARGIN_DP); + float minX = edgeMargin - localVideoWrap.getLeft(); + float maxX = contentView.getWidth() - edgeMargin - localVideoWrap.getRight(); + float targetX = Math.abs(localVideoWrap.getTranslationX() - minX) <= Math.abs(localVideoWrap.getTranslationX() - maxX) ? minX : maxX; + setLocalPreviewTranslation(localVideoWrap.getTranslationX(), localVideoWrap.getTranslationY()); + localVideoWrap.animate().cancel(); + localVideoWrap.setAlpha(1f); + localVideoWrap.setScaleX(1f); + localVideoWrap.setScaleY(1f); + localVideoWrap.animate() + .translationX(targetX) + .setDuration(180l) + .setInterpolator(AnimatorUtils.DECELERATE_INTERPOLATOR) + .start(); + } + private void setLocalPreviewVisible (boolean visible) { if (localPreviewVisible == visible) { return; @@ -917,7 +1025,7 @@ public boolean enterPictureInPictureIfPossible () { } try { PictureInPictureParams params = new PictureInPictureParams.Builder() - .setAspectRatio(new Rational(9, 16)) + .setAspectRatio(new Rational(3, 4)) .build(); return context().enterPictureInPictureMode(params); } catch (Throwable t) { @@ -1129,20 +1237,16 @@ public void onClick (View v) { if (!TD.isFinished(call)) { switchCameraButtonView.animate().cancel(); switchCameraButtonView.animate() - .rotationBy(180f) - .setDuration(260l) - .setInterpolator(AnimatorUtils.DECELERATE_INTERPOLATOR) - .start(); - localVideoWrap.animate().cancel(); - localVideoWrap.animate() - .scaleX(.97f) - .scaleY(.97f) - .setDuration(120l) - .withEndAction(() -> localVideoWrap.animate() + .alpha(.65f) + .scaleX(.82f) + .scaleY(.82f) + .setDuration(80l) + .withEndAction(() -> switchCameraButtonView.animate() + .alpha(1f) .scaleX(1f) .scaleY(1f) - .setDuration(160l) - .setInterpolator(AnimatorUtils.DECELERATE_INTERPOLATOR) + .setDuration(140l) + .setInterpolator(new OvershootInterpolator(1.4f)) .start()) .start(); bindVideoService(); From d1edc95d38a087df8c3185dd74bf89e9d58d75f3 Mon Sep 17 00:00:00 2001 From: AbdulKus <73951988+AbdulKus@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:36:39 +0300 Subject: [PATCH 2/5] Improve incoming video calls and stabilize camera startup --- app/jni/tgvoip/tgvoip.cpp | 13 ++--- .../challegram/service/TGCallService.java | 30 +++++++--- .../challegram/telegram/CallManager.java | 35 ++++++++++-- .../challegram/ui/CallController.java | 21 ++++--- .../challegram/voip/TgCallsController.java | 14 ++++- .../org/thunderdog/challegram/voip/VoIP.java | 5 +- .../widget/voip/CallControlsLayout.java | 55 +++++++++++++++++-- .../main/res/values-ru/frogram_strings.xml | 3 + app/src/main/res/values/local_strings.xml | 3 + 9 files changed, 142 insertions(+), 37 deletions(-) diff --git a/app/jni/tgvoip/tgvoip.cpp b/app/jni/tgvoip/tgvoip.cpp index 62513aa59d..240d0607ba 100644 --- a/app/jni/tgvoip/tgvoip.cpp +++ b/app/jni/tgvoip/tgvoip.cpp @@ -470,7 +470,6 @@ 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 @@ -633,7 +632,9 @@ JNI_OBJECT_FUNC(jlong, voip_TgCallsController, newInstance, std::move(encryptionKey), isOutgoingCall ), - .videoCapture = isVideoCall ? videoCapture : nullptr, + // Attach the camera only after Java has installed the EGL-backed sinks. + // Starting capture inside Meta::Create raced the first direct video call. + .videoCapture = nullptr, .stateUpdated = [javaController](tgcalls::State state) { javaController->runSafely([javaController, state](JNIEnv *env) { jint javaState = toJavaCallState(env, state); @@ -689,7 +690,7 @@ JNI_OBJECT_FUNC(jlong, voip_TgCallsController, newInstance, auto *context = new TgCallsContext; context->javaController = javaController; context->videoCapture = videoCapture; - context->videoEnabled = isVideoCall; + context->videoEnabled = false; context->tgcalls = tgcalls::Meta::Create(version, std::move(descriptor)); if (context->tgcalls == nullptr) { delete context; @@ -699,10 +700,6 @@ JNI_OBJECT_FUNC(jlong, voip_TgCallsController, newInstance, context->tgcalls->setAudioOutputGainControlEnabled(audioOutputGainControlEnabled); context->tgcalls->setEchoCancellationStrength(echoCancellationStrength); context->tgcalls->setMuteMicrophone(muteMicrophone); - if (isVideoCall && context->videoCapture != nullptr) { - context->videoCapture->setState(tgcalls::VideoState::Active); - } - return jni::ptr_to_jlong(context); } @@ -797,8 +794,8 @@ JNI_OBJECT_FUNC(void, voip_TgCallsController, nativeSetVideoEnabled, jlong ptr, } context->videoEnabled = enabled; if (enabled) { - context->videoCapture->setState(tgcalls::VideoState::Active); context->tgcalls->setVideoCapture(context->videoCapture); + context->videoCapture->setState(tgcalls::VideoState::Active); } else { context->tgcalls->setVideoCapture(nullptr); context->videoCapture->setState(tgcalls::VideoState::Inactive); 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 ca6377d6ff..39afbc4f74 100644 --- a/app/src/main/java/org/thunderdog/challegram/service/TGCallService.java +++ b/app/src/main/java/org/thunderdog/challegram/service/TGCallService.java @@ -105,6 +105,8 @@ public interface VideoStateListener { void onVideoStateChanged (boolean supported, boolean localVideoEnabled, @VideoState int remoteVideoState, boolean frontCamera); } + private static final String EXTRA_ANSWER_WITH_VIDEO = "answer_with_video"; + @Override public IBinder onBind (Intent intent) { return null; @@ -243,7 +245,7 @@ public void onReceive (Context context, Intent intent) { } if (Intents.ACTION_ANSWER_CALL.equals(action)) { - acceptIncomingCall(); + acceptIncomingCall(intent.getBooleanExtra(EXTRA_ANSWER_WITH_VIDEO, true)); return; } } @@ -724,9 +726,9 @@ public void onCallSettingsChanged (int callId, CallSettings settings) { // Implementation - private void acceptIncomingCall () { + private void acceptIncomingCall (boolean withVideo) { if (call != null) { - tdlib.context().calls().acceptCall(this, tdlib, call.id); + tdlib.context().calls().acceptCall(this, tdlib, call.id, withVideo); if (UI.getUiState() != UI.State.RESUMED) { bringCallToFront(); } @@ -1016,7 +1018,7 @@ private boolean showIncomingNotification () { } builder - .setContentTitle(Lang.getString(R.string.CallBrandingIncoming)) + .setContentTitle(Lang.getString(call != null && call.isVideo ? R.string.IncomingVideoCall : R.string.CallBrandingIncoming)) .setContentText(TD.getUserName(user)) .setSmallIcon(CALL_ICON_RES) .setContentIntent(PendingIntent.getActivity(UI.getContext(), 0, Intents.valueOfCall(), PendingIntent.FLAG_ONE_SHOT | Intents.mutabilityFlags(false))); @@ -1039,12 +1041,25 @@ private boolean showIncomingNotification () { Intent answerIntent = new Intent(); Intents.secureIntent(answerIntent, false); answerIntent.setAction(Intents.ACTION_ANSWER_CALL); - CharSequence answerTitle = Lang.getString(R.string.AnswerCall); + answerIntent.putExtra(EXTRA_ANSWER_WITH_VIDEO, true); + CharSequence answerTitle = Lang.getString(call != null && call.isVideo ? R.string.AnswerWithVideo : R.string.AnswerCall); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { answerTitle = new SpannableString(answerTitle); ((SpannableString) answerTitle).setSpan(new ForegroundColorSpan(Theme.getColor(ColorId.circleButtonPositive)), 0, answerTitle.length(), 0); } - builder.addAction(R.drawable.round_call_24_white, answerTitle, PendingIntent.getBroadcast(this, 0, answerIntent, PendingIntent.FLAG_ONE_SHOT | Intents.mutabilityFlags(false))); + if (call != null && call.isVideo) { + Intent audioAnswerIntent = new Intent(); + Intents.secureIntent(audioAnswerIntent, false); + audioAnswerIntent.setAction(Intents.ACTION_ANSWER_CALL); + audioAnswerIntent.putExtra(EXTRA_ANSWER_WITH_VIDEO, false); + CharSequence audioAnswerTitle = Lang.getString(R.string.AnswerWithoutVideo); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + audioAnswerTitle = new SpannableString(audioAnswerTitle); + ((SpannableString) audioAnswerTitle).setSpan(new ForegroundColorSpan(Theme.getColor(ColorId.circleButtonPositive)), 0, audioAnswerTitle.length(), 0); + } + builder.addAction(R.drawable.round_call_24_white, audioAnswerTitle, PendingIntent.getBroadcast(this, 1, audioAnswerIntent, PendingIntent.FLAG_ONE_SHOT | Intents.mutabilityFlags(false))); + } + builder.addAction(R.drawable.round_call_24_white, answerTitle, PendingIntent.getBroadcast(this, 2, answerIntent, PendingIntent.FLAG_ONE_SHOT | Intents.mutabilityFlags(false))); builder.setPriority(Notification.PRIORITY_MAX); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { @@ -1509,7 +1524,8 @@ public void onRemoteMediaStateChanged (VoIPInstance context, @AudioState int aud lastNetworkType, audioGainControlEnabled, echoCancellationStrength, - isMicDisabled + isMicDisabled, + tdlib.context().calls().shouldStartVideo(tdlib, call.id) ); } catch (Throwable t) { tgcallsTemp = null; 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 bada4f232b..fbd0c2c78d 100644 --- a/app/src/main/java/org/thunderdog/challegram/telegram/CallManager.java +++ b/app/src/main/java/org/thunderdog/challegram/telegram/CallManager.java @@ -67,6 +67,8 @@ public interface CurrentCallListener { private Tdlib currentCallTdlib; private @Nullable TdApi.Call currentCall; private boolean currentCallAcknowledged; + private int answerWithoutVideoAccountId = TdlibAccount.NO_ID; + private int answerWithoutVideoCallId; public void addCurrentCallListener (CurrentCallListener listener) { listeners.add(listener); @@ -83,6 +85,10 @@ private void setCurrentCall (final Tdlib tdlib, @Nullable final TdApi.Call call) return; } if (currentCall == null || call == null) { + if (call == null) { + answerWithoutVideoAccountId = TdlibAccount.NO_ID; + answerWithoutVideoCallId = 0; + } this.currentCallTdlib = tdlib; this.currentCall = call; this.currentCallAcknowledged = call == null || UI.getUiState() != UI.State.RESUMED || UI.isNavigationBusyWithSomething(); @@ -526,31 +532,48 @@ private boolean checkConnection (final Context context, final Tdlib tdlib) { } public void acceptCall (Context context, Tdlib tdlib, final int callId) { + acceptCall(context, tdlib, callId, true); + } + + public void acceptCall (Context context, Tdlib tdlib, final int callId, final boolean withVideo) { if (checkConnection(context, tdlib)) { TdApi.Call pendingCall = tdlib.cache().getCall(callId); if (!checkRecordPermissions(context, tdlib, pendingCall, 0, null)) { return; } BaseActivity activity = UI.getUiContext(); - if (pendingCall != null && pendingCall.isVideo && activity != null && + if (withVideo && pendingCall != null && pendingCall.isVideo && activity != null && activity.permissions().requestAccessCameraPermission(granted -> { if (granted) { - acceptCall(context, tdlib, callId); + acceptCall(context, tdlib, callId, true); } else { - sendAcceptCall(tdlib, callId); + acceptCall(context, tdlib, callId, false); } })) { return; } - sendAcceptCall(tdlib, callId); + sendAcceptCall(tdlib, callId, withVideo); } } - private void sendAcceptCall (Tdlib tdlib, int callId) { - Log.v(Log.TAG_VOIP, "#%d: AcceptCall requested", callId); + private void sendAcceptCall (Tdlib tdlib, int callId, boolean withVideo) { + if (withVideo) { + if (answerWithoutVideoAccountId == tdlib.id() && answerWithoutVideoCallId == callId) { + answerWithoutVideoAccountId = TdlibAccount.NO_ID; + answerWithoutVideoCallId = 0; + } + } else { + answerWithoutVideoAccountId = tdlib.id(); + answerWithoutVideoCallId = callId; + } + Log.v(Log.TAG_VOIP, "#%d: AcceptCall requested, withVideo:%b", callId, withVideo); tdlib.client().send(new TdApi.AcceptCall(callId, VoIP.getProtocol()), object -> Log.v(Log.TAG_VOIP, "#%d: AcceptCall completed: %s", callId, object)); } + public boolean shouldStartVideo (Tdlib tdlib, int callId) { + return answerWithoutVideoAccountId != tdlib.id() || answerWithoutVideoCallId != callId; + } + public void hangUpCurrentCall () { int currentCall = getCurrentCallId(); if (currentCall != 0) { 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 39f595eaf3..45a6d10e9b 100644 --- a/app/src/main/java/org/thunderdog/challegram/ui/CallController.java +++ b/app/src/main/java/org/thunderdog/challegram/ui/CallController.java @@ -1010,6 +1010,7 @@ private void applyPictureInPictureUi () { emojiViewSmall.setVisibility(visibility); emojiViewBig.setVisibility(visibility); emojiViewHint.setVisibility(visibility); + remoteVideoView.setScalingType(inPictureInPicture ? RendererCommon.ScalingType.SCALE_ASPECT_FIT : RendererCommon.ScalingType.SCALE_ASPECT_FILL); if (!inPictureInPicture) { updateControlsAlpha(); } @@ -1025,7 +1026,7 @@ public boolean enterPictureInPictureIfPossible () { } try { PictureInPictureParams params = new PictureInPictureParams.Builder() - .setAspectRatio(new Rational(3, 4)) + .setAspectRatio(new Rational(16, 9)) .build(); return context().enterPictureInPictureMode(params); } catch (Throwable t) { @@ -1068,8 +1069,8 @@ private void setVideoEnabledWithPermission () { } @Override - public void onCallAccept (TdApi.Call call) { - tdlib.context().calls().acceptCall(context(), tdlib, call.id); + public void onCallAccept (TdApi.Call call, boolean withVideo) { + tdlib.context().calls().acceptCall(context(), tdlib, call.id, withVideo); } @Override @@ -1379,15 +1380,17 @@ private void updateCallState () { updateLoop(); String str; callDuration = tdlib.context().calls().getCallDuration(tdlib, call.id); - if (previousCallState != null && call.state.getConstructor() == TdApi.CallStateHangingUp.CONSTRUCTOR) { + if (!call.isOutgoing && call.isVideo && call.state.getConstructor() == TdApi.CallStatePending.CONSTRUCTOR) { + str = Lang.getString(R.string.IncomingVideoCall); + } else if (previousCallState != null && call.state.getConstructor() == TdApi.CallStateHangingUp.CONSTRUCTOR) { str = TD.getCallState2(call, previousCallState, callDuration, false); } else { str = TD.getCallState(call, callDuration, false); - if (!call.isOutgoing && call.state.getConstructor() == TdApi.CallStatePending.CONSTRUCTOR && tdlib.context().isMultiUser()) { - String longName = tdlib.accountLongName(); - if (longName != null) { - str = str + "\n" + Lang.getString(R.string.VoipAnsweringAsAccount, longName); - } + } + if (!call.isOutgoing && call.state.getConstructor() == TdApi.CallStatePending.CONSTRUCTOR && tdlib.context().isMultiUser()) { + String longName = tdlib.accountLongName(); + if (longName != null) { + str = str + "\n" + Lang.getString(R.string.VoipAnsweringAsAccount, longName); } } stateView.setText(str.toUpperCase()); 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 56af64a785..b9e0c91771 100644 --- a/app/src/main/java/org/thunderdog/challegram/voip/TgCallsController.java +++ b/app/src/main/java/org/thunderdog/challegram/voip/TgCallsController.java @@ -30,14 +30,19 @@ public class TgCallsController extends VoIPInstance { private final String version; private long nativePtr; private boolean videoEnabled; + private boolean startVideoWhenSinksReady; 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.videoEnabled = false; + this.startVideoWhenSinksReady = configuration.isVideo; this.nativePtr = newInstance(version, configuration, options); + if (nativePtr == 0) { + throw new IllegalStateException("Unable to initialize tgcalls " + version); + } } private long nativePtr () { @@ -124,6 +129,9 @@ public boolean isFrontCamera () { @Override public void setVideoEnabled (boolean enabled) { + if (!enabled) { + startVideoWhenSinksReady = false; + } if (videoEnabled != enabled) { videoEnabled = enabled; nativeSetVideoEnabled(nativePtr(), enabled); @@ -145,6 +153,10 @@ public void switchCamera () { public void setVideoSinks (@Nullable VideoSink localSink, @Nullable VideoSink remoteSink) { nativeSetLocalVideoOutput(nativePtr(), localSink); nativeSetRemoteVideoOutput(nativePtr(), remoteSink); + if (startVideoWhenSinksReady && localSink != null) { + startVideoWhenSinksReady = false; + setVideoEnabled(true); + } } @Override 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 014e8cca1f..86fd88aa63 100644 --- a/app/src/main/java/org/thunderdog/challegram/voip/VoIP.java +++ b/app/src/main/java/org/thunderdog/challegram/voip/VoIP.java @@ -370,7 +370,8 @@ public static VoIPInstance instantiateAndConnect ( @CallNetworkType int networkType, boolean audioGainControlEnabled, int echoCancellationStrength, - boolean isMicDisabled + boolean isMicDisabled, + boolean allowInitialVideo ) throws IllegalArgumentException { final String libtgvoipVersion = VoIPController.getVersion(); final String[] tgCallsVersions = N.getTgCallsVersions(); @@ -384,7 +385,7 @@ 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 && ( + final boolean startWithVideo = allowInitialVideo && call.isVideo && ( Build.VERSION.SDK_INT < Build.VERSION_CODES.M || ContextUtils.getApplicationContext().checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED ); diff --git a/app/src/main/java/org/thunderdog/challegram/widget/voip/CallControlsLayout.java b/app/src/main/java/org/thunderdog/challegram/widget/voip/CallControlsLayout.java index 1c3199b136..70f4100911 100644 --- a/app/src/main/java/org/thunderdog/challegram/widget/voip/CallControlsLayout.java +++ b/app/src/main/java/org/thunderdog/challegram/widget/voip/CallControlsLayout.java @@ -27,6 +27,7 @@ import org.drinkless.tdlib.TdApi; import org.thunderdog.challegram.R; import org.thunderdog.challegram.component.preview.FlingDetector; +import org.thunderdog.challegram.core.Lang; import org.thunderdog.challegram.telegram.Tdlib; import org.thunderdog.challegram.theme.ColorId; import org.thunderdog.challegram.tool.Screen; @@ -44,9 +45,11 @@ public class CallControlsLayout extends FrameLayoutFix implements View.OnClickLi private static final float CALL_BUTTON_SIZE = 70.5f; private static final float CALL_BUTTON_MARGIN = 24f; private static final float CALL_BUTTON_BOTTOM_MARGIN = 70.5f; + private static final float AUDIO_ANSWER_BUTTON_SIZE = 58f; + private static final float AUDIO_ANSWER_BUTTON_BOTTOM_MARGIN = CALL_BUTTON_BOTTOM_MARGIN + CALL_BUTTON_SIZE + 18f; public interface CallControlCallback { - void onCallAccept (TdApi.Call call); + void onCallAccept (TdApi.Call call, boolean withVideo); void onCallDecline (TdApi.Call call, boolean isHangUp); void onCallRestart (TdApi.Call call); void onCallClose (TdApi.Call call); @@ -75,6 +78,7 @@ public interface CallControlCallback { private SlideHintView slideHintView; private CircleButton acceptButton; + private CircleButton audioAcceptButton; private CircleButton declineButton; private CircleButton closeButton; private FlingDetector flingDetector; @@ -139,6 +143,22 @@ public CallControlsLayout (@NonNull Context context, CallController parent) { declineButton.setLayoutParams(params); declineButton.setOnClickListener(this); addView(declineButton); + + params = FrameLayoutFix.newParams( + Screen.dp(AUDIO_ANSWER_BUTTON_SIZE) + buttonPadding * 2, + Screen.dp(AUDIO_ANSWER_BUTTON_SIZE) + buttonPadding * 2, + Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL + ); + params.bottomMargin = Screen.dp(AUDIO_ANSWER_BUTTON_BOTTOM_MARGIN); + + audioAcceptButton = new CircleButton(context); + audioAcceptButton.init(R.drawable.baseline_call_24, AUDIO_ANSWER_BUTTON_SIZE, CALL_BUTTON_PADDING, ColorId.circleButtonPositive, ColorId.circleButtonPositiveIcon); + audioAcceptButton.setBottomText(Lang.getString(R.string.AnswerWithoutVideo)); + audioAcceptButton.setContentDescription(Lang.getString(R.string.AnswerWithoutVideo)); + audioAcceptButton.setLayoutParams(params); + audioAcceptButton.setIsHidden(true, false); + audioAcceptButton.setOnClickListener(this); + addView(audioAcceptButton); } public void setCallback (@Nullable CallControlCallback callback) { @@ -162,6 +182,11 @@ public void setCall (final Tdlib tdlib, final TdApi.Call call, final boolean ani final boolean acceptVisible; final boolean declineVisible; final boolean closeVisible; + final boolean incomingVideoCall = isIncomingVideoCall(); + + acceptButton.setIcon(incomingVideoCall ? R.drawable.baseline_videocam_24 : R.drawable.baseline_phone_36); + acceptButton.setBottomText(incomingVideoCall ? Lang.getString(R.string.AnswerWithVideo) : null); + acceptButton.setContentDescription(Lang.getString(incomingVideoCall ? R.string.AnswerWithVideo : R.string.AnswerCall)); switch (call.state.getConstructor()) { case TdApi.CallStatePending.CONSTRUCTOR: { @@ -245,6 +270,7 @@ public void setCall (final Tdlib tdlib, final TdApi.Call call, final boolean ani declineButton.setIsHidden(!declineVisible, animated); acceptButton.setIsHidden(!acceptVisible, animated); + audioAcceptButton.setIsHidden(!incomingVideoCall, animated); setCloseVisible(closeVisible, animated); @@ -259,6 +285,10 @@ public void onClick (View v) { if (callback == null || call == null) { return; } + if (v == audioAcceptButton) { + callback.onCallAccept(call, false); + return; + } final int viewId = v.getId(); if (viewId == R.id.btn_acceptOrHangCall) { switch (call.state.getConstructor()) { @@ -270,7 +300,7 @@ public void onClick (View v) { if (call.isOutgoing) { callback.onCallDecline(call, false); } else { - callback.onCallAccept(call); + callback.onCallAccept(call, true); } break; default: @@ -308,6 +338,17 @@ public void onFactorChangeFinished (int id, float finalFactor, FactorAnimator ca @Override public boolean onInterceptTouchEvent (MotionEvent ev) { + if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) { + audioAnswerTouch = isIncomingVideoCall() && + ev.getX() >= audioAcceptButton.getLeft() && ev.getX() <= audioAcceptButton.getRight() && + ev.getY() >= audioAcceptButton.getTop() && ev.getY() <= audioAcceptButton.getBottom(); + } + if (audioAnswerTouch) { + if (ev.getActionMasked() == MotionEvent.ACTION_UP || ev.getActionMasked() == MotionEvent.ACTION_CANCEL) { + audioAnswerTouch = false; + } + return false; + } return inSlideMode; } @@ -323,6 +364,11 @@ protected void onMeasure (int widthMeasureSpec, int heightMeasureSpec) { private float startX; private int slideMode; + private boolean audioAnswerTouch; + + private boolean isIncomingVideoCall () { + return call != null && call.isVideo && !call.isOutgoing && call.state.getConstructor() == TdApi.CallStatePending.CONSTRUCTOR; + } private void setSlideMode (int mode, boolean animated) { setSlideMode(mode, false, SLIDE_MODE_NONE, animated); @@ -335,6 +381,7 @@ private void setSlideMode (int mode, boolean allowApply, int forceSlideMode, boo getParent().requestDisallowInterceptTouchEvent(mode != SLIDE_MODE_NONE); declineButton.setIsDragging(mode == SLIDE_MODE_DECLINE); acceptButton.setIsDragging(mode == SLIDE_MODE_ACCEPT); + audioAcceptButton.setIsHidden(mode != SLIDE_MODE_NONE || !isIncomingVideoCall(), animated); boolean needReturnButtons = true; if (oldSlideMode != SLIDE_MODE_NONE) { @@ -343,7 +390,7 @@ private void setSlideMode (int mode, boolean allowApply, int forceSlideMode, boo setTransform(1f, forceSlideMode - 1, animated); if (callback != null && call != null) { if (forceSlideMode == SLIDE_MODE_ACCEPT) { - callback.onCallAccept(call); + callback.onCallAccept(call, true); } else if (forceSlideMode == SLIDE_MODE_DECLINE) { callback.onCallDecline(call, false); } @@ -354,7 +401,7 @@ private void setSlideMode (int mode, boolean allowApply, int forceSlideMode, boo setTransform(1f, oldSlideMode - 1, animated); if (callback != null && call != null) { if (oldSlideMode == SLIDE_MODE_ACCEPT) { - callback.onCallAccept(call); + callback.onCallAccept(call, true); } else if (oldSlideMode == SLIDE_MODE_DECLINE) { callback.onCallDecline(call, false); } diff --git a/app/src/main/res/values-ru/frogram_strings.xml b/app/src/main/res/values-ru/frogram_strings.xml index 1fb24554a1..7c18e77a96 100644 --- a/app/src/main/res/values-ru/frogram_strings.xml +++ b/app/src/main/res/values-ru/frogram_strings.xml @@ -45,6 +45,9 @@ Дважды нажмите на сообщение, чтобы поставить первую выбранную ниже реакцию. Если выбрана одна реакция, реакции по свайпу отключаются. В некоторых группах и каналах отдельные реакции могут быть недоступны. Аудиозвонок Видеозвонок + Входящий видеозвонок + С видео + Без видео Видеозвонки недоступны для этого пользователя или устройства Включить камеру Выключить камеру diff --git a/app/src/main/res/values/local_strings.xml b/app/src/main/res/values/local_strings.xml index 02c68544a0..c0e51ba576 100644 --- a/app/src/main/res/values/local_strings.xml +++ b/app/src/main/res/values/local_strings.xml @@ -14,6 +14,9 @@ Audio call Video call + Incoming video call + With video + Without video Video calls aren\'t supported by this user or device Turn camera on Turn camera off From 960f38a3de89aa1d16d6c545f93d4363bda480ca Mon Sep 17 00:00:00 2001 From: AbdulKus <73951988+AbdulKus@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:42:55 +0300 Subject: [PATCH 3/5] Fix direct video call startup and portrait PiP --- app/jni/tgvoip/tgvoip.cpp | 8 +++++--- .../java/org/thunderdog/challegram/ui/CallController.java | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/jni/tgvoip/tgvoip.cpp b/app/jni/tgvoip/tgvoip.cpp index 240d0607ba..f3d292ed10 100644 --- a/app/jni/tgvoip/tgvoip.cpp +++ b/app/jni/tgvoip/tgvoip.cpp @@ -470,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 @@ -632,9 +633,10 @@ JNI_OBJECT_FUNC(jlong, voip_TgCallsController, newInstance, std::move(encryptionKey), isOutgoingCall ), - // Attach the camera only after Java has installed the EGL-backed sinks. - // Starting capture inside Meta::Create raced the first direct video call. - .videoCapture = nullptr, + // Direct video sessions must be created with a capture source attached so + // tgcalls can build the correct media graph. The source stays inactive + // until Java installs the EGL-backed sinks. + .videoCapture = isVideoCall ? videoCapture : nullptr, .stateUpdated = [javaController](tgcalls::State state) { javaController->runSafely([javaController, state](JNIEnv *env) { jint javaState = toJavaCallState(env, state); 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 45a6d10e9b..8799d3c926 100644 --- a/app/src/main/java/org/thunderdog/challegram/ui/CallController.java +++ b/app/src/main/java/org/thunderdog/challegram/ui/CallController.java @@ -1026,7 +1026,7 @@ public boolean enterPictureInPictureIfPossible () { } try { PictureInPictureParams params = new PictureInPictureParams.Builder() - .setAspectRatio(new Rational(16, 9)) + .setAspectRatio(new Rational(5, 6)) .build(); return context().enterPictureInPictureMode(params); } catch (Throwable t) { From ed091254b8d90d8a2d772fa49672bd1d9d7af856 Mon Sep 17 00:00:00 2001 From: AbdulKus <73951988+AbdulKus@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:45:09 +0300 Subject: [PATCH 4/5] Match PiP to portrait video aspect ratio --- .../main/java/org/thunderdog/challegram/ui/CallController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8799d3c926..d5808bdb1a 100644 --- a/app/src/main/java/org/thunderdog/challegram/ui/CallController.java +++ b/app/src/main/java/org/thunderdog/challegram/ui/CallController.java @@ -1026,7 +1026,7 @@ public boolean enterPictureInPictureIfPossible () { } try { PictureInPictureParams params = new PictureInPictureParams.Builder() - .setAspectRatio(new Rational(5, 6)) + .setAspectRatio(new Rational(9, 16)) .build(); return context().enterPictureInPictureMode(params); } catch (Throwable t) { From 4cea9efd4e31bc3c37dd338d6081722d71f42527 Mon Sep 17 00:00:00 2001 From: AbdulKus <73951988+AbdulKus@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:00:22 +0300 Subject: [PATCH 5/5] Fix incoming video capture activation --- app/jni/tgvoip/tgvoip.cpp | 12 ++++++++++-- .../challegram/widget/voip/CallControlsLayout.java | 3 +-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/jni/tgvoip/tgvoip.cpp b/app/jni/tgvoip/tgvoip.cpp index f3d292ed10..a64280c1a8 100644 --- a/app/jni/tgvoip/tgvoip.cpp +++ b/app/jni/tgvoip/tgvoip.cpp @@ -409,6 +409,7 @@ struct TgCallsContext { std::shared_ptr videoCapture; std::shared_ptr> localVideoSink; std::shared_ptr> remoteVideoSink; + bool videoCaptureAttached = false; bool videoEnabled = false; bool frontCamera = true; }; @@ -692,6 +693,7 @@ JNI_OBJECT_FUNC(jlong, voip_TgCallsController, newInstance, auto *context = new TgCallsContext; context->javaController = javaController; context->videoCapture = videoCapture; + context->videoCaptureAttached = isVideoCall && videoCapture != nullptr; context->videoEnabled = false; context->tgcalls = tgcalls::Meta::Create(version, std::move(descriptor)); if (context->tgcalls == nullptr) { @@ -796,10 +798,16 @@ JNI_OBJECT_FUNC(void, voip_TgCallsController, nativeSetVideoEnabled, jlong ptr, } context->videoEnabled = enabled; if (enabled) { - context->tgcalls->setVideoCapture(context->videoCapture); + if (!context->videoCaptureAttached) { + context->tgcalls->setVideoCapture(context->videoCapture); + context->videoCaptureAttached = true; + } context->videoCapture->setState(tgcalls::VideoState::Active); } else { - context->tgcalls->setVideoCapture(nullptr); + if (context->videoCaptureAttached) { + context->tgcalls->setVideoCapture(nullptr); + context->videoCaptureAttached = false; + } context->videoCapture->setState(tgcalls::VideoState::Inactive); } } diff --git a/app/src/main/java/org/thunderdog/challegram/widget/voip/CallControlsLayout.java b/app/src/main/java/org/thunderdog/challegram/widget/voip/CallControlsLayout.java index 70f4100911..343718784a 100644 --- a/app/src/main/java/org/thunderdog/challegram/widget/voip/CallControlsLayout.java +++ b/app/src/main/java/org/thunderdog/challegram/widget/voip/CallControlsLayout.java @@ -153,7 +153,6 @@ public CallControlsLayout (@NonNull Context context, CallController parent) { audioAcceptButton = new CircleButton(context); audioAcceptButton.init(R.drawable.baseline_call_24, AUDIO_ANSWER_BUTTON_SIZE, CALL_BUTTON_PADDING, ColorId.circleButtonPositive, ColorId.circleButtonPositiveIcon); - audioAcceptButton.setBottomText(Lang.getString(R.string.AnswerWithoutVideo)); audioAcceptButton.setContentDescription(Lang.getString(R.string.AnswerWithoutVideo)); audioAcceptButton.setLayoutParams(params); audioAcceptButton.setIsHidden(true, false); @@ -185,7 +184,7 @@ public void setCall (final Tdlib tdlib, final TdApi.Call call, final boolean ani final boolean incomingVideoCall = isIncomingVideoCall(); acceptButton.setIcon(incomingVideoCall ? R.drawable.baseline_videocam_24 : R.drawable.baseline_phone_36); - acceptButton.setBottomText(incomingVideoCall ? Lang.getString(R.string.AnswerWithVideo) : null); + acceptButton.setBottomText(null); acceptButton.setContentDescription(Lang.getString(incomingVideoCall ? R.string.AnswerWithVideo : R.string.AnswerCall)); switch (call.state.getConstructor()) {