mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-18 09:53:05 +08:00
[feat] keep mobile sessions connected in background
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user