From bfa743fd1bf0214c48d4c1ddab7c2b579f7d9987 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Mon, 2 Nov 2026 03:17:17 +0800 Subject: [PATCH] [fix] correct mobile viewport safe areas and zoom reset --- .../main/java/cn/crossdesk/mobile/MainActivity.java | 11 ++++++++--- .../java/cn/crossdesk/mobile/RemoteViewport.java | 10 ++++++---- .../CrossDeskMobile/Input/RemoteTouchInputView.swift | 12 +++++++++++- apps/ios/CrossDeskMobile/RemoteSessionView.swift | 5 ++++- 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java index 74835269..77b23235 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java @@ -34,7 +34,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen private View settingsUpdateDot, aboutUpdateDot, settingsUpdateButton, aboutUpdateRow; private TextView aboutUpdateLabel; private boolean navigatingBack; - private FrameLayout canvas; + private FrameLayout canvas, safeArea; private LinearLayout root; private EditText remote; private TextView status, signalBadge, identitySummary; @@ -133,10 +133,11 @@ public final class MainActivity extends Activity implements NativeSession.Listen settingsUpdateDot=null;aboutUpdateDot=null;settingsUpdateButton=null;aboutUpdateRow=null;aboutUpdateLabel=null; previewSwitch=null;clearPreviewsButton=null;previewCleanupError=null;previewCards.clear();recentGrid=null; canvas=new FrameLayout(this);canvas.setBackgroundColor(dark?Color.BLACK:BACKGROUND); - FrameLayout safeArea=new FrameLayout(this);canvas.addView(safeArea,new FrameLayout.LayoutParams(-1,-1)); + safeArea=new FrameLayout(this);canvas.addView(safeArea,new FrameLayout.LayoutParams(-1,-1)); root=ui.pageColumn(dark?0:nextPage.equals("home")?1200:720); safeArea.addView(root,new FrameLayout.LayoutParams(-1,-1,Gravity.TOP|Gravity.CENTER_HORIZONTAL)); safeArea.setOnApplyWindowInsetsListener((view,insets)->{ + if(dark&&(pictureInPictureUi||isInPictureInPictureMode())){view.setPadding(0,0,0,0);return insets;} if(dark&&android.os.Build.VERSION.SDK_INT>=30){ android.graphics.Insets bars=insets.getInsets(WindowInsets.Type.systemBars()|WindowInsets.Type.displayCutout()); view.setPadding(bars.left,bars.top,bars.right,bars.bottom); @@ -328,7 +329,10 @@ public final class MainActivity extends Activity implements NativeSession.Listen dialog.setContentView(card);dialog.setCanceledOnTouchOutside(false);dialog.setOnCancelListener(d->end(""));Window w=dialog.getWindow();if(w!=null){w.setBackgroundDrawableResource(android.R.color.transparent);w.setDimAmount(.08f);}dialog.show();if(w!=null)w.setLayout(dp(292),-2); } private void sessionScreen(boolean autoLandscape){ - installRoot("session",true);remoteOrientation.begin(autoLandscape,!isTablet()&&!isInMultiWindowMode());video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));canvas.addView(video,0,new FrameLayout.LayoutParams(-1,-1)); + installRoot("session",true);remoteOrientation.begin(autoLandscape,!isTablet()&&!isInMultiWindowMode());video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true)); + // Video, cursor and touch input must share the area inside system bars + // and display cutouts; fitting to the outer canvas hides image edges. + safeArea.addView(video,0,new FrameLayout.LayoutParams(-1,-1)); controls=new SessionControls(this,ui,new SessionControls.Actions(){ public void disconnect(){confirmDisconnect();}public void display(){selectDisplay();}public void clipboard(){MainActivity.this.clipboard();} public void sendFile(){chooseFile();}public void saveFile(){saveReceivedFile();}public void secureAttention(){MainActivity.this.secureAttention();} @@ -803,6 +807,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen if(value){releaseKeys();hideKeyboard();pageHost.finish();if(disconnectDialog!=null)disconnectDialog.dismiss();if(remoteUpdateDialog!=null)remoteUpdateDialog.dismiss();} if(controls!=null)controls.setPictureInPicture(value); if(video!=null)video.setPictureInPicture(value); + if(safeArea!=null)safeArea.requestApplyInsets(); } @Override protected void onUserLeaveHint(){ super.onUserLeaveHint(); diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteViewport.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteViewport.java index 2ee04c09..598d6da1 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteViewport.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteViewport.java @@ -42,6 +42,9 @@ final class RemoteViewport { void zoomBy(float factor, float focusX, float focusY) { if (baseWidth == 0 || baseHeight == 0 || !Float.isFinite(factor) || factor <= 0) return; float nextScale = clamp(scale * factor, 1, MAXIMUM_SCALE); + // Returning to fit must clear cursor-edge panning too, including a + // pinch that starts at 1x with an already shifted image. + if (nextScale == 1) { reset(); return; } float ratio = nextScale / scale; float focusFromCenterX = focusX - (baseLeft + baseWidth / 2f); float focusFromCenterY = focusY - (baseTop + baseHeight / 2f); @@ -75,10 +78,9 @@ final class RemoteViewport { } void finishGesture() { - if (!isZoomed()) { - scale = 1; - constrainOffset(); - } + // Ordinary pointer gestures at 1x retain edge panning. Only a zoom + // snapping back to its minimum restores the complete centered image. + if (scale > 1 && !isZoomed()) reset(); } void reset() { scale = 1; offsetX = offsetY = 0; edgePanned = false; } diff --git a/apps/ios/CrossDeskMobile/Input/RemoteTouchInputView.swift b/apps/ios/CrossDeskMobile/Input/RemoteTouchInputView.swift index 9c865280..c5d66480 100644 --- a/apps/ios/CrossDeskMobile/Input/RemoteTouchInputView.swift +++ b/apps/ios/CrossDeskMobile/Input/RemoteTouchInputView.swift @@ -384,6 +384,15 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate { let newScale = min(max(oldScale * recognizer.scale, 1), maximumViewportScale) recognizer.scale = 1 + if newScale == 1 { + // Pinching back to fit also ends any cursor-edge pan, even + // when the gesture starts at 1x with an off-center image. + viewportScale = 1 + viewportOffset = .zero + edgePanned = false + publishViewport() + return + } guard abs(newScale - oldScale) > 0.0001 else { return } let center = CGPoint(x: bounds.midX, y: bounds.midY) @@ -402,7 +411,8 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate { pinchActive = false if viewportScale <= 1.001 { viewportScale = 1 - viewportOffset = constrainedViewportOffset(viewportOffset, scale: 1) + viewportOffset = .zero + edgePanned = false publishViewport() } default: diff --git a/apps/ios/CrossDeskMobile/RemoteSessionView.swift b/apps/ios/CrossDeskMobile/RemoteSessionView.swift index cb3ffe13..d75585e6 100644 --- a/apps/ios/CrossDeskMobile/RemoteSessionView.swift +++ b/apps/ios/CrossDeskMobile/RemoteSessionView.swift @@ -140,8 +140,11 @@ struct RemoteSessionView: View { } .frame(width: proxy.size.width, height: proxy.size.height) + .clipped() } - .ignoresSafeArea() + // Fit video and input to the same unobscured area. Only the black + // background extends beneath the home indicator and display cutout. + .ignoresSafeArea(.keyboard) .transaction { transaction in transaction.animation = nil transaction.disablesAnimations = true