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