[fix] align Android viewport zoom and pan with iOS

This commit is contained in:
dijunkun
2026-10-02 04:53:44 +08:00
parent 42498ac33d
commit 9a1371879b
5 changed files with 306 additions and 33 deletions
+5 -3
View File
@@ -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` 检查。
@@ -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<count;i++){JSONObject display=displays.optJSONObject(i);labels[i]=(i+1)+" · "+(display==null?"显示器":display.optString("name","显示器"));}new AlertDialog.Builder(this).setTitle("显示器").setItems(labels,(d,w)->{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<count;i++){JSONObject display=displays.optJSONObject(i);labels[i]=(i+1)+" · "+(display==null?"显示器":display.optString("name","显示器"));}new AlertDialog.Builder(this).setTitle("显示器").setItems(labels,(d,w)->{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;
@@ -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<event.getPointerCount();i++) {
if (i==excludedIndex) continue;
total += horizontal ? event.getX(i) : event.getY(i); count++;
}
return total / Math.max(1,count);
}
@Override public boolean performClick() { super.performClick(); return true; }
@Override public void surfaceCreated(SurfaceHolder holder) { session.surface(holder.getSurface()); }
@Override public void surfaceChanged(SurfaceHolder holder,int format,int width,int height) { }
@@ -150,7 +218,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
@Override public boolean performClick() { super.performClick(); click(1, 2); return true; }
@Override protected void onDraw(Canvas canvas) {
int saved=canvas.save();
canvas.translate(x*getWidth(),y*getHeight());
canvas.translate(viewport.left()+x*viewport.width(),viewport.top()+y*viewport.height());
float density=getResources().getDisplayMetrics().density;canvas.scale(density,density);
// Anchor the arrow tip to the same normalized coordinate sent to the desktop.
canvas.translate(-arrowWidth*.08f,-arrowHeight*.04f);
@@ -0,0 +1,74 @@
package cn.crossdesk.mobile;
/** Geometry shared by the video Surface, cursor and remote pointer input. */
final class RemoteViewport {
private static final float MAXIMUM_SCALE = 10;
private int baseWidth, baseHeight, baseLeft, baseTop;
private float scale = 1, offsetX, offsetY;
void fit(int width, int height, int videoWidth, int videoHeight) {
if (width <= 0 || height <= 0 || videoWidth <= 0 || videoHeight <= 0) return;
double fitScale = Math.min((double) width / videoWidth, (double) height / videoHeight);
baseWidth = Math.max(1, (int) (videoWidth * fitScale));
baseHeight = Math.max(1, (int) (videoHeight * fitScale));
// Match the integer positioning of Gravity.CENTER, including odd margins.
baseLeft = (width - baseWidth) / 2;
baseTop = (height - baseHeight) / 2;
constrainOffset();
}
int baseWidth() { return baseWidth; }
int baseHeight() { return baseHeight; }
float scale() { return scale; }
float offsetX() { return offsetX; }
float offsetY() { return offsetY; }
float width() { return baseWidth * scale; }
float height() { return baseHeight * scale; }
float left() { return baseLeft + baseWidth * (1 - scale) / 2 + offsetX; }
float top() { return baseTop + baseHeight * (1 - scale) / 2 + offsetY; }
boolean isZoomed() { return scale > 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));
}
}
@@ -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);
}
}