mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-19 02:13:03 +08:00
[feat] add Android system keyboard input and improve computer keyboard, refs #118
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
- 横竖屏等比例显示、远端多显示器切换。与 iOS 一致的画面质量(低/中/高)、采集帧率(30/60 fps)、画面偏好;支持能力检测、电脑端确认、失败/5 秒超时回退及连续操作的应答排序。
|
||||
- 根据常见移动设备解码能力和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。
|
||||
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
|
||||
- 可拖动的悬浮控制球、三列控制菜单和浮动 ASCII / 电脑键盘、常用快捷键;通过剪贴板发送中文等文本,再手动执行远端粘贴。
|
||||
- 可拖动的悬浮控制球、三列控制菜单;默认使用系统输入法,保留候选词、纠错和听写,确认文字后直接输入远端。电脑键盘固定底部,分“字母数字 / 符号功能”两页,共用末两行布局,按系统键盘高度适配横竖屏;统一切换、收起按钮和 Android 键盘触感反馈。
|
||||
- 悬浮菜单中的“虚拟鼠标”展开 140 × 48 dp 横向左键/滚轮/右键条,跟随悬浮球移动,可随时收起,不压缩远程画面。按住左键或右键后,同一根手指拖动即可拖拽,滑出按键范围仍继续,松开结束;也支持另一指在画面上移动光标。采用无文字的扁平分体按键,中间滚轮外观为 18 × 30 dp,三键均保留 44 × 44 dp 触控范围,通过留白分隔。无阴影、凹凸或按压位移,按压仅变色。等色横条随手指上下循环移动,松手停止;上下滑动可超出按键范围,在画面放大后仍控制远端滚动。切换模式、显示器、进入后台或取消触摸会释放按键。状态菜单按内容高度完整展开,只有安全区域空间不足时才滚动。
|
||||
- 悬浮控制栏支持选择并发送文件、接收电脑端文件、查看确认进度及保存收到的文件;Ctrl+Alt+Del 使用远端 Windows 系统服务,未启用服务时显示提示。
|
||||
- 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意。远程会话在切换应用时自动使用系统画中画,不可用时自动使用通知保活。
|
||||
@@ -151,7 +151,7 @@ gh secret set ANDROID_KEY_PASSWORD --repo kunkundi/crossdesk
|
||||
|
||||
身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 MiniRTC 的诊断日志包含登录身份,Android 桥接层关闭其会话日志。身份存储损坏会显示错误,不会静默重新注册。
|
||||
|
||||
会话中的“剪贴板”发送用户输入的文本;收到的远端文本先留在会话内,点击“复制远端文本”才写入手机剪贴板。退出会话后清除会话内容。每次发送最多 128 KB。浮动键盘切换“电脑”后的“复制 / 粘贴 / 全选”根据远端平台选择 Ctrl 或 macOS Command。
|
||||
会话中的“剪贴板”发送用户输入的文本;收到的远端文本先留在会话内,点击“复制远端文本”才写入手机剪贴板。退出会话后清除会话内容。每次发送最多 128 KB。电脑键盘的 Ctrl / Win / Alt / Shift 可锁定组合键;macOS 的 Command 对应 Win。系统键盘通过 `supports_text_input` 检测远端能力,支持 UTF-8 的 macOS 被控端可直接输入中文、表情和听写文本;其他端或旧版使用 ASCII 按键回退,不支持的文本会提示使用剪贴板,不会先删除远端内容。输入法尚未确认的组字不会发送,自动纠错仅替换已发送文本的变化后缀;点击远端、收起、切换键盘或进入后台会清除本地输入上下文。
|
||||
|
||||
“发送文件”调用系统文件选择器,后台读取所选文件后使用桌面端相同的 `file` / `file_feedback` 协议传输,支持空文件和中文文件名。每次发送一个文件,以 64 KiB 分块、最多 1 MiB 未确认数据限制发送队列;进度来自接收端确认,30 秒没有有效进展则报告失败。接收时校验大小并追踪完整字节范围,允许跨线程回调乱序,未完成的文件不会提供保存入口。同名文件分开存储,不覆盖已有文件;可通过“保存收到的文件”导出最近收到的文件,连续接收多个文件时应逐个保存。
|
||||
|
||||
|
||||
@@ -124,6 +124,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
else motion=navigatingBack||nextPage.equals("home")?PageHost.Motion.POP:PageHost.Motion.PUSH;
|
||||
}
|
||||
if(root!=null)hideKeyboard();page=nextPage;
|
||||
// Remote controls own the IME inset. Android 11–14 otherwise also resize
|
||||
// the content, unlike Android 15's enforced edge-to-edge layout.
|
||||
if(android.os.Build.VERSION.SDK_INT>=30&&android.os.Build.VERSION.SDK_INT<35)getWindow().setDecorFitsSystemWindows(!dark);
|
||||
signalBadge=null;identitySummary=null;announcementBadge=null;announcementView=null;announcementRefresh=null;
|
||||
settingsUpdateDot=null;aboutUpdateDot=null;settingsUpdateButton=null;aboutUpdateRow=null;aboutUpdateLabel=null;
|
||||
previewSwitch=null;clearPreviewsButton=null;previewCleanupError=null;previewCards.clear();recentGrid=null;
|
||||
@@ -132,7 +135,11 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
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)->{
|
||||
view.setPadding(insets.getSystemWindowInsetLeft(),insets.getSystemWindowInsetTop(),insets.getSystemWindowInsetRight(),insets.getSystemWindowInsetBottom());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);
|
||||
if(controls!=null)controls.keyboardInsets(Math.max(0,insets.getInsets(WindowInsets.Type.ime()).bottom-bars.bottom),insets.isVisible(WindowInsets.Type.ime()),bars.bottom);
|
||||
}else view.setPadding(insets.getSystemWindowInsetLeft(),insets.getSystemWindowInsetTop(),insets.getSystemWindowInsetRight(),insets.getSystemWindowInsetBottom());return insets;
|
||||
});
|
||||
root.setFocusableInTouchMode(true);pageHost.show(canvas,motion);
|
||||
applySystemBars(dark);
|
||||
@@ -320,12 +327,15 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
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();}
|
||||
public void key(int code){tapKey(code);}
|
||||
public boolean textEdit(int count,String value){return sendTextEdit(count,value);}
|
||||
public void chord(int code,int[] modifiers){if(session==null)return;for(int modifier:modifiers)session.key(modifier,true);tapKey(code);for(int i=modifiers.length-1;i>=0;i--)session.key(modifiers[i],false);}public void shortcut(int code){MainActivity.this.shortcut(code);}public void text(String value){sendText(value);}
|
||||
public void mute(){muted=!muted;if(session!=null)session.control(2,muted?0:1);controls.setMuted(muted);}
|
||||
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.setMuted(muted);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));
|
||||
video.remoteFocusChanged=()->{if(controls!=null)controls.resetKeyboardContext();};
|
||||
controls.requestApplyInsets();
|
||||
video.addOnLayoutChangeListener((view,left,top,right,bottom,oldLeft,oldTop,oldRight,oldBottom)->{
|
||||
if(!pictureInPictureUi&&!isInPictureInPictureMode())updatePictureInPicture();
|
||||
});
|
||||
@@ -463,7 +473,12 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
private void about(){
|
||||
if(aboutPages==null)aboutPages=new AboutPages(this,ui,this::settings,appUpdates);aboutPages.show();
|
||||
}
|
||||
private void sendText(String value){if(session==null)return;if(value.length()>2048){toast("单次最多 2048 个字符");return;}for(char ch:value.toCharArray())if(KeyMap.ascii(ch)==0){toast("中文等文本请通过剪贴板发送,再点击粘贴");return;}for(char ch:value.toCharArray()){int code=KeyMap.ascii(ch);boolean shift=(code&0x100)!=0;if(shift)session.key(0x10,true);tapKey(code&0xFF);if(shift)session.key(0x10,false);}}
|
||||
private void sendText(String value){sendTextEdit(0,value);}
|
||||
private boolean sendTextEdit(int count,String value){
|
||||
if(session==null)return false;
|
||||
if(session.textEdit(count,value))return true;
|
||||
toast("当前远端不支持此文本,中文或表情需更新 macOS 被控端,或通过剪贴板发送");return false;
|
||||
}
|
||||
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(){
|
||||
@@ -635,11 +650,20 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
@Override public void clipboard(String value){remoteClipboard=value;}
|
||||
@Override public void statistics(RemoteNetworkStatistics.Snapshot value){if(controls!=null)controls.statistics(value);if(value.fps>0)capturePreviewIfNeeded();}
|
||||
@Override public boolean dispatchKeyEvent(KeyEvent event){
|
||||
if(ready&&session!=null&&!pictureInPictureUi&&hasWindowFocus()&&!(getCurrentFocus() instanceof EditText)&&event.getKeyCode()!=KeyEvent.KEYCODE_BACK){int code=KeyMap.windowsCode(event.getKeyCode());if(code!=0){boolean down=event.getAction()==KeyEvent.ACTION_DOWN;if(down)pressed.add(code);else pressed.remove(code);session.key(code,down);return true;}}return super.dispatchKeyEvent(event);
|
||||
int code=KeyMap.windowsCode(event.getKeyCode());View focus=getCurrentFocus();
|
||||
boolean remoteEditor=focus instanceof RemoteKeyboardInput;
|
||||
boolean desktopKey=event.isCtrlPressed()||event.isAltPressed()||event.isMetaPressed()||pressed.contains(code)
|
||||
||code==17||code==18||code==91||code==27||code==9||code>=33&&code<=40||code==45||code==46||code>=112&&code<=123;
|
||||
if(ready&&session!=null&&!pictureInPictureUi&&hasWindowFocus()&&(!(focus instanceof EditText)||remoteEditor&&(!((RemoteKeyboardInput)focus).isAcceptingInput()||desktopKey))&&event.getKeyCode()!=KeyEvent.KEYCODE_BACK&&code!=0){
|
||||
boolean down=event.getAction()==KeyEvent.ACTION_DOWN;
|
||||
if(down){pressed.add(code);if(remoteEditor)controls.resetKeyboardContext();}else pressed.remove(code);
|
||||
session.key(code,down);return true;
|
||||
}
|
||||
return super.dispatchKeyEvent(event);
|
||||
}
|
||||
private void releaseKeys(){if(session!=null)for(int code:pressed)session.key(code,false);pressed.clear();}
|
||||
private void navigateBack(){if(backAction==null)return;navigatingBack=true;try{backAction.run();}finally{navigatingBack=false;}}
|
||||
private void back(){pageHost.finish();if(session!=null){if(ready)confirmDisconnect();else end("");}else if(backAction!=null)navigateBack();else finish();}
|
||||
private void back(){if(controls!=null&&controls.closeKeyboard())return;pageHost.finish();if(session!=null){if(ready)confirmDisconnect();else end("");}else if(backAction!=null)navigateBack();else finish();}
|
||||
@Override public void onWindowFocusChanged(boolean focus){super.onWindowFocusChanged(focus);if(!focus)releaseKeys();else applySystemBars(ready);}
|
||||
@android.annotation.SuppressLint("GestureBackNavigation") @Override public void onBackPressed(){back();}
|
||||
@Override public void onConfigurationChanged(Configuration config){
|
||||
@@ -751,7 +775,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
// onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
|
||||
@Override protected void onPause(){releaseKeys();if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();super.onPause();}
|
||||
@Override protected void onStart(){super.onStart();foreground=true;restoreBackgroundSession();appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();showConnectionMessage();}
|
||||
@Override protected void onStop(){foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();
|
||||
@Override protected void onStop(){if(controls!=null)controls.closeKeyboard();foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();
|
||||
if(connectionDialog!=null)connectionDialog.dismiss();
|
||||
// Closing native PiP is an explicit end, not a request for invisible
|
||||
// fallback. An unavailable/failed PiP can hand its live session off.
|
||||
|
||||
@@ -118,6 +118,8 @@ final class MobileUi {
|
||||
case "refresh":c.drawArc(4,4,20,20,35,310,false,paint);path(c,20,3,20,9,14,9);break;
|
||||
case "close":path(c,6,6,18,18);path(c,18,6,6,18);break;
|
||||
case "arrow":path(c,4,12,20,12);path(c,14,6,20,12,14,18);break;
|
||||
case "keyboard_hide":
|
||||
c.drawRoundRect(2,2,22,16,2,2,paint);for(int y=6;y<=9;y+=3)for(int x=6;x<=18;x+=4)c.drawPoint(x,y,paint);c.drawLine(7,13,17,13,paint);path(c,7,20,12,23,17,20);break;
|
||||
case "keyboard":
|
||||
c.drawRoundRect(2,5,22,19,3,3,paint);for(int y=9;y<=12;y+=3)for(int x=6;x<=18;x+=4)c.drawPoint(x,y,paint);c.drawLine(7,16,17,16,paint);break;
|
||||
case "display":c.drawRoundRect(2,3,22,17,2,2,paint);path(c,12,17,12,21);path(c,7,21,17,21);break;
|
||||
|
||||
@@ -47,7 +47,8 @@ final class NativeSession implements AutoCloseable {
|
||||
private String password, identity = "";
|
||||
private final int port;
|
||||
private long handle;
|
||||
private boolean signalReady, joining, connected, started;
|
||||
private boolean signalReady, joining, started;
|
||||
private volatile boolean connected, supportsTextInput;
|
||||
private int signalState;
|
||||
private volatile long signalGeneration;
|
||||
private boolean presenceRequested;
|
||||
@@ -189,7 +190,11 @@ final class NativeSession implements AutoCloseable {
|
||||
"远端密码错误", "找不到该设备 ID", "远端当前不可用"};
|
||||
fail(status >= 0 && status < reasons.length ? reasons[status] : "连接已结束");
|
||||
}
|
||||
} else if (type == 4) ui(() -> listener.data(event));
|
||||
} else if (type == 4) {
|
||||
JSONObject info=event.optJSONObject("host_info");
|
||||
if(info!=null)supportsTextInput=info.optBoolean("supports_text_input",false);
|
||||
ui(() -> listener.data(event));
|
||||
}
|
||||
else if (type == 5) {
|
||||
int width = event.getInt("width"), height = event.getInt("height");
|
||||
ui(() -> listener.videoSize(width, height));
|
||||
@@ -239,6 +244,23 @@ final class NativeSession implements AutoCloseable {
|
||||
void surface(Surface value) { execute(() -> { surface = value; if (handle != 0) nSurface(handle, value); }); }
|
||||
void pointer(float x, float y, int flag, int wheel) { execute(() -> { if (connected) nPointer(handle, x, y, flag, wheel); }); }
|
||||
void key(int code, boolean down) { execute(() -> { if (connected) nKey(handle, code, down); }); }
|
||||
boolean textEdit(int deletions,String text) {
|
||||
byte[] bytes=text.getBytes(StandardCharsets.UTF_8);
|
||||
boolean unicode=supportsTextInput;
|
||||
if(closed||!connected||deletions<0||deletions>4096||bytes.length>16*1024||text.indexOf('\0')>=0)return false;
|
||||
if(!unicode)for(char ch:text.toCharArray())if(KeyMap.ascii(ch)==0)return false;
|
||||
// Validate the entire edit before deleting anything. One RTC task preserves order.
|
||||
execute(()->{
|
||||
if(!connected)return;
|
||||
for(int i=0;i<deletions;i++){nKey(handle,8,true);nKey(handle,8,false);}
|
||||
if(unicode){if(bytes.length>0)nText(handle,bytes);}
|
||||
else for(char ch:text.toCharArray()){
|
||||
int code=KeyMap.ascii(ch);boolean shift=(code&0x100)!=0;
|
||||
if(shift)nKey(handle,16,true);nKey(handle,code&255,true);nKey(handle,code&255,false);if(shift)nKey(handle,16,false);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
void control(int type, int value) {
|
||||
AudioPlayer player = audio;
|
||||
if (type == 2 && player != null) player.setEnabled(value != 0);
|
||||
@@ -290,6 +312,7 @@ final class NativeSession implements AutoCloseable {
|
||||
private static native void nReady(long handle);
|
||||
private static native void nPointer(long handle, float x, float y, int flag, int wheel);
|
||||
private static native void nKey(long handle, int code, boolean down);
|
||||
private static native void nText(long handle,byte[] text);
|
||||
private static native void nControl(long handle, int type, int value);
|
||||
private static native boolean nVideoSettings(long handle,int quality,int frameRate,int preference,long requestId);
|
||||
private static native boolean nAnnouncementRequest(long handle,byte[] data);
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.drawable.StateListDrawable;
|
||||
import android.os.Build;
|
||||
import android.os.CancellationSignal;
|
||||
import android.util.SparseIntArray;
|
||||
import android.view.*;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.*;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/** Docked native IME accessory and two-page desktop keyboard share one toolbar. */
|
||||
@android.annotation.SuppressLint("ViewConstructor")
|
||||
final class RemoteKeyboard extends LinearLayout {
|
||||
private final MobileUi ui;
|
||||
private final SessionControls.Actions actions;
|
||||
final RemoteKeyboardInput editor;
|
||||
private final Runnable changed;
|
||||
private final LinearLayout toolbar,keys;
|
||||
private final TextView switchButton,lettersButton,functionsButton;
|
||||
private final Set<Integer> modifiers=new LinkedHashSet<>();
|
||||
private final SparseIntArray heights=new SparseIntArray();
|
||||
private boolean opened,computer,functions,imeVisible,measuring,switching,targetComputer;
|
||||
private int imeHeight,navigationBottom,layoutWidth,layoutHeight,revision,lastInsetsHeight=-1,lastInsetsKey;
|
||||
private CancellationSignal transition;
|
||||
|
||||
RemoteKeyboard(Context context,MobileUi ui,SessionControls.Actions actions,Runnable changed) {
|
||||
super(context);this.ui=ui;this.actions=actions;this.changed=changed;setOrientation(VERTICAL);
|
||||
setVisibility(GONE);setClickable(true);
|
||||
editor=new RemoteKeyboardInput(context,new RemoteKeyboardInput.Sender(){
|
||||
public boolean edit(int count,String text){return actions.textEdit(count,text);}
|
||||
public void key(int code){actions.key(code);}
|
||||
});
|
||||
FrameLayout header=new FrameLayout(context);addView(header,new LayoutParams(-1,ui.dp(44)));
|
||||
header.addView(editor,new FrameLayout.LayoutParams(1,1,Gravity.BOTTOM));
|
||||
toolbar=ui.row();toolbar.setGravity(Gravity.CENTER_VERTICAL);toolbar.setPadding(ui.dp(6),0,ui.dp(6),0);
|
||||
header.addView(toolbar,new FrameLayout.LayoutParams(-1,-1));
|
||||
switchButton=button("电脑键盘",this::switchKeyboard);switchButton.setPadding(ui.dp(8),0,ui.dp(8),0);
|
||||
toolbar.addView(switchButton,new LayoutParams(-2,-1));
|
||||
LinearLayout pages=ui.row();pages.setGravity(Gravity.CENTER);toolbar.addView(pages,new LayoutParams(0,-1,1));
|
||||
lettersButton=button("字母数字",()->page(false));functionsButton=button("符号功能",()->page(true));
|
||||
pages.addView(lettersButton,new LayoutParams(-2,ui.dp(32)));pages.addView(functionsButton,new LayoutParams(-2,ui.dp(32)));
|
||||
FrameLayout hide=new FrameLayout(context);hide.setContentDescription("收起键盘");hide.setClickable(true);hide.setFocusable(true);
|
||||
hide.addView(ui.icon("keyboard_hide",foreground()),new FrameLayout.LayoutParams(ui.dp(24),ui.dp(24),Gravity.CENTER));
|
||||
hide.setOnClickListener(v->{feedback(v);close();});toolbar.addView(hide,new LayoutParams(ui.dp(44),ui.dp(44)));
|
||||
keys=ui.column();keys.setPadding(ui.dp(3),ui.dp(4),ui.dp(3),ui.dp(4));addView(keys,new LayoutParams(-1,0,1));
|
||||
render();
|
||||
}
|
||||
private boolean dark(){return (getResources().getConfiguration().uiMode&Configuration.UI_MODE_NIGHT_MASK)==Configuration.UI_MODE_NIGHT_YES;}
|
||||
private int foreground(){return dark()?Color.WHITE:0xFF202124;}
|
||||
private TextView button(String label,Runnable action){
|
||||
TextView view=ui.action(label,foreground(),action);view.setTypeface(android.graphics.Typeface.DEFAULT);
|
||||
view.setTextSize(13);view.setMinHeight(0);view.setPadding(ui.dp(8),0,ui.dp(8),0);
|
||||
view.setOnClickListener(v->{feedback(v);action.run();});return view;
|
||||
}
|
||||
private void feedback(View view){view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);}
|
||||
boolean isOpen(){return opened;}
|
||||
boolean isComputer(){return computer;}
|
||||
int bottomInset(){return opened&&!computer?imeHeight:0;}
|
||||
private int sizeKey(){Configuration c=getResources().getConfiguration();return c.screenWidthDp*10000+c.screenHeightDp;}
|
||||
private int keyAreaHeight(){
|
||||
int height=heights.get(sizeKey(),0);
|
||||
if(height==0){Configuration c=getResources().getConfiguration();height=ui.dp(c.screenWidthDp>c.screenHeightDp?200:280);}
|
||||
int available=getParent() instanceof View?((View)getParent()).getMeasuredHeight()-ui.dp(44):height;
|
||||
return available>0?Math.min(height,available):height;
|
||||
}
|
||||
int preferredHeight(){return ui.dp(44)+(computer?keyAreaHeight():0);}
|
||||
void toggle(){if(opened)close();else open();}
|
||||
void open(){
|
||||
opened=true;computer=false;modifiers.clear();editor.start();setVisibility(VISIBLE);render();changed.run();
|
||||
post(()->{if(opened&&!computer)changeIme(true,false,false);});
|
||||
}
|
||||
boolean close(){
|
||||
if(!opened)return false;opened=false;measuring=switching=false;cancelTransition();modifiers.clear();editor.stop();
|
||||
getContext().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(getWindowToken(),0);
|
||||
setVisibility(GONE);changed.run();return true;
|
||||
}
|
||||
void resetContext(){editor.resetContext();}
|
||||
private void page(boolean next){functions=next;render();}
|
||||
private void switchKeyboard(){
|
||||
if(!opened||switching)return;
|
||||
targetComputer=!computer;switching=true;modifiers.clear();
|
||||
if(targetComputer){
|
||||
editor.pause();computer=true;render();changed.run();
|
||||
}else editor.start();
|
||||
// Lay out computer keys behind the still-visible IME before onReady,
|
||||
// which runs after layout. Keep them until the native IME is ready too.
|
||||
if(targetComputer&&Build.VERSION.SDK_INT>=30&&isHardwareAccelerated()){
|
||||
int token=revision;
|
||||
// Insets surfaces can update ahead of the app's render thread.
|
||||
// Submit the replacement frame before releasing the IME surface.
|
||||
getViewTreeObserver().registerFrameCommitCallback(()->post(()->{
|
||||
if(opened&&switching&&targetComputer&&token==revision)changeIme(false,true,false);
|
||||
}));
|
||||
invalidate();
|
||||
}else changeIme(!targetComputer,true,false);
|
||||
}
|
||||
private void finishSwitch(){
|
||||
if(!switching)return;switching=false;
|
||||
if(computer!=targetComputer){computer=targetComputer;render();}
|
||||
changed.run();
|
||||
}
|
||||
void updateInsets(int height,boolean visible,int navigation){
|
||||
int key=sizeKey();
|
||||
if(lastInsetsHeight==height&&lastInsetsKey==key&&imeVisible==visible&&navigationBottom==navigation)return;
|
||||
lastInsetsHeight=height;lastInsetsKey=key;
|
||||
navigationBottom=navigation;imeHeight=Build.VERSION.SDK_INT>=30?Math.max(0,height):0;
|
||||
boolean wasVisible=imeVisible;imeVisible=visible;
|
||||
if(visible&&height>ui.dp(80)){
|
||||
heights.put(sizeKey(),height);
|
||||
if(measuring){measuring=false;post(()->{if(opened&&computer)changeIme(false,true,false);});}
|
||||
}
|
||||
if(switching&&visible!=targetComputer&&(!visible||height>ui.dp(80)))finishSwitch();
|
||||
if(opened&&!computer&&!switching&&wasVisible&&!visible&&transition==null){close();return;}
|
||||
if(opened){renderKeysIfNeeded(getMeasuredWidth());changed.run();}
|
||||
}
|
||||
private void cancelTransition(){revision++;if(transition!=null){CancellationSignal old=transition;transition=null;old.cancel();}}
|
||||
private void changeIme(boolean show,boolean immediate,boolean measureOnly){
|
||||
cancelTransition();if(!opened)return;
|
||||
measuring=measureOnly;
|
||||
if(Build.VERSION.SDK_INT>=30&&immediate&&getWindowInsetsController()!=null){
|
||||
// Request control directly: showSoftInput first starts a competing
|
||||
// system animation before we can replace the computer keyboard.
|
||||
if(show)editor.start();else if(measureOnly)editor.requestFocus();
|
||||
int token=revision;transition=new CancellationSignal();
|
||||
getWindowInsetsController().controlWindowInsetsAnimation(WindowInsets.Type.ime(),0,null,transition,new WindowInsetsAnimationControlListener(){
|
||||
public void onReady(WindowInsetsAnimationController controller,int types){
|
||||
if(token!=revision||!opened){controller.finish(false);return;}
|
||||
int height=controller.getShownStateInsets().bottom-navigationBottom;
|
||||
if(height>ui.dp(80)){
|
||||
heights.put(sizeKey(),height);measuring=false;
|
||||
if(!measureOnly){imeHeight=show?height:0;finishSwitch();}
|
||||
controller.finish(show&&!measureOnly);
|
||||
}else if(measureOnly){
|
||||
// Some IMEs report zero until their new input view is laid out.
|
||||
// The first nonzero inset completes measurement and hides it.
|
||||
controller.finish(true);
|
||||
}else controller.finish(show);
|
||||
renderKeysIfNeeded(getMeasuredWidth());changed.run();
|
||||
}
|
||||
public void onFinished(WindowInsetsAnimationController controller){if(token==revision)transition=null;}
|
||||
public void onCancelled(WindowInsetsAnimationController controller){
|
||||
if(token!=revision)return;transition=null;
|
||||
if(opened)normalIme(show||measureOnly);
|
||||
}
|
||||
});
|
||||
}else normalIme(show||measureOnly);
|
||||
}
|
||||
private void normalIme(boolean show){
|
||||
InputMethodManager manager=getContext().getSystemService(InputMethodManager.class);
|
||||
if(show){if(computer)editor.requestFocus();else editor.start();manager.showSoftInput(editor,InputMethodManager.SHOW_IMPLICIT);}
|
||||
else manager.hideSoftInputFromWindow(editor.getWindowToken(),0);
|
||||
}
|
||||
private void render(){
|
||||
setBackgroundColor(dark()?0xFF202124:0xFFE8EAED);
|
||||
switchButton.setText(computer?"系统键盘":"电脑键盘");switchButton.setTextColor(foreground());
|
||||
lettersButton.setVisibility(computer?VISIBLE:GONE);functionsButton.setVisibility(computer?VISIBLE:GONE);
|
||||
for(TextView view:new TextView[]{lettersButton,functionsButton}){
|
||||
boolean selected=(view==functionsButton)==functions;view.setSelected(selected);
|
||||
view.setTextColor(selected?(dark()?0xFFA8C7FA:0xFF185ABC):foreground());
|
||||
view.setBackground(ui.background(selected?(dark()?0xFF394457:0xFFD2E3FC):Color.TRANSPARENT,6));
|
||||
}
|
||||
keys.setVisibility(computer?VISIBLE:GONE);layoutWidth=layoutHeight=0;renderKeysIfNeeded(getMeasuredWidth());
|
||||
}
|
||||
private void renderKeysIfNeeded(int width){
|
||||
if(!computer)return;int height=keyAreaHeight();
|
||||
if(layoutWidth==width&&layoutHeight==height)return;layoutWidth=width;layoutHeight=height;keys.removeAllViews();
|
||||
boolean landscape=getResources().getConfiguration().screenWidthDp>getResources().getConfiguration().screenHeightDp;
|
||||
String[][] upper;
|
||||
if(!functions)upper=new String[][]{{"1","2","3","4","5","6","7","8","9","0"},{"q","w","e","r","t","y","u","i","o","p"},{"a","s","d","f","g","h","j","k","l"}};
|
||||
else if(landscape)upper=new String[][]{{"`","-","=","[","]","\\",";","'",",",".","/"},{"F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12"},{"Esc","Tab","Caps","Ins","Del","Home","End"}};
|
||||
else upper=new String[][]{{"`","-","=","[","]","\\",";","'",",",".","/"},{"F1","F2","F3","F4","F5","F6"},{"F7","F8","F9","F10","F11","F12"},{"Esc","Tab","Caps","Ins","Del","Home","End"}};
|
||||
LinearLayout upperKeys=ui.column();keys.addView(upperKeys,new LayoutParams(-1,0,1));
|
||||
for(String[] row:upper)upperKeys.addView(keyRow(row),new LayoutParams(-1,0,1));
|
||||
// The same bottom two rows keep their geometry on both pages.
|
||||
int rowHeight=Math.max(1,(height-ui.dp(8))/5);
|
||||
keys.addView(keyRow(functions?new String[]{"⇧","←","↓","↑","→","PgUp","PgDn","⌫"}:new String[]{"⇧","z","x","c","v","b","n","m","⌫"}),new LayoutParams(-1,rowHeight));
|
||||
keys.addView(keyRow(new String[]{"Ctrl","Win","Alt","空格","Enter"}),new LayoutParams(-1,rowHeight));
|
||||
}
|
||||
private LinearLayout keyRow(String[] values){
|
||||
LinearLayout line=ui.row();line.setGravity(Gravity.CENTER);
|
||||
for(String value:values){
|
||||
boolean shift=modifiers.contains(16);String shown=shift&&value.length()==1?shifted(value):value;
|
||||
TextView key=button(shown,()->key(value));key.setPadding(0,0,0,0);
|
||||
key.setTextSize(value.length()>1?12:18);key.setSingleLine(true);
|
||||
int modifier=modifier(value);boolean selected=modifier!=0&&modifiers.contains(modifier);key.setSelected(selected);
|
||||
int color=selected?(dark()?0xFF596D8C:0xFFA8C7FA):value.length()>1||value.equals("⇧")||value.equals("⌫")?(dark()?0xFF30343B:0xFFD0D5DC):(dark()?0xFF3C4043:Color.WHITE);
|
||||
StateListDrawable background=new StateListDrawable();background.addState(new int[]{android.R.attr.state_pressed},ui.background(dark()?0xFF68717D:0xFFBDC9D8,7));background.addState(new int[]{},ui.background(color,7));key.setBackground(background);
|
||||
if(value.equals("⇧"))key.setContentDescription("Shift");else if(value.equals("⌫"))key.setContentDescription("退格");
|
||||
// Both penultimate rows allocate exactly 1/8 to Shift and Backspace.
|
||||
boolean penultimate=values[0].equals("⇧");float weight=value.equals("空格")?4:1;
|
||||
if(penultimate)weight=value.equals("⇧")||value.equals("⌫")?1:(6f/(values.length-2));
|
||||
boolean edge=penultimate&&(value.equals("⇧")||value.equals("⌫"));
|
||||
int edgeWidth=Math.max(1,(layoutWidth-ui.dp(6))/8-ui.dp(5));
|
||||
LayoutParams p=new LayoutParams(edge?edgeWidth:0,-1,edge?0:weight);p.setMargins(ui.dp(2.5f),ui.dp(3),ui.dp(2.5f),ui.dp(3));line.addView(key,p);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
private static int modifier(String value){return switch(value){case "⇧"->16;case "Ctrl"->17;case "Alt"->18;case "Win"->91;default->0;};}
|
||||
private static String shifted(String value){
|
||||
String plain="1234567890`-=[]\\;',./",shifted="!@#$%^&*()~_+{}|:\"<>?";int index=plain.indexOf(value);
|
||||
return index>=0?shifted.substring(index,index+1):value.toUpperCase(java.util.Locale.ROOT);
|
||||
}
|
||||
private void key(String value){
|
||||
int modifier=modifier(value);if(modifier!=0){if(!modifiers.add(modifier))modifiers.remove(modifier);render();return;}
|
||||
int code=switch(value){case "空格"->32;case "Enter"->13;case "⌫"->8;case "Esc"->27;case "Tab"->9;case "Caps"->20;case "Ins"->45;case "Del"->46;case "Home"->36;case "End"->35;case "PgUp"->33;case "PgDn"->34;case "←"->37;case "↑"->38;case "→"->39;case "↓"->40;default->value.startsWith("F")?111+Integer.parseInt(value.substring(1)):KeyMap.ascii(value.charAt(0))&255;};
|
||||
actions.chord(code,modifiers.stream().mapToInt(Integer::intValue).toArray());
|
||||
}
|
||||
@Override protected void onMeasure(int widthSpec,int heightSpec){
|
||||
renderKeysIfNeeded(MeasureSpec.getSize(widthSpec));super.onMeasure(widthSpec,heightSpec);
|
||||
}
|
||||
@Override protected void onSizeChanged(int width,int height,int oldWidth,int oldHeight){
|
||||
super.onSizeChanged(width,height,oldWidth,oldHeight);
|
||||
if(opened&&computer&&(width!=oldWidth||height!=oldHeight)&&heights.get(sizeKey(),0)==0)post(()->{
|
||||
if(opened&&computer&&!switching&&!measuring&&transition==null&&heights.get(sizeKey(),0)==0)changeIme(false,true,true);
|
||||
});
|
||||
}
|
||||
@Override protected void onDetachedFromWindow(){close();super.onDetachedFromWindow();}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.content.Context;
|
||||
import android.icu.text.BreakIterator;
|
||||
import android.text.Editable;
|
||||
import android.text.InputType;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.inputmethod.BaseInputConnection;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputConnection;
|
||||
import android.view.inputmethod.InputConnectionWrapper;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.EditText;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** A real editor keeps IME candidates, corrections and dictation local until committed. */
|
||||
@android.annotation.SuppressLint("ViewConstructor")
|
||||
final class RemoteKeyboardInput extends EditText {
|
||||
interface Sender {
|
||||
boolean edit(int deletions, String text);
|
||||
void key(int code);
|
||||
}
|
||||
private final Sender sender;
|
||||
private final android.os.Handler main=new android.os.Handler(android.os.Looper.getMainLooper());
|
||||
private String sent = "";
|
||||
private boolean accepting, resetting, enterPending;
|
||||
private int batches, generation;
|
||||
private final Runnable synchronize = this::flush;
|
||||
private void flush(){if(synchronize()&&enterPending){sender.key(13);resetContext();}}
|
||||
|
||||
RemoteKeyboardInput(Context context, Sender sender) {
|
||||
super(context);this.sender=sender;
|
||||
setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_AUTO_CORRECT | InputType.TYPE_TEXT_FLAG_MULTI_LINE);
|
||||
setImeOptions(EditorInfo.IME_FLAG_NO_EXTRACT_UI | EditorInfo.IME_FLAG_NO_FULLSCREEN | EditorInfo.IME_ACTION_NONE);
|
||||
setCursorVisible(false);setBackground(null);setPadding(0,0,0,0);setAlpha(0);
|
||||
setImportantForAutofill(IMPORTANT_FOR_AUTOFILL_NO);
|
||||
setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
|
||||
addTextChangedListener(new TextWatcher() {
|
||||
public void beforeTextChanged(CharSequence s,int start,int count,int after) { }
|
||||
public void onTextChanged(CharSequence s,int start,int before,int count) { }
|
||||
public void afterTextChanged(Editable value) { schedule(); }
|
||||
});
|
||||
}
|
||||
void start() {
|
||||
boolean resume=!accepting&&hasFocus();accepting=true;requestFocus();
|
||||
if(resume)getContext().getSystemService(InputMethodManager.class).restartInput(this);
|
||||
}
|
||||
// Keep focus while replacing the IME, but reject its late edits without
|
||||
// restarting it. A restart here can show it again after the controlled hide.
|
||||
void pause() { accepting=false;resetContext(); }
|
||||
void stop() { pause();clearFocus(); }
|
||||
boolean isAcceptingInput() { return accepting; }
|
||||
void resetContext() {
|
||||
generation++;batches=0;main.removeCallbacks(synchronize);resetting=true;
|
||||
// EditText.setText replaces the buffer and internally restarts the IME,
|
||||
// even while paused. Clear in place so only the explicit restart below
|
||||
// can renew the input connection.
|
||||
enterPending=false;sent="";BaseInputConnection.removeComposingSpans(getText());getText().clear();setSelection(0);resetting=false;
|
||||
if(accepting&&hasFocus())getContext().getSystemService(InputMethodManager.class).restartInput(this);
|
||||
}
|
||||
private void schedule() { if(!resetting&&accepting){main.removeCallbacks(synchronize);main.post(synchronize);} }
|
||||
private boolean composing() { return BaseInputConnection.getComposingSpanStart(getText())>=0; }
|
||||
private static List<String> characters(String text) {
|
||||
BreakIterator iterator=BreakIterator.getCharacterInstance();iterator.setText(text);
|
||||
List<String> result=new ArrayList<>();int start=iterator.first();
|
||||
for(int end=iterator.next();end!=BreakIterator.DONE;start=end,end=iterator.next())result.add(text.substring(start,end));
|
||||
return result;
|
||||
}
|
||||
private boolean synchronize() {
|
||||
if(!accepting||resetting||batches>0||composing())return false;
|
||||
String current=getText().toString();if(current.equals(sent))return true;
|
||||
List<String> old=characters(sent),next=characters(current);int prefix=0,offset=0;
|
||||
while(prefix<old.size()&&prefix<next.size()&&old.get(prefix).equals(next.get(prefix))){offset+=next.get(prefix).length();prefix++;}
|
||||
if(!sender.edit(old.size()-prefix,current.substring(offset))){resetContext();return false;}
|
||||
sent=current;
|
||||
// Bound the correction context without changing anything on the remote desktop.
|
||||
if(current.length()>2048||current.indexOf('\n')>=0)resetContext();
|
||||
return true;
|
||||
}
|
||||
private void enter() {
|
||||
if(!accepting)return;
|
||||
BaseInputConnection.removeComposingSpans(getText());
|
||||
enterPending=true;schedule();
|
||||
}
|
||||
@Override public boolean onKeyDown(int code,KeyEvent event) {
|
||||
if(accepting&&code==KeyEvent.KEYCODE_ENTER){enter();return true;}
|
||||
if(accepting&&code==KeyEvent.KEYCODE_DEL&&length()==0){sender.key(8);return true;}
|
||||
return super.onKeyDown(code,event);
|
||||
}
|
||||
@Override public boolean onKeyUp(int code,KeyEvent event) {
|
||||
if(accepting&&(code==KeyEvent.KEYCODE_ENTER||code==KeyEvent.KEYCODE_DEL))return true;
|
||||
return super.onKeyUp(code,event);
|
||||
}
|
||||
@Override public InputConnection onCreateInputConnection(EditorInfo info) {
|
||||
InputConnection base=super.onCreateInputConnection(info);if(base==null)return null;
|
||||
int revision=++generation;batches=0;
|
||||
return new InputConnectionWrapper(base,false) {
|
||||
private boolean active(){return accepting&&generation==revision;}
|
||||
@Override public boolean beginBatchEdit(){if(!active())return false;batches++;return super.beginBatchEdit();}
|
||||
@Override public boolean endBatchEdit(){if(!active())return false;boolean result=super.endBatchEdit();batches=Math.max(0,batches-1);schedule();return result;}
|
||||
@Override public boolean setComposingText(CharSequence text,int cursor){if(!active())return false;boolean result=super.setComposingText(text,cursor);schedule();return result;}
|
||||
@Override public boolean setComposingRegion(int start,int end){return active()&&super.setComposingRegion(start,end);}
|
||||
@Override public boolean commitText(CharSequence text,int cursor){if(!active())return false;boolean result=super.commitText(text,cursor);schedule();return result;}
|
||||
@Override public boolean finishComposingText(){if(!active())return false;boolean result=super.finishComposingText();schedule();return result;}
|
||||
@Override public boolean deleteSurroundingText(int before,int after){
|
||||
if(!active())return false;
|
||||
if(length()==0&&before>0&&after==0){for(int i=0;i<Math.min(before,2048);i++)sender.key(8);return true;}
|
||||
boolean result=super.deleteSurroundingText(before,after);schedule();return result;
|
||||
}
|
||||
@Override public boolean deleteSurroundingTextInCodePoints(int before,int after){
|
||||
if(!active())return false;
|
||||
if(length()==0&&before>0&&after==0){for(int i=0;i<Math.min(before,2048);i++)sender.key(8);return true;}
|
||||
boolean result=super.deleteSurroundingTextInCodePoints(before,after);schedule();return result;
|
||||
}
|
||||
@Override public boolean performEditorAction(int action){if(!active())return false;enter();return true;}
|
||||
@Override public boolean sendKeyEvent(KeyEvent event){return active()&&super.sendKeyEvent(event);}
|
||||
@Override public boolean setSelection(int start,int end){return active()&&super.setSelection(start,end);}
|
||||
@Override public void closeConnection(){if(active())super.closeConnection();}
|
||||
@android.annotation.SuppressLint("NewApi") // Framework calls these overloads only on API 33+.
|
||||
@Override public boolean commitText(CharSequence text,int cursor,android.view.inputmethod.TextAttribute attributes){
|
||||
if(!active())return false;boolean result=super.commitText(text,cursor,attributes);schedule();return result;
|
||||
}
|
||||
@android.annotation.SuppressLint("NewApi") // Framework calls these overloads only on API 33+.
|
||||
@Override public boolean setComposingText(CharSequence text,int cursor,android.view.inputmethod.TextAttribute attributes){
|
||||
if(!active())return false;boolean result=super.setComposingText(text,cursor,attributes);schedule();return result;
|
||||
}
|
||||
@android.annotation.SuppressLint("NewApi") // Framework calls these overloads only on API 33+.
|
||||
@Override public boolean setComposingRegion(int start,int end,android.view.inputmethod.TextAttribute attributes){return active()&&super.setComposingRegion(start,end,attributes);}
|
||||
@android.annotation.SuppressLint("NewApi") // Framework calls this overload only on API 34+.
|
||||
@Override public boolean replaceText(int start,int end,CharSequence text,int cursor,android.view.inputmethod.TextAttribute attributes){
|
||||
if(!active())return false;boolean result=super.replaceText(start,end,text,cursor,attributes);schedule();return result;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
|
||||
private final Cursor cursor;
|
||||
private final NativeSession session;
|
||||
final RemoteMouseInput mouseInput;
|
||||
Runnable remoteFocusChanged;
|
||||
private final GestureDetector gestures;
|
||||
private final ScaleGestureDetector scales;
|
||||
private final RemoteViewport viewport = new RemoteViewport();
|
||||
@@ -141,6 +142,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
|
||||
}
|
||||
private void sendMouse(float x, float y, int flag, int wheel) {
|
||||
this.x = x; this.y = y;
|
||||
if((flag==1||flag==3||flag==5)&&remoteFocusChanged!=null)remoteFocusChanged.run();
|
||||
session.pointer(x,y,flag,wheel);
|
||||
cursor.invalidate();
|
||||
}
|
||||
|
||||
@@ -6,12 +6,13 @@ import android.view.*;
|
||||
import android.widget.*;
|
||||
import static cn.crossdesk.mobile.MobileUi.*;
|
||||
|
||||
/** Safe-area overlay: a draggable 54 dp orb, an adaptive menu and a floating keyboard. */
|
||||
/** Safe-area overlay: a draggable 54 dp orb, an adaptive menu and a docked keyboard. */
|
||||
@android.annotation.SuppressLint("ViewConstructor")
|
||||
final class SessionControls extends FrameLayout {
|
||||
interface Actions {
|
||||
void disconnect(); void display(); void clipboard(); void key(int code);
|
||||
void chord(int code,int[] modifiers); void shortcut(int code); void text(String value); void mute(); void mouse();
|
||||
default boolean textEdit(int deletions,String value){return false;}
|
||||
void videoSettings(int field,int value);
|
||||
void sendFile(); void saveFile(); void secureAttention();
|
||||
}
|
||||
@@ -20,14 +21,20 @@ final class SessionControls extends FrameLayout {
|
||||
private final View orb;
|
||||
private final LinearLayout waiting;
|
||||
private View shield;
|
||||
private LinearLayout menu,keyboard;
|
||||
private LinearLayout menu;
|
||||
private final RemoteKeyboard keyboard;
|
||||
private VirtualMouseBar mouseBar;
|
||||
private FrameLayout mouseHost;
|
||||
private boolean mouseVisible,pictureInPicture;
|
||||
private ScrollView keyboardKeys;
|
||||
private ScrollView menuScroll;
|
||||
private int keyboardPreferredHeight;
|
||||
private int menuColumns=3;
|
||||
private final ViewTreeObserver.OnGlobalLayoutListener legacyKeyboardLayout=this::legacyKeyboardLayout;
|
||||
private void legacyKeyboardLayout(){
|
||||
if(android.os.Build.VERSION.SDK_INT>=30)return;
|
||||
android.graphics.Rect visible=new android.graphics.Rect();getWindowVisibleDisplayFrame(visible);
|
||||
int height=Math.max(0,getRootView().getHeight()-visible.bottom);
|
||||
keyboardInsets(height,height>ui.dp(80),0);
|
||||
}
|
||||
private TextView networkLabel,videoFeedback;
|
||||
private TextView transferLabel,saveFileButton;
|
||||
private String transferStatus="";
|
||||
@@ -40,10 +47,11 @@ final class SessionControls extends FrameLayout {
|
||||
private LinearLayout[] trafficRows;
|
||||
private TextView[] networkDetails;
|
||||
private View encryptionLock,encryptionOpen;
|
||||
private boolean muted,relative=true,shift,computerKeyboard,orbPositioned;
|
||||
private final java.util.Set<Integer> modifiers=new java.util.LinkedHashSet<>();
|
||||
private boolean muted,relative=true,orbPositioned;
|
||||
SessionControls(Context context,MobileUi ui,Actions actions){
|
||||
super(context);this.ui=ui;this.actions=actions;
|
||||
keyboard=new RemoteKeyboard(context,ui,actions,()->{requestLayout();positionMouse();});
|
||||
addView(keyboard,new LayoutParams(-1,ui.dp(44),Gravity.BOTTOM));
|
||||
// This container must remain transparent to remote touch outside its controls.
|
||||
waiting=ui.column();waiting.setMinimumWidth(ui.dp(200));waiting.setGravity(Gravity.CENTER);waiting.setPadding(ui.dp(20),ui.dp(20),ui.dp(20),ui.dp(20));waiting.setBackground(ui.background(0xB0202024,14));
|
||||
ProgressBar progress=new ProgressBar(context);progress.setIndeterminateTintList(android.content.res.ColorStateList.valueOf(Color.WHITE));waiting.addView(progress,ui.size(28,28));ui.gap(waiting,12);TextView message=ui.text("等待远端画面…",14,false);message.setTextColor(Color.WHITE);message.setGravity(Gravity.CENTER);waiting.addView(message);addView(waiting,new LayoutParams(-2,-2,Gravity.CENTER));
|
||||
@@ -65,7 +73,7 @@ final class SessionControls extends FrameLayout {
|
||||
void cancelMouseInput(){if(mouseBar!=null)mouseBar.cancelInput();}
|
||||
void setPictureInPicture(boolean value){
|
||||
pictureInPicture=value;
|
||||
if(value){cancelMouseInput();closeMenu();closeKeyboard();modifiers.clear();}
|
||||
if(value){cancelMouseInput();closeMenu();closeKeyboard();}
|
||||
setVisibility(value?GONE:VISIBLE);positionMouse();
|
||||
}
|
||||
private void toggleMouse(){cancelMouseInput();mouseVisible=!mouseVisible;closeMenu();positionMouse();}
|
||||
@@ -80,7 +88,7 @@ final class SessionControls extends FrameLayout {
|
||||
// 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()){
|
||||
if(keyboard.isOpen()&&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));
|
||||
@@ -201,38 +209,11 @@ final class SessionControls extends FrameLayout {
|
||||
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)));}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(){
|
||||
float oldX=keyboard==null?-1:keyboard.getX(),oldY=keyboard==null?-1:keyboard.getY();int oldScroll=keyboardKeys==null?0:keyboardKeys.getScrollY();closeKeyboard();keyboard=ui.column();keyboard.setPadding(ui.dp(5),ui.dp(5),ui.dp(5),ui.dp(5));keyboard.setBackground(ui.background(0xFFD1D6DE,12));keyboard.setElevation(ui.dp(10));
|
||||
LinearLayout top=ui.row();TextView handle=ui.text("━",19,true);handle.setGravity(Gravity.CENTER);handle.setTextColor(SECONDARY);handle.setContentDescription("拖动键盘");top.addView(handle,new LinearLayout.LayoutParams(0,ui.dp(28),1));top.addView(ui.iconButton("close","收起键盘",this::closeKeyboard),ui.size(30,28));keyboard.addView(top);drag(handle,keyboard,()->{});
|
||||
// Keep the drag/close row fixed while short split windows can scroll every key into view.
|
||||
keyboardKeys=new ScrollView(getContext());keyboard.addView(keyboardKeys,new LinearLayout.LayoutParams(-1,0,1));LinearLayout keys=ui.column();keyboardKeys.addView(keys);
|
||||
String[][] rows=computerKeyboard?new String[][]{{"Esc","F1","F2","F3","F4","F5","F6"},{"Tab","Ctrl","Alt","Win","↑","⌫"},{"复制","粘贴","全选","←","↓","→"},{"ABC","空格","Enter"}}:new String[][]{{"1","2","3","4","5","6","7","8","9","0"},{"q","w","e","r","t","y","u","i","o","p"},{"a","s","d","f","g","h","j","k","l"},{"⇧","z","x","c","v","b","n","m","⌫"},{"电脑","剪贴板","空格",".","Enter"}};
|
||||
for(String[] values:rows){LinearLayout line=ui.row();for(String value:values){String shown=shift&&value.length()==1?value.toUpperCase(java.util.Locale.ROOT):value;TextView key=ui.action(shown,INK,()->keyboardKey(value));key.setTextSize(value.length()>2?11:14);key.setMinHeight(ui.dp(31));int modifier=value.equals("Ctrl")?17:value.equals("Alt")?18:value.equals("Win")?91:0;key.setBackground(ui.background(modifiers.contains(modifier)?0xFF9BC7F7:value.length()>1||value.equals("⇧")?0xFFAFB8C5:Color.WHITE,5));LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(31),value.equals("空格")?3:1);p.setMargins(ui.dp(1.5f),0,ui.dp(1.5f),0);line.addView(key,p);}keys.addView(line);ui.gap(keys,3);}
|
||||
keyboardPreferredHeight=ui.dp(5)*2+ui.dp(28)+rows.length*(ui.dp(31)+ui.dp(3));
|
||||
addView(keyboard,new LayoutParams(keyboardWidth(getWidth()),keyboardHeight(getHeight())));LinearLayout shownKeyboard=keyboard;ScrollView shownKeys=keyboardKeys;
|
||||
keyboard.post(()->{if(keyboard==shownKeyboard){keyboard.setX(oldX<0?(getWidth()-keyboard.getWidth())/2f:oldX);keyboard.setY(oldY<0?getHeight()-keyboard.getHeight()-ui.dp(20):oldY);shownKeys.scrollTo(0,oldScroll);constrainPanels();}});
|
||||
}
|
||||
private void keyboardKey(String value){
|
||||
switch(value){
|
||||
case "⇧":shift=!shift;showKeyboard();return;
|
||||
case "电脑":computerKeyboard=true;showKeyboard();return;
|
||||
case "ABC":computerKeyboard=false;showKeyboard();return;
|
||||
case "剪贴板":actions.clipboard();return;
|
||||
case "复制":actions.shortcut(0x43);return;case "粘贴":actions.shortcut(0x56);return;case "全选":actions.shortcut(0x41);return;
|
||||
case "空格":sendKey(32);return;case "⌫":sendKey(8);return;case "Enter":sendKey(13);return;case "Esc":sendKey(27);return;case "Tab":sendKey(9);return;
|
||||
case "↑":sendKey(38);return;case "↓":sendKey(40);return;case "←":sendKey(37);return;case "→":sendKey(39);return;
|
||||
// Latch locally; the next key sends a complete chord without leaving remote modifiers down.
|
||||
case "Ctrl":toggleModifier(17);return;case "Alt":toggleModifier(18);return;case "Win":toggleModifier(91);return;
|
||||
default:if(value.matches("F[1-6]")){sendKey(111+Integer.parseInt(value.substring(1)));return;}if(!modifiers.isEmpty())sendKey(KeyMap.ascii(value.charAt(0))&255);else actions.text(shift?value.toUpperCase(java.util.Locale.ROOT):value);
|
||||
}
|
||||
}
|
||||
private void toggleModifier(int code){if(!modifiers.add(code))modifiers.remove(code);showKeyboard();}
|
||||
private void sendKey(int code){if(modifiers.isEmpty())actions.key(code);else{actions.chord(code,modifiers.stream().mapToInt(Integer::intValue).toArray());modifiers.clear();showKeyboard();}}
|
||||
private void positionKeyboard(){positionMouse();}
|
||||
boolean closeKeyboard(){return keyboard.close();}
|
||||
void resetKeyboardContext(){keyboard.resetContext();}
|
||||
void keyboardInsets(int height,boolean visible,int navigation){keyboard.updateInsets(height,visible,navigation);}
|
||||
private void toggleKeyboard(){keyboard.bringToFront();keyboard.toggle();}
|
||||
@android.annotation.SuppressLint("ClickableViewAccessibility") // Tap dispatches performClick; dragging is an additional action.
|
||||
private void drag(View handle,View target,Runnable tap){
|
||||
handle.setOnClickListener(v->tap.run());handle.setOnTouchListener(new View.OnTouchListener(){float startX,startY,originX,originY;boolean moved;
|
||||
@@ -251,7 +232,7 @@ final class SessionControls extends FrameLayout {
|
||||
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();
|
||||
if(menu!=null||keyboard.isOpen())requestLayout();
|
||||
positionKeyboard();
|
||||
positionMouse();
|
||||
}
|
||||
@@ -265,9 +246,10 @@ final class SessionControls extends FrameLayout {
|
||||
// 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());
|
||||
if(p.width!=width||p.height!=height){p.width=width;p.height=height;resized=true;}
|
||||
if(keyboard.isOpen()){
|
||||
LayoutParams p=(LayoutParams)keyboard.getLayoutParams();
|
||||
int height=keyboard.preferredHeight(),inset=keyboard.bottomInset();
|
||||
if(p.height!=height||p.bottomMargin!=inset){p.height=height;p.bottomMargin=inset;resized=true;}
|
||||
}
|
||||
if(resized)super.onMeasure(widthSpec,heightSpec);
|
||||
}
|
||||
@@ -280,7 +262,8 @@ final class SessionControls extends FrameLayout {
|
||||
positionKeyboard();
|
||||
positionMouse();
|
||||
}
|
||||
@Override protected void onDetachedFromWindow(){cancelMouseInput();super.onDetachedFromWindow();}
|
||||
@Override protected void onAttachedToWindow(){super.onAttachedToWindow();if(android.os.Build.VERSION.SDK_INT<30)getViewTreeObserver().addOnGlobalLayoutListener(legacyKeyboardLayout);}
|
||||
@Override protected void onDetachedFromWindow(){getViewTreeObserver().removeOnGlobalLayoutListener(legacyKeyboardLayout);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)));}
|
||||
}
|
||||
|
||||
@@ -392,6 +392,17 @@ extern "C" JNIEXPORT void JNICALL JNI_METHOD(nKey)(JNIEnv*, jclass, jlong handle
|
||||
if (down) s->pressed_keys.insert(key); else s->pressed_keys.erase(key);
|
||||
s->Send(kKeyboardStream, action.to_json());
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nText)(JNIEnv* env, jclass, jlong handle, jbyteArray bytes) {
|
||||
auto* s = From(handle); if (!s || !bytes) return;
|
||||
const auto size = env->GetArrayLength(bytes);
|
||||
if (size <= 0 || static_cast<size_t>(size) > kMaxTextInputBytes) return;
|
||||
RemoteAction action{}; action.type = text_input;
|
||||
action.text.resize(size);
|
||||
env->GetByteArrayRegion(bytes, 0, size, reinterpret_cast<jbyte*>(action.text.data()));
|
||||
if (env->ExceptionCheck() || action.text.find('\0') != std::string::npos) return;
|
||||
// Java supplies standard UTF-8, including supplementary Unicode characters.
|
||||
s->Send(kKeyboardStream, action.to_json());
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nControl)(
|
||||
JNIEnv*, jclass, jlong handle, jint type, jint value) {
|
||||
auto* s = From(handle); if (!s) return;
|
||||
|
||||
Reference in New Issue
Block a user