[feat] keep mobile sessions connected in background

This commit is contained in:
dijunkun
2026-10-10 00:29:07 +08:00
parent be639eaf97
commit 1dccbe42a3
17 changed files with 693 additions and 37 deletions
@@ -2,12 +2,23 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application android:theme="@style/AppTheme" android:label="CrossDesk"
android:icon="@mipmap/ic_launcher" android:allowBackup="false"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:supportsRtl="true" android:usesCleartextTraffic="false">
<service android:name=".RemoteSessionService" android:exported="false"
android:foregroundServiceType="specialUse">
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Maintain a user-started remote desktop RTC/control session when picture-in-picture is unavailable. Background rendering and audio are suspended; the user can return or disconnect from the notification." />
</service>
<activity android:name=".MainActivity" android:exported="true"
android:launchMode="singleTop"
android:supportsPictureInPicture="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
android:resizeableActivity="true"
android:windowSoftInputMode="adjustResize">
@@ -0,0 +1,85 @@
package cn.crossdesk.mobile;
import android.content.Context;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.File;
import java.util.UUID;
/** Main-thread session state; never retains a stopped Activity or its Views. */
final class BackgroundSession implements NativeSession.Listener {
final Context context;
final NativeSession session;
final String token = UUID.randomUUID().toString();
final String host, remote;
final int port;
NativeSession.Listener view;
String identity = "", platform = "", clipboard = "", fileStatus = "", endReason = "";
JSONArray displays = new JSONArray();
RemoteVideoSettings videoSettings;
int signalState, selectedDisplay, width, height;
boolean muted, remoteServiceAvailable, background, ended;
File receivedFile;
BackgroundSession(Context context, NativeSession session, String host, int port, String remote) {
this.context = context.getApplicationContext();
this.session = session; this.host = host; this.port = port; this.remote = remote;
}
void attach(NativeSession.Listener listener) { view = listener; }
void detach(NativeSession.Listener listener) { if (view == listener) view = null; }
void setBackground(boolean value) {
if (background == value || ended) return;
background = value;
// With no visible window, release rendering buffers and stop remote
// audio. The RTC/control connection and file transfers remain alive.
if (value) session.surface(null);
session.control(2, value || muted ? 0 : 1);
RemoteSessionService.refresh(this);
}
void finish(String reason) {
if (ended) return;
ended = true; endReason = reason; clipboard = "";
session.close();
RemoteSessionService.release(context, this);
NativeSession.Listener listener = view; view = null;
if (listener != null) listener.ended(reason);
}
@Override public void ended(String reason) { finish(reason); }
@Override public void passwordRejected() { finish("远端访问密码已失效,请重新连接"); }
@Override public void connected() { /* Already connected before the service takes ownership. */ }
@Override public void status(String message) { if (view != null) view.status(message); }
@Override public void signaling(int state, String id) {
signalState = state; identity = id;
if (view != null) view.signaling(state, id);
}
@Override public void data(JSONObject message) {
if (view != null) { view.data(message); return; }
if (message.optInt("type", -1) == 12 && videoSettings != null) videoSettings.receive(message);
if (message.optInt("type", -1) == 5) {
JSONObject service = message.optJSONObject("service_status");
remoteServiceAvailable = service != null && service.optBoolean("available", false);
}
JSONObject info = message.optJSONObject("host_info");
if (info != null) {
platform = info.optString("platform", "");
JSONArray values = info.optJSONArray("displays"); if (values != null) displays = values;
if (videoSettings != null) videoSettings.setSupported(info.optBoolean("supports_video_settings", false));
}
}
@Override public void videoSize(int w, int h) {
width = w; height = h;
if (view != null) view.videoSize(w, h);
}
@Override public void clipboard(String text) { clipboard = text; if (view != null) view.clipboard(text); }
@Override public void statistics(RemoteNetworkStatistics.Snapshot value) { if (view != null) view.statistics(value); }
@Override public void fileTransfer(String name, double progress, boolean sending, File received) {
if (received != null) receivedFile = received;
fileStatus = progress < 0 ? name + " 传输失败" : (sending ? "发送 " : "接收 ") + name
+ (progress >= 1 ? " · 已完成" : " · " + (int)(progress * 100) + "%");
if (view != null) view.fileTransfer(name, progress, sending, received);
}
@Override public void controlFeedback(String text) { if (view != null) view.controlFeedback(text); }
@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); }
}
@@ -66,10 +66,17 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private String signalingServer="", localIdentity="", signalError="";
private int signalState=-1;
private boolean foreground;
private boolean pictureInPictureUi;
private boolean enteredPictureInPicture;
private BackgroundSession backgroundSession;
private int videoWidth,videoHeight;
private final RemotePictureInPicture pictureInPicture=new RemotePictureInPicture();
private RemoteVideoView video;
private SessionControls controls;
private Dialog passwordDialog, progressDialog, privacyDialog;
private AlertDialog disconnectDialog;
private AlertDialog connectionDialog;
private String pendingConnectionMessage="";
private AlertDialog remoteUpdateDialog;
private RemoteVersionCheck remoteVersionCheck;
private String host, connectedRemote="", hostPlatform="", remoteClipboard="", pendingPassword="", page="home";
@@ -217,7 +224,15 @@ public final class MainActivity extends Activity implements NativeSession.Listen
});renderRecentConnections();
}
ensureSignaling();
if(!message.isEmpty())new AlertDialog.Builder(this).setTitle("连接提示").setMessage(message).setPositiveButton("确定",null).show();
if(!message.isEmpty())pendingConnectionMessage=message;
showConnectionMessage();
}
private void showConnectionMessage(){
if(pendingConnectionMessage.isEmpty()||!foreground||isFinishing()||isDestroyed())return;
if(connectionDialog!=null)connectionDialog.dismiss();
connectionDialog=new AlertDialog.Builder(this).setTitle("连接提示").setMessage(pendingConnectionMessage).setPositiveButton("确定",null).create();
connectionDialog.setOnDismissListener(dialog->connectionDialog=null);
pendingConnectionMessage="";connectionDialog.show();
}
private String id(){return remote.getText().toString().replace(" ","");}
static String formatId(String value){String digits=value.replaceAll("[^0-9]","");if(digits.length()>9)digits=digits.substring(0,9);return digits.replaceAll("(.{3})(?=.)","$1 ");}
@@ -283,6 +298,8 @@ public final class MainActivity extends Activity implements NativeSession.Listen
window.setGravity(width>=600?Gravity.CENTER:Gravity.BOTTOM);
}
private void begin(String id,String secret){
videoWidth=videoHeight=0;enteredPictureInPicture=false;
pictureInPicture.disconnect();updatePictureInPicture();
remoteServiceAvailable=false;receivedFile=null;
resetRemoteVersionCheck();
resetVideoSettings();
@@ -307,17 +324,26 @@ public final class MainActivity extends Activity implements NativeSession.Listen
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.videoSettings(videoSettings);root.addView(controls,new LinearLayout.LayoutParams(-1,-1));getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
});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.addOnLayoutChangeListener((view,left,top,right,bottom,oldLeft,oldTop,oldRight,oldBottom)->{
if(!pictureInPictureUi&&!isInPictureInPictureMode())updatePictureInPicture();
});
pictureInPicture.connected();updatePictureInPicture();
}
private void confirmDisconnect(){disconnectDialog=new AlertDialog.Builder(this).setTitle("断开远程连接?").setMessage("断开后将返回首页").setNegativeButton("取消",null).setPositiveButton("断开连接",(d,w)->end("")).show();}
private void end(String reason){
if(backgroundSession!=null){BackgroundSession retained=backgroundSession;backgroundSession=null;retained.detach(this);retained.finish(reason);}
boolean closeWindow=isInPictureInPictureMode();
pictureInPicture.disconnect();updatePictureInPicture();
if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();
mainHandler.removeCallbacks(documentTimeout);documentSession=null;documentToSave=null;receivedFile=null;remoteServiceAvailable=false;
resetRemoteVersionCheck();
resetVideoSettings();
previewCapture=null;previewCopying=false;
if(progressDialog!=null){progressDialog.dismiss();progressDialog=null;}if(session!=null){releaseKeys();session.close();session=null;}stopSignaling();pendingPassword="";remoteClipboard="";hostPlatform="";displays=new JSONArray();ready=false;video=null;controls=null;getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);home(reason);
if(progressDialog!=null){progressDialog.dismiss();progressDialog=null;}if(session!=null){releaseKeys();session.close();session=null;}stopSignaling();pendingPassword="";remoteClipboard="";hostPlatform="";displays=new JSONArray();ready=false;video=null;controls=null;getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
if(closeWindow){finish();return;}
home(reason);
appUpdates.checkNow();
}
private void settings(){
@@ -409,7 +435,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
@Override public void announcementFailed(String requestId){announcementInbox.failed(requestId);}
private void privacyPage(){
LinearLayout content=formPage("privacy","隐私与授权",this::settings);LinearLayout card=section(content,"");boolean consent=preferences.getBoolean("networkConsent",false);link(card,"授权状态",consent?"已授权":"未授权",()->{});ui.divider(card);link(card,"隐私政策","",this::privacyPolicyPage);
footer(content,"设备身份及选择保存的访问密码使用 Android Keystore 加密。连接记录仅保存在本机,不纳入设备备份。剪贴板内容在结束会话时清除。离开应用会结束当前会话。");
footer(content,"设备身份及选择保存的访问密码使用 Android Keystore 加密。连接记录仅保存在本机,不纳入设备备份。剪贴板内容在结束会话时清除。离开应用后通过画中画或连接通知保持会话,可在应用或通知中断开。");
if(consent)content.addView(ui.action("撤回联网授权",RED,()->new AlertDialog.Builder(this).setTitle("撤回联网授权?").setMessage("撤回后将停止远程连接、关闭画面预览保存并清除已有预览。连接记录和密码会保留,重新连接前需再次授权。").setNegativeButton("取消",null).setPositiveButton("撤回",(d,w)->{preferences.edit().putBoolean("networkConsent",false).apply();appUpdates.setEnabled(false);previews.setEnabled(false);stopSignaling();announcementInbox.reset();privacyPage();}).show()));else content.addView(ui.primary("阅读并授权",()->consent(this::privacyPage)));
}
private void privacyPolicyPage(){
@@ -460,14 +486,16 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private void openDocument(Intent intent,int request){
if(documentSession!=null)return;
documentSession=session;releaseKeys();
pictureInPicture.choosingDocument(true);updatePictureInPicture();
try{startActivityForResult(intent,request);mainHandler.postDelayed(documentTimeout,5*60*1000);}
catch(android.content.ActivityNotFoundException error){documentSession=null;documentToSave=null;controlFeedback("此设备没有可用的文件选择器");}
catch(android.content.ActivityNotFoundException error){documentSession=null;documentToSave=null;pictureInPicture.choosingDocument(false);updatePictureInPicture();controlFeedback("此设备没有可用的文件选择器");}
}
@Override protected void onActivityResult(int request,int result,Intent data){
super.onActivityResult(request,result,data);
if(request!=PICK_FILE&&request!=SAVE_FILE)return;
NativeSession source=documentSession;java.io.File saved=documentToSave;
documentSession=null;documentToSave=null;mainHandler.removeCallbacks(documentTimeout);
pictureInPicture.choosingDocument(false);updatePictureInPicture();
if(source==null||source!=session||!ready||result!=RESULT_OK||data==null||data.getData()==null)return;
if(request==PICK_FILE)source.sendFile(data.getData());else if(saved!=null)source.saveFile(saved,data.getData());
}
@@ -515,6 +543,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
renderSignaling();signaling.start();
}
private void stopSignaling(){
if(backgroundSession!=null&&backgroundSession.session==signaling){BackgroundSession retained=backgroundSession;backgroundSession=null;retained.detach(this);retained.finish("");}
mainHandler.removeCallbacks(presenceTick);recentPresence.setConnected(false,android.os.SystemClock.elapsedRealtime());
announcementInbox.setConnected(false);
if(signaling!=null)signaling.close();
@@ -550,6 +579,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
// The first success consumes pendingPassword; a duplicate must not save it again.
if(session==null||ready)return;ready=true;if(progressDialog!=null){progressDialog.dismiss();progressDialog=null;}
try{history.save(serverKey(),connectedRemote,connectedRemote,hostPlatform,rememberPassword,pendingPassword);}catch(Exception error){toast("密码保存失败,本次连接仍可使用");}pendingPassword="";sessionScreen();
if(!canUsePictureInPicture())prepareBackgroundSession(true);
if(videoSettings.supported&&videoSettings.requestId==0)updateVideoSettings(videoSettings.selection);
if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.connected();
}
@@ -566,7 +596,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
}
private void cancelVideoSettingsTimeout(){if(videoSettingsTimeout!=null)mainHandler.removeCallbacks(videoSettingsTimeout);videoSettingsTimeout=null;}
private void showRemoteUpdate(){
if(!ready||session==null||!foreground||isFinishing()||!preferences.getBoolean("networkConsent",false))return;
if(!ready||session==null||!foreground||pictureInPictureUi||isInPictureInPictureMode()||isFinishing()||!preferences.getBoolean("networkConsent",false))return;
remoteUpdateDialog=new AlertDialog.Builder(this).setTitle("升级提示").setMessage(RemoteVersionCheck.NOTICE).setPositiveButton("知道了",null).create();
remoteUpdateDialog.setOnDismissListener(dialog->remoteUpdateDialog=null);
remoteUpdateDialog.setCanceledOnTouchOutside(false);
@@ -601,11 +631,11 @@ public final class MainActivity extends Activity implements NativeSession.Listen
if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.hostInfo(info);
}
}
@Override public void videoSize(int width,int height){if(video!=null)video.videoSize(width,height);if(controls!=null)controls.videoSize(width,height);}
@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();}
@Override public boolean dispatchKeyEvent(KeyEvent event){
if(ready&&session!=null&&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);
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);
}
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;}}
@@ -618,14 +648,124 @@ public final class MainActivity extends Activity implements NativeSession.Listen
if(!page.equals("session"))updateHomeOrientation();
sizePasswordDialog();
if(privacyDialog instanceof PrivacyConsentDialog)((PrivacyConsentDialog)privacyDialog).updateSize();
if(controls!=null)controls.post(controls::constrainPanels);
if(controls!=null&&!pictureInPictureUi)controls.post(controls::constrainPanels);
if(video!=null)video.post(this::updatePictureInPicture);
}
private boolean supportsPictureInPicture(){return getPackageManager().hasSystemFeature(android.content.pm.PackageManager.FEATURE_PICTURE_IN_PICTURE);}
private boolean canUsePictureInPicture(){
if(!supportsPictureInPicture())return false;
android.app.AppOpsManager ops=getSystemService(android.app.AppOpsManager.class);
int mode=ops.checkOpNoThrow(android.app.AppOpsManager.OPSTR_PICTURE_IN_PICTURE,android.os.Process.myUid(),getPackageName());
return mode==android.app.AppOpsManager.MODE_ALLOWED||mode==android.app.AppOpsManager.MODE_DEFAULT;
}
private void captureBackgroundSession(){
BackgroundSession retained=backgroundSession;if(retained==null)return;
retained.identity=localIdentity;retained.signalState=signalState;retained.platform=hostPlatform;retained.displays=displays;
retained.muted=muted;retained.selectedDisplay=selectedDisplay;retained.remoteServiceAvailable=remoteServiceAvailable;
retained.clipboard=remoteClipboard;retained.videoSettings=videoSettings;retained.receivedFile=receivedFile;
retained.width=videoWidth;retained.height=videoHeight;
}
private boolean prepareBackgroundSession(boolean inform){
if(!ready||session==null||session.isClosed()||isFinishing()||!hasNetworkConsent())return false;
if(backgroundSession!=null&&!backgroundSession.ended)return RemoteSessionService.current()==backgroundSession;
BackgroundSession retained=new BackgroundSession(this,session,host,port,connectedRemote);
backgroundSession=retained;captureBackgroundSession();retained.attach(this);session.setListener(retained);
if(!RemoteSessionService.retain(this,retained)){
session.setListener(this);retained.detach(this);backgroundSession=null;
if(inform)toast("后台保活未能启动,请保持应用在前台");return false;
}
if(inform){
toast("已启用后台保活,可从连接通知返回或断开");
requestBackgroundNotifications();
}
return true;
}
private void requestBackgroundNotifications(){
if(android.os.Build.VERSION.SDK_INT>=33&&checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)!=android.content.pm.PackageManager.PERMISSION_GRANTED&&!preferences.getBoolean("notificationPermissionAsked",false)){
preferences.edit().putBoolean("notificationPermissionAsked",true).apply();
requestPermissions(new String[]{android.Manifest.permission.POST_NOTIFICATIONS},43);
}
}
private boolean restoreBackgroundSession(){
BackgroundSession retained=backgroundSession!=null?backgroundSession:RemoteSessionService.current();
if(retained==null)return false;
if(retained.ended||retained.session.isClosed()||!hasNetworkConsent()||!host.equals(retained.host)||port!=retained.port){
retained.detach(this);retained.finish("后台连接已结束,请重新连接");backgroundSession=null;
if(session==retained.session)end(retained.endReason);return false;
}
boolean rebuild=session!=retained.session||video==null||!page.equals("session");
backgroundSession=retained;session=signaling=retained.session;signalingServer=serverKey();
connectedRemote=retained.remote;localIdentity=retained.identity;signalState=retained.signalState;signalError="";
hostPlatform=retained.platform;displays=retained.displays;muted=retained.muted;selectedDisplay=retained.selectedDisplay;
remoteServiceAvailable=retained.remoteServiceAvailable;remoteClipboard=retained.clipboard;receivedFile=retained.receivedFile;
videoSettings=retained.videoSettings;ready=true;retained.attach(this);
if(rebuild)sessionScreen();
if(retained.width>0&&retained.height>0)videoSize(retained.width,retained.height);
if(controls!=null){controls.setMuted(muted);controls.videoSettings(videoSettings);controls.fileStatus(retained.fileStatus,receivedFile!=null);}
retained.setBackground(false);signaling(signalState,localIdentity);
return true;
}
private android.app.PictureInPictureParams pictureInPictureParams(){
return pictureInPicture.parameters(video==null||pictureInPictureUi||isInPictureInPictureMode()?null:video.pictureInPictureBounds());
}
private void updatePictureInPicture(){
if(!supportsPictureInPicture()||isDestroyed())return;
try{setPictureInPictureParams(pictureInPictureParams());}
catch(IllegalArgumentException|IllegalStateException error){android.util.Log.w("CrossDesk","Picture in Picture parameters unavailable",error);}
}
private void enterRemotePictureInPicture(){
if(isInPictureInPictureMode())return;
if(!canUsePictureInPicture()){
prepareBackgroundSession(false);return;
}
if(!pictureInPicture.canEnter()||session==null||session.isClosed()||isFinishing())return;
android.app.PictureInPictureParams params=pictureInPictureParams();
setPictureInPictureUi(true);
boolean entered=false;
try{entered=enterPictureInPictureMode(params);}
catch(IllegalArgumentException|IllegalStateException error){android.util.Log.w("CrossDesk","Picture in Picture could not start",error);}
if(!entered){setPictureInPictureUi(false);prepareBackgroundSession(false);}
}
private void setPictureInPictureUi(boolean value){
pictureInPictureUi=value;
if(value){releaseKeys();hideKeyboard();pageHost.finish();if(disconnectDialog!=null)disconnectDialog.dismiss();if(remoteUpdateDialog!=null)remoteUpdateDialog.dismiss();}
if(controls!=null)controls.setPictureInPicture(value);
if(video!=null)video.setPictureInPicture(value);
}
@Override protected void onUserLeaveHint(){
super.onUserLeaveHint();
if(documentSession!=null)return;
if(!canUsePictureInPicture()||!pictureInPicture.canEnter())prepareBackgroundSession(false);
else if(android.os.Build.VERSION.SDK_INT<31)enterRemotePictureInPicture();
}
@Override public void onPictureInPictureModeChanged(boolean active,Configuration config){
super.onPictureInPictureModeChanged(active,config);if(active)enteredPictureInPicture=true;setPictureInPictureUi(active);
if(!active&&video!=null)video.post(this::updatePictureInPicture);
}
@android.annotation.TargetApi(31)
@Override public void onPictureInPictureUiStateChanged(android.app.PictureInPictureUiState state){
super.onPictureInPictureUiStateChanged(state);
if(android.os.Build.VERSION.SDK_INT>=35&&state.isTransitioningToPip())setPictureInPictureUi(true);
}
@Override protected void onResume(){super.onResume();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(){if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();super.onPause();}
@Override protected void onStart(){super.onStart();foreground=true;appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();}
@Override protected void 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();
// The system document picker is part of this session's explicit file action.
if(documentSession==null||documentSession!=session||isFinishing()){if(session!=null)end(isFinishing()?"":"应用已进入后台,会话已结束");stopSignaling();}
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.
if(!isFinishing()&&!enteredPictureInPicture&&ready&&(backgroundSession!=null||documentSession==null)&&prepareBackgroundSession(false)){
captureBackgroundSession();backgroundSession.detach(this);backgroundSession.setBackground(true);
mainHandler.removeCallbacks(presenceTick);recentPresence.setConnected(false,android.os.SystemClock.elapsedRealtime());announcementInbox.setConnected(false);
cancelVideoSettingsTimeout();if(videoSettings.pending)videoSettings.expire(videoSettings.requestId);
resetRemoteVersionCheck();if(disconnectDialog!=null)disconnectDialog.dismiss();
}else if(documentSession==null||documentSession!=session||isFinishing()){if(session!=null)end(isFinishing()?"":"远程窗口已关闭,或后台保活未能启动,会话已结束");stopSignaling();}
super.onStop();}
@Override protected void onDestroy(){mainHandler.removeCallbacks(documentTimeout);documentSession=null;documentToSave=null;appUpdates.setEnabled(false);pageHost.finish();resetRemoteVersionCheck();cancelVideoSettingsTimeout();previews.removeListener(previewChanged);if(privacyDialog!=null)privacyDialog.dismiss();if(previewDialog!=null)previewDialog.dismiss();announcementInbox.close();if(disconnectDialog!=null)disconnectDialog.dismiss();if(passwordDialog!=null)passwordDialog.dismiss();if(progressDialog!=null)progressDialog.dismiss();if(session!=null)session.close();stopSignaling();super.onDestroy();}
@Override protected void onDestroy(){mainHandler.removeCallbacks(documentTimeout);documentSession=null;documentToSave=null;appUpdates.setEnabled(false);pageHost.finish();resetRemoteVersionCheck();cancelVideoSettingsTimeout();previews.removeListener(previewChanged);if(privacyDialog!=null)privacyDialog.dismiss();if(previewDialog!=null)previewDialog.dismiss();announcementInbox.close();if(disconnectDialog!=null)disconnectDialog.dismiss();if(passwordDialog!=null)passwordDialog.dismiss();if(progressDialog!=null)progressDialog.dismiss();
if(connectionDialog!=null)connectionDialog.dismiss();
boolean retained=!isFinishing()&&backgroundSession!=null&&!backgroundSession.ended&&RemoteSessionService.current()==backgroundSession;
if(backgroundSession!=null)backgroundSession.detach(this);
if(!retained){if(session!=null)session.close();stopSignaling();}else{mainHandler.removeCallbacks(presenceTick);session=signaling=null;}
super.onDestroy();}
}
@@ -40,7 +40,7 @@ final class NativeSession implements AutoCloseable {
private static final ExecutorService RTC = Executors.newSingleThreadExecutor(r -> new Thread(r, "CrossDesk RTC"));
private final Handler main = new Handler(Looper.getMainLooper());
private final Context context;
private final Listener listener;
private volatile Listener listener;
private final SecretStore secrets;
private final String host, scope;
private volatile String remote;
@@ -108,6 +108,9 @@ final class NativeSession implements AutoCloseable {
});
}
boolean isClosed() { return closed; }
// UI callbacks run on the main thread. A service-owned state can replace
// the Activity listener without recreating either native peer.
void setListener(Listener value) { listener = java.util.Objects.requireNonNull(value); }
private void publishSignaling() {
int state = signalState;
// Only expose the public device ID, never the identity's @password suffix.
@@ -0,0 +1,39 @@
package cn.crossdesk.mobile;
import android.app.PictureInPictureParams;
import android.graphics.Rect;
import android.os.Build;
import android.util.Rational;
/** Builds platform PiP parameters only for a live, visible desktop video. */
final class RemotePictureInPicture {
private boolean connected, choosingDocument;
private int width, height;
void connected() { connected = true; }
void videoSize(int width, int height) { this.width = width; this.height = height; }
void choosingDocument(boolean value) { choosingDocument = value; }
void clearVideo() { width = height = 0; }
void disconnect() { connected = choosingDocument = false; clearVideo(); }
boolean canEnter() { return connected && !choosingDocument && width > 0 && height > 0; }
PictureInPictureParams parameters(Rect source) {
PictureInPictureParams.Builder builder = new PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio(width, height));
if (source != null && !source.isEmpty()) builder.setSourceRectHint(source);
if (Build.VERSION.SDK_INT >= 31) {
builder.setAutoEnterEnabled(canEnter()).setSeamlessResizeEnabled(true);
}
return builder.build();
}
static Rational aspectRatio(int width, int height) {
if (width <= 0 || height <= 0) return new Rational(16, 9);
// Android's standard PiP accepts only 1/2.39 through 2.39. Keep
// ultrawide and portrait desktops valid, with letterboxing in the view.
if ((long) width * 100 > (long) height * 239) return new Rational(239, 100);
if ((long) height * 100 > (long) width * 239) return new Rational(100, 239);
return new Rational(width, height);
}
}
@@ -0,0 +1,109 @@
package cn.crossdesk.mobile;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.IBinder;
import android.os.PowerManager;
import android.util.Log;
/** Foreground fallback for a user-started RTC session when native PiP is unavailable. */
public final class RemoteSessionService extends Service {
private static final String CHANNEL = "remote_connection";
private static final String STOP = "cn.crossdesk.mobile.STOP_REMOTE_SESSION";
private static final int NOTIFICATION = 41;
private static BackgroundSession current;
private static RemoteSessionService running;
private BackgroundSession owned;
private PowerManager.WakeLock wakeLock;
static BackgroundSession current() { return current; }
static boolean retain(Context context, BackgroundSession value) {
if (value.ended || value.session.isClosed()) return false;
if (current == value) return true;
if (current != null) return false;
current = value;
try {
context.startForegroundService(new Intent(context, RemoteSessionService.class).putExtra("session", value.token));
return true;
} catch (RuntimeException error) {
current = null;
Log.w("CrossDesk", "Background session service could not start", error);
return false;
}
}
static void release(Context context, BackgroundSession value) {
if (current != value) return;
current = null;
if (running != null) running.releaseWakeLock();
context.stopService(new Intent(context, RemoteSessionService.class));
}
static void refresh(BackgroundSession value) {
if (current == value && running != null && running.owned == value) running.update();
}
@Override public IBinder onBind(Intent intent) { return null; }
@Override public void onCreate() {
super.onCreate(); running = this;
getSystemService(NotificationManager.class).createNotificationChannel(
new NotificationChannel(CHANNEL, "远程连接", NotificationManager.IMPORTANCE_LOW));
wakeLock = getSystemService(PowerManager.class).newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "CrossDesk:RemoteSession");
wakeLock.setReferenceCounted(false);
}
@Override public int onStartCommand(Intent intent, int flags, int startId) {
BackgroundSession value = current;
if (value == null || value.ended || value.session.isClosed()) { stopSelf(startId); return START_NOT_STICKY; }
if (intent == null || !value.token.equals(intent.getStringExtra("session"))) return START_NOT_STICKY;
owned = value;
if (STOP.equals(intent.getAction())) { value.finish("已从通知断开远程连接"); return START_NOT_STICKY; }
try {
Notification notification = notification();
if (Build.VERSION.SDK_INT >= 34) startForeground(NOTIFICATION, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE);
else startForeground(NOTIFICATION, notification);
update();
} catch (RuntimeException error) {
Log.w("CrossDesk", "Background session foreground promotion failed", error);
value.finish("后台保活未能启动,请保持应用在前台后重新连接");
}
return START_NOT_STICKY;
}
private Notification notification() {
PendingIntent open = PendingIntent.getActivity(this, 0,
new Intent(this, MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
Intent stopIntent = new Intent(this, RemoteSessionService.class).setAction(STOP)
.setData(android.net.Uri.parse("crossdesk-session:" + owned.token)).putExtra("session", owned.token);
PendingIntent stop = PendingIntent.getService(this, 1, stopIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
return new Notification.Builder(this, CHANNEL).setSmallIcon(R.drawable.ic_remote_session)
.setContentTitle("CrossDesk 远程连接")
.setContentText(owned.background ? "后台保持连接,点按返回远程桌面" : "已启用后台保活,切换应用可继续保持连接")
.setContentIntent(open).setOngoing(true).setOnlyAlertOnce(true).setShowWhen(false)
.setCategory(Notification.CATEGORY_SERVICE).setVisibility(Notification.VISIBILITY_PRIVATE)
.addAction(new Notification.Action.Builder(null, "返回", open).build())
.addAction(new Notification.Action.Builder(null, "断开", stop).build()).build();
}
@android.annotation.SuppressLint("WakelockTimeout") // Released on foreground return, disconnect, task removal and service destruction.
private void update() {
if (owned == null || owned.ended) return;
if (owned.background) { if (!wakeLock.isHeld()) wakeLock.acquire(); }
else releaseWakeLock();
getSystemService(NotificationManager.class).notify(NOTIFICATION, notification());
}
private void releaseWakeLock() { if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); }
@Override public void onTaskRemoved(Intent rootIntent) {
if (current == owned && owned != null) owned.finish("远程任务已关闭");
stopSelf();
}
@Override public void onDestroy() {
if (running == this) running = null;
releaseWakeLock(); stopForeground(STOP_FOREGROUND_REMOVE);
BackgroundSession value = owned;
if (current == value && value != null) { current = null; value.finish("后台连接服务已停止,请重新连接"); }
super.onDestroy();
}
}
@@ -27,6 +27,8 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
private final GestureDetector gestures;
private final ScaleGestureDetector scales;
private final RemoteViewport viewport = new RemoteViewport();
private final RemoteViewport pictureInPictureViewport = new RemoteViewport();
private boolean pictureInPicture;
private int frameWidth = 16, frameHeight = 9;
private float x = .5f, y = .5f, scrollY;
private boolean dragging, twoFinger, relative, viewportGesture, viewportPanActive;
@@ -34,6 +36,13 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
private long touchRevision;
private boolean suppressPointerGesture;
void setRelative(boolean value) { releaseMouse(); relative=value; }
void setPictureInPicture(boolean value) {
if(pictureInPicture==value)return;
releaseMouse();pictureInPicture=value;cursor.setVisibility(value?GONE:VISIBLE);fit();
}
Rect pictureInPictureBounds() {
Rect bounds=new Rect();video.getGlobalVisibleRect(bounds);return bounds;
}
void releaseMouse() { mouseInput.releaseAll(); dragging=false; suppressPointerGesture=true; }
private boolean suppressPointerGesture() {
return suppressPointerGesture || mouseInput.hasVirtualButton() || touchRevision != mouseInput.revision();
@@ -117,15 +126,17 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
}
private void fit() {
if (getWidth() == 0 || getHeight() == 0) return;
viewport.fit(getWidth(), getHeight(), frameWidth, frameHeight);
video.setLayoutParams(new LayoutParams(viewport.baseWidth(),viewport.baseHeight(),android.view.Gravity.CENTER));
RemoteViewport active=pictureInPicture?pictureInPictureViewport:viewport;
active.fit(getWidth(), getHeight(), frameWidth, frameHeight);
video.setLayoutParams(new LayoutParams(active.baseWidth(),active.baseHeight(),android.view.Gravity.CENTER));
applyViewport();
}
private void applyViewport() {
// Transform the existing Surface without enlarging its decoder buffers.
video.setScaleX(viewport.scale()); video.setScaleY(viewport.scale());
video.setTranslationX(viewport.offsetX()); video.setTranslationY(viewport.offsetY());
mouseInput.viewportWidth = viewport.width(); mouseInput.viewportHeight = viewport.height();
RemoteViewport active=pictureInPicture?pictureInPictureViewport:viewport;
video.setScaleX(active.scale()); video.setScaleY(active.scale());
video.setTranslationX(active.offsetX()); video.setTranslationY(active.offsetY());
mouseInput.viewportWidth = active.width(); mouseInput.viewportHeight = active.height();
cursor.invalidate();
}
private void sendMouse(float x, float y, int flag, int wheel) {
@@ -23,7 +23,7 @@ final class SessionControls extends FrameLayout {
private LinearLayout menu,keyboard;
private VirtualMouseBar mouseBar;
private FrameLayout mouseHost;
private boolean mouseVisible;
private boolean mouseVisible,pictureInPicture;
private ScrollView keyboardKeys;
private ScrollView menuScroll;
private int keyboardPreferredHeight;
@@ -63,10 +63,15 @@ final class SessionControls extends FrameLayout {
bar.setVisibility(GONE);
}
void cancelMouseInput(){if(mouseBar!=null)mouseBar.cancelInput();}
void setPictureInPicture(boolean value){
pictureInPicture=value;
if(value){cancelMouseInput();closeMenu();closeKeyboard();modifiers.clear();}
setVisibility(value?GONE:VISIBLE);positionMouse();
}
private void toggleMouse(){cancelMouseInput();mouseVisible=!mouseVisible;closeMenu();positionMouse();}
private void positionMouse(){
if(mouseBar==null)return;
boolean visible=mouseVisible&&menu==null&&waiting.getVisibility()==GONE;
boolean visible=!pictureInPicture&&mouseVisible&&menu==null&&waiting.getVisibility()==GONE;
if(!visible){if(mouseBar.getVisibility()==VISIBLE)cancelMouseInput();mouseBar.setVisibility(GONE);return;}
int[] location=new int[2],hostLocation=new int[2];getLocationOnScreen(location);mouseHost.getLocationOnScreen(hostLocation);
float width=ui.dp(140),height=ui.dp(48),gap=ui.dp(6),margin=ui.dp(8);
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M3,3h18v14h-8v3h4v2H7v-2h4v-3H3zM5,5v10h14V5z" />
</vector>