[feat] add mobile privacy controls and Windows secure text input

This commit is contained in:
dijunkun
2026-10-27 02:37:23 +08:00
parent 1e25ec15e1
commit b15925f857
37 changed files with 795 additions and 80 deletions
@@ -80,6 +80,7 @@ final class BackgroundSession implements NativeSession.Listener {
if (view != null) view.fileTransfer(name, progress, sending, received);
}
@Override public void controlFeedback(String text) { if (view != null) view.controlFeedback(text); }
@Override public void privacyChanged() { if (view != null) view.privacyChanged(); }
@Override public void presence(JSONObject message) { if (view != null) view.presence(message); }
@Override public void announcement(JSONObject message) { if (view != null) view.announcement(message); }
@Override public void announcementFailed(String id) { if (view != null) view.announcementFailed(id); }
@@ -218,7 +218,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
remote.setOnFocusChangeListener((view,focused)->{GradientDrawable shape=ui.background(BACKGROUND,12);shape.setStroke(dp(focused?2:1),focused?BLUE:LINE);remote.setBackground(shape);});
remote.setFilters(new InputFilter[]{new InputFilter.LengthFilter(11)});remote.setText(formatId(preferences.getString("lastRemote","")));
entry.addView(remote,new LinearLayout.LayoutParams(0,-1,1));
TextView connect=ui.primary("连接 →",()->promptConnection(id()));connect.setContentDescription("连接");LinearLayout.LayoutParams buttonParams=ui.size(96,54);buttonParams.leftMargin=dp(10);entry.addView(connect,buttonParams);
TextView connect=ui.primary("连接",()->promptConnection(id()));connect.setContentDescription("连接");LinearLayout.LayoutParams buttonParams=ui.size(96,54);buttonParams.leftMargin=dp(10);entry.addView(connect,buttonParams);
Runnable update=()->{connect.setEnabled(!id().isEmpty());connect.setAlpha(id().isEmpty()?.45f:1);};update.run();
remote.addTextChangedListener(new TextWatcher(){boolean editing;public void beforeTextChanged(CharSequence s,int start,int count,int after){}public void onTextChanged(CharSequence s,int start,int before,int count){update.run();}public void afterTextChanged(Editable value){if(editing)return;String formatted=formatId(value.toString());if(!formatted.equals(value.toString())){editing=true;int cursor=remote.getSelectionStart(),digits=value.subSequence(0,Math.max(0,cursor)).toString().replaceAll("[^0-9]","").length();remote.setText(formatted);remote.setSelection(Math.min(formatted.length(),digits+Math.max(0,digits-1)/3));editing=false;}}});
LinearLayout recent=column();recent.setPadding(dp(16),dp(16),dp(16),dp(16));ui.card(recent,18);LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(-1,0,1);p.topMargin=dp(20);content.addView(recent,p);
@@ -295,7 +295,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
View eye=ui.iconButton("eye","显示密码",()->{boolean hidden=password.getTransformationMethod()!=null;password.setTransformationMethod(hidden?null:PasswordTransformationMethod.getInstance());password.setSelection(password.length());});passwordRow.addView(eye,ui.size(44,44));sheet.addView(passwordRow);ui.gap(sheet,18);
LinearLayout save=row(),saveText=column();saveText.addView(text("保存密码",15,true));ui.gap(saveText,3);TextView security=text("密码将加密保存在本机 Android Keystore 中",11,false);security.setTextColor(SECONDARY);saveText.addView(security);save.addView(saveText,new LinearLayout.LayoutParams(0,-2,1));Switch remember=new Switch(this);remember.setContentDescription("保存密码");save.addView(remember);sheet.addView(save);
remember.setChecked(rememberDefault);
ui.gap(sheet,18);TextView connect=ui.primary("连接 →",()->{
ui.gap(sheet,18);TextView connect=ui.primary("连接",()->{
String secret=password.getText().toString();if(secret.isEmpty()||secret.length()>128||secret.contains("@")){password.setError("请输入有效访问密码");return;}
rememberPassword=remember.isChecked();password.setText("");dialog.dismiss();begin(id,secret);
});connect.setContentDescription("确认连接");sheet.addView(connect,ui.size(-1,48));
@@ -331,6 +331,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
controls=new SessionControls(this,ui,new SessionControls.Actions(){
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 togglePrivacy(){if(ready&&session!=null)session.togglePrivacy();}
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);}
@@ -339,6 +340,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
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));
privacyChanged();
video.remoteFocusChanged=()->{if(controls!=null)controls.resetKeyboardContext();};
controls.requestApplyInsets();
video.addOnLayoutChangeListener((view,left,top,right,bottom,oldLeft,oldTop,oldRight,oldBottom)->{
@@ -655,6 +657,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.hostInfo(info);
}
}
@Override public void privacyChanged(){if(controls!=null&&session!=null)controls.privacyScreen(session.privacyScreen);}
@Override public void videoSize(int width,int height){videoWidth=width;videoHeight=height;if(video!=null)video.videoSize(width,height);if(controls!=null)controls.videoSize(width,height);pictureInPicture.videoSize(width,height);updatePictureInPicture();}
@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();}
@@ -782,7 +785,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
super.onPictureInPictureUiStateChanged(state);
if(android.os.Build.VERSION.SDK_INT>=35&&state.isTransitioningToPip())setPictureInPictureUi(true);
}
@Override protected void onResume(){super.onResume();remoteOrientation.resume();if(!isInPictureInPictureMode())enteredPictureInPicture=false;setPictureInPictureUi(isInPictureInPictureMode());updatePictureInPicture();if(ready&&!canUsePictureInPicture())prepareBackgroundSession(true);if(backgroundSession!=null)requestBackgroundNotifications();}
@Override protected void onResume(){super.onResume();privacyChanged();remoteOrientation.resume();if(!isInPictureInPictureMode())enteredPictureInPicture=false;setPictureInPictureUi(isInPictureInPictureMode());updatePictureInPicture();if(ready&&!canUsePictureInPicture())prepareBackgroundSession(true);if(backgroundSession!=null)requestBackgroundNotifications();}
// onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
@Override protected void onPause(){remoteOrientation.pause();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();}
@@ -30,6 +30,7 @@ final class NativeSession implements AutoCloseable {
void statistics(RemoteNetworkStatistics.Snapshot value);
default void fileTransfer(String name,double progress,boolean sending,File received) { }
default void controlFeedback(String message) { }
default void privacyChanged() { }
default void signaling(int state, String deviceId) { }
default void presence(JSONObject message) { }
default void announcement(JSONObject message) { }
@@ -49,6 +50,7 @@ final class NativeSession implements AutoCloseable {
private long handle;
private boolean signalReady, joining, started;
private volatile boolean connected, supportsTextInput;
final RemotePrivacyScreen privacyScreen = new RemotePrivacyScreen();
private int signalState;
private volatile long signalGeneration;
private boolean presenceRequested;
@@ -63,6 +65,8 @@ final class NativeSession implements AutoCloseable {
private final RemoteNetworkStatistics networkStatistics=new RemoteNetworkStatistics();
private final Runnable statisticsTick=new Runnable(){public void run(){
if(closed)return;
privacyScreen.tick(android.os.SystemClock.elapsedRealtime());
listener.privacyChanged();
listener.statistics(networkStatistics.snapshot(RemoteNetworkStatistics.now()));
if(!closed)main.postDelayed(this,1000);
}};
@@ -193,7 +197,13 @@ final class NativeSession implements AutoCloseable {
} else if (type == 4) {
JSONObject info=event.optJSONObject("host_info");
if(info!=null)supportsTextInput=info.optBoolean("supports_text_input",false);
ui(() -> listener.data(event));
ui(() -> {
if(event.optInt("type",-1)==10){
privacyScreen.receive(event,android.os.SystemClock.elapsedRealtime());
listener.privacyChanged();
}
listener.data(event);
});
}
else if (type == 5) {
int width = event.getInt("width"), height = event.getInt("height");
@@ -266,6 +276,16 @@ final class NativeSession implements AutoCloseable {
if (type == 2 && player != null) player.setEnabled(value != 0);
execute(() -> { if (connected){nControl(handle, type, value);if(type==4)networkStatistics.resetVideo();} });
}
void togglePrivacy() {
if(closed||!connected)return;
boolean enable=!privacyScreen.active();
long request=privacyScreen.begin(android.os.SystemClock.elapsedRealtime());
if(request<0)return;
listener.privacyChanged();
execute(()->{if(connected&&!nPrivacy(handle,enable?1:2,true))ui(()->{
privacyScreen.failed(request);listener.privacyChanged();
});});
}
void clipboard(String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
if (bytes.length == 0 || bytes.length > 128 * 1024) return;
@@ -319,6 +339,7 @@ final class NativeSession implements AutoCloseable {
private static native boolean nPresenceRequest(long handle,byte[] data);
private static native void nClipboard(long handle, byte[] data);
private static native boolean nSecureAttention(long handle);
private static native boolean nPrivacy(long handle,int flag,boolean blockLocalInput);
private static native void nFileDirectory(long handle,String directory);
private static native boolean nSendFile(long handle,String path,byte[] name);
private static native boolean nPumpFiles(long handle);
@@ -0,0 +1,79 @@
package cn.crossdesk.mobile;
import org.json.JSONObject;
/** Host-confirmed privacy state; all access belongs to the main thread. */
final class RemotePrivacyScreen {
boolean received, supported, inputBlockSupported, overlay, inputBlocked, remotePaused, pending;
int state;
long revision, requestRevision, requestId, receivedAt, requestedAt;
String reason = "", failure = "";
void receive(JSONObject message, long now) {
try {
if (message.getInt("type") != 10) return;
JSONObject value = message.getJSONObject("privacy_status");
int next = value.getInt("state");
long rev = value.getLong("revision");
if (next < 0 || next > 5 || rev < 0 || rev > 0xffffffffL ||
received && (int)(rev - revision) < 0) return;
boolean support = value.getBoolean("supported"), cover = value.getBoolean("overlay_active");
boolean paused = value.getBoolean("remote_paused");
if (next == 3 && (!support || !cover || paused)) return;
boolean blockSupport = value.getBoolean("input_block_supported"), blocked = value.getBoolean("input_blocked");
String detail = value.getString("reason");
state = next; revision = rev; supported = support; overlay = cover; remotePaused = paused;
inputBlockSupported = blockSupport; inputBlocked = blocked; reason = detail;
received = true; receivedAt = now; failure = "";
if (pending && (int)(revision - requestRevision) > 0 && state != 2 && state != 4) pending = false;
} catch (org.json.JSONException ignored) { }
}
boolean active() { return overlay || remotePaused || state == 2 || state == 3 || state == 4; }
boolean fresh(long now) { return received && now - receivedAt <= 5000; }
boolean enabled(long now) { return fresh(now) && state == 3 && overlay && !remotePaused; }
String buttonTitle(long now) {
if (enabled(now)) return "隐私屏已开启";
if (fresh(now) && supported && state == 1 && !active()) return "隐私屏已关闭";
return "隐私屏";
}
String feedback(long now) {
if (failure.isEmpty() && (pending || fresh(now) && (state == 2 || state == 4))) return "";
if (failure.isEmpty() && !pending && fresh(now) && supported &&
(enabled(now) || state == 1 && !active())) return "";
String detail = detail(now);
return status(now) + (detail.isEmpty() ? "" : ":" + detail);
}
boolean canToggle(long now) {
return !pending && fresh(now) && state != 4 && (active() || supported && state == 1);
}
long begin(long now) {
tick(now);
if (!canToggle(now)) return -1;
pending = true; requestedAt = now; requestRevision = revision; failure = "";
return ++requestId;
}
void failed(long id) {
if (pending && requestId == id) { pending = false; failure = "指令发送失败,请重试"; }
}
void tick(long now) {
if (pending && now - requestedAt >= 15000) {
pending = false; failure = "请求超时,尚未确认电脑的隐私屏状态";
}
}
String status(long now) {
if (!failure.isEmpty()) return failure;
if (!received) return "等待电脑返回隐私屏状态;旧版电脑端可能不支持";
if (!fresh(now)) return "隐私屏状态已过期,等待电脑确认";
if (state == 2) return "正在开启隐私屏…";
if (state == 4) return "正在关闭隐私屏…";
if (pending) return "指令已发送,等待电脑确认…";
if (state == 5) return "隐私屏异常";
if (!supported || state == 0) return "隐私屏暂不可用";
if (state == 3 && overlay && !remotePaused)
return "隐私屏已开启";
return state == 1 && !active() ? "隐私屏已关闭" : "隐私屏状态待确认";
}
String detail(long now) {
return fresh(now) && (state == 0 || state == 5 || !supported) ? reason : "";
}
}
@@ -15,6 +15,7 @@ final class SessionControls extends FrameLayout {
default boolean textEdit(int deletions,String value){return false;}
void videoSettings(int field,int value);
void sendFile(); void saveFile(); void secureAttention();
default void togglePrivacy() { }
}
private final MobileUi ui;
private final Actions actions;
@@ -36,6 +37,9 @@ final class SessionControls extends FrameLayout {
keyboardInsets(height,height>ui.dp(80),0);
}
private TextView networkLabel,videoFeedback;
private RemotePrivacyScreen privacy=new RemotePrivacyScreen();
private View privacyButton;
private TextView privacyFeedback,privacyCaption;
private TextView transferLabel,saveFileButton;
private String transferStatus="";
private boolean hasReceivedFile;
@@ -100,12 +104,13 @@ final class SessionControls extends FrameLayout {
void resetVideoStatistics(){statistics(new RemoteNetworkStatistics.Snapshot(network.report,0,0,0,Double.NaN,network.rtt));}
void statistics(RemoteNetworkStatistics.Snapshot value){network=value;renderNetwork();}
void videoSettings(RemoteVideoSettings value){settings=value;renderVideoSettings();}
void privacyScreen(RemotePrivacyScreen value){privacy=value;renderPrivacy();}
void fileStatus(String status,boolean received){transferStatus=status;hasReceivedFile=received;renderFileStatus();}
private void renderFileStatus(){
if(transferLabel!=null){transferLabel.setText(transferStatus);transferLabel.setVisibility(transferStatus.isEmpty()?GONE:VISIBLE);}
if(saveFileButton!=null)saveFileButton.setVisibility(hasReceivedFile?VISIBLE:GONE);
}
private void closeMenu(){if(menu!=null){removeView(menu);menu=null;menuScroll=null;}if(shield!=null){removeView(shield);shield=null;}networkLabel=videoFeedback=transferLabel=saveFileButton=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription("展开远程控制菜单");positionMouse();}
private void closeMenu(){if(menu!=null){removeView(menu);menu=null;menuScroll=null;}if(shield!=null){removeView(shield);shield=null;}networkLabel=videoFeedback=transferLabel=saveFileButton=null;privacyFeedback=privacyCaption=null;privacyButton=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription("展开远程控制菜单");positionMouse();}
private void openMenu(String page){
cancelMouseInput();
closeMenu();menuPage=page;shield=new View(getContext());shield.setOnClickListener(v->closeMenu());addView(shield,new LayoutParams(-1,-1));
@@ -121,13 +126,15 @@ final class SessionControls extends FrameLayout {
scroll.setScrollBarStyle(View.SCROLLBARS_OUTSIDE_INSET);body.setPadding(0,0,ui.dp(6),0);
}
if(page.equals("controls")){
String[] labels={"键盘",mouseVisible?"收起鼠标":"虚拟鼠标",relative?"相对鼠标":"绝对鼠标","显示器","画面设置",muted?"静音":"声音","发送文件","网络状态","Ctrl+Alt+Del","剪贴板"};
String[] icons={"keyboard","mouse","mouse","display","sliders","audio","folder","chart","lock","clipboard"};
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},this::toggleMouse,actions::mouse,actions::display,()->openMenu("video"),actions::mute,actions::sendFile,()->openMenu("network"),actions::secureAttention,actions::clipboard};
String[] labels={"键盘",mouseVisible?"收起鼠标":"虚拟鼠标",relative?"相对鼠标":"绝对鼠标","显示器","画面设置",muted?"静音":"声音","发送文件","网络状态","Ctrl+Alt+Del","剪贴板","隐私屏"};
String[] icons={"keyboard","mouse","mouse","display","sliders","audio","folder","chart","lock","clipboard","lock"};
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},this::toggleMouse,actions::mouse,actions::display,()->openMenu("video"),actions::mute,actions::sendFile,()->openMenu("network"),actions::secureAttention,actions::clipboard,actions::togglePrivacy};
menuColumns=controlColumns();
for(int row=0;row<(labels.length+menuColumns-1)/menuColumns;row++){LinearLayout line=ui.row();for(int col=0;col<menuColumns;col++){int index=row*menuColumns+col;if(index>=labels.length){View spacer=new View(getContext());LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(spacer,p);continue;}LinearLayout control=ui.column();control.setGravity(Gravity.CENTER);control.setBackground(ui.background(0xFFF0F0F2,10));control.addView(ui.icon(icons[index],INK),ui.size(19,19));ui.gap(control,4);TextView caption=ui.text(labels[index],10,false);caption.setSingleLine();caption.setGravity(Gravity.CENTER);control.addView(caption);control.setContentDescription(labels[index]);control.setClickable(true);control.setFocusable(true);control.setOnClickListener(v->clicks[index].run());
if(index==10){privacyButton=control;privacyCaption=caption;}
LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(control,p);
}body.addView(line);if((row+1)*menuColumns<labels.length)ui.gap(body,7);}
privacyFeedback=ui.text("",11,false);privacyFeedback.setTextColor(SECONDARY);privacyFeedback.setPadding(0,ui.dp(7),0,0);privacyFeedback.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE);body.addView(privacyFeedback);renderPrivacy();
ui.gap(body,7);transferLabel=ui.text(transferStatus,11,false);transferLabel.setTextColor(SECONDARY);transferLabel.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE);body.addView(transferLabel);
saveFileButton=ui.action("保存收到的文件",BLUE,actions::saveFile);body.addView(saveFileButton);renderFileStatus();
}else if(page.equals("network")){
@@ -141,6 +148,16 @@ final class SessionControls extends FrameLayout {
addView(menu,new LayoutParams(panelWidth(getWidth()),panelHeight(getHeight())));orb.bringToFront();orb.setContentDescription("收起远程控制菜单");
positionMouse();
}
private void renderPrivacy(){
if(privacyButton==null)return;
long now=android.os.SystemClock.elapsedRealtime();privacy.tick(now);
boolean enabled=privacy.enabled(now);String action=privacy.active()?"关闭隐私屏":"开启隐私屏";
privacyCaption.setText(privacy.buttonTitle(now));
privacyButton.setSelected(enabled);
privacyButton.setContentDescription(action+","+privacy.status(now));
privacyButton.setEnabled(privacy.canToggle(now));privacyButton.setAlpha(privacyButton.isEnabled()?1f:.45f);
String feedback=privacy.feedback(now);privacyFeedback.setText(feedback);privacyFeedback.setVisibility(feedback.isEmpty()?GONE:VISIBLE);
}
private final class SettingRow {
final int field;final int[] values;final SegmentedControl group;
SettingRow(LinearLayout body,int field,String title,String[] labels,int[] values){