From 9a1371879b277f2d78d69ed93c66f7d636aa7bf7 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Fri, 2 Oct 2026 04:53:44 +0800 Subject: [PATCH] [fix] align Android viewport zoom and pan with iOS --- apps/android/README.md | 8 +- .../cn/crossdesk/mobile/MainActivity.java | 6 +- .../cn/crossdesk/mobile/RemoteVideoView.java | 122 +++++++++++++---- .../cn/crossdesk/mobile/RemoteViewport.java | 74 ++++++++++ .../crossdesk/mobile/RemoteViewportTest.java | 129 ++++++++++++++++++ 5 files changed, 306 insertions(+), 33 deletions(-) create mode 100644 apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteViewport.java create mode 100644 apps/android/test/android/java/cn/crossdesk/mobile/RemoteViewportTest.java diff --git a/apps/android/README.md b/apps/android/README.md index 3dedd094..fb0e8c43 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -7,7 +7,7 @@ - 官方或自托管 WSS 信令服务器;授权后进入前台即自动注册或登录本机身份,首页实时显示连接、重连及失败状态。发起桌面连接时复用该身份;切换服务器会重新登录,撤回授权或进入后台会关闭连接。 - 优先使用 MediaCodec 硬件解码 H.264 / AV1,硬件缓冲区直接提交 Surface;设备无对应硬件、分辨率超限或运行失败时,按原编码格式回退到 OpenH264 / dav1d。软件输出由独立显示线程将 NV12 转 RGBA;只保留最新待显示帧。MiniRTC 原有的通用压缩帧解码队列保持不变。 - 48 kHz 单声道 PCM 播放,支持静音,音频队列有容量限制。 -- 相对鼠标(触控板)和绝对位置模式;单指点击、绝对模式按住拖动、长按右键、双指滚轮,以及外接键盘、鼠标。 +- 相对鼠标(触控板)和绝对位置模式;单指点击、绝对模式按住拖动、长按右键,以及外接键盘、鼠标。画面手势与 iOS 一致:双指捏合以触点为中心缩放(1–10 倍),放大后双指拖动画面,原始比例下双指上下滑动控制滚轮。 - 横竖屏等比例显示、远端多显示器切换。与 iOS 一致的画面质量(低/中/高)、采集帧率(30/60 fps)、画面偏好;支持能力检测、电脑端确认、失败/5 秒超时回退及连续操作的应答排序。 - 根据常见移动设备解码能力和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。 - 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。 @@ -21,7 +21,7 @@ - 可选保存远程画面预览:默认关闭,经确认开启后,从下一次连接起保存一张 640×360 预览,用于最近连接卡片。支持清除预览,关闭选项或撤回授权会清除已有图片。 - “关于 CrossDesk”对齐 iOS 的版本、软件许可、开源组件及源码与构建说明页面,包含 OpenFEC 告知、组件详情和可离线选择复制的完整许可;版本号读取当前 APK,构建时记录应用与 MiniRTC 的基础版本。 -当前不包含安卓被控端、文件传输、隐私屏、Ctrl+Alt+Del 服务命令、捏合缩放或 iOS 全部附加功能。相关入口保留 iOS 的布局并明确标注不可用。硬件能力取决于设备和编码格式,软件回退时高分辨率或高帧率可能增加功耗;可通过悬浮菜单的画面设置降低画质或采集帧率。暂只构建 `arm64-v8a`,不支持 x86 模拟器或 32 位设备。 +当前不包含安卓被控端、文件传输、隐私屏、Ctrl+Alt+Del 服务命令或 iOS 全部附加功能。相关入口保留 iOS 的布局并明确标注不可用。硬件能力取决于设备和编码格式,软件回退时高分辨率或高帧率可能增加功耗;可通过悬浮菜单的画面设置降低画质或采集帧率。暂只构建 `arm64-v8a`,不支持 x86 模拟器或 32 位设备。 ## 构建 @@ -191,7 +191,9 @@ python3 test/run_native.py - `test/android/assets/tls/`:本机环回 TLS 测试证书,仅进入测试 APK。 - `test/verify_apk.py`:发布 APK 的 arm64 ABI、16 KB ELF / ZIP 对齐、许可证完整性和源码资料检查。 -设备回归仅保留 TLS 证书校验、凭据加密及服务器隔离、原生连接生命周期、键盘映射、画面设置状态、网络采样、公告与预览数据持久化、版本兼容判断。预览存储测试使用生成的纯色位图,不采集屏幕或视频画面。 +设备回归仅保留 TLS 证书校验、凭据加密及服务器隔离、原生连接生命周期、键盘映射、画面设置状态、缩放及输入坐标计算、网络采样、公告与预览数据持久化、版本兼容判断。预览存储测试使用生成的纯色位图,不采集屏幕或视频画面。 + +画面手势需在远程会话中人工验证:两种鼠标模式均检查捏合放大至 10 倍、缩回 1 倍、放大后的双指平移和边界限制;原始比例下双指滑动仍控制滚轮,捏合及平移不应产生远端点击或拖动。放大后检查点击与光标位置一致、横竖屏切换后位置正确,切换显示器或重新连接恢复 1 倍。自动化回归只验证几何计算,不注入 UI 手势。 UI 验证统一由人工完成,不维护页面导航、布局、动画、悬浮窗和截图采集的自动化用例。许可证和构建资料的完整性由 `test/verify_apk.py` 检查。 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 09ac5fbd..f309da34 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 @@ -68,7 +68,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen private AlertDialog remoteUpdateDialog; private RemoteVersionCheck remoteVersionCheck; private String host, connectedRemote="", hostPlatform="", remoteClipboard="", pendingPassword="", page="home"; - private int port; + private int port,selectedDisplay; private JSONArray displays=new JSONArray(); private boolean muted,ready,rememberPassword; private RemoteVideoSettings videoSettings=new RemoteVideoSettings(1); @@ -246,7 +246,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen resetRemoteVersionCheck(); resetVideoSettings(); previewCapture=previews.begin(serverKey(),id);previewCopying=false;previewAttempts=0; - connectedRemote=id;pendingPassword=rememberPassword?secret:"";ready=false;muted=false;hostPlatform="";remoteClipboard="";displays=new JSONArray();preferences.edit().putString("lastRemote",id).apply(); + connectedRemote=id;pendingPassword=rememberPassword?secret:"";ready=false;muted=false;selectedDisplay=0;hostPlatform="";remoteClipboard="";displays=new JSONArray();preferences.edit().putString("lastRemote",id).apply(); ensureSignaling(); if(signaling==null){pendingPassword="";toast("请先完成联网授权");return;} session=signaling;showProgress();session.connect(id,secret); @@ -376,7 +376,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen private void tapKey(int code){if(session!=null){session.key(code,true);session.key(code,false);}} private void shortcut(int code){if(session!=null){int modifier=hostPlatform.equals("macos")?0x5B:0x11;session.key(modifier,true);tapKey(code);session.key(modifier,false);}} private void selectDisplay(){ - if(!ready)return;int count=Math.min(displays.length(),8);if(count==0){toast("等待远端屏幕信息");return;}String[] labels=new String[count];for(int i=0;i{if(session!=null){session.control(4,w);if(controls!=null)controls.resetVideoStatistics();}}).show(); + if(!ready)return;int count=Math.min(displays.length(),8);if(count==0){toast("等待远端屏幕信息");return;}String[] labels=new String[count];for(int i=0;i{if(session!=null){if(w!=selectedDisplay&&video!=null)video.resetViewport();selectedDisplay=w;session.control(4,w);if(controls!=null)controls.resetVideoStatistics();}}).show(); } private void clipboard(){ if(!ready)return; diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteVideoView.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteVideoView.java index 0b596f4d..2c0727c6 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteVideoView.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteVideoView.java @@ -12,6 +12,7 @@ import android.os.Looper; import android.view.PixelCopy; import android.view.GestureDetector; import android.view.MotionEvent; +import android.view.ScaleGestureDetector; import android.view.SurfaceHolder; import android.view.SurfaceView; import android.view.View; @@ -23,10 +24,12 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac private final Cursor cursor; private final NativeSession session; private final GestureDetector gestures; + private final ScaleGestureDetector scales; + private final RemoteViewport viewport = new RemoteViewport(); private int frameWidth = 16, frameHeight = 9; private float x = .5f, y = .5f, scrollY; - private boolean dragging, twoFinger, relative; - private float lastX,lastY; + private boolean dragging, twoFinger, relative, viewportGesture, viewportPanActive; + private float lastX,lastY,twoFingerX,twoFingerY; void setRelative(boolean value) { relative=value; } RemoteVideoView(Context context, NativeSession session) { super(context); this.session = session; @@ -34,30 +37,57 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac video = new SurfaceView(context); video.getHolder().addCallback(this); addView(video, new LayoutParams(1, 1, android.view.Gravity.CENTER)); cursor = new Cursor(context); - addView(cursor, new LayoutParams(1, 1, android.view.Gravity.CENTER)); + // Keep input in container coordinates so transforms never move its hit area. + addView(cursor, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); gestures = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { - @Override public boolean onDown(MotionEvent e) { point(e); return true; } + @Override public boolean onDown(MotionEvent e) { return point(e); } @Override public boolean onSingleTapUp(MotionEvent e) { - if (!twoFinger) { point(e); cursor.performClick(); } return true; + if (!twoFinger && point(e)) cursor.performClick(); return true; } - @Override public void onLongPress(MotionEvent e) { if (!twoFinger && !dragging) { point(e); click(3, 4); } } + @Override public void onLongPress(MotionEvent e) { if (!twoFinger && !dragging && point(e)) click(3, 4); } @Override public boolean onScroll(MotionEvent first, MotionEvent current, float dx, float dy) { if (twoFinger) return true; - if (!relative && !dragging) { point(first); session.pointer(x, y, 1, 0); dragging = true; } + if (!relative && !dragging) { + if (!point(first)) return true; + session.pointer(x, y, 1, 0); dragging = true; + } point(current); return true; } }); + scales = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() { + @Override public boolean onScaleBegin(ScaleGestureDetector detector) { + if (!viewport.contains(detector.getFocusX(), detector.getFocusY())) return false; + viewportGesture = true; + viewportPanActive = true; + return true; + } + @Override public boolean onScale(ScaleGestureDetector detector) { + viewport.zoomBy(detector.getScaleFactor(), detector.getFocusX(), detector.getFocusY()); + applyViewport(); + return true; + } + @Override public void onScaleEnd(ScaleGestureDetector detector) { + viewport.finishGesture(); + applyViewport(); + } + }); + scales.setQuickScaleEnabled(false); + scales.setStylusScaleEnabled(false); cursor.setOnGenericMotionListener((v, event) -> { - point(event); + if (!point(event)) return true; if (event.getActionMasked() == MotionEvent.ACTION_SCROLL) { float delta = event.getAxisValue(MotionEvent.AXIS_VSCROLL); if (delta != 0) session.pointer(x, y, 7, delta > 0 ? 1 : -1); } return true; }); - setContentDescription("远程桌面:轻触点击,拖动移动,长按右键,双指上下滑动滚动"); + setContentDescription("远程桌面:轻触点击,拖动移动,长按右键,双指捏合缩放,放大后双指拖动画面,原始比例下双指上下滑动滚动"); } - void videoSize(int width, int height) { frameWidth = width; frameHeight = height; fit(); } + void videoSize(int width, int height) { + if (width <= 0 || height <= 0) return; + frameWidth = width; frameHeight = height; fit(); + } + void resetViewport() { viewport.reset(); applyViewport(); } static Rect previewCrop(int width,int height){ int w=width,h=height; if((long)width*RemotePreviewStore.HEIGHT>(long)height*RemotePreviewStore.WIDTH)w=Math.max(1,height*RemotePreviewStore.WIDTH/RemotePreviewStore.HEIGHT); @@ -76,54 +106,92 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac } private void fit() { if (getWidth() == 0 || getHeight() == 0) return; - double scale = Math.min((double)getWidth()/frameWidth, (double)getHeight()/frameHeight); - int w = Math.max(1,(int)(frameWidth*scale)), h = Math.max(1,(int)(frameHeight*scale)); - video.setLayoutParams(new LayoutParams(w,h,android.view.Gravity.CENTER)); - cursor.setLayoutParams(new LayoutParams(w,h,android.view.Gravity.CENTER)); + viewport.fit(getWidth(), getHeight(), frameWidth, frameHeight); + video.setLayoutParams(new LayoutParams(viewport.baseWidth(),viewport.baseHeight(),android.view.Gravity.CENTER)); + applyViewport(); + } + private void applyViewport() { + // Transform the existing Surface without enlarging its decoder buffers. + video.setScaleX(viewport.scale()); video.setScaleY(viewport.scale()); + video.setTranslationX(viewport.offsetX()); video.setTranslationY(viewport.offsetY()); + cursor.invalidate(); } @Override protected void onSizeChanged(int w,int h,int oldw,int oldh) { post(this::fit); } - private void point(MotionEvent e) { + private boolean point(MotionEvent e) { boolean touchpad = relative && e.getToolType(0) != MotionEvent.TOOL_TYPE_MOUSE; float px=e.getX(),py=e.getY(); + if (!touchpad && !viewport.contains(px,py)) return false; if(touchpad){ if(e.getActionMasked()==MotionEvent.ACTION_MOVE){ - x=Math.max(0,Math.min(1,x+(px-lastX)/Math.max(1,cursor.getWidth()))); - y=Math.max(0,Math.min(1,y+(py-lastY)/Math.max(1,cursor.getHeight()))); + x=Math.max(0,Math.min(1,x+(px-lastX)/Math.max(1,viewport.width()))); + y=Math.max(0,Math.min(1,y+(py-lastY)/Math.max(1,viewport.height()))); } }else{ - x=Math.max(0,Math.min(1,px/Math.max(1,cursor.getWidth()))); - y=Math.max(0,Math.min(1,py/Math.max(1,cursor.getHeight()))); + x=viewport.normalizedX(px); + y=viewport.normalizedY(py); } lastX=px;lastY=py; session.pointer(x,y,0,0); cursor.invalidate(); + return true; } private void click(int down, int up) { session.pointer(x,y,down,0); session.pointer(x,y,up,0); } private boolean touch(MotionEvent e) { int action = e.getActionMasked(); - if (action == MotionEvent.ACTION_DOWN) twoFinger = false; + if (action == MotionEvent.ACTION_DOWN) { + twoFinger = viewportGesture = viewportPanActive = false; + } if (action == MotionEvent.ACTION_POINTER_DOWN) { if (dragging) { session.pointer(x,y,2,0); dragging = false; } - twoFinger = true; scrollY = e.getY(); + twoFinger = true; + twoFingerX = focus(e,true,-1); twoFingerY = scrollY = focus(e,false,-1); + viewportPanActive = viewport.isZoomed() && viewport.contains(twoFingerX,twoFingerY); MotionEvent cancel = MotionEvent.obtain(e); cancel.setAction(MotionEvent.ACTION_CANCEL); gestures.onTouchEvent(cancel); cancel.recycle(); } - if (twoFinger && action == MotionEvent.ACTION_MOVE) { - float delta = e.getY() - scrollY; - if (Math.abs(delta) > 16 * getResources().getDisplayMetrics().density) { - session.pointer(x,y,7,delta > 0 ? 1 : -1); scrollY = e.getY(); + scales.onTouchEvent(e); + if (twoFinger && action == MotionEvent.ACTION_MOVE && e.getPointerCount() >= 2) { + float focusX = focus(e,true,-1), focusY = focus(e,false,-1); + if (viewport.isZoomed() || viewportGesture || scales.isInProgress()) { + viewportGesture = true; + if (!viewportPanActive) viewportPanActive = viewport.contains(focusX,focusY); + if (viewportPanActive) { + viewport.panBy(focusX-twoFingerX,focusY-twoFingerY); + applyViewport(); + } + } else if (viewport.contains(focusX,focusY)) { + float delta = focusY - scrollY; + if (Math.abs(delta) > 16 * getResources().getDisplayMetrics().density) { + session.pointer(x,y,7,delta > 0 ? 1 : -1); scrollY = focusY; + } } + twoFingerX = focusX; twoFingerY = focusY; } else if (!twoFinger) { if ((e.getButtonState() & MotionEvent.BUTTON_SECONDARY) != 0 && action == MotionEvent.ACTION_DOWN) { - point(e); click(3,4); return true; + if (point(e)) click(3,4); return true; } gestures.onTouchEvent(e); } + if (action == MotionEvent.ACTION_POINTER_UP) { + // Rebase when a finger lifts; never resume a click/drag until all lift. + twoFingerX = focus(e,true,e.getActionIndex()); + twoFingerY = scrollY = focus(e,false,e.getActionIndex()); + } if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { if (dragging) session.pointer(x,y,2,0); dragging = false; + viewport.finishGesture(); applyViewport(); + viewportPanActive = false; } return true; } + private static float focus(MotionEvent event, boolean horizontal, int excludedIndex) { + float total = 0; int count = 0; + for (int i=0;i 1.001f; } + + boolean contains(float x, float y) { + return baseWidth > 0 && baseHeight > 0 && + x >= left() && x < left() + width() && y >= top() && y < top() + height(); + } + + float normalizedX(float x) { return clamp((x - left()) / Math.max(1, width()), 0, 1); } + float normalizedY(float y) { return clamp((y - top()) / Math.max(1, height()), 0, 1); } + + 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); + float ratio = nextScale / scale; + float focusFromCenterX = focusX - (baseLeft + baseWidth / 2f); + float focusFromCenterY = focusY - (baseTop + baseHeight / 2f); + offsetX = focusFromCenterX - (focusFromCenterX - offsetX) * ratio; + offsetY = focusFromCenterY - (focusFromCenterY - offsetY) * ratio; + scale = nextScale; + constrainOffset(); + } + + void panBy(float dx, float dy) { + offsetX += dx; + offsetY += dy; + constrainOffset(); + } + + void finishGesture() { + if (!isZoomed()) reset(); + } + + void reset() { scale = 1; offsetX = offsetY = 0; } + + private void constrainOffset() { + // Keep the original fitted area covered, as on iOS, even with letterboxing. + float maximumX = baseWidth * (scale - 1) / 2; + float maximumY = baseHeight * (scale - 1) / 2; + offsetX = clamp(offsetX, -maximumX, maximumX); + offsetY = clamp(offsetY, -maximumY, maximumY); + } + + private static float clamp(float value, float minimum, float maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } +} diff --git a/apps/android/test/android/java/cn/crossdesk/mobile/RemoteViewportTest.java b/apps/android/test/android/java/cn/crossdesk/mobile/RemoteViewportTest.java new file mode 100644 index 00000000..de5178d3 --- /dev/null +++ b/apps/android/test/android/java/cn/crossdesk/mobile/RemoteViewportTest.java @@ -0,0 +1,129 @@ +package cn.crossdesk.mobile; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.Test; +import org.junit.runner.RunWith; +import static org.junit.Assert.*; + +/** Non-UI regression checks for zoom and remote input geometry. */ +@RunWith(AndroidJUnit4.class) +public class RemoteViewportTest { + private static final float EPSILON = .0001f; + + @Test public void fittedVideoRejectsLetterboxingAndMapsItsEdges() { + RemoteViewport viewport = new RemoteViewport(); + viewport.fit(1080,1920,1920,1080); + assertEquals(1080,viewport.baseWidth()); + assertEquals(607,viewport.baseHeight()); + assertEquals(656,viewport.top(),EPSILON); + assertFalse(viewport.contains(540,200)); + assertTrue(viewport.contains(0,656)); + assertFalse(viewport.contains(1080,1263)); + assertEquals(.5f,viewport.normalizedX(540),EPSILON); + assertEquals(.5f,viewport.normalizedY(959.5f),EPSILON); + } + + @Test public void pinchKeepsTheRemotePointUnderAnOffCenterFocus() { + RemoteViewport viewport = new RemoteViewport(); + viewport.fit(1000,600,1000,600); + viewport.zoomBy(2,750,350); + assertEquals(2,viewport.scale(),EPSILON); + assertEquals(-250,viewport.offsetX(),EPSILON); + assertEquals(-50,viewport.offsetY(),EPSILON); + assertEquals(.75f,viewport.normalizedX(750),EPSILON); + assertEquals(350f/600,viewport.normalizedY(350),EPSILON); + viewport.zoomBy(.5f,750,350); + assertEquals(1,viewport.scale(),EPSILON); + assertEquals(0,viewport.offsetX(),EPSILON); + assertEquals(0,viewport.offsetY(),EPSILON); + } + + @Test public void zoomAndPanUseTheSameInverseTransformForRemoteInput() { + RemoteViewport viewport = new RemoteViewport(); + viewport.fit(1200,800,1920,1080); + viewport.zoomBy(3,600,399.5f); + viewport.panBy(200,100); + assertEquals(-1000,viewport.left(),EPSILON); + assertEquals(-513,viewport.top(),EPSILON); + assertEquals(1380f/3600,viewport.normalizedX(380),EPSILON); + assertEquals(.28f,viewport.normalizedY(54),EPSILON); + // Trackpad deltas use the zoomed dimensions, too. + assertEquals(.01f,36/viewport.width(),EPSILON); + assertEquals(.01f,20.25f/viewport.height(),EPSILON); + } + + @Test public void panCannotMoveTheImagePastTheOriginalFittedArea() { + RemoteViewport viewport = new RemoteViewport(); + viewport.fit(1000,800,1600,900); + viewport.zoomBy(2,500,400); + viewport.panBy(10000,-10000); + assertEquals(500,viewport.offsetX(),EPSILON); + assertEquals(-281,viewport.offsetY(),EPSILON); + assertEquals(0,viewport.left(),EPSILON); + assertEquals(119,viewport.top()+viewport.height()-viewport.baseHeight(),EPSILON); + viewport.panBy(-20000,20000); + assertEquals(-500,viewport.offsetX(),EPSILON); + assertEquals(281,viewport.offsetY(),EPSILON); + } + + @Test public void scaleMatchesIosLimitsAndFinishingNearOneRestoresFit() { + RemoteViewport viewport = new RemoteViewport(); + viewport.fit(1000,600,1000,600); + viewport.zoomBy(100,500,300); + assertEquals(10,viewport.scale(),EPSILON); + viewport.zoomBy(.10005f,500,300); + viewport.panBy(.1f,.1f); + viewport.finishGesture(); + assertEquals(1,viewport.scale(),EPSILON); + assertEquals(0,viewport.offsetX(),EPSILON); + assertEquals(0,viewport.offsetY(),EPSILON); + assertFalse(viewport.isZoomed()); + viewport.zoomBy(.01f,500,300); + assertEquals(1,viewport.scale(),EPSILON); + } + + @Test public void rotationAndResolutionChangesConstrainExistingPan() { + RemoteViewport viewport = new RemoteViewport(); + viewport.fit(1000,600,1000,600); + viewport.zoomBy(2,500,300); + viewport.panBy(500,300); + viewport.fit(600,1000,1000,600); + assertEquals(2,viewport.scale(),EPSILON); + assertEquals(300,viewport.offsetX(),EPSILON); + assertEquals(180,viewport.offsetY(),EPSILON); + viewport.fit(600,1000,600,1000); + assertEquals(2,viewport.scale(),EPSILON); + assertEquals(1000,viewport.baseHeight()); + viewport.reset(); + assertEquals(1,viewport.scale(),EPSILON); + assertEquals(0,viewport.offsetX(),EPSILON); + assertEquals(0,viewport.offsetY(),EPSILON); + } + + @Test public void oddMarginsShareTheActualSurfaceCenterAtMaximumZoom() { + RemoteViewport viewport = new RemoteViewport(); + viewport.fit(801,603,800,600); + assertEquals(801,viewport.baseWidth()); + assertEquals(600,viewport.baseHeight()); + assertEquals(1,viewport.top(),EPSILON); + viewport.zoomBy(10,400.5f,301); + assertEquals(-3604.5f,viewport.left(),EPSILON); + assertEquals(-2699,viewport.top(),EPSILON); + assertEquals(.5f,viewport.normalizedX(400.5f),EPSILON); + assertEquals(.5f,viewport.normalizedY(301),EPSILON); + } + + @Test public void invalidGeometryAndScaleDoNotCorruptViewport() { + RemoteViewport viewport = new RemoteViewport(); + viewport.fit(0,0,1920,1080); + assertFalse(viewport.contains(0,0)); + viewport.zoomBy(2,0,0); + assertEquals(1,viewport.scale(),EPSILON); + viewport.fit(1000,600,1000,600); + viewport.zoomBy(Float.NaN,500,300); + viewport.zoomBy(Float.POSITIVE_INFINITY,500,300); + viewport.zoomBy(-1,500,300); + assertEquals(1,viewport.scale(),EPSILON); + assertEquals(0,viewport.offsetX(),EPSILON); + } +}