From cff7fc8ab356827ede3704c996145c797a5e06e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E5=8D=9A=E6=96=87?= <1323515022@qq.com> Date: Sun, 1 Nov 2026 21:57:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(android):=20=E6=A8=AA=E5=B1=8F=E8=BF=9C?= =?UTF-8?q?=E6=8E=A7=EF=BC=88=E8=87=AA=E5=8A=A8=E6=96=B9=E5=90=91=20+=20?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E6=9D=A1=E8=87=AA=E5=8A=A8=E9=9A=90=E8=97=8F?= =?UTF-8?q?=20+=20=E9=BB=91=E8=BE=B9=E5=94=A4=E5=87=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 竖屏看 16:9 桌面太小(用户 2026-11-01 反馈)。探针实测(docs/output/ 2026-11-01-landscape-probe/):App 级 setRequestedOrientation 在这台 vivo 上有效(761ms),且旋转只触发 surfaceChanged、不销毁 surface、不断会话、 横屏 fps=58~60——所以不需要 TextureView(探针另测 TextureView 掉 30~37% 帧率)、不需要任何解码器重建配合,旧 S3 作废。 - OrientationPolicy(AUTO/LANDSCAPE/PORTRAIT/SYSTEM)纯逻辑 + 10 单测, 常量值经 javap 对 android-34 核实;已做 mutation 反向验证(AUTO 判定 > 改 >= 时方屏用例确实红)。 - 表单页强制竖屏;连接成功按策略设方向(默认 AUTO:远端宽>高即横屏)。 - 横屏 immersive + 状态行压 36dp + 控制条 3s 无操作自动隐藏。 - 唤出区 = letterbox 黑边(stage 在隐藏态消费画面外触摸)。刻意不用 底部边缘带:R7 回归实测那种带会吃掉远端桌面底部 ~11%(Windows 任务栏 就在那里),点任务栏会变成唤出菜单。黑边不属于 SurfaceView,零冲突。 - 竖屏永不隐藏(裁决只针对横屏空间不足)。 --- apps/android-viewer/app/build.gradle.kts | 4 +- .../kotlin/com/verify/remote/MainActivity.kt | 189 +++++++++++++++++- .../com/verify/remote/OrientationPolicy.kt | 60 ++++++ .../com/verify/remote/RemoteSurfaceView.kt | 16 ++ .../verify/remote/OrientationPolicyTest.kt | 100 +++++++++ 5 files changed, 363 insertions(+), 6 deletions(-) create mode 100644 apps/android-viewer/app/src/main/kotlin/com/verify/remote/OrientationPolicy.kt create mode 100644 apps/android-viewer/app/src/test/java/com/verify/remote/OrientationPolicyTest.kt diff --git a/apps/android-viewer/app/build.gradle.kts b/apps/android-viewer/app/build.gradle.kts index ef7f8f9..9d93317 100644 --- a/apps/android-viewer/app/build.gradle.kts +++ b/apps/android-viewer/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.verify.remote" minSdk = 26 targetSdk = 34 - versionCode = 3 - versionName = "0.1-phase3b" + versionCode = 4 + versionName = "0.1-phase4a" // 阶段 1 只测真机 arm64;x86_64 留给模拟器回归 ndk { abiFilters += listOf("arm64-v8a") diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt index 5c4b0c8..f8205f6 100644 --- a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt +++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt @@ -46,10 +46,14 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { private lateinit var connectButton: Button private lateinit var disconnectButton: Button private lateinit var modeButton: Button + private lateinit var orientationButton: Button private lateinit var sensUpButton: Button private lateinit var sensDownButton: Button private lateinit var sensLabel: TextView private lateinit var connectStatus: TextView + /** 4a:横屏自动隐藏的两个控制条(Q3=A)+ 状态行 */ + private lateinit var controlBar: LinearLayout + private lateinit var sensBar: LinearLayout @Volatile private var handle = 0L @@ -80,13 +84,119 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { @Volatile private var lastTouchDbg = "-" + /** 4a:方向策略(Q1=A 默认 AUTO,Q4=A 底栏按钮循环切换并落 prefs) */ + private var orientationPolicy = OrientationPolicy.AUTO + + /** 4a:首次进横屏提示一次「点底部唤出菜单」(Q7=A),只提示一次不骚扰 */ + private var landscapeHintShown = false + + // ---- 4a/Q3=A:横屏控制条自动隐藏(Q7=A:3s + 40dp 边缘带 + 首次 toast) ---- + + private val ui = android.os.Handler(android.os.Looper.getMainLooper()) + private var controlsVisible = true + + private val hideControlsTask = Runnable { + // 调度后可能已转回竖屏:执行时再判一次,竖屏永不隐藏(裁决只针对横屏) + if (isLandscape()) setControlsVisible(false) + } + + private fun isLandscape(): Boolean = + resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + + /** + * 控制条显隐的唯一入口(状态行 + 灵敏度行 + 底栏一起)。 + * `controlsVisible` 同时是 stage 黑边唤出的判据,只有这一处写它。 + */ + private fun setControlsVisible(v: Boolean) { + controlsVisible = v + val vis = if (v) View.VISIBLE else View.GONE + statusView.visibility = vis + sensBar.visibility = vis + controlBar.visibility = vis + } + + /** 任何触摸/唤出都重新计时;只在横屏 + 已连接 + 非等待期才挂隐藏任务 */ + private fun scheduleControlsHide() { + ui.removeCallbacks(hideControlsTask) + if (isLandscape() && handle != 0L && !connecting) { + ui.postDelayed(hideControlsTask, CONTROLS_HIDE_MS) + } + } + + /** + * 横屏沉浸式:隐藏系统状态栏 + 导航栏(minSdk 26 → 用 legacy 标志; + * API 30+ 走 insetsController 分支,本机 API 29 实测走的是 legacy 路径)。 + * IMMERSIVE_STICKY:滑动边缘临时唤出后自动收回,不打断远控操作。 + * + * 名字刻意不叫 setImmersive——`Activity` 基类有同名废弃方法, + * Kotlin 会要求 override(真机编译实测 2026-11-01)。 + */ + @Suppress("DEPRECATION") + private fun applyImmersive(on: Boolean) { + val flags = android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + android.view.View.SYSTEM_UI_FLAG_FULLSCREEN or + android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or + android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or + android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + window.decorView.systemUiVisibility = if (on) flags else 0 + } + + /** 横屏时状态行压成 36dp 单行(纵向空间是稀缺资源),竖屏恢复 64dp 三行 */ + private fun applyStatusLayout() { + val lp = statusView.layoutParams as LinearLayout.LayoutParams + lp.height = dp(if (isLandscape()) 36 else 64) + statusView.layoutParams = lp + statusView.maxLines = if (isLandscape()) 1 else 3 + } + + /** + * 按当前方向应用远控页形态(进面板、旋转各调一次)。 + * 竖屏保持 3b 原样(常驻控制条)——本轮裁决只针对横屏空间不足的问题。 + */ + private fun applyRemoteChrome() { + val land = isLandscape() + applyImmersive(land) + applyStatusLayout() + if (land) { + if (!landscapeHintShown) { + landscapeHintShown = true + android.widget.Toast.makeText(this, "无操作 3 秒隐藏菜单,点屏幕底部唤出", + android.widget.Toast.LENGTH_LONG).show() + } + setControlsVisible(true) + scheduleControlsHide() + } else { + setControlsVisible(true) + } + } + + /** + * 按当前策略 + 已知远端尺寸应用屏幕方向(4a)。 + * + * 探针实测(2026-11-01):App 级 requestedOrientation 在这台 vivo 上有效(761ms), + * 且旋转只触发 surfaceChanged、不销毁 surface、不断会话——所以这里**只设方向**, + * 不需要任何解码器/会话保护配套(旧 S3 作废的根据)。 + */ + private fun applyOrientation() { + requestedOrientation = Orientation.requestFor(orientationPolicy, remoteW, remoteH) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Log.i(TAG, "onCreate start") + // 4a/Q6=A:表单页强制竖屏(输入框 + 软键盘在竖屏好用)。 + // 放在 setContentView 前:首帧就定下方向,避免横屏手机打开 App 先闪一下。 + orientationPolicy = Orientation.fromName(prefs().getString(PREF_ORIENTATION, null)) + requestedOrientation = Orientation.REQUEST_PORTRAIT + surfaceView = RemoteSurfaceView(this).apply { listener = this@MainActivity onDebug = { msg -> lastTouchDbg = msg; refreshStatus() } + // 4a:画面内的任何触摸重置隐藏计时;黑边唤出在 stage 的 + // dispatchTouchEvent 里做(那里能区分「落在画面外」) + onAnyTouch = { scheduleControlsHide() } holder.addCallback(object : SurfaceHolder.Callback { override fun surfaceCreated(holder: SurfaceHolder) {} override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, ht: Int) {} @@ -200,7 +310,32 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { // (2026-10-30 实测:视图被撑成 1080x2001,触摸按 2001 归一化 → 坐标全错)。 // MATCH_PARENT + gravity=center 时父容器传 AT_MOST,onMeasure 的 // setMeasuredDimension 才真正决定尺寸,再被 gravity 居中摆放。 - val stage = FrameLayout(this).apply { + // + // 4a:控制条隐藏时,落在**画面黑边**(SurfaceView 之外)的触摸唤出控制条。 + // 为什么用黑边而不是底部边缘带(后者已废弃):letterbox 黑边本来就不承载 + // 任何远端操作,拿它当唤出区是零冲突的;底部边缘带会吃掉远端桌面最下 + // ~11% 的高度,而 Windows 任务栏就在那里(2026-11-01 R7 回归实测: + // 任务栏位置的 tap 被唤出带吞掉,映射误差 357px)。 + // 落在画面内的触摸一律放行给 SurfaceView。 + val stage = object : FrameLayout(this) { + override fun dispatchTouchEvent(e: android.view.MotionEvent): Boolean { + if (e.actionMasked == android.view.MotionEvent.ACTION_DOWN && + !controlsVisible && remotePanel.visibility == View.VISIBLE + ) { + val v = surfaceView + val inside = e.x >= v.x && e.x <= v.x + v.width && + e.y >= v.y && e.y <= v.y + v.height + if (!inside) { + setControlsVisible(true) + scheduleControlsHide() + lastTouchDbg = "唤出菜单" + refreshStatus() + return true // 消费:黑边不产生远端事件 + } + } + return super.dispatchTouchEvent(e) + } + }.apply { setBackgroundColor(0xFF000000.toInt()) addView( surfaceView, @@ -247,13 +382,26 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { // 取消语义必须只有一条路径(V-A1 判据依赖取消后的固定状态)。 setOnClickListener { if (connecting) cancelConnect() else disconnect() } } - val bar = LinearLayout(this).apply { + // 4a/Q4=A:方向循环按钮 AUTO→横→竖→系统 + orientationButton = Button(this).apply { + text = Orientation.label(orientationPolicy) + setOnClickListener { + orientationPolicy = Orientation.next(orientationPolicy) + prefs().edit().putString(PREF_ORIENTATION, orientationPolicy.name).apply() + text = Orientation.label(orientationPolicy) + applyOrientation() + lastTouchDbg = Orientation.label(orientationPolicy) + refreshStatus() + } + } + controlBar = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL addView(keyboardButton, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) addView(modeButton, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + addView(orientationButton, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) addView(disconnectButton, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) } - val sensBar = LinearLayout(this).apply { + sensBar = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL addView(sensDownButton, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) addView(sensLabel, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) @@ -267,7 +415,7 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { // WRAP_CONTENT 可能被压成 0(实测 bounds=[0,0][0,0],状态栏不可见) addView(statusView, LayoutParams(LayoutParams.MATCH_PARENT, dp(64))) addView(sensBar, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) - addView(bar, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) + addView(controlBar, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) } } @@ -290,6 +438,13 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { connecting = false remotePanelVisible(false) connectStatus.text = msg + // 4a/Q6=A:回表单就恢复竖屏(AUTO 策略下 remoteW/H 已被清零, + // 但显式设 PORTRAIT 更稳:即使策略是 LANDSCAPE,表单也不该横着)。 + requestedOrientation = Orientation.REQUEST_PORTRAIT + // 4a:表单页永远完整可见——收掉沉浸式、恢复控制条、撤掉隐藏计时 + ui.removeCallbacks(hideControlsTask) + applyImmersive(false) + setControlsVisible(true) } private fun showRemoteState() { @@ -299,6 +454,8 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { // 远控面板上的按钮第一下 tap 只被用来「丢焦点」而不触发点击——真机上表现 // 为「按钮要点两次」(3a 的 D8 已实测到 4 轮全丢 tap)。 clearFormFocus() + // 4a:按当前方向应用形态(此刻可能还没转完,onConfigurationChanged 会再来一次) + applyRemoteChrome() } /** @@ -442,6 +599,20 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { override fun onConfigurationChanged(newConfig: android.content.res.Configuration) { super.onConfigurationChanged(newConfig) updateKeyboardVisibility() + // 4a:旋转(含 AUTO 策略触发的)会走到这里——探针实测 Activity 不重建、 + // surface 只 resize,所以这里只做形态切换,不碰会话。 + if (remotePanel.visibility == View.VISIBLE) { + applyRemoteChrome() + } + } + + /** + * 4a:任何用户交互都重置隐藏计时——系统栏临时唤出、面板按钮点击 + * (按钮在 SurfaceView 之外,onAnyTouch 覆盖不到)。 + */ + override fun onUserInteraction() { + super.onUserInteraction() + if (remotePanel.visibility == View.VISIBLE) scheduleControlsHide() } /** @@ -614,6 +785,9 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { if (w > 0 && ht > 0) { surfaceView.videoAspectRatio = w.toFloat() / ht } + // 4a:拿到远端尺寸才谈得上 AUTO(Q1=A)。尺寸未知时 requestFor + // 落回竖屏,不会把表单尺寸错当远端尺寸用。 + applyOrientation() refreshStatus() startDecodeLoop(h, gen) } @@ -775,6 +949,9 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { override fun onDestroy() { super.onDestroy() + // 4a:撤掉隐藏计时,防止 Handler 持有已销毁 Activity(RemoteSurfaceView + // 的长按任务有自己的 cancel,这里只管本 Activity 的) + ui.removeCallbacks(hideControlsTask) disconnect() } @@ -808,6 +985,10 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { private const val PREF_RELATIVE = "relative_mode" private const val PREF_SENSITIVITY = "sensitivity" private const val SENS_STEP = 0.25f + /** 4a/Q4=A:方向策略落盘(存枚举名,未知值 fromName 落回 AUTO) */ + private const val PREF_ORIENTATION = "orientation_policy" + /** 4a/Q7=A:横屏无操作隐藏控制条的延时 */ + private const val CONTROLS_HIDE_MS = 3000L /** * 与协议版本保持一致:远控画面状态栏显示当前握手版本(方案 R5 要求)。 diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/OrientationPolicy.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/OrientationPolicy.kt new file mode 100644 index 0000000..e3d0a76 --- /dev/null +++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/OrientationPolicy.kt @@ -0,0 +1,60 @@ +package com.verify.remote + +/** 4a:指针方向策略(Q1=A:默认 AUTO)。 */ +enum class OrientationPolicy { AUTO, LANDSCAPE, PORTRAIT, SYSTEM } + +/** + * 方向策略 → `Activity.requestedOrientation` 的纯映射(4a)。 + * + * 抽成纯 Kotlin 的原因(与 WinScancode/ConnectErrorText 同一模式): + * 映射表必须能在 JVM 单测里钉死,而 `ActivityInfo` 常量在 android.jar 桩上 + * 引用类加载有坑,这里直接落整数常量。 + * + * 数值来源(不凭记忆,2026-11-01 `javap -constants` 对 android-34 android.jar 核实): + * - SCREEN_ORIENTATION_LANDSCAPE = 0 + * - SCREEN_ORIENTATION_PORTRAIT = 1 + * - SCREEN_ORIENTATION_UNSPECIFIED = -1 + * 刻意只用这三个:探针在真机验证过 LANDSCAPE(0) 与 PORTRAIT 行为; + * SENSOR_LANDSCAPE(6)(允许左右横)未在本机验证过,不引入——固定横屏对 + * 「看 16:9 桌面」的场景足够,翻转手机方向时系统不会再把画面翻到另一侧, + * 这反而是更稳定的行为。 + */ +object Orientation { + + const val REQUEST_LANDSCAPE = 0 + const val REQUEST_PORTRAIT = 1 + const val REQUEST_UNSPECIFIED = -1 + + /** + * AUTO 的语义:远端画面是横的(宽>高)就用横屏,否则竖屏。 + * 尺寸未知(0,连接前/断开后)按竖屏兜底——表单页本来就是竖屏。 + */ + fun requestFor(policy: OrientationPolicy, remoteW: Int, remoteH: Int): Int = when (policy) { + OrientationPolicy.AUTO -> + if (remoteW > remoteH) REQUEST_LANDSCAPE else REQUEST_PORTRAIT + OrientationPolicy.LANDSCAPE -> REQUEST_LANDSCAPE + OrientationPolicy.PORTRAIT -> REQUEST_PORTRAIT + OrientationPolicy.SYSTEM -> REQUEST_UNSPECIFIED + } + + /** 方向按钮的循环次序(Q4=A):AUTO→横→竖→系统→AUTO。 */ + fun next(policy: OrientationPolicy): OrientationPolicy = when (policy) { + OrientationPolicy.AUTO -> OrientationPolicy.LANDSCAPE + OrientationPolicy.LANDSCAPE -> OrientationPolicy.PORTRAIT + OrientationPolicy.PORTRAIT -> OrientationPolicy.SYSTEM + OrientationPolicy.SYSTEM -> OrientationPolicy.AUTO + } + + /** 按钮文案。每个策略都必须有非空、互不相同的文案(单测锁)。 */ + fun label(policy: OrientationPolicy): String = when (policy) { + OrientationPolicy.AUTO -> "方向:自动" + OrientationPolicy.LANDSCAPE -> "方向:横屏" + OrientationPolicy.PORTRAIT -> "方向:竖屏" + OrientationPolicy.SYSTEM -> "方向:系统" + } + + /** prefs 读写:未知字符串(版本回退/手改)必须落回 AUTO,不得抛。 */ + fun fromName(name: String?): OrientationPolicy = + runCatching { OrientationPolicy.valueOf(name.orEmpty().uppercase()) } + .getOrDefault(OrientationPolicy.AUTO) +} diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteSurfaceView.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteSurfaceView.kt index f4edefd..176ad56 100644 --- a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteSurfaceView.kt +++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteSurfaceView.kt @@ -246,6 +246,22 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) { */ var onDebug: ((String) -> Unit)? = null + /** + * 4a/Q3=A:任何触摸都回调一次(Activity 用它重置自动隐藏计时)。 + * + * 唤出区刻意**不在本视图内**(原底部 40dp 边缘带的设计已废弃):那条带会吃掉 + * 远端桌面底部约 11% 的高度——Windows 任务栏就在那里,点任务栏会变成唤出菜单 + * 而不是点击任务栏(2026-11-01 R7 回归实测暴露:frac=0.9 的 tap 被边缘带吞掉)。 + * 改为由 Activity 在 stage 上消费「画面黑边」的触摸:黑边不属于本视图, + * 天然与远端操作零冲突。 + */ + var onAnyTouch: (() -> Unit)? = null + + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + onAnyTouch?.invoke() + return super.dispatchTouchEvent(event) + } + override fun onTouchEvent(event: MotionEvent): Boolean { val l = listener ?: run { onDebug?.invoke("触摸(未接线)") diff --git a/apps/android-viewer/app/src/test/java/com/verify/remote/OrientationPolicyTest.kt b/apps/android-viewer/app/src/test/java/com/verify/remote/OrientationPolicyTest.kt new file mode 100644 index 0000000..d0d8ae0 --- /dev/null +++ b/apps/android-viewer/app/src/test/java/com/verify/remote/OrientationPolicyTest.kt @@ -0,0 +1,100 @@ +package com.verify.remote + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +/** + * 4a:方向策略纯逻辑。 + * + * 常量数值不是随手写的——`javap -constants` 对 android-34 android.jar 核实过 + * (LANDSCAPE=0、PORTRAIT=1、UNSPECIFIED=-1)。这里把它们钉成断言: + * 若有人「顺手改成 SENSOR_LANDSCAPE=6」或调换数值,测试先红。 + */ +class OrientationPolicyTest { + + @Test + fun android_constants_pinned_to_verified_values() { + assertEquals(0, Orientation.REQUEST_LANDSCAPE) + assertEquals(1, Orientation.REQUEST_PORTRAIT) + assertEquals(-1, Orientation.REQUEST_UNSPECIFIED) + } + + // ---- AUTO 的判定 ---- + + @Test + fun auto_picks_landscape_for_wider_remote() { + // 本机被控端就是 1920x1080 —— 这正是 4a 需求的来源 + assertEquals(Orientation.REQUEST_LANDSCAPE, Orientation.requestFor(OrientationPolicy.AUTO, 1920, 1080)) + } + + @Test + fun auto_picks_portrait_for_taller_remote() { + assertEquals(Orientation.REQUEST_PORTRAIT, Orientation.requestFor(OrientationPolicy.AUTO, 1080, 1920)) + } + + @Test + fun auto_picks_portrait_for_square_and_unknown() { + // 方屏没有「更宽」的优势,按竖屏兜底 + assertEquals(Orientation.REQUEST_PORTRAIT, Orientation.requestFor(OrientationPolicy.AUTO, 1080, 1080)) + // 尺寸未知(连接前/断开后)必须竖屏,不能让表单页横掉 + assertEquals(Orientation.REQUEST_PORTRAIT, Orientation.requestFor(OrientationPolicy.AUTO, 0, 0)) + } + + @Test + fun forced_policies_ignore_remote_geometry() { + for (wh in listOf(1920 to 1080, 1080 to 1920, 0 to 0)) { + assertEquals(Orientation.REQUEST_LANDSCAPE, + Orientation.requestFor(OrientationPolicy.LANDSCAPE, wh.first, wh.second)) + assertEquals(Orientation.REQUEST_PORTRAIT, + Orientation.requestFor(OrientationPolicy.PORTRAIT, wh.first, wh.second)) + assertEquals(Orientation.REQUEST_UNSPECIFIED, + Orientation.requestFor(OrientationPolicy.SYSTEM, wh.first, wh.second)) + } + } + + // ---- 按钮循环(Q4=A)---- + + @Test + fun button_cycles_all_four_in_order() { + assertEquals(OrientationPolicy.LANDSCAPE, Orientation.next(OrientationPolicy.AUTO)) + assertEquals(OrientationPolicy.PORTRAIT, Orientation.next(OrientationPolicy.LANDSCAPE)) + assertEquals(OrientationPolicy.SYSTEM, Orientation.next(OrientationPolicy.PORTRAIT)) + assertEquals(OrientationPolicy.AUTO, Orientation.next(OrientationPolicy.SYSTEM)) + } + + @Test + fun four_clicks_return_to_start() { + var p = OrientationPolicy.AUTO + repeat(4) { p = Orientation.next(p) } + assertEquals(OrientationPolicy.AUTO, p) + } + + @Test + fun every_policy_has_distinct_nonempty_label() { + val labels = OrientationPolicy.values().map { Orientation.label(it) } + labels.forEach { assertNotEquals("", it) } + assertEquals("四个策略文案必须互不相同", labels.size, labels.toSet().size) + // 文案要让用户看出当前状态(含「方向:」前缀),不能只写「自动/横/竖」 + labels.forEach { assertEquals(true, it.startsWith("方向:")) } + } + + // ---- prefs 反序列化 ---- + + @Test + fun from_name_roundtrips_all() { + OrientationPolicy.values().forEach { + assertEquals(it, Orientation.fromName(it.name)) + } + } + + @Test + fun from_name_falls_back_to_auto_for_garbage() { + // 未知值不得抛异常(会让 Activity 起不来),必须落回默认 AUTO + assertEquals(OrientationPolicy.AUTO, Orientation.fromName(null)) + assertEquals(OrientationPolicy.AUTO, Orientation.fromName("")) + assertEquals(OrientationPolicy.AUTO, Orientation.fromName("DIAGONAL")) + // 大小写宽容:早期版本可能存过小写 + assertEquals(OrientationPolicy.LANDSCAPE, Orientation.fromName("landscape")) + } +}