From b948422bfe31a5cf639f2f7d6a255b65ecc583f4 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Sun, 18 Oct 2026 21:20:37 +0800 Subject: [PATCH] [feat] add Android system keyboard input and improve computer keyboard, refs #118 --- apps/android/README.md | 4 +- .../cn/crossdesk/mobile/MainActivity.java | 34 ++- .../java/cn/crossdesk/mobile/MobileUi.java | 2 + .../cn/crossdesk/mobile/NativeSession.java | 27 ++- .../cn/crossdesk/mobile/RemoteKeyboard.java | 223 ++++++++++++++++++ .../crossdesk/mobile/RemoteKeyboardInput.java | 137 +++++++++++ .../cn/crossdesk/mobile/RemoteVideoView.java | 2 + .../cn/crossdesk/mobile/SessionControls.java | 73 +++--- apps/android/native/rtc_bridge.cpp | 11 + 9 files changed, 459 insertions(+), 54 deletions(-) create mode 100644 apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteKeyboard.java create mode 100644 apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteKeyboardInput.java diff --git a/apps/android/README.md b/apps/android/README.md index 58f36842..28653993 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -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 秒没有有效进展则报告失败。接收时校验大小并追踪完整字节范围,允许跨线程回调乱序,未完成的文件不会提供保存入口。同名文件分开存储,不覆盖已有文件;可通过“保存收到的文件”导出最近收到的文件,连续接收多个文件时应逐个保存。 diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java index 42e05470..8b1cb77b 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java @@ -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. diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/MobileUi.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/MobileUi.java index 7f96e1a3..671cecbe 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/MobileUi.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/MobileUi.java @@ -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; diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java index c5a3915e..13716bf6 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java @@ -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;i0)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); diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteKeyboard.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteKeyboard.java new file mode 100644 index 00000000..43acb0e3 --- /dev/null +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteKeyboard.java @@ -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 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();} +} diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteKeyboardInput.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteKeyboardInput.java new file mode 100644 index 00000000..bbb3ab45 --- /dev/null +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteKeyboardInput.java @@ -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 characters(String text) { + BreakIterator iterator=BreakIterator.getCharacterInstance();iterator.setText(text); + List 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 old=characters(sent),next=characters(current);int prefix=0,offset=0; + while(prefix2048||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;i0&&after==0){for(int i=0;i=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 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&&xkeyboard.getX()&&ykeyboard.getY()){ + if(keyboard.isOpen()&&xkeyboard.getX()&&ykeyboard.getY()){ y=keyboard.getY()-gap-height;if(ygetWidth()/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)));} } diff --git a/apps/android/native/rtc_bridge.cpp b/apps/android/native/rtc_bridge.cpp index e74aff84..96035bd4 100644 --- a/apps/android/native/rtc_bridge.cpp +++ b/apps/android/native/rtc_bridge.cpp @@ -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) > kMaxTextInputBytes) return; + RemoteAction action{}; action.type = text_input; + action.text.resize(size); + env->GetByteArrayRegion(bytes, 0, size, reinterpret_cast(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;