feat(android): 横屏远控(自动方向 + 控制条自动隐藏 + 黑边唤出)
竖屏看 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,零冲突。 - 竖屏永不隐藏(裁决只针对横屏空间不足)。
This commit is contained in:
parent
1a6c3f15a2
commit
cff7fc8ab3
@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.verify.remote"
|
applicationId = "com.verify.remote"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 3
|
versionCode = 4
|
||||||
versionName = "0.1-phase3b"
|
versionName = "0.1-phase4a"
|
||||||
// 阶段 1 只测真机 arm64;x86_64 留给模拟器回归
|
// 阶段 1 只测真机 arm64;x86_64 留给模拟器回归
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a")
|
abiFilters += listOf("arm64-v8a")
|
||||||
|
|||||||
@ -46,10 +46,14 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
|
|||||||
private lateinit var connectButton: Button
|
private lateinit var connectButton: Button
|
||||||
private lateinit var disconnectButton: Button
|
private lateinit var disconnectButton: Button
|
||||||
private lateinit var modeButton: Button
|
private lateinit var modeButton: Button
|
||||||
|
private lateinit var orientationButton: Button
|
||||||
private lateinit var sensUpButton: Button
|
private lateinit var sensUpButton: Button
|
||||||
private lateinit var sensDownButton: Button
|
private lateinit var sensDownButton: Button
|
||||||
private lateinit var sensLabel: TextView
|
private lateinit var sensLabel: TextView
|
||||||
private lateinit var connectStatus: TextView
|
private lateinit var connectStatus: TextView
|
||||||
|
/** 4a:横屏自动隐藏的两个控制条(Q3=A)+ 状态行 */
|
||||||
|
private lateinit var controlBar: LinearLayout
|
||||||
|
private lateinit var sensBar: LinearLayout
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var handle = 0L
|
private var handle = 0L
|
||||||
@ -80,13 +84,119 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var lastTouchDbg = "-"
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
Log.i(TAG, "onCreate start")
|
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 {
|
surfaceView = RemoteSurfaceView(this).apply {
|
||||||
listener = this@MainActivity
|
listener = this@MainActivity
|
||||||
onDebug = { msg -> lastTouchDbg = msg; refreshStatus() }
|
onDebug = { msg -> lastTouchDbg = msg; refreshStatus() }
|
||||||
|
// 4a:画面内的任何触摸重置隐藏计时;黑边唤出在 stage 的
|
||||||
|
// dispatchTouchEvent 里做(那里能区分「落在画面外」)
|
||||||
|
onAnyTouch = { scheduleControlsHide() }
|
||||||
holder.addCallback(object : SurfaceHolder.Callback {
|
holder.addCallback(object : SurfaceHolder.Callback {
|
||||||
override fun surfaceCreated(holder: SurfaceHolder) {}
|
override fun surfaceCreated(holder: SurfaceHolder) {}
|
||||||
override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, ht: Int) {}
|
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 归一化 → 坐标全错)。
|
// (2026-10-30 实测:视图被撑成 1080x2001,触摸按 2001 归一化 → 坐标全错)。
|
||||||
// MATCH_PARENT + gravity=center 时父容器传 AT_MOST,onMeasure 的
|
// MATCH_PARENT + gravity=center 时父容器传 AT_MOST,onMeasure 的
|
||||||
// setMeasuredDimension 才真正决定尺寸,再被 gravity 居中摆放。
|
// 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())
|
setBackgroundColor(0xFF000000.toInt())
|
||||||
addView(
|
addView(
|
||||||
surfaceView,
|
surfaceView,
|
||||||
@ -247,13 +382,26 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
|
|||||||
// 取消语义必须只有一条路径(V-A1 判据依赖取消后的固定状态)。
|
// 取消语义必须只有一条路径(V-A1 判据依赖取消后的固定状态)。
|
||||||
setOnClickListener { if (connecting) cancelConnect() else disconnect() }
|
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
|
orientation = LinearLayout.HORIZONTAL
|
||||||
addView(keyboardButton, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f))
|
addView(keyboardButton, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f))
|
||||||
addView(modeButton, 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))
|
addView(disconnectButton, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f))
|
||||||
}
|
}
|
||||||
val sensBar = LinearLayout(this).apply {
|
sensBar = LinearLayout(this).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
addView(sensDownButton, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT))
|
addView(sensDownButton, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT))
|
||||||
addView(sensLabel, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f))
|
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],状态栏不可见)
|
// WRAP_CONTENT 可能被压成 0(实测 bounds=[0,0][0,0],状态栏不可见)
|
||||||
addView(statusView, LayoutParams(LayoutParams.MATCH_PARENT, dp(64)))
|
addView(statusView, LayoutParams(LayoutParams.MATCH_PARENT, dp(64)))
|
||||||
addView(sensBar, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
|
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
|
connecting = false
|
||||||
remotePanelVisible(false)
|
remotePanelVisible(false)
|
||||||
connectStatus.text = msg
|
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() {
|
private fun showRemoteState() {
|
||||||
@ -299,6 +454,8 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
|
|||||||
// 远控面板上的按钮第一下 tap 只被用来「丢焦点」而不触发点击——真机上表现
|
// 远控面板上的按钮第一下 tap 只被用来「丢焦点」而不触发点击——真机上表现
|
||||||
// 为「按钮要点两次」(3a 的 D8 已实测到 4 轮全丢 tap)。
|
// 为「按钮要点两次」(3a 的 D8 已实测到 4 轮全丢 tap)。
|
||||||
clearFormFocus()
|
clearFormFocus()
|
||||||
|
// 4a:按当前方向应用形态(此刻可能还没转完,onConfigurationChanged 会再来一次)
|
||||||
|
applyRemoteChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -442,6 +599,20 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
|
|||||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||||
super.onConfigurationChanged(newConfig)
|
super.onConfigurationChanged(newConfig)
|
||||||
updateKeyboardVisibility()
|
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) {
|
if (w > 0 && ht > 0) {
|
||||||
surfaceView.videoAspectRatio = w.toFloat() / ht
|
surfaceView.videoAspectRatio = w.toFloat() / ht
|
||||||
}
|
}
|
||||||
|
// 4a:拿到远端尺寸才谈得上 AUTO(Q1=A)。尺寸未知时 requestFor
|
||||||
|
// 落回竖屏,不会把表单尺寸错当远端尺寸用。
|
||||||
|
applyOrientation()
|
||||||
refreshStatus()
|
refreshStatus()
|
||||||
startDecodeLoop(h, gen)
|
startDecodeLoop(h, gen)
|
||||||
}
|
}
|
||||||
@ -775,6 +949,9 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
// 4a:撤掉隐藏计时,防止 Handler 持有已销毁 Activity(RemoteSurfaceView
|
||||||
|
// 的长按任务有自己的 cancel,这里只管本 Activity 的)
|
||||||
|
ui.removeCallbacks(hideControlsTask)
|
||||||
disconnect()
|
disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -808,6 +985,10 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
|
|||||||
private const val PREF_RELATIVE = "relative_mode"
|
private const val PREF_RELATIVE = "relative_mode"
|
||||||
private const val PREF_SENSITIVITY = "sensitivity"
|
private const val PREF_SENSITIVITY = "sensitivity"
|
||||||
private const val SENS_STEP = 0.25f
|
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 要求)。
|
* 与协议版本保持一致:远控画面状态栏显示当前握手版本(方案 R5 要求)。
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
@ -246,6 +246,22 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
|
|||||||
*/
|
*/
|
||||||
var onDebug: ((String) -> Unit)? = null
|
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 {
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||||
val l = listener ?: run {
|
val l = listener ?: run {
|
||||||
onDebug?.invoke("触摸(未接线)")
|
onDebug?.invoke("触摸(未接线)")
|
||||||
|
|||||||
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user