From a9e31780422448fce5be4f4adc1e8a19437a78e2 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Tue, 27 Oct 2026 03:55:17 +0800 Subject: [PATCH] [fix] make mobile landscape locking opt-in --- apps/android/README.md | 2 +- .../crossdesk/mobile/BackgroundSession.java | 2 +- .../cn/crossdesk/mobile/MainActivity.java | 22 +++---- .../crossdesk/mobile/RemoteOrientation.java | 45 +++----------- apps/harmony/README.md | 2 +- .../src/main/ets/mobile/MobileController.ets | 4 +- .../src/main/ets/mobile/MobilePlatform.ets | 45 +++----------- .../entry/src/main/ets/pages/Mobile.ets | 6 +- apps/ios/CrossDeskMobile/ContentView.swift | 4 +- .../CrossDeskMobile/CrossDeskMobileApp.swift | 61 +++---------------- .../CrossDeskMobile/RemoteSessionModel.swift | 3 +- apps/ios/README.md | 15 +++-- 12 files changed, 57 insertions(+), 154 deletions(-) diff --git a/apps/android/README.md b/apps/android/README.md index f427bdcf..23aeaf2f 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -9,7 +9,7 @@ - 48 kHz 单声道 PCM 播放,支持静音,音频队列有容量限制。 - 相对鼠标(触控板)和绝对位置模式;单指点击、绝对模式按住拖动、长按右键,以及外接键盘、鼠标。画面手势与 iOS 一致:双指可在整个触控区域(包括画面外黑边)捏合,以触点为中心缩放(1–10 倍),放大后双指拖动画面,原始比例下双指上下滑动控制滚轮。相对鼠标移到可见画面的上、下边缘后继续向外移动,会反向上下平移画面,不触发左右平移;未放大时和按住虚拟鼠标键拖动时同样生效,停止移动即停止平移,抬手保留位置。移动到极限时,画面可见部分至少占当前显示区域高度的 20%,与缩放倍数无关;整幅画面高度不足该比例时完整保留,范围计入上下黑边;缩放结束及缩回 1 倍时保留平移位置,仅在超过可见边界时限制位置;切换显示器恢复居中。 - 横竖屏等比例显示、远端多显示器切换。与 iOS 一致的画面质量(低/中/高)、采集帧率(30/60 fps)、画面偏好;支持能力检测、电脑端确认、失败/5 秒超时回退及连续操作的应答排序。 -- “设置 → 屏幕方向 → 连接后自动横屏”默认开启并保存在本机。手机进入会话后先保持横屏,检测到手机实际横过来后才恢复跟随设备方向;竖持或平放不会立即切回竖屏。关闭后沿用自动旋转,平板与多窗口不受此选项影响。退出会话恢复首页方向,返回同一后台会话不重复锁定已解锁的方向。 +- “设置 → 屏幕方向 → 连接建立后锁定横屏”默认关闭并保存在本机。开启后,手机连接时进入并保持横屏;关闭时跟随设备方向。平板和多窗口不受此选项影响。退出会话恢复首页方向,返回后台会话沿用该会话的方向设置。已移除检测手机姿态后解除初始横屏的逻辑。 - 根据常见移动设备解码能力和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。 - 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。 - 可拖动的悬浮控制球、三列控制菜单;默认使用系统输入法,保留候选词、纠错和听写,确认文字后直接输入远端。电脑键盘固定底部,分“字母数字 / 符号功能”两页,共用末两行布局,按系统键盘高度适配横竖屏;统一切换、收起按钮和 Android 键盘触感反馈。 diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/BackgroundSession.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/BackgroundSession.java index 4dda25e6..1d7ebf75 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/BackgroundSession.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/BackgroundSession.java @@ -19,7 +19,7 @@ final class BackgroundSession implements NativeSession.Listener { RemoteVideoSettings videoSettings; int signalState, selectedDisplay, width, height; boolean muted, remoteServiceAvailable, background, ended; - boolean waitingForLandscape; + boolean autoLandscape; File receivedFile; BackgroundSession(Context context, NativeSession session, String host, int port, String remote) { diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java index bdd68efe..c7b2fc14 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java @@ -326,8 +326,8 @@ public final class MainActivity extends Activity implements NativeSession.Listen ProgressBar spinner=new ProgressBar(this);spinner.setIndeterminateTintList(ColorStateList.valueOf(BLUE));body.addView(spinner,ui.size(34,34));ui.gap(body,14);TextView title=text("正在连接",20,true);title.setGravity(Gravity.CENTER);body.addView(title);ui.gap(body,6);status=text("正在连接信令服务器…",14,false);status.setGravity(Gravity.CENTER);status.setTextColor(SECONDARY);status.setMinHeight(dp(36));body.addView(status);card.addView(body,ui.size(-1,-2));ui.divider(card);card.addView(ui.action("取消连接",RED,()->end("")),ui.size(-1,46)); dialog.setContentView(card);dialog.setCanceledOnTouchOutside(false);dialog.setOnCancelListener(d->end(""));Window w=dialog.getWindow();if(w!=null){w.setBackgroundDrawableResource(android.R.color.transparent);w.setDimAmount(.08f);}dialog.show();if(w!=null)w.setLayout(dp(292),-2); } - private void sessionScreen(boolean waitForLandscape){ - installRoot("session",true);remoteOrientation.begin(waitForLandscape&&!isTablet()&&!isInMultiWindowMode());video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));canvas.addView(video,0,new FrameLayout.LayoutParams(-1,-1)); + private void sessionScreen(boolean autoLandscape){ + installRoot("session",true);remoteOrientation.begin(autoLandscape,!isTablet()&&!isInMultiWindowMode());video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));canvas.addView(video,0,new FrameLayout.LayoutParams(-1,-1)); 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();} @@ -370,9 +370,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen mouse.addView(segments(new String[]{"相对位置","绝对位置"},relative?0:1,true,index->preferences.edit().putBoolean("relativeMouse",index==0).apply())); LinearLayout picture=section(content,"画面偏好",MobileUi.SETTINGS_CARD_RADIUS);picture.setPadding(dp(12),dp(12),dp(12),dp(12));int preference=Math.max(0,Math.min(2,preferences.getInt("videoPreference",1))); picture.addView(segments(RemoteVideoSettings.PREFERENCES,preference,true,index->preferences.edit().putInt("videoPreference",index).apply()));ui.gap(content,28); - LinearLayout direction=section(content,"屏幕方向",MobileUi.SETTINGS_CARD_RADIUS);LinearLayout directionRow=row();directionRow.setPadding(dp(16),dp(10),dp(16),dp(10));directionRow.addView(text("连接后自动横屏",16,false),new LinearLayout.LayoutParams(0,-2,1)); - Switch autoLandscape=new Switch(this);autoLandscape.setContentDescription("连接后自动横屏");autoLandscape.setChecked(preferences.getBoolean("autoLandscape",true));autoLandscape.setOnCheckedChangeListener((button,checked)->preferences.edit().putBoolean("autoLandscape",checked).apply());directionRow.addView(autoLandscape);direction.addView(directionRow); - footer(content,"手机连接后先进入横屏,将手机横过来后恢复跟随设备方向。关闭后始终跟随设备方向。平板和多窗口不受影响。"); + LinearLayout direction=section(content,"屏幕方向",MobileUi.SETTINGS_CARD_RADIUS);LinearLayout directionRow=row();directionRow.setPadding(dp(16),dp(10),dp(16),dp(10));directionRow.addView(text("连接建立后锁定横屏",16,false),new LinearLayout.LayoutParams(0,-2,1)); + Switch autoLandscape=new Switch(this);autoLandscape.setContentDescription("连接建立后锁定横屏");autoLandscape.setChecked(preferences.getBoolean("autoLandscape",false));autoLandscape.setOnCheckedChangeListener((button,checked)->preferences.edit().putBoolean("autoLandscape",checked).apply());directionRow.addView(autoLandscape);direction.addView(directionRow); + footer(content,"默认关闭。开启后,手机连接时进入并保持横屏;关闭时跟随设备方向。平板和多窗口不受影响。"); LinearLayout server=section(content,"",MobileUi.SETTINGS_CARD_RADIUS);LinearLayout fields=column();fields.setVisibility(View.GONE);boolean custom=!host.equals("api.crossdesk.cn")||port!=9099; link(server,"服务器",custom?"自定义":"默认",()->fields.setVisibility(fields.getVisibility()==View.VISIBLE?View.GONE:View.VISIBLE));server.addView(fields); LinearLayout toggleRow=row();toggleRow.setPadding(dp(16),dp(8),dp(16),dp(8));toggleRow.addView(text("使用自定义服务器",16,false),new LinearLayout.LayoutParams(0,-2,1));Switch toggle=new Switch(this);toggle.setContentDescription("使用自定义服务器");toggle.setChecked(custom);toggleRow.addView(toggle);fields.addView(toggleRow); @@ -604,7 +604,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen // Transport readiness can be reported again after ICE changes its selected path. // 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(preferences.getBoolean("autoLandscape",true)); + try{history.save(serverKey(),connectedRemote,connectedRemote,hostPlatform,rememberPassword,pendingPassword);}catch(Exception error){toast("密码保存失败,本次连接仍可使用");}pendingPassword="";sessionScreen(preferences.getBoolean("autoLandscape",false)); if(!canUsePictureInPicture())prepareBackgroundSession(true); if(videoSettings.supported&&videoSettings.requestId==0)updateVideoSettings(videoSettings.selection); if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.connected(); @@ -682,7 +682,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen super.onConfigurationChanged(config); releaseKeys(); if(!page.equals("session"))updateHomeOrientation(); - else if(!isInPictureInPictureMode()&&(isTablet()||isInMultiWindowMode()))remoteOrientation.followDevice(); + else if(!isInPictureInPictureMode())remoteOrientation.update(!isTablet()&&!isInMultiWindowMode()); sizePasswordDialog(); if(privacyDialog instanceof PrivacyConsentDialog)((PrivacyConsentDialog)privacyDialog).updateSize(); if(controls!=null&&!pictureInPictureUi)controls.post(controls::constrainPanels); @@ -701,7 +701,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen retained.muted=muted;retained.selectedDisplay=selectedDisplay;retained.remoteServiceAvailable=remoteServiceAvailable; retained.clipboard=remoteClipboard;retained.videoSettings=videoSettings;retained.receivedFile=receivedFile; retained.width=videoWidth;retained.height=videoHeight; - retained.waitingForLandscape=remoteOrientation.waitingForLandscape(); + retained.autoLandscape=remoteOrientation.autoLandscape(); } private boolean prepareBackgroundSession(boolean inform){ if(!ready||session==null||session.isClosed()||isFinishing()||!hasNetworkConsent())return false; @@ -737,7 +737,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen 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(retained.waitingForLandscape); + if(rebuild)sessionScreen(retained.autoLandscape); 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); @@ -785,9 +785,9 @@ 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();privacyChanged();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();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 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(){if(controls!=null)controls.closeKeyboard();foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish(); if(connectionDialog!=null)connectionDialog.dismiss(); diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteOrientation.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteOrientation.java index c6b45a82..ed06047a 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteOrientation.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteOrientation.java @@ -2,54 +2,27 @@ package cn.crossdesk.mobile; import android.app.Activity; import android.content.pm.ActivityInfo; -import android.view.OrientationEventListener; -/** Holds the initial landscape until the phone itself has been turned sideways. */ +/** Applies the selected orientation policy for the whole remote session. */ final class RemoteOrientation { private final Activity activity; - private final OrientationEventListener sensor; - private boolean waitingForLandscape, resumed; + private boolean autoLandscape; RemoteOrientation(Activity activity) { this.activity = activity; - sensor = new OrientationEventListener(activity) { - @Override public void onOrientationChanged(int degrees) { - if (!resumed || !waitingForLandscape || activity.isInPictureInPictureMode()) return; - // Use physical orientation, never the window rotated by our own request. - // Ignore flat/unknown poses and keep a margin away from the diagonal. - if ((degrees >= 60 && degrees <= 120) || (degrees >= 240 && degrees <= 300)) { - followDevice(); - } - } - }; } - void begin(boolean autoLandscape) { - sensor.disable(); - waitingForLandscape = autoLandscape && sensor.canDetectOrientation(); - apply(); - if (resumed && waitingForLandscape) sensor.enable(); + void begin(boolean autoLandscape, boolean fullScreenPhone) { + this.autoLandscape = autoLandscape; + update(fullScreenPhone); } - boolean waitingForLandscape() { return waitingForLandscape; } + boolean autoLandscape() { return autoLandscape; } - void resume() { - resumed = true; - if (waitingForLandscape && !activity.isInPictureInPictureMode()) sensor.enable(); - } + void end() { autoLandscape = false; } - void pause() { resumed = false; sensor.disable(); } - - void end() { waitingForLandscape = false; sensor.disable(); } - - void followDevice() { - waitingForLandscape = false; - sensor.disable(); - apply(); - } - - private void apply() { - int orientation = waitingForLandscape + void update(boolean fullScreenPhone) { + int orientation = autoLandscape && fullScreenPhone ? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE : ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR; if (activity.getRequestedOrientation() != orientation) activity.setRequestedOrientation(orientation); diff --git a/apps/harmony/README.md b/apps/harmony/README.md index b8117f93..49a1521a 100644 --- a/apps/harmony/README.md +++ b/apps/harmony/README.md @@ -9,7 +9,7 @@ 两者仍为开发版。已完成的模拟器验证及未完成的真机验收见各端说明。 -手机控制端的“设置 → 屏幕方向 → 连接后自动横屏”默认开启并保存在本机。连接后先保持横屏,检测到手机实际横过来后恢复跟随设备方向;竖持、平放或斜持不会解除初始横屏。关闭后沿用自动旋转,平板和多窗口不受此选项影响。退出会话恢复首页方向,返回同一后台会话不重复锁定已解锁的方向;重力传感器不可用时沿用自动旋转。 +手机控制端的“设置 → 屏幕方向 → 连接建立后锁定横屏”默认关闭并保存在本机。开启后,手机连接时进入并保持横屏;关闭时跟随设备方向。平板和多窗口不受此选项影响。退出会话恢复首页方向,返回后台会话沿用该会话的方向设置。已移除检测手机姿态后解除初始横屏的逻辑。 ## 构建 diff --git a/apps/harmony/entry/src/main/ets/mobile/MobileController.ets b/apps/harmony/entry/src/main/ets/mobile/MobileController.ets index bb2e576f..1a8c98e3 100644 --- a/apps/harmony/entry/src/main/ets/mobile/MobileController.ets +++ b/apps/harmony/entry/src/main/ets/mobile/MobileController.ets @@ -51,7 +51,7 @@ export class MobileState { muted: boolean = false; previews: boolean = false; videoPreference: number = 1; - autoLandscape: boolean = true; + autoLandscape: boolean = false; version: string = '0.1.0'; update: string = ''; updateStatus: string = ''; @@ -132,7 +132,7 @@ export class MobileController { this.state.consent = this.store.flag('consent'); this.state.server = this.store.text('server', 'api.crossdesk.cn'); this.state.port = this.store.text('port', '9099'); this.state.remote = this.store.text('lastRemote'); this.state.relative = this.store.flag('relative', true); this.state.videoPreference = this.store.number('videoPreference', 1); - this.state.autoLandscape = this.store.flag('autoLandscape', true); + this.state.autoLandscape = this.store.flag('autoLandscape', false); this.state.previews = this.store.flag('previews'); try { this.state.version = bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT).versionName; } catch {} try { diff --git a/apps/harmony/entry/src/main/ets/mobile/MobilePlatform.ets b/apps/harmony/entry/src/main/ets/mobile/MobilePlatform.ets index d9783579..705be76e 100644 --- a/apps/harmony/entry/src/main/ets/mobile/MobilePlatform.ets +++ b/apps/harmony/entry/src/main/ets/mobile/MobilePlatform.ets @@ -4,7 +4,6 @@ import { PiPWindow, window, display } from '@kit.ArkUI'; import { backgroundTaskManager } from '@kit.BackgroundTasksKit'; import { picker, fileIo as fs, fileUri } from '@kit.CoreFileKit'; import { http } from '@kit.NetworkKit'; -import { sensor } from '@kit.SensorServiceKit'; import { isAnnouncementURL } from './AnnouncementText'; export class MobileLifecycle { @@ -22,19 +21,9 @@ export class MobilePlatform { private generation: number = 0; private backgroundActive: boolean = false; private active: boolean = false; - private waitingForLandscape: boolean = false; - private observingOrientation: boolean = false; + private autoLandscape: boolean = false; private orientationRevision: number = 0; private orientationQueue: Promise = Promise.resolve(); - private gravityChanged: (data: sensor.GravityResponse) => void = (data: sensor.GravityResponse) => { - if (!this.active || !this.foreground || !this.waitingForLandscape || !this.observingOrientation) return; - // Gravity is in device coordinates, independent of programmatic window rotation. - // Reject flat and diagonal poses before handing rotation back to the system. - const x = Math.abs(data.x), y = Math.abs(data.y), z = Math.abs(data.z); - if (x >= 6 && x >= y * 1.7 && x >= z) { - this.waitingForLandscape = false; this.stopOrientationSensor(); this.orientation(true); - } - }; private foreground: boolean = true; choosing: boolean = false; pipActive: boolean = false; @@ -51,9 +40,9 @@ export class MobilePlatform { this.context = context; this.ended = ended; this.release = release; this.feedback = feedback; try { backgroundTaskManager.on('continuousTaskCancel', this.backgroundCancelled); } catch {} } - async connected(autoLandscape: boolean = true): Promise { + async connected(autoLandscape: boolean = false): Promise { if (this.active) return; - this.waitingForLandscape = autoLandscape && deviceInfo.deviceType === 'phone'; + this.autoLandscape = autoLandscape; this.active = true; const generation = ++this.generation; this.muted = false; this.orientation(true); this.backgroundQueue = this.backgroundQueue.then(async () => { @@ -106,7 +95,7 @@ export class MobilePlatform { } videoSize(width: number, height: number): void { try { this.pip?.updateContentSize(width, height); } catch { /* Not supported on this device. */ } } async background(): Promise { - this.foreground = false; this.stopOrientationSensor(); this.release(); this.cancelRequests(); + this.foreground = false; this.release(); this.cancelRequests(); this.onVisibility(this.pipActive); if (!this.active) return; const generation = this.generation; @@ -125,7 +114,7 @@ export class MobilePlatform { } dispose(): void { this.disconnect(); try { backgroundTaskManager.off('continuousTaskCancel', this.backgroundCancelled); } catch {} } disconnect(): void { - this.waitingForLandscape = false; this.stopOrientationSensor(); + this.autoLandscape = false; this.active = false; this.choosing = false; this.generation++; this.cancelRequests(); const pip = this.pip; this.pip = undefined; this.pipActive = false; if (pip) { try { pip.setAutoStartEnabled(false); pip.stopPiP().catch(() => {}); } catch { /* Already stopped. */ } } @@ -134,27 +123,10 @@ export class MobilePlatform { }); this.orientation(false); } - private stopOrientationSensor(): void { - if (!this.observingOrientation) return; - this.observingOrientation = false; - try { sensor.off(sensor.SensorId.GRAVITY, this.gravityChanged); } catch {} - } - private startOrientationSensor(): void { - if (!this.waitingForLandscape || !this.foreground || this.observingOrientation) return; - try { - sensor.getSingleSensorSync(sensor.SensorId.GRAVITY); - sensor.on(sensor.SensorId.GRAVITY, this.gravityChanged, { interval: 100000000 }); - this.observingOrientation = true; - } catch { - // A phone without a usable sensor keeps the previous system-following policy. - this.waitingForLandscape = false; - } - } private orientation(session: boolean): void { const generation = this.generation; const revision = ++this.orientationRevision; - // Serialize window requests so an earlier landscape request cannot win after - // the sensor has released it or the session has disconnected. + // Serialize window requests so the latest policy wins after a lifecycle change. this.orientationQueue = this.orientationQueue.then(async () => { if (generation !== this.generation || revision !== this.orientationRevision) return; try { @@ -167,10 +139,9 @@ export class MobilePlatform { const rect = win.getWindowProperties().windowRect; const narrow = Math.min(rect.width, rect.height) / display.getDefaultDisplaySync().densityPixels < 600; const fullScreen = win.getWindowStatus() === window.WindowStatusType.FULL_SCREEN; - if (!phone || !narrow || !fullScreen) { this.waitingForLandscape = false; this.stopOrientationSensor(); } - if (session) this.startOrientationSensor(); const orientation = !session && phone && narrow && fullScreen ? window.Orientation.PORTRAIT : - session && this.waitingForLandscape ? window.Orientation.AUTO_ROTATION_LANDSCAPE : window.Orientation.AUTO_ROTATION; + session && this.autoLandscape && phone && narrow && fullScreen ? + window.Orientation.AUTO_ROTATION_LANDSCAPE : window.Orientation.AUTO_ROTATION; await win.setPreferredOrientation(orientation); } catch { /* Window may already be going away. */ } }); diff --git a/apps/harmony/entry/src/main/ets/pages/Mobile.ets b/apps/harmony/entry/src/main/ets/pages/Mobile.ets index 095adcdf..6ecdc2b9 100644 --- a/apps/harmony/entry/src/main/ets/pages/Mobile.ets +++ b/apps/harmony/entry/src/main/ets/pages/Mobile.ets @@ -269,12 +269,12 @@ struct Mobile { }.padding(12).width('100%').backgroundColor(Color.White).borderRadius(26).border({ width: 0.5, color: MobileTheme.line }) this.SectionTitle('屏幕方向') Row() { - Text('连接后自动横屏').fontSize(16).fontColor(MobileTheme.ink).layoutWeight(1) + Text('连接建立后锁定横屏').fontSize(16).fontColor(MobileTheme.ink).layoutWeight(1) Toggle({ type: ToggleType.Switch, isOn: this.state.autoLandscape }).selectedColor(MobileTheme.green) - .onChange((value: boolean) => this.controller?.setAutoLandscape(value)).accessibilityText('连接后自动横屏') + .onChange((value: boolean) => this.controller?.setAutoLandscape(value)).accessibilityText('连接建立后锁定横屏') }.padding({ left: 16, right: 16, top: 10, bottom: 10 }).width('100%').backgroundColor(Color.White) .borderRadius(26).border({ width: 0.5, color: MobileTheme.line }) - Text('手机连接后先进入横屏,将手机横过来后恢复跟随设备方向。关闭后始终跟随设备方向。平板和多窗口不受影响。') + Text('默认关闭。开启后,手机连接时进入并保持横屏;关闭时跟随设备方向。平板和多窗口不受影响。') .fontSize(12).fontColor(MobileTheme.secondary).width('100%').padding({ left: 16, right: 16, top: 8, bottom: 12 }) Column() { this.SettingRow('服务器', this.customServer ? '自定义' : '默认', () => this.serverExpanded = !this.serverExpanded) diff --git a/apps/ios/CrossDeskMobile/ContentView.swift b/apps/ios/CrossDeskMobile/ContentView.swift index a2b45b4d..2cc333c7 100644 --- a/apps/ios/CrossDeskMobile/ContentView.swift +++ b/apps/ios/CrossDeskMobile/ContentView.swift @@ -840,9 +840,11 @@ private struct ServerSettingsView: View { .pickerStyle(.segmented) } Section { - Toggle("连接后自动横屏", isOn: $session.autoLandscape) + Toggle("连接建立后锁定横屏", isOn: $session.autoLandscape) } header: { Text("屏幕方向") + } footer: { + Text("默认关闭。开启后,iPhone 连接时进入并保持横屏;关闭时跟随设备方向。iPad 不受影响。") } Section { DisclosureGroup(isExpanded: $serverConfigurationExpanded) { diff --git a/apps/ios/CrossDeskMobile/CrossDeskMobileApp.swift b/apps/ios/CrossDeskMobile/CrossDeskMobileApp.swift index d8ce253d..f3ad5ca3 100644 --- a/apps/ios/CrossDeskMobile/CrossDeskMobileApp.swift +++ b/apps/ios/CrossDeskMobile/CrossDeskMobileApp.swift @@ -15,79 +15,38 @@ final class CrossDeskAppDelegate: NSObject, UIApplicationDelegate { @MainActor enum AppOrientation { nonisolated static let autoLandscapeKey = "crossdesk.mobile.auto-landscape" - private static var sessionActive = false - private static var waitingForLandscape = false - private static var deviceObserver: NSObjectProtocol? + private static var sessionOrientations: UIInterfaceOrientationMask? nonisolated static func beginSession(autoLandscape: Bool) { DispatchQueue.main.async { beginSessionOnMain(autoLandscape: autoLandscape) } } private static func beginSessionOnMain(autoLandscape: Bool) { - // Repeated transport-ready callbacks must not lock an existing session again. - guard !sessionActive else { return } - sessionActive = true - waitingForLandscape = autoLandscape && UIDevice.current.userInterfaceIdiom == .phone - if waitingForLandscape { - CrossDeskAppDelegate.supportedOrientations = .landscape - resumeObserving() - } else { - apply(to: .allButUpsideDown) - } + // Preserve the selected policy through repeated transport-ready callbacks. + guard sessionOrientations == nil else { return } + let orientations: UIInterfaceOrientationMask = autoLandscape ? .landscape : .allButUpsideDown + sessionOrientations = orientations + apply(to: orientations) } nonisolated static func endSession() { DispatchQueue.main.async { - sessionActive = false - waitingForLandscape = false - stopObserving() + sessionOrientations = nil apply(to: .portrait) } } nonisolated static func resume() { - DispatchQueue.main.async { resumeObserving() } - } - - private static func resumeObserving() { - guard sessionActive, waitingForLandscape, deviceObserver == nil, - UIApplication.shared.applicationState == .active else { return } - apply(to: .landscape) - deviceObserver = NotificationCenter.default.addObserver( - forName: UIDevice.orientationDidChangeNotification, object: nil, queue: .main - ) { _ in - MainActor.assumeIsolated { deviceDidRotate() } + DispatchQueue.main.async { + apply(to: sessionOrientations ?? .portrait) } - UIDevice.current.beginGeneratingDeviceOrientationNotifications() - deviceDidRotate() - } - - nonisolated static func suspend() { - DispatchQueue.main.async { stopObserving() } - } - - private static func stopObserving() { - guard let observer = deviceObserver else { return } - NotificationCenter.default.removeObserver(observer) - deviceObserver = nil - UIDevice.current.endGeneratingDeviceOrientationNotifications() - } - - private static func deviceDidRotate() { - // UIDevice reports physical posture; window geometry also changes when - // we request landscape and must never release the initial lock itself. - guard sessionActive, waitingForLandscape, deviceObserver != nil, - UIApplication.shared.applicationState == .active, - UIDevice.current.orientation.isLandscape else { return } - waitingForLandscape = false - stopObserving() - apply(to: .allButUpsideDown) } private static func apply(to orientations: UIInterfaceOrientationMask) { // iPad keeps every orientation available, including in resizable windows. guard UIDevice.current.userInterfaceIdiom != .pad else { return } CrossDeskAppDelegate.supportedOrientations = orientations + guard UIApplication.shared.applicationState == .active else { return } for case let scene as UIWindowScene in UIApplication.shared.connectedScenes { scene.windows.forEach { $0.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() diff --git a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift index c99aff6c..ea3651e9 100644 --- a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift +++ b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift @@ -312,7 +312,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe @Published var videoAdaptationPolicy = VideoAdaptationPolicy.saved { didSet { videoAdaptationPolicy.save() } } - @Published var autoLandscape = UserDefaults.standard.object(forKey: AppOrientation.autoLandscapeKey) as? Bool ?? true { + @Published var autoLandscape = UserDefaults.standard.object(forKey: AppOrientation.autoLandscapeKey) as? Bool ?? false { didSet { UserDefaults.standard.set(autoLandscape, forKey: AppOrientation.autoLandscapeKey) } } @Published private(set) var videoSettings = RemoteVideoSettingsState() @@ -457,7 +457,6 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe func applicationDidEnterBackground() { appIsActive = false - AppOrientation.suspend() appUpdates.setEnabled(false) privacyNotice.didEnterBackground() suspendVideoForBackground() diff --git a/apps/ios/README.md b/apps/ios/README.md index f83db913..606d52fe 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -214,14 +214,13 @@ the available space. Settings and reading pages are limited to 720 points. Resizing preserves the connection fields and active session. iPhone retains its portrait home screen and two-column recent-connection grid. -On iPhone, Settings → Screen orientation (屏幕方向) → Landscape after connecting -(连接后自动横屏) is enabled by default and saved locally. A new session starts in -landscape and keeps that orientation until the phone is physically held sideways; -it then resumes the existing device-following policy for the rest of the session. -Portrait, flat and unknown device poses do not release the initial landscape hold. -Turning the option off keeps the existing rotation behavior. Leaving the session -restores portrait; foregrounding the same session does not reapply a released hold. -iPad orientation and multitasking are unaffected. +On iPhone, Settings → Screen orientation (屏幕方向) → Lock landscape after connecting +(连接建立后锁定横屏) is disabled by default and saved locally. When enabled, the session +enters and stays in landscape; when disabled, it follows the device orientation. +Leaving the session restores portrait, and returning to a background session keeps +that session's orientation setting. The physical-device monitoring that released +the initial landscape hold has been removed. iPad orientation and multitasking +are unaffected. ## System keyboard and remote text