[feat] add compact mobile virtual mouse controls

This commit is contained in:
dijunkun
2026-10-09 14:26:36 +08:00
parent 41aed0984f
commit d3ecbe2cc9
12 changed files with 961 additions and 205 deletions
+1
View File
@@ -12,6 +12,7 @@
- 根据常见移动设备解码能力和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
- 可拖动的悬浮控制球、三列控制菜单和浮动 ASCII / 电脑键盘、常用快捷键;通过剪贴板发送中文等文本,再手动执行远端粘贴。
- 悬浮菜单中的“虚拟鼠标”展开 140 × 48 dp 横向左键/滚轮/右键条,跟随悬浮球移动,可随时收起,不压缩远程画面。按住左键或右键后,同一根手指拖动即可拖拽,滑出按键范围仍继续,松开结束;也支持另一指在画面上移动光标。采用无文字的扁平分体按键,中间滚轮外观为 18 × 30 dp,三键均保留 44 × 44 dp 触控范围,通过留白分隔。无阴影、凹凸或按压位移,按压仅变色。等色横条随手指上下循环移动,松手停止;上下滑动可超出按键范围,在画面放大后仍控制远端滚动。切换模式、显示器、进入后台或取消触摸会释放按键。状态菜单按内容高度完整展开,只有安全区域空间不足时才滚动。
- 悬浮控制栏支持选择并发送文件、接收电脑端文件、查看确认进度及保存收到的文件;Ctrl+Alt+Del 使用远端 Windows 系统服务,未启用服务时显示提示。
- 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意,后台切换会结束会话。
- 与 iOS 一致的首页卡片、顶部状态/公告/设置入口、密码弹层及分组设置页;最近连接按服务器隔离,长按可删除记录及密码。
@@ -308,9 +308,11 @@ public final class MainActivity extends Activity implements NativeSession.Listen
public void mouse(){boolean relative=!preferences.getBoolean("relativeMouse",true);preferences.edit().putBoolean("relativeMouse",relative).apply();video.setRelative(relative);controls.setRelative(relative);}
public void videoSettings(int field,int value){RemoteVideoSettings.Values old=MainActivity.this.videoSettings.selection;updateVideoSettings(new RemoteVideoSettings.Values(field==0?value:old.quality,field==1?value:old.frameRate,field==2?value:old.preference));}
});controls.setRelative(preferences.getBoolean("relativeMouse",true));controls.videoSettings(videoSettings);root.addView(controls,new LinearLayout.LayoutParams(-1,-1));getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
controls.attachMouseBar(canvas,new VirtualMouseBar(ui,video.mouseInput));
}
private void confirmDisconnect(){disconnectDialog=new AlertDialog.Builder(this).setTitle("断开远程连接?").setMessage("断开后将返回首页").setNegativeButton("取消",null).setPositiveButton("断开连接",(d,w)->end("")).show();}
private void end(String reason){
if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();
mainHandler.removeCallbacks(documentTimeout);documentSession=null;documentToSave=null;receivedFile=null;remoteServiceAvailable=false;
resetRemoteVersionCheck();
resetVideoSettings();
@@ -619,6 +621,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
if(controls!=null)controls.post(controls::constrainPanels);
}
// onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
@Override protected void onPause(){if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();super.onPause();}
@Override protected void onStart(){super.onStart();foreground=true;appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();}
@Override protected void onStop(){foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();
// The system document picker is part of this session's explicit file action.
@@ -0,0 +1,54 @@
package cn.crossdesk.mobile;
/** Shared button ownership and cursor position for the video surface and mouse bar. */
final class RemoteMouseInput {
interface Sender { void send(float x, float y, int flag, int wheel); }
private final Sender sender;
private float x = .5f, y = .5f;
private boolean gestureLeft, virtualLeft, virtualRight;
private long revision;
Runnable didCancel;
float viewportWidth, viewportHeight;
RemoteMouseInput(Sender sender) { this.sender = sender; }
boolean hasVirtualButton() { return virtualLeft || virtualRight; }
long revision() { return revision; }
void move(float x, float y) {
this.x = Math.max(0, Math.min(1, x)); this.y = Math.max(0, Math.min(1, y));
send(0, 0);
}
void drag(float dx, float dy) {
if (!hasVirtualButton() || viewportWidth <= 0 || viewportHeight <= 0) return;
move(x + dx / viewportWidth, y + dy / viewportHeight);
}
void gestureLeft(boolean down) {
if (down && hasVirtualButton()) return;
if (gestureLeft == down) return;
gestureLeft = down;
send(down ? 1 : 2, 0);
}
void click(int down, int up) {
if (hasVirtualButton()) return;
send(down, 0); send(up, 0);
}
void virtualButton(boolean left, boolean down) {
if ((left ? virtualLeft : virtualRight) == down) return;
if (down) {
gestureLeft(false);
revision++;
}
if (left) virtualLeft = down; else virtualRight = down;
send(left ? (down ? 1 : 2) : (down ? 3 : 4), 0);
}
void scroll(int direction) {
if (direction != 0) send(7, direction > 0 ? 1 : -1);
}
void releaseAll() {
gestureLeft(false);
virtualButton(true, false);
virtualButton(false, false);
revision++;
if (didCancel != null) didCancel.run();
}
private void send(int flag, int wheel) { sender.send(x, y, flag, wheel); }
}
@@ -23,6 +23,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
private final SurfaceView video;
private final Cursor cursor;
private final NativeSession session;
final RemoteMouseInput mouseInput;
private final GestureDetector gestures;
private final ScaleGestureDetector scales;
private final RemoteViewport viewport = new RemoteViewport();
@@ -30,9 +31,16 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
private float x = .5f, y = .5f, scrollY;
private boolean dragging, twoFinger, relative, viewportGesture, viewportPanActive;
private float lastX,lastY,twoFingerX,twoFingerY;
void setRelative(boolean value) { relative=value; }
private long touchRevision;
private boolean suppressPointerGesture;
void setRelative(boolean value) { releaseMouse(); relative=value; }
void releaseMouse() { mouseInput.releaseAll(); dragging=false; suppressPointerGesture=true; }
private boolean suppressPointerGesture() {
return suppressPointerGesture || mouseInput.hasVirtualButton() || touchRevision != mouseInput.revision();
}
RemoteVideoView(Context context, NativeSession session) {
super(context); this.session = session;
mouseInput = new RemoteMouseInput(this::sendMouse);
setBackgroundColor(Color.BLACK);
video = new SurfaceView(context); video.getHolder().addCallback(this);
addView(video, new LayoutParams(1, 1, android.view.Gravity.CENTER));
@@ -42,14 +50,14 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
gestures = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {
@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 && !suppressPointerGesture() && 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 && !suppressPointerGesture() && point(e)) click(3, 4); }
@Override public boolean onScroll(MotionEvent first, MotionEvent current, float dx, float dy) {
if (twoFinger) return true;
if (!relative && !dragging) {
if (!relative && !dragging && !suppressPointerGesture()) {
if (!point(first)) return true;
session.pointer(x, y, 1, 0); dragging = true;
mouseInput.gestureLeft(true); dragging = true;
}
point(current); return true;
}
@@ -58,6 +66,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
gestures.setOnDoubleTapListener(null);
scales = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() {
@Override public boolean onScaleBegin(ScaleGestureDetector detector) {
if (mouseInput.hasVirtualButton()) return false;
if (!viewport.contains(detector.getFocusX(), detector.getFocusY())) return false;
viewportGesture = true;
viewportPanActive = true;
@@ -89,7 +98,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
if (width <= 0 || height <= 0) return;
frameWidth = width; frameHeight = height; fit();
}
void resetViewport() { viewport.reset(); applyViewport(); }
void resetViewport() { releaseMouse(); 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);
@@ -116,6 +125,12 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
// 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());
mouseInput.viewportWidth = viewport.width(); mouseInput.viewportHeight = viewport.height();
cursor.invalidate();
}
private void sendMouse(float x, float y, int flag, int wheel) {
this.x = x; this.y = y;
session.pointer(x,y,flag,wheel);
cursor.invalidate();
}
@Override protected void onSizeChanged(int w,int h,int oldw,int oldh) { post(this::fit); }
@@ -133,17 +148,19 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
y=viewport.normalizedY(py);
}
lastX=px;lastY=py;
session.pointer(x,y,0,0); cursor.invalidate();
mouseInput.move(x,y); cursor.invalidate();
return true;
}
private void click(int down, int up) { session.pointer(x,y,down,0); session.pointer(x,y,up,0); }
private void click(int down, int up) { mouseInput.click(down,up); }
private boolean touch(MotionEvent e) {
int action = e.getActionMasked();
if (action == MotionEvent.ACTION_DOWN) {
twoFinger = viewportGesture = viewportPanActive = false;
touchRevision = mouseInput.revision();
suppressPointerGesture = mouseInput.hasVirtualButton();
}
if (action == MotionEvent.ACTION_POINTER_DOWN) {
if (dragging) { session.pointer(x,y,2,0); dragging = false; }
if (dragging) { mouseInput.gestureLeft(false); dragging = false; }
twoFinger = true;
twoFingerX = focus(e,true,-1); twoFingerY = scrollY = focus(e,false,-1);
viewportPanActive = viewport.isZoomed() && viewport.contains(twoFingerX,twoFingerY);
@@ -152,6 +169,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
}
scales.onTouchEvent(e);
if (twoFinger && action == MotionEvent.ACTION_MOVE && e.getPointerCount() >= 2) {
if (mouseInput.hasVirtualButton()) return true;
float focusX = focus(e,true,-1), focusY = focus(e,false,-1);
if (viewport.isZoomed() || viewportGesture || scales.isInProgress()) {
viewportGesture = true;
@@ -179,7 +197,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
twoFingerY = scrollY = focus(e,false,e.getActionIndex());
}
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
if (dragging) session.pointer(x,y,2,0);
if (dragging) mouseInput.gestureLeft(false);
dragging = false;
viewport.finishGesture(); applyViewport();
viewportPanActive = false;
@@ -197,7 +215,8 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
@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) { }
@Override public void surfaceDestroyed(SurfaceHolder holder) { session.surface(null); }
@Override public void surfaceDestroyed(SurfaceHolder holder) { releaseMouse(); session.surface(null); }
@Override protected void onDetachedFromWindow() { releaseMouse(); super.onDetachedFromWindow(); }
private final class Cursor extends View {
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path arrow = new Path();
@@ -6,7 +6,7 @@ import android.view.*;
import android.widget.*;
import static cn.crossdesk.mobile.MobileUi.*;
/** Safe-area overlay: a draggable 54 dp orb, a three-column menu and a floating keyboard. */
/** Safe-area overlay: a draggable 54 dp orb, an adaptive menu and a floating keyboard. */
@android.annotation.SuppressLint("ViewConstructor")
final class SessionControls extends FrameLayout {
interface Actions {
@@ -21,8 +21,13 @@ final class SessionControls extends FrameLayout {
private final LinearLayout waiting;
private View shield;
private LinearLayout menu,keyboard;
private VirtualMouseBar mouseBar;
private FrameLayout mouseHost;
private boolean mouseVisible;
private ScrollView keyboardKeys;
private ScrollView menuScroll;
private int keyboardPreferredHeight;
private int menuColumns=3;
private TextView networkLabel,videoFeedback;
private TextView transferLabel,saveFileButton;
private String transferStatus="";
@@ -49,8 +54,36 @@ final class SessionControls extends FrameLayout {
drag(orb,orb,()->{if(menu==null)openMenu("controls");else closeMenu();});
}
void setRelative(boolean value){relative=value;if(menu!=null&&menuPage.equals("controls"))openMenu("controls");}
void attachMouseBar(FrameLayout host, VirtualMouseBar bar) {
mouseHost=host;mouseBar=bar;
// A direct, tightly bounded sibling of the video is essential: otherwise
// an existing touch in this full-screen controls container captures the
// second finger before Android can dispatch it to the remote surface.
host.addView(bar,new FrameLayout.LayoutParams(ui.dp(140),ui.dp(48)));
bar.setVisibility(GONE);
}
void cancelMouseInput(){if(mouseBar!=null)mouseBar.cancelInput();}
private void toggleMouse(){cancelMouseInput();mouseVisible=!mouseVisible;closeMenu();positionMouse();}
private void positionMouse(){
if(mouseBar==null)return;
boolean visible=mouseVisible&&menu==null&&waiting.getVisibility()==GONE;
if(!visible){if(mouseBar.getVisibility()==VISIBLE)cancelMouseInput();mouseBar.setVisibility(GONE);return;}
int[] location=new int[2],hostLocation=new int[2];getLocationOnScreen(location);mouseHost.getLocationOnScreen(hostLocation);
float width=ui.dp(140),height=ui.dp(48),gap=ui.dp(6),margin=ui.dp(8);
float x=orb.getX()>=width+gap+margin?orb.getX()-gap-width:orb.getX()+orb.getWidth()+gap;
float y=orb.getY()+(orb.getHeight()-height)/2;
// When the orb is near the middle of a narrow phone, use the adjacent
// row rather than letting the two controls overlap.
if(x+width>getWidth()-margin){x=bound(orb.getX(),margin,getWidth()-width-margin);y=orb.getY()+orb.getHeight()+gap;if(y+height>getHeight()-margin)y=orb.getY()-gap-height;}
if(keyboard!=null&&x<keyboard.getX()+keyboard.getWidth()&&x+width>keyboard.getX()&&y<keyboard.getY()+keyboard.getHeight()&&y+height>keyboard.getY()){
y=keyboard.getY()-gap-height;if(y<margin)y=keyboard.getY()+keyboard.getHeight()+gap;
}
mouseBar.setX(location[0]-hostLocation[0]+bound(x,margin,getWidth()-width-margin));
mouseBar.setY(location[1]-hostLocation[1]+bound(y,margin,getHeight()-height-margin));
mouseBar.setVisibility(VISIBLE);
}
void setMuted(boolean value){muted=value;if(menu!=null&&menuPage.equals("controls"))openMenu("controls");}
void videoSize(int width,int height){waiting.setVisibility(GONE);}
void videoSize(int width,int height){waiting.setVisibility(GONE);positionMouse();}
void resetVideoStatistics(){statistics(new RemoteNetworkStatistics.Snapshot(network.report,0,0,0,Double.NaN,network.rtt));}
void statistics(RemoteNetworkStatistics.Snapshot value){network=value;renderNetwork();}
void videoSettings(RemoteVideoSettings value){settings=value;renderVideoSettings();}
@@ -59,8 +92,9 @@ final class SessionControls extends FrameLayout {
if(transferLabel!=null){transferLabel.setText(transferStatus);transferLabel.setVisibility(transferStatus.isEmpty()?GONE:VISIBLE);}
if(saveFileButton!=null)saveFileButton.setVisibility(hasReceivedFile?VISIBLE:GONE);
}
private void closeMenu(){if(menu!=null){removeView(menu);menu=null;}if(shield!=null){removeView(shield);shield=null;}networkLabel=videoFeedback=transferLabel=saveFileButton=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription("展开远程控制菜单");}
private void closeMenu(){if(menu!=null){removeView(menu);menu=null;menuScroll=null;}if(shield!=null){removeView(shield);shield=null;}networkLabel=videoFeedback=transferLabel=saveFileButton=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription("展开远程控制菜单");positionMouse();}
private void openMenu(String page){
cancelMouseInput();
closeMenu();menuPage=page;shield=new View(getContext());shield.setOnClickListener(v->closeMenu());addView(shield,new LayoutParams(-1,-1));
menu=ui.column();menu.setPadding(ui.dp(12),ui.dp(12),ui.dp(12),ui.dp(12));ui.card(menu,18);menu.setElevation(ui.dp(12));
LinearLayout header=ui.row();
@@ -68,18 +102,19 @@ final class SessionControls extends FrameLayout {
LinearLayout titles=ui.column();TextView title=ui.text(page.equals("controls")?"● 已连接":page.equals("video")?"画面设置":"网络状态",14,true);titles.addView(title);
if(page.equals("controls")){ui.gap(titles,3);networkLabel=ui.text(network.summary(),10,false);networkLabel.setFontFeatureSettings("tnum");networkLabel.setTextColor(SECONDARY);titles.addView(networkLabel);}header.addView(titles,new LinearLayout.LayoutParams(0,-2,1));header.addView(ui.iconButton("close","关闭控制栏",this::closeMenu),ui.size(30,32));menu.addView(header);ui.gap(menu,9);
if(page.equals("video")){videoFeedback=ui.text("",12,false);videoFeedback.setTextColor(SECONDARY);videoFeedback.setPadding(0,0,0,ui.dp(9));menu.addView(videoFeedback);}
ScrollView scroll=new ScrollView(getContext());menu.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));LinearLayout body=ui.column();scroll.addView(body);
ScrollView scroll=new ScrollView(getContext());menuScroll=scroll;menu.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));LinearLayout body=ui.column();scroll.addView(body);
if(page.equals("network")){
// Reserve the scrollbar's width plus a gap before right-aligned statistics.
scroll.setScrollBarStyle(View.SCROLLBARS_OUTSIDE_INSET);body.setPadding(0,0,ui.dp(6),0);
}
if(page.equals("controls")){
String[] labels={"键盘","显示器","画面设置",muted?"静音":"声音",relative?"相对鼠标":"绝对鼠标","发送文件","网络状态","Ctrl+Alt+Del","剪贴板"};
String[] icons={"keyboard","display","sliders","audio","mouse","folder","chart","lock","clipboard"};
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},actions::display,()->openMenu("video"),actions::mute,actions::mouse,actions::sendFile,()->openMenu("network"),actions::secureAttention,actions::clipboard};
for(int row=0;row<3;row++){LinearLayout line=ui.row();for(int col=0;col<3;col++){int index=row*3+col;LinearLayout control=ui.column();control.setGravity(Gravity.CENTER);control.setBackground(ui.background(0xFFF0F0F2,10));control.addView(ui.icon(icons[index],INK),ui.size(19,19));ui.gap(control,4);TextView caption=ui.text(labels[index],10,false);caption.setSingleLine();caption.setGravity(Gravity.CENTER);control.addView(caption);control.setContentDescription(labels[index]);control.setClickable(true);control.setFocusable(true);control.setOnClickListener(v->clicks[index].run());
String[] labels={"键盘",relative?"相对鼠标":"绝对鼠标",mouseVisible?"收起鼠标":"虚拟鼠标","显示器","画面设置",muted?"静音":"声音","发送文件","网络状态","Ctrl+Alt+Del","剪贴板"};
String[] icons={"keyboard","mouse","mouse","display","sliders","audio","folder","chart","lock","clipboard"};
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},actions::mouse,this::toggleMouse,actions::display,()->openMenu("video"),actions::mute,actions::sendFile,()->openMenu("network"),actions::secureAttention,actions::clipboard};
menuColumns=controlColumns();
for(int row=0;row<(labels.length+menuColumns-1)/menuColumns;row++){LinearLayout line=ui.row();for(int col=0;col<menuColumns;col++){int index=row*menuColumns+col;if(index>=labels.length){View spacer=new View(getContext());LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(spacer,p);continue;}LinearLayout control=ui.column();control.setGravity(Gravity.CENTER);control.setBackground(ui.background(0xFFF0F0F2,10));control.addView(ui.icon(icons[index],INK),ui.size(19,19));ui.gap(control,4);TextView caption=ui.text(labels[index],10,false);caption.setSingleLine();caption.setGravity(Gravity.CENTER);control.addView(caption);control.setContentDescription(labels[index]);control.setClickable(true);control.setFocusable(true);control.setOnClickListener(v->clicks[index].run());
LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(control,p);
}body.addView(line);if(row<2)ui.gap(body,7);}
}body.addView(line);if((row+1)*menuColumns<labels.length)ui.gap(body,7);}
ui.gap(body,7);transferLabel=ui.text(transferStatus,11,false);transferLabel.setTextColor(SECONDARY);transferLabel.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE);body.addView(transferLabel);
saveFileButton=ui.action("保存收到的文件",BLUE,actions::saveFile);body.addView(saveFileButton);renderFileStatus();
}else if(page.equals("network")){
@@ -91,6 +126,7 @@ final class SessionControls extends FrameLayout {
}
ui.gap(menu,9);TextView disconnect=ui.action("断开连接",Color.WHITE,()->{closeMenu();closeKeyboard();actions.disconnect();});disconnect.setMinHeight(ui.dp(34));disconnect.setTextSize(14);disconnect.setBackground(ui.background(RED,8));menu.addView(disconnect,ui.size(-1,34));
addView(menu,new LayoutParams(panelWidth(getWidth()),panelHeight(getHeight())));orb.bringToFront();orb.setContentDescription("收起远程控制菜单");
positionMouse();
}
private final class SettingRow {
final int field;final int[] values;final SegmentedControl group;
@@ -145,12 +181,24 @@ final class SessionControls extends FrameLayout {
for(int i=0;i<values.length;i++)networkDetails[i].setText(values[i]);networkDetails[5].setTextColor(network.report!=null&&network.report.srtp?GREEN:SECONDARY);
encryptionLock.setVisibility(network.report!=null&&network.report.srtp?VISIBLE:GONE);encryptionOpen.setVisibility(network.report!=null&&!network.report.srtp?VISIBLE:GONE);
}
private int panelWidth(int width){return Math.max(1,Math.min(width-ui.dp(16),Math.max(ui.dp(280),Math.min(ui.dp(340),width-ui.dp(160)))));}
private int panelHeight(int height){return Math.min(ui.dp(310),Math.max(1,height-ui.dp(16)));}
private int controlColumns(){return getMeasuredHeight()<ui.dp(400)&&getMeasuredWidth()>=ui.dp(400)?4:3;}
private int panelWidth(int width){return Math.max(1,Math.min(width-ui.dp(16),controlColumns()==4?ui.dp(380):Math.max(ui.dp(280),Math.min(ui.dp(340),width-ui.dp(160)))));}
private int panelHeight(int height){
// First measure every row at its natural height, then cap only to the
// safe viewport. The weighted ScrollView takes any remaining space.
LinearLayout.LayoutParams scrollParams=(LinearLayout.LayoutParams)menuScroll.getLayoutParams();
scrollParams.height=LayoutParams.WRAP_CONTENT;scrollParams.weight=0;
menu.measure(MeasureSpec.makeMeasureSpec(panelWidth(getMeasuredWidth()),MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(0,MeasureSpec.UNSPECIFIED));
int contentHeight=menu.getMeasuredHeight();
scrollParams.height=0;scrollParams.weight=1;
menu.forceLayout();menuScroll.forceLayout();
return Math.min(contentHeight,Math.max(1,height-ui.dp(16)));
}
private void positionMenu(){if(menu==null)return;float ox=orb.getX()+orb.getWidth()/2f;float x=ox>getWidth()/2f?orb.getX()-ui.dp(12)-menu.getWidth():orb.getX()+orb.getWidth()+ui.dp(12);menu.setX(bound(x,ui.dp(8),getWidth()-menu.getWidth()-ui.dp(8)));menu.setY(bound(orb.getY(),ui.dp(8),getHeight()-menu.getHeight()-ui.dp(8)));}
private int keyboardWidth(int width){return Math.min(ui.dp(computerKeyboard?520:440),Math.max(1,width-ui.dp(16)));}
private int keyboardHeight(int height){return Math.min(keyboardPreferredHeight,Math.max(1,height-ui.dp(16)));}
private void positionKeyboard(){if(keyboard!=null){keyboard.setX(bound(keyboard.getX(),ui.dp(8),getWidth()-keyboard.getWidth()-ui.dp(8)));keyboard.setY(bound(keyboard.getY(),ui.dp(8),getHeight()-keyboard.getHeight()-ui.dp(8)));}}
private void positionKeyboard(){if(keyboard!=null){keyboard.setX(bound(keyboard.getX(),ui.dp(8),getWidth()-keyboard.getWidth()-ui.dp(8)));keyboard.setY(bound(keyboard.getY(),ui.dp(8),getHeight()-keyboard.getHeight()-ui.dp(8)));}positionMouse();}
private void closeKeyboard(){if(keyboard!=null){removeView(keyboard);keyboard=null;keyboardKeys=null;}}
private void toggleKeyboard(){if(keyboard!=null){closeKeyboard();modifiers.clear();return;}modifiers.clear();showKeyboard();}
private void showKeyboard(){
@@ -185,8 +233,8 @@ final class SessionControls extends FrameLayout {
handle.setOnClickListener(v->tap.run());handle.setOnTouchListener(new View.OnTouchListener(){float startX,startY,originX,originY;boolean moved;
public boolean onTouch(View v,MotionEvent event){
switch(event.getActionMasked()){
case MotionEvent.ACTION_DOWN:startX=event.getRawX();startY=event.getRawY();originX=target.getX();originY=target.getY();moved=false;return true;
case MotionEvent.ACTION_MOVE:float dx=event.getRawX()-startX,dy=event.getRawY()-startY;if(Math.hypot(dx,dy)>ui.dp(4))moved=true;if(moved){if(target==orb)closeMenu();target.setX(bound(originX+dx,ui.dp(8),getWidth()-target.getWidth()-ui.dp(8)));target.setY(bound(originY+dy,ui.dp(8),getHeight()-target.getHeight()-ui.dp(8)));}return true;
case MotionEvent.ACTION_DOWN:if(target==orb)cancelMouseInput();startX=event.getRawX();startY=event.getRawY();originX=target.getX();originY=target.getY();moved=false;return true;
case MotionEvent.ACTION_MOVE:float dx=event.getRawX()-startX,dy=event.getRawY()-startY;if(Math.hypot(dx,dy)>ui.dp(4))moved=true;if(moved){if(target==orb)closeMenu();target.setX(bound(originX+dx,ui.dp(8),getWidth()-target.getWidth()-ui.dp(8)));target.setY(bound(originY+dy,ui.dp(8),getHeight()-target.getHeight()-ui.dp(8)));positionMouse();}return true;
case MotionEvent.ACTION_UP:if(!moved)v.performClick();return true;
case MotionEvent.ACTION_CANCEL:return true;
default:return false;
@@ -195,9 +243,12 @@ final class SessionControls extends FrameLayout {
});
}
void constrainPanels(){
cancelMouseInput();
if(menu!=null&&menuPage.equals("controls")&&menuColumns!=controlColumns())openMenu("controls");
orb.setX(bound(orb.getX(),ui.dp(8),getWidth()-orb.getWidth()-ui.dp(8)));orb.setY(bound(orb.getY(),ui.dp(8),getHeight()-orb.getHeight()-ui.dp(8)));
if(menu!=null||keyboard!=null)requestLayout();
positionKeyboard();
positionMouse();
}
@Override protected void onMeasure(int widthSpec,int heightSpec){
super.onMeasure(widthSpec,heightSpec);
@@ -205,7 +256,9 @@ final class SessionControls extends FrameLayout {
if(menu!=null){
// Use this traversal's viewport, including rotation/inset changes, before drawing.
ViewGroup.LayoutParams p=menu.getLayoutParams();int width=panelWidth(getMeasuredWidth()),height=panelHeight(getMeasuredHeight());
if(p.width!=width||p.height!=height){p.width=width;p.height=height;resized=true;}
// panelHeight performed an unconstrained measurement; always
// measure again with the final cap, even when it did not change.
p.width=width;p.height=height;resized=true;
}
if(keyboard!=null){
ViewGroup.LayoutParams p=keyboard.getLayoutParams();int width=keyboardWidth(getMeasuredWidth()),height=keyboardHeight(getMeasuredHeight());
@@ -220,7 +273,9 @@ final class SessionControls extends FrameLayout {
// New/rebuilt menus have real dimensions now; position them in the same frame.
positionMenu();
positionKeyboard();
positionMouse();
}
@Override protected void onDetachedFromWindow(){cancelMouseInput();super.onDetachedFromWindow();}
@Override protected void onSizeChanged(int width,int height,int oldWidth,int oldHeight){super.onSizeChanged(width,height,oldWidth,oldHeight);post(this::constrainPanels);}
private static float bound(float value,float min,float max){return Math.max(min,Math.min(value,Math.max(min,max)));}
}
@@ -0,0 +1,153 @@
package cn.crossdesk.mobile;
import android.annotation.SuppressLint;
import android.graphics.Color;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.InsetDrawable;
import android.view.MotionEvent;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.TextView;
/** A 140 × 48 dp bar. Touch ownership continues outside the original key bounds. */
@SuppressLint("ViewConstructor")
final class VirtualMouseBar extends LinearLayout {
private final RemoteMouseInput input;
private final MobileUi ui;
private final TextView[] keys = new TextView[3];
private int generation;
VirtualMouseBar(MobileUi ui, RemoteMouseInput input) {
super(ui.context); this.ui = ui; this.input = input;
setOrientation(HORIZONTAL);
setPadding(ui.dp(4), ui.dp(2), ui.dp(4), ui.dp(2));
GradientDrawable shell = ui.background(Color.WHITE, 18);
shell.setStroke(ui.dp(.5f), 0xFFD4DAE2);
setBackground(shell);
setMotionEventSplittingEnabled(true);
keys[0] = key("鼠标左键,按住并拖动,松开结束拖拽", 0);
keys[1] = key("鼠标滚轮,上下滑动,可滑出按键范围", 1);
keys[2] = key("鼠标右键,按住并拖动,松开结束拖拽", 2);
input.didCancel = this::resetTouches;
setContentDescription("虚拟鼠标");
}
@SuppressLint("ClickableViewAccessibility") // Touch sends real down/up; performClick serves accessibility only.
private TextView key(String description, int index) {
TextView key = index == 1 ? new WheelKey(ui) : new TextView(ui.context);
key.setGravity(android.view.Gravity.CENTER);
key.setContentDescription(description);
key.setClickable(true); key.setFocusable(true);
highlight(key, index, false);
addView(key, new LayoutParams(0, ui.dp(44), 1));
key.setOnClickListener(v -> {
if (index == 1) { ((WheelKey)key).advance(ui.dp(10)); input.scroll(-1); }
else { input.virtualButton(index == 0, true); input.virtualButton(index == 0, false); }
});
if (index == 1) {
key.setAccessibilityDelegate(new View.AccessibilityDelegate() {
@Override public void onInitializeAccessibilityNodeInfo(View host, android.view.accessibility.AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(host, info);
info.setScrollable(true);
info.addAction(android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD);
info.addAction(android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD);
}
@Override public boolean performAccessibilityAction(View host, int action, android.os.Bundle args) {
if (action == android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_FORWARD || action == android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
int direction = action == android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_FORWARD ? -1 : 1;
((WheelKey)key).advance(-direction * ui.dp(10));
input.scroll(direction); return true;
}
return super.performAccessibilityAction(host, action, args);
}
});
}
key.setOnTouchListener(new View.OnTouchListener() {
int pointer = -1, startedGeneration;
float lastX, lastY, remainder;
@Override public boolean onTouch(View v, MotionEvent event) {
int action = event.getActionMasked();
if (action == MotionEvent.ACTION_DOWN) {
pointer = event.getPointerId(0); startedGeneration = generation;
lastX = event.getX(); lastY = event.getY(); remainder = 0;
highlight(key, index, true);
if (index != 1) input.virtualButton(index == 0, true);
return true;
}
if (startedGeneration != generation || pointer == -1) return true;
if (action == MotionEvent.ACTION_MOVE) {
int p = event.findPointerIndex(pointer);
if (p < 0) return true;
float dx = event.getX(p) - lastX, dy = event.getY(p) - lastY;
lastX = event.getX(p); lastY = event.getY(p);
if (index != 1) { input.drag(dx, dy); return true; }
((WheelKey)key).advance(dy);
remainder += dy;
float step = ui.dp(10);
int ticks = (int)(remainder / step);
// Positive protocol wheel values scroll up; finger-down scrolls down.
for (int i = 0; i < Math.min(Math.abs(ticks), 32); i++) input.scroll(ticks > 0 ? -1 : 1);
remainder -= ticks * step;
}
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL ||
(action == MotionEvent.ACTION_POINTER_UP && event.getPointerId(event.getActionIndex()) == pointer)) {
if (index != 1) input.virtualButton(index == 0, false);
pointer = -1; remainder = 0; highlight(key, index, false);
}
return true;
}
});
return key;
}
private void highlight(TextView key, int index, boolean pressed) {
key.setPressed(pressed);
key.setTextColor(pressed ? Color.WHITE : 0xB8303640);
// Narrow only the wheel's visible face; its 44 × 44 dp target remains.
GradientDrawable face = ui.background(pressed ? MobileUi.BLUE : 0xFFEDF0F4, 6);
float left = ui.dp(index == 0 ? 14 : 6), right = ui.dp(index == 2 ? 14 : 6);
face.setCornerRadii(new float[]{left,left,right,right,right,right,left,left});
int horizontal = ui.dp(index == 1 ? 13 : 1), vertical = ui.dp(index == 1 ? 7 : 2);
key.setBackground(new InsetDrawable(face, horizontal, vertical, horizontal, vertical));
}
void cancelInput() {
input.releaseAll();
}
private void resetTouches() {
generation++;
for (int i=0;i<keys.length;i++) if (keys[i] != null) highlight(keys[i], i, false);
}
/** Flat, uniformly colored treads follow the finger, wrapping every 5 dp. */
private static final class WheelKey extends TextView {
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path clip = new Path();
private final float density;
private float phase = 2;
WheelKey(MobileUi ui) {
super(ui.context); density = getResources().getDisplayMetrics().density;
clip.addRoundRect(new RectF(0,0,12,24),2,2,Path.Direction.CW);
}
void advance(float distance) {
phase = (phase + distance / density * .65f) % 5;
invalidate();
}
@Override protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.save();
canvas.translate((getWidth()-12*density)/2f, (getHeight()-24*density)/2f);
canvas.scale(density,density);
canvas.clipPath(clip);
paint.setColor(getCurrentTextColor());
for (int i=-1;i<=6;i++) {
float y=i*5+phase;
canvas.drawRect(0,y,12,y+1.5f,paint);
}
canvas.restore();
}
}
@Override protected void onDetachedFromWindow() { cancelInput(); super.onDetachedFromWindow(); }
@Override public void onWindowFocusChanged(boolean focused) { super.onWindowFocusChanged(focused); if (!focused) cancelInput(); }
}