From e43ef53e7e0442b66c0b5bf106f091e20f183e47 Mon Sep 17 00:00:00 2001 From: not-linyi Date: Thu, 18 Jun 2026 18:50:47 +0800 Subject: [PATCH 1/2] feat(input): add hardware key mappings --- .../com/limelight/KeyboardInputHandler.kt | 9 + .../binding/input/HardwareKeyMappingStore.kt | 146 ++++++++++++++++ .../limelight/preferences/StreamSettings.kt | 156 +++++++++++++++++- .../services/KeyboardAccessibilityService.kt | 17 +- app/src/main/res/values-zh-rCN/strings.xml | 10 ++ app/src/main/res/values/strings.xml | 10 ++ app/src/main/res/xml/preferences.xml | 4 + .../input/HardwareKeyMappingStoreTest.kt | 26 +++ 8 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/limelight/binding/input/HardwareKeyMappingStore.kt create mode 100644 app/src/test/java/com/limelight/binding/input/HardwareKeyMappingStoreTest.kt diff --git a/app/src/main/java/com/limelight/KeyboardInputHandler.kt b/app/src/main/java/com/limelight/KeyboardInputHandler.kt index c7c03f0ef5..188dd0e7e5 100644 --- a/app/src/main/java/com/limelight/KeyboardInputHandler.kt +++ b/app/src/main/java/com/limelight/KeyboardInputHandler.kt @@ -10,6 +10,7 @@ import android.view.KeyEvent import android.widget.Toast import com.limelight.binding.input.ControllerHandler import com.limelight.binding.input.KeyboardTranslator +import com.limelight.binding.input.HardwareKeyMappingStore import com.limelight.nvstream.input.KeyboardPacket import com.limelight.nvstream.input.MouseButtonPacket import com.limelight.nvstream.jni.MoonBridge @@ -130,6 +131,10 @@ class KeyboardInputHandler(private val game: Game) { } fun handleKeyDown(event: KeyEvent): Boolean { + return handleKeyDownMapped(HardwareKeyMappingStore.remap(game, event)) + } + + private fun handleKeyDownMapped(event: KeyEvent): Boolean { when (event.keyCode) { KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_HOME, KeyEvent.KEYCODE_APP_SWITCH, KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, @@ -302,6 +307,10 @@ class KeyboardInputHandler(private val game: Game) { } fun handleKeyUp(event: KeyEvent): Boolean { + return handleKeyUpMapped(HardwareKeyMappingStore.remap(game, event)) + } + + private fun handleKeyUpMapped(event: KeyEvent): Boolean { when (event.keyCode) { KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_HOME, KeyEvent.KEYCODE_APP_SWITCH -> { // 系统导航键直接跳过去重逻辑 diff --git a/app/src/main/java/com/limelight/binding/input/HardwareKeyMappingStore.kt b/app/src/main/java/com/limelight/binding/input/HardwareKeyMappingStore.kt new file mode 100644 index 0000000000..17ebb18f03 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/HardwareKeyMappingStore.kt @@ -0,0 +1,146 @@ +package com.limelight.binding.input + +import android.content.Context +import android.view.InputDevice +import android.view.KeyEvent +import org.json.JSONArray +import org.json.JSONObject + +data class HardwareKeyMapping( + val deviceKey: String, + val deviceName: String, + val sourceScanCode: Int, + val sourceKeyCode: Int, + val targetKeyCode: Int +) { + fun matches(event: KeyEvent): Boolean { + if (deviceKey != HardwareKeyMappingStore.deviceKey(event.device)) return false + return if (sourceScanCode > 0) { + event.scanCode == sourceScanCode + } else { + event.keyCode == sourceKeyCode + } + } +} + +object HardwareKeyMappingStore { + private const val PREFS_NAME = "hardware_key_mappings" + private const val KEY_MAPPINGS = "mappings_v1" + + fun mappingFrom(event: KeyEvent, targetKeyCode: Int): HardwareKeyMapping { + val device = event.device + return HardwareKeyMapping( + deviceKey = deviceKey(device), + deviceName = device?.name ?: "Unknown keyboard", + sourceScanCode = event.scanCode, + sourceKeyCode = event.keyCode, + targetKeyCode = targetKeyCode + ) + } + + fun resolve(context: Context, event: KeyEvent): Int { + return load(context).firstOrNull { it.matches(event) }?.targetKeyCode ?: event.keyCode + } + + fun remap(context: Context, event: KeyEvent): KeyEvent { + val targetKeyCode = resolve(context, event) + if (targetKeyCode == event.keyCode) return event + return KeyEvent( + event.downTime, + event.eventTime, + event.action, + targetKeyCode, + event.repeatCount, + event.metaState, + event.deviceId, + event.scanCode, + event.flags, + event.source + ) + } + + fun save(context: Context, mapping: HardwareKeyMapping) { + val mappings = load(context).toMutableList() + mappings.removeAll { + it.deviceKey == mapping.deviceKey && + if (mapping.sourceScanCode > 0) { + it.sourceScanCode == mapping.sourceScanCode + } else { + it.sourceScanCode <= 0 && it.sourceKeyCode == mapping.sourceKeyCode + } + } + mappings.add(mapping) + write(context, mappings) + } + + fun remove(context: Context, mapping: HardwareKeyMapping) { + write(context, load(context).filterNot { it == mapping }) + } + + fun clear(context: Context) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .remove(KEY_MAPPINGS) + .apply() + } + + fun load(context: Context): List { + val json = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(KEY_MAPPINGS, null) ?: return emptyList() + return decode(json) + } + + internal fun encode(mappings: List): String { + val array = JSONArray() + mappings.forEach { mapping -> + array.put(JSONObject().apply { + put("deviceKey", mapping.deviceKey) + put("deviceName", mapping.deviceName) + put("sourceScanCode", mapping.sourceScanCode) + put("sourceKeyCode", mapping.sourceKeyCode) + put("targetKeyCode", mapping.targetKeyCode) + }) + } + return array.toString() + } + + internal fun decode(json: String): List { + return try { + val array = JSONArray(json) + buildList { + for (index in 0 until array.length()) { + val item = array.optJSONObject(index) ?: continue + val target = item.optInt("targetKeyCode", KeyEvent.KEYCODE_UNKNOWN) + if (target == KeyEvent.KEYCODE_UNKNOWN) continue + add( + HardwareKeyMapping( + deviceKey = item.optString("deviceKey"), + deviceName = item.optString("deviceName", "Unknown keyboard"), + sourceScanCode = item.optInt("sourceScanCode"), + sourceKeyCode = item.optInt("sourceKeyCode"), + targetKeyCode = target + ) + ) + } + } + } catch (_: Exception) { + emptyList() + } + } + + internal fun deviceKey(device: InputDevice?): String { + if (device == null) return "unknown" + return if (device.vendorId != 0 || device.productId != 0) { + "%04x:%04x".format(device.vendorId, device.productId) + } else { + "name:${device.name}" + } + } + + private fun write(context: Context, mappings: List) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(KEY_MAPPINGS, encode(mappings)) + .apply() + } +} diff --git a/app/src/main/java/com/limelight/preferences/StreamSettings.kt b/app/src/main/java/com/limelight/preferences/StreamSettings.kt index f13c1e987b..7ae3574a6d 100644 --- a/app/src/main/java/com/limelight/preferences/StreamSettings.kt +++ b/app/src/main/java/com/limelight/preferences/StreamSettings.kt @@ -70,7 +70,10 @@ import com.limelight.R import com.limelight.ExternalDisplayManager import com.limelight.binding.input.advance_setting.config.PageConfigController import com.limelight.binding.input.advance_setting.sqlite.SuperConfigDatabaseHelper +import com.limelight.binding.input.HardwareKeyMapping +import com.limelight.binding.input.HardwareKeyMappingStore import com.limelight.binding.video.MediaCodecHelper +import com.limelight.services.KeyboardAccessibilityService import com.limelight.utils.AspectRatioConverter import com.limelight.utils.ConfigurationSyncManager import com.limelight.utils.ConfigurationSyncScheduler @@ -92,7 +95,7 @@ import kotlin.concurrent.thread import kotlin.math.roundToInt import kotlin.math.sqrt -class StreamSettings : AppCompatActivity() { +class StreamSettings : AppCompatActivity(), KeyboardAccessibilityService.KeyEventCallback { private lateinit var previousPrefs: PreferenceConfiguration private var previousDisplayPixelCount = 0 @@ -110,6 +113,8 @@ class StreamSettings : AppCompatActivity() { private var searchInput: EditText? = null private var searchToggle: ImageView? = null private var menuToggleView: ImageView? = null + private var hardwareKeyCapture: ((KeyEvent) -> Unit)? = null + private var hardwareKeyCaptureDialog: AlertDialog? = null // 状态保存键 companion object { @@ -208,6 +213,149 @@ class StreamSettings : AppCompatActivity() { setupVersionInfo() } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + val capture = hardwareKeyCapture + if (capture != null && event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + capture(event) + return true + } + return super.dispatchKeyEvent(event) + } + + override fun onKeyEvent(event: KeyEvent) { + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + runOnUiThread { hardwareKeyCapture?.invoke(event) } + } + } + + override fun onPause() { + stopHardwareKeyCapture() + super.onPause() + } + + private fun stopHardwareKeyCapture() { + hardwareKeyCapture = null + hardwareKeyCaptureDialog?.dismiss() + hardwareKeyCaptureDialog = null + KeyboardAccessibilityService.captureAllKeys = false + KeyboardAccessibilityService.setIntercepting(false) + if (KeyboardAccessibilityService.instance?.keyEventCallback === this) { + KeyboardAccessibilityService.instance?.keyEventCallback = null + } + } + + fun showHardwareKeyMappings() { + val mappings = HardwareKeyMappingStore.load(this) + val labels = mappings.map(::mappingLabel).toMutableList() + labels.add(getString(R.string.hardware_key_mapping_add)) + if (mappings.isNotEmpty()) labels.add(getString(R.string.hardware_key_mapping_clear)) + + AlertDialog.Builder(this) + .setTitle(R.string.title_hardware_key_mappings) + .setItems(labels.toTypedArray()) { _, index -> + when { + index < mappings.size -> confirmDeleteMapping(mappings[index]) + index == mappings.size -> beginHardwareKeyCapture() + else -> { + HardwareKeyMappingStore.clear(this) + showHardwareKeyMappings() + } + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun beginHardwareKeyCapture() { + val service = KeyboardAccessibilityService.instance + val message = if (service == null) { + getString(R.string.hardware_key_mapping_press_source) + "\n\n" + + getString(R.string.hardware_key_mapping_accessibility_hint) + } else { + getString(R.string.hardware_key_mapping_press_source) + } + + val dialog = AlertDialog.Builder(this) + .setTitle(R.string.hardware_key_mapping_add) + .setMessage(message) + .setNegativeButton(android.R.string.cancel) { _, _ -> stopHardwareKeyCapture() } + .create() + hardwareKeyCaptureDialog = dialog + hardwareKeyCapture = { event -> + hardwareKeyCapture = null + hardwareKeyCaptureDialog = null + dialog.dismiss() + KeyboardAccessibilityService.captureAllKeys = false + KeyboardAccessibilityService.setIntercepting(false) + service?.keyEventCallback = null + showHardwareKeyTargetPicker(event) + } + if (service != null) { + service.keyEventCallback = this + KeyboardAccessibilityService.captureAllKeys = true + KeyboardAccessibilityService.setIntercepting(true) + } + dialog.setOnDismissListener { + if (hardwareKeyCapture != null) stopHardwareKeyCapture() + } + dialog.show() + } + + private fun showHardwareKeyTargetPicker(sourceEvent: KeyEvent) { + val targets = buildList { + for (keyCode in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12) add(keyCode) + addAll( + listOf( + KeyEvent.KEYCODE_ESCAPE, + KeyEvent.KEYCODE_TAB, + KeyEvent.KEYCODE_INSERT, + KeyEvent.KEYCODE_FORWARD_DEL, + KeyEvent.KEYCODE_MOVE_HOME, + KeyEvent.KEYCODE_MOVE_END, + KeyEvent.KEYCODE_PAGE_UP, + KeyEvent.KEYCODE_PAGE_DOWN, + KeyEvent.KEYCODE_SYSRQ, + KeyEvent.KEYCODE_BREAK + ) + ) + } + val labels = targets.map { keyCodeLabel(it) }.toTypedArray() + AlertDialog.Builder(this) + .setTitle(R.string.hardware_key_mapping_choose_target) + .setItems(labels) { _, index -> + HardwareKeyMappingStore.save( + this, + HardwareKeyMappingStore.mappingFrom(sourceEvent, targets[index]) + ) + Toast.makeText(this, R.string.hardware_key_mapping_saved, Toast.LENGTH_SHORT).show() + showHardwareKeyMappings() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun confirmDeleteMapping(mapping: HardwareKeyMapping) { + AlertDialog.Builder(this) + .setTitle(mappingLabel(mapping)) + .setMessage(R.string.hardware_key_mapping_delete) + .setPositiveButton(android.R.string.ok) { _, _ -> + HardwareKeyMappingStore.remove(this, mapping) + showHardwareKeyMappings() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun mappingLabel(mapping: HardwareKeyMapping): String { + val source = KeyEvent.keyCodeToString(mapping.sourceKeyCode).removePrefix("KEYCODE_") + return "${mapping.deviceName}: $source [${mapping.sourceScanCode}] -> ${keyCodeLabel(mapping.targetKeyCode)}" + } + + private fun keyCodeLabel(keyCode: Int): String { + return com.limelight.utils.KeyCodeMapper.getDisplayName(keyCode) + ?: KeyEvent.keyCodeToString(keyCode).removePrefix("KEYCODE_") + } + /** * 设置版本号显示 */ @@ -2560,6 +2708,12 @@ class StreamSettings : AppCompatActivity() { true } + findPreference("hardware_key_mappings")!!.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + (requireActivity() as StreamSettings).showHardwareKeyMappings() + true + } + addCustomResolutionsEntries() // 添加检查更新选项的点击事件 diff --git a/app/src/main/java/com/limelight/services/KeyboardAccessibilityService.kt b/app/src/main/java/com/limelight/services/KeyboardAccessibilityService.kt index e461de9cad..906fc24fae 100644 --- a/app/src/main/java/com/limelight/services/KeyboardAccessibilityService.kt +++ b/app/src/main/java/com/limelight/services/KeyboardAccessibilityService.kt @@ -6,6 +6,7 @@ import android.util.Log import android.view.KeyEvent import android.view.accessibility.AccessibilityEvent import com.limelight.preferences.PreferenceConfiguration +import com.limelight.binding.input.HardwareKeyMappingStore /** * 一个无障碍服务,用于在系统级别拦截硬件键盘事件。 @@ -28,15 +29,26 @@ class KeyboardAccessibilityService : AccessibilityService() { } override fun onKeyEvent(event: KeyEvent): Boolean { + if (captureAllKeys && interceptingEnabled) { + keyEventCallback?.onKeyEvent(event) + return true + } + + val mappedEvent = HardwareKeyMappingStore.remap(this, event) + if (mappedEvent !== event && interceptingEnabled) { + keyEventCallback?.onKeyEvent(mappedEvent) + return true + } + if (interceptingEnabled && PreferenceConfiguration.readPreferences(this).enableCustomKeyMap) { - var fixedKeyCode = event.keyCode + var fixedKeyCode = mappedEvent.keyCode when (fixedKeyCode) { KeyEvent.KEYCODE_HOME -> fixedKeyCode = KeyEvent.KEYCODE_ESCAPE KeyEvent.KEYCODE_MEDIA_PREVIOUS -> fixedKeyCode = KeyEvent.KEYCODE_F5 KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> fixedKeyCode = KeyEvent.KEYCODE_F10 KeyEvent.KEYCODE_MEDIA_NEXT -> fixedKeyCode = KeyEvent.KEYCODE_F11 } - if (fixedKeyCode == KeyEvent.KEYCODE_SYSRQ || fixedKeyCode != event.keyCode) { + if (fixedKeyCode == KeyEvent.KEYCODE_SYSRQ || fixedKeyCode != mappedEvent.keyCode) { if (keyEventCallback != null) { val fixedEvent = KeyEvent( event.downTime, @@ -118,6 +130,7 @@ class KeyboardAccessibilityService : AccessibilityService() { private set var interceptingEnabled = false + var captureAllKeys = false fun setIntercepting(enabled: Boolean) { Log.d(TAG, "Setting interception to: $enabled") diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c27031db4b..af138f4b4f 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1327,4 +1327,14 @@ 仅自定义模式显示。插帧变慢时会自动回退直通渲染,默认:18 ms。 运动节奏修正 仅在画面节奏不稳时尝试,默认关闭。 + 硬件按键映射 + 学习实体按键并映射为电脑按键 + 添加映射 + 清空全部映射 + 暂无硬件按键映射 + 请按下需要映射的实体按键 + 选择要发送到电脑的按键 + 按键映射已保存 + 确定删除此映射吗? + 如需学习系统保留键,请启用 Moonlight V+ 键盘无障碍服务。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2e8486649c..537b9ce9e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1035,6 +1035,16 @@ Tap again to replace it. Fix mouse middle button + Hardware key mappings + Learn a physical key and map it to a PC key + Add mapping + Clear all mappings + No hardware key mappings + Press the physical key to map + Choose the PC key to send + Key mapping saved + Delete this mapping? + Enable the Moonlight V+ keyboard accessibility service to learn system-reserved keys. On some devices the mouse middle button may be recognized as the back key. Enable this to try to fix the issue. diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index ea8619df27..bb5172d85a 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -670,6 +670,10 @@ android:title="@string/title_special_key_map" android:summary="@string/summary_special_key_map" android:defaultValue="false" /> + Date: Thu, 18 Jun 2026 23:58:35 +0800 Subject: [PATCH 2/2] fix(input): address hardware mapping review feedback --- .../binding/input/HardwareKeyMappingStore.kt | 54 ++++++++++-- .../limelight/preferences/StreamSettings.kt | 20 +++-- .../services/KeyboardAccessibilityService.kt | 82 ++++++++++--------- .../input/HardwareKeyMappingStoreTest.kt | 46 +++++++++++ .../KeyboardAccessibilityServiceTest.kt | 18 ++++ 5 files changed, 169 insertions(+), 51 deletions(-) create mode 100644 app/src/test/java/com/limelight/services/KeyboardAccessibilityServiceTest.kt diff --git a/app/src/main/java/com/limelight/binding/input/HardwareKeyMappingStore.kt b/app/src/main/java/com/limelight/binding/input/HardwareKeyMappingStore.kt index 17ebb18f03..bdd1c7d401 100644 --- a/app/src/main/java/com/limelight/binding/input/HardwareKeyMappingStore.kt +++ b/app/src/main/java/com/limelight/binding/input/HardwareKeyMappingStore.kt @@ -23,9 +23,36 @@ data class HardwareKeyMapping( } } +internal class HardwareKeyMappingCache { + private var initialized = false + private var cachedJson: String? = null + private var cachedMappings: List = emptyList() + + @Synchronized + fun load( + json: String?, + decoder: (String) -> List + ): List { + if (!initialized || json != cachedJson) { + cachedJson = json + cachedMappings = json?.let(decoder) ?: emptyList() + initialized = true + } + return cachedMappings + } + + @Synchronized + fun update(json: String?, mappings: List) { + cachedJson = json + cachedMappings = mappings + initialized = true + } +} + object HardwareKeyMappingStore { private const val PREFS_NAME = "hardware_key_mappings" private const val KEY_MAPPINGS = "mappings_v1" + private val cache = HardwareKeyMappingCache() fun mappingFrom(event: KeyEvent, targetKeyCode: Int): HardwareKeyMapping { val device = event.device @@ -82,12 +109,13 @@ object HardwareKeyMappingStore { .edit() .remove(KEY_MAPPINGS) .apply() + cache.update(null, emptyList()) } fun load(context: Context): List { val json = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - .getString(KEY_MAPPINGS, null) ?: return emptyList() - return decode(json) + .getString(KEY_MAPPINGS, null) + return cache.load(json, ::decode) } internal fun encode(mappings: List): String { @@ -110,14 +138,23 @@ object HardwareKeyMappingStore { buildList { for (index in 0 until array.length()) { val item = array.optJSONObject(index) ?: continue + val deviceKey = item.optString("deviceKey") + val sourceScanCode = item.optInt("sourceScanCode") + val sourceKeyCode = item.optInt("sourceKeyCode", KeyEvent.KEYCODE_UNKNOWN) val target = item.optInt("targetKeyCode", KeyEvent.KEYCODE_UNKNOWN) - if (target == KeyEvent.KEYCODE_UNKNOWN) continue + if ( + deviceKey.isBlank() || + (sourceScanCode <= 0 && sourceKeyCode <= KeyEvent.KEYCODE_UNKNOWN) || + target <= KeyEvent.KEYCODE_UNKNOWN + ) { + continue + } add( HardwareKeyMapping( - deviceKey = item.optString("deviceKey"), + deviceKey = deviceKey, deviceName = item.optString("deviceName", "Unknown keyboard"), - sourceScanCode = item.optInt("sourceScanCode"), - sourceKeyCode = item.optInt("sourceKeyCode"), + sourceScanCode = sourceScanCode, + sourceKeyCode = sourceKeyCode, targetKeyCode = target ) ) @@ -138,9 +175,12 @@ object HardwareKeyMappingStore { } private fun write(context: Context, mappings: List) { + val storedMappings = mappings.toList() + val json = encode(storedMappings) context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) .edit() - .putString(KEY_MAPPINGS, encode(mappings)) + .putString(KEY_MAPPINGS, json) .apply() + cache.update(json, storedMappings) } } diff --git a/app/src/main/java/com/limelight/preferences/StreamSettings.kt b/app/src/main/java/com/limelight/preferences/StreamSettings.kt index 7ae3574a6d..29270effa0 100644 --- a/app/src/main/java/com/limelight/preferences/StreamSettings.kt +++ b/app/src/main/java/com/limelight/preferences/StreamSettings.kt @@ -246,13 +246,19 @@ class StreamSettings : AppCompatActivity(), KeyboardAccessibilityService.KeyEven fun showHardwareKeyMappings() { val mappings = HardwareKeyMappingStore.load(this) - val labels = mappings.map(::mappingLabel).toMutableList() - labels.add(getString(R.string.hardware_key_mapping_add)) - if (mappings.isNotEmpty()) labels.add(getString(R.string.hardware_key_mapping_clear)) - - AlertDialog.Builder(this) + val builder = AlertDialog.Builder(this) .setTitle(R.string.title_hardware_key_mappings) - .setItems(labels.toTypedArray()) { _, index -> + if (mappings.isEmpty()) { + builder + .setMessage(R.string.hardware_key_mapping_empty) + .setPositiveButton(R.string.hardware_key_mapping_add) { _, _ -> + beginHardwareKeyCapture() + } + } else { + val labels = mappings.map(::mappingLabel).toMutableList() + labels.add(getString(R.string.hardware_key_mapping_add)) + labels.add(getString(R.string.hardware_key_mapping_clear)) + builder.setItems(labels.toTypedArray()) { _, index -> when { index < mappings.size -> confirmDeleteMapping(mappings[index]) index == mappings.size -> beginHardwareKeyCapture() @@ -262,6 +268,8 @@ class StreamSettings : AppCompatActivity(), KeyboardAccessibilityService.KeyEven } } } + } + builder .setNegativeButton(android.R.string.cancel, null) .show() } diff --git a/app/src/main/java/com/limelight/services/KeyboardAccessibilityService.kt b/app/src/main/java/com/limelight/services/KeyboardAccessibilityService.kt index 906fc24fae..ee4f2c0199 100644 --- a/app/src/main/java/com/limelight/services/KeyboardAccessibilityService.kt +++ b/app/src/main/java/com/limelight/services/KeyboardAccessibilityService.kt @@ -8,6 +8,10 @@ import android.view.accessibility.AccessibilityEvent import com.limelight.preferences.PreferenceConfiguration import com.limelight.binding.input.HardwareKeyMappingStore +internal fun shouldConsumeKeyEvent(interceptingEnabled: Boolean, hasCallback: Boolean): Boolean { + return interceptingEnabled && hasCallback +} + /** * 一个无障碍服务,用于在系统级别拦截硬件键盘事件。 * 主要目的是捕获像 Win 键、Alt+Tab 等被 Android 系统默认行为占用的按键, @@ -29,14 +33,12 @@ class KeyboardAccessibilityService : AccessibilityService() { } override fun onKeyEvent(event: KeyEvent): Boolean { - if (captureAllKeys && interceptingEnabled) { - keyEventCallback?.onKeyEvent(event) + if (captureAllKeys && forwardIfReady(event)) { return true } val mappedEvent = HardwareKeyMappingStore.remap(this, event) - if (mappedEvent !== event && interceptingEnabled) { - keyEventCallback?.onKeyEvent(mappedEvent) + if (mappedEvent !== event && forwardIfReady(mappedEvent)) { return true } @@ -49,43 +51,39 @@ class KeyboardAccessibilityService : AccessibilityService() { KeyEvent.KEYCODE_MEDIA_NEXT -> fixedKeyCode = KeyEvent.KEYCODE_F11 } if (fixedKeyCode == KeyEvent.KEYCODE_SYSRQ || fixedKeyCode != mappedEvent.keyCode) { - if (keyEventCallback != null) { - val fixedEvent = KeyEvent( - event.downTime, - event.eventTime, - event.action, - fixedKeyCode, - event.repeatCount, - event.metaState, - event.deviceId, - event.scanCode, - event.flags, - event.source - ) - keyEventCallback!!.onKeyEvent(fixedEvent) + val fixedEvent = KeyEvent( + event.downTime, + event.eventTime, + event.action, + fixedKeyCode, + event.repeatCount, + event.metaState, + event.deviceId, + event.scanCode, + event.flags, + event.source + ) + if (forwardIfReady(fixedEvent)) { + return true } - return true } } // 小米平板将物理 ESC 键(ScanCode=1)映射为 Android 的 BACK 键(Code=4)。 - if (event.scanCode == 1) { - if (interceptingEnabled) { - if (keyEventCallback != null) { - val fixedEvent = KeyEvent( - event.downTime, - event.eventTime, - event.action, - KeyEvent.KEYCODE_ESCAPE, - event.repeatCount, - event.metaState, - event.deviceId, - event.scanCode, - event.flags, - event.source - ) - keyEventCallback!!.onKeyEvent(fixedEvent) - } + if (event.scanCode == 1 && interceptingEnabled) { + val fixedEvent = KeyEvent( + event.downTime, + event.eventTime, + event.action, + KeyEvent.KEYCODE_ESCAPE, + event.repeatCount, + event.metaState, + event.deviceId, + event.scanCode, + event.flags, + event.source + ) + if (forwardIfReady(fixedEvent)) { return true } } @@ -100,14 +98,22 @@ class KeyboardAccessibilityService : AccessibilityService() { KeyEvent.KEYCODE_POWER -> return false } - if (interceptingEnabled) { - keyEventCallback?.onKeyEvent(event) + if (forwardIfReady(event)) { return true } return super.onKeyEvent(event) } + private fun forwardIfReady(event: KeyEvent): Boolean { + val callback = keyEventCallback + if (!shouldConsumeKeyEvent(interceptingEnabled, callback != null)) { + return false + } + callback!!.onKeyEvent(event) + return true + } + override fun onAccessibilityEvent(event: AccessibilityEvent) { // 对于只过滤按键事件的场景,不需要在这里做什么。 } diff --git a/app/src/test/java/com/limelight/binding/input/HardwareKeyMappingStoreTest.kt b/app/src/test/java/com/limelight/binding/input/HardwareKeyMappingStoreTest.kt index 618d73cfc7..cd41a953a5 100644 --- a/app/src/test/java/com/limelight/binding/input/HardwareKeyMappingStoreTest.kt +++ b/app/src/test/java/com/limelight/binding/input/HardwareKeyMappingStoreTest.kt @@ -23,4 +23,50 @@ class HardwareKeyMappingStoreTest { fun ignoresCorruptJson() { assertTrue(HardwareKeyMappingStore.decode("not-json").isEmpty()) } + + @Test + fun ignoresMappingsWithoutADevice() { + val json = """ + [{ + "deviceKey": "", + "deviceName": "Broken keyboard", + "sourceScanCode": 224, + "sourceKeyCode": ${KeyEvent.KEYCODE_BRIGHTNESS_DOWN}, + "targetKeyCode": ${KeyEvent.KEYCODE_F1} + }] + """.trimIndent() + + assertTrue(HardwareKeyMappingStore.decode(json).isEmpty()) + } + + @Test + fun ignoresMappingsWithoutASourceKey() { + val json = """ + [{ + "deviceKey": "17ef:61ba", + "deviceName": "Broken keyboard", + "sourceScanCode": 0, + "sourceKeyCode": ${KeyEvent.KEYCODE_UNKNOWN}, + "targetKeyCode": ${KeyEvent.KEYCODE_F1} + }] + """.trimIndent() + + assertTrue(HardwareKeyMappingStore.decode(json).isEmpty()) + } + + @Test + fun reusesDecodedMappingsUntilStoredJsonChanges() { + val cache = HardwareKeyMappingCache() + var decodeCount = 0 + val decode: (String) -> List = { + decodeCount++ + emptyList() + } + + cache.load("[]", decode) + cache.load("[]", decode) + cache.load("[ ]", decode) + + assertEquals(2, decodeCount) + } } diff --git a/app/src/test/java/com/limelight/services/KeyboardAccessibilityServiceTest.kt b/app/src/test/java/com/limelight/services/KeyboardAccessibilityServiceTest.kt new file mode 100644 index 0000000000..84c65e17dc --- /dev/null +++ b/app/src/test/java/com/limelight/services/KeyboardAccessibilityServiceTest.kt @@ -0,0 +1,18 @@ +package com.limelight.services + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class KeyboardAccessibilityServiceTest { + @Test + fun doesNotConsumeEventsWithoutARegisteredCallback() { + assertFalse(shouldConsumeKeyEvent(interceptingEnabled = true, hasCallback = false)) + } + + @Test + fun consumesEventsOnlyWhileInterceptingWithARegisteredCallback() { + assertFalse(shouldConsumeKeyEvent(interceptingEnabled = false, hasCallback = true)) + assertTrue(shouldConsumeKeyEvent(interceptingEnabled = true, hasCallback = true)) + } +}