From 4109e685fa29f5a8d3345f1e289b2a16986ef806 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 6 Jul 2026 12:35:43 +0100 Subject: [PATCH 1/3] Replace PC movement size with pointer speed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Parse PC pointer speed capabilities from pointer.profile - Remove Android-owned PC movement size preference and selector - Show the current PC pointer speed as read-only information 🤖 Auto-generated --- .../backend/preferences/PreferenceManager.kt | 1 - .../switchify/pc/PcPointerProfile.kt | 19 +++- .../com/enaboapps/switchify/pc/PcProtocol.kt | 47 +++++++- .../screens/pc/PcMouseCommandGrid.kt | 100 +++++------------- .../screens/pc/PcMouseControlActivity.kt | 6 +- .../screens/pc/PcMouseControlViewModel.kt | 79 +++++++------- .../screens/pc/PcMouseMovementSize.kt | 66 ------------ app/src/main/res/values/strings.xml | 5 +- .../enaboapps/switchify/pc/PcProtocolTest.kt | 30 ++++++ .../screens/pc/PcMouseControlViewModelTest.kt | 55 +++++----- 10 files changed, 192 insertions(+), 216 deletions(-) delete mode 100644 app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseMovementSize.kt diff --git a/app/src/main/java/com/enaboapps/switchify/backend/preferences/PreferenceManager.kt b/app/src/main/java/com/enaboapps/switchify/backend/preferences/PreferenceManager.kt index d70bd46a..1c380fd6 100644 --- a/app/src/main/java/com/enaboapps/switchify/backend/preferences/PreferenceManager.kt +++ b/app/src/main/java/com/enaboapps/switchify/backend/preferences/PreferenceManager.kt @@ -25,7 +25,6 @@ class PreferenceManager(context: Context) { const val PREFERENCE_KEY_PAUSE_TIMEOUT = "pause_timeout" const val PREFERENCE_KEY_MOVE_REPEAT = "move_repeat" const val PREFERENCE_KEY_MOVE_REPEAT_DELAY = "move_repeat_delay" - const val PREFERENCE_KEY_PC_MOUSE_MOVEMENT_SIZE = "pc_mouse_movement_size" const val PREFERENCE_KEY_PC_CONTROL_SURFACE = "pc_control_surface" const val PREFERENCE_KEY_PC_MOUSE_REPEAT = "pc_mouse_repeat" const val PREFERENCE_KEY_PC_MOUSE_REPEAT_INTERVAL = "pc_mouse_repeat_interval" diff --git a/app/src/main/java/com/enaboapps/switchify/pc/PcPointerProfile.kt b/app/src/main/java/com/enaboapps/switchify/pc/PcPointerProfile.kt index 745ee6b3..8139f1fc 100644 --- a/app/src/main/java/com/enaboapps/switchify/pc/PcPointerProfile.kt +++ b/app/src/main/java/com/enaboapps/switchify/pc/PcPointerProfile.kt @@ -13,7 +13,18 @@ data class PcPointerCapabilities( val noAckMouseMove: Boolean = false, val noAckCommands: Set = emptySet(), val supportedCommands: Set = emptySet(), - val mouseRepeat: PcMouseRepeatCapabilities = PcMouseRepeatCapabilities() + val mouseRepeat: PcMouseRepeatCapabilities = PcMouseRepeatCapabilities(), + val pointerSpeed: PcPointerSpeedCapabilities = PcPointerSpeedCapabilities() +) + +data class PcPointerSpeedCapabilities( + val supported: Boolean = false, + val scalePercent: Double = 100.0, + val minScalePercent: Double = 25.0, + val maxScalePercent: Double = 225.0, + val stepPercent: Double = 5.0, + val baseMoveDelta: Int = 128, + val effectiveMoveDelta: Int = 128 ) data class PcMouseRepeatCapabilities( @@ -56,3 +67,9 @@ fun PcPointerMovementProfile.supportsModifierToggle(): Boolean { ) ) } + +fun PcPointerMovementProfile.pointerMoveStep(): Int { + val speed = capabilities.pointerSpeed + val candidate = if (speed.supported) speed.baseMoveDelta else recommendedDeltas.medium + return candidate.coerceIn(1, maxDelta) +} diff --git a/app/src/main/java/com/enaboapps/switchify/pc/PcProtocol.kt b/app/src/main/java/com/enaboapps/switchify/pc/PcProtocol.kt index cb42ae00..5a1f4c0b 100644 --- a/app/src/main/java/com/enaboapps/switchify/pc/PcProtocol.kt +++ b/app/src/main/java/com/enaboapps/switchify/pc/PcProtocol.kt @@ -568,7 +568,8 @@ object PcProtocol { noAckMouseMove = capabilitiesJson?.optBoolean("noAckMouseMove", false) ?: false, noAckCommands = noAckCommands, supportedCommands = supportedCommands, - mouseRepeat = parseMouseRepeatCapabilities(capabilitiesJson) ?: return PcProtocolResponse.Invalid + mouseRepeat = parseMouseRepeatCapabilities(capabilitiesJson) ?: return PcProtocolResponse.Invalid, + pointerSpeed = parsePointerSpeedCapabilities(capabilitiesJson) ?: return PcProtocolResponse.Invalid ) val bounds = PcPointerBounds( x = boundsJson.optInt("x"), @@ -626,6 +627,50 @@ object PcProtocol { ) } + private fun parsePointerSpeedCapabilities(capabilitiesJson: JSONObject?): PcPointerSpeedCapabilities? { + val speedJson = capabilitiesJson?.opt("pointerSpeed") ?: return PcPointerSpeedCapabilities() + if (speedJson !is JSONObject) return null + if (speedJson.has("supported") && speedJson.opt("supported") !is Boolean) return null + + val scalePercent = speedJson.optDouble("scalePercent", 100.0) + val minScalePercent = speedJson.optDouble("minScalePercent", 25.0) + val maxScalePercent = speedJson.optDouble("maxScalePercent", 225.0) + val stepPercent = speedJson.optDouble("stepPercent", 5.0) + val baseMoveDeltaValue = speedJson.opt("baseMoveDelta") + val effectiveMoveDeltaValue = speedJson.opt("effectiveMoveDelta") + if (baseMoveDeltaValue != null && baseMoveDeltaValue !is Number) return null + if (effectiveMoveDeltaValue != null && effectiveMoveDeltaValue !is Number) return null + val baseMoveDelta = baseMoveDeltaValue?.toInt() ?: 128 + val effectiveMoveDelta = effectiveMoveDeltaValue?.toInt() ?: baseMoveDelta + + if ( + !scalePercent.isFinite() || + !minScalePercent.isFinite() || + !maxScalePercent.isFinite() || + !stepPercent.isFinite() || + scalePercent <= 0.0 || + minScalePercent <= 0.0 || + maxScalePercent < minScalePercent || + scalePercent < minScalePercent || + scalePercent > maxScalePercent || + stepPercent <= 0.0 || + baseMoveDelta <= 0 || + effectiveMoveDelta <= 0 + ) { + return null + } + + return PcPointerSpeedCapabilities( + supported = speedJson.optBoolean("supported", false), + scalePercent = scalePercent, + minScalePercent = minScalePercent, + maxScalePercent = maxScalePercent, + stepPercent = stepPercent, + baseMoveDelta = baseMoveDelta, + effectiveMoveDelta = effectiveMoveDelta + ) + } + private fun parseNoAckCommands(capabilitiesJson: JSONObject?): Set? { if (capabilitiesJson == null || !capabilitiesJson.has("noAckCommands")) return emptySet() val commandsJson = capabilitiesJson.opt("noAckCommands") diff --git a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGrid.kt b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGrid.kt index e99fa567..cf7979e8 100644 --- a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGrid.kt +++ b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGrid.kt @@ -41,9 +41,6 @@ import androidx.compose.material.icons.filled.OpenWith import androidx.compose.material.icons.filled.TouchApp import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -183,43 +180,19 @@ fun PcTransientMessage( } } -@Composable -fun PcMovementSizeSection( - selectedSize: PcMouseMovementSize, - onSizeSelected: (PcMouseMovementSize) -> Unit, - enabled: Boolean = true, - modifier: Modifier = Modifier -) { - Column(modifier = modifier.fillMaxWidth()) { - Section(titleResId = R.string.pc_mouse_movement_size) { - Box(modifier = Modifier.padding(12.dp)) { - PcMouseMovementSizeSelector( - selectedSize = selectedSize, - onSizeSelected = onSizeSelected, - enabled = enabled - ) - } - } - } -} - @Composable fun PcControlCommandGrid( enabled: Boolean, movementStep: Int, - selectedSize: PcMouseMovementSize, - onSizeSelected: (PcMouseMovementSize) -> Unit, + pointerSpeedLabel: String, onCommandSelected: (PcControlCommand, Boolean) -> Unit, - sizeSelectorEnabled: Boolean = true, modifier: Modifier = Modifier ) { PcControlCommandSections( enabled = enabled, movementStep = movementStep, - selectedSize = selectedSize, - onSizeSelected = onSizeSelected, + pointerSpeedLabel = pointerSpeedLabel, onCommandSelected = onCommandSelected, - sizeSelectorEnabled = sizeSelectorEnabled, modifier = modifier ) } @@ -228,10 +201,8 @@ fun PcControlCommandGrid( fun PcControlCommandSections( enabled: Boolean, movementStep: Int, - selectedSize: PcMouseMovementSize, - onSizeSelected: (PcMouseMovementSize) -> Unit, + pointerSpeedLabel: String, onCommandSelected: (PcControlCommand, Boolean) -> Unit, - sizeSelectorEnabled: Boolean = true, modifier: Modifier = Modifier ) { val specs = pcMouseCompactControlSpecs(movementStep) @@ -257,11 +228,30 @@ fun PcControlCommandSections( ) } } - PcMovementSizeSection( - selectedSize = selectedSize, - onSizeSelected = onSizeSelected, - enabled = sizeSelectorEnabled - ) + PcPointerSpeedSection(pointerSpeedLabel = pointerSpeedLabel) + } +} + +@Composable +fun PcPointerSpeedSection( + pointerSpeedLabel: String, + modifier: Modifier = Modifier +) { + Section(titleResId = R.string.pc_mouse_pointer_speed) { + Surface( + modifier = modifier + .fillMaxWidth() + .padding(12.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Text( + text = pointerSpeedLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp) + ) + } } } @@ -498,42 +488,6 @@ fun PcScannedCommandTile( } } -@Composable -fun PcMouseMovementSizeSelector( - selectedSize: PcMouseMovementSize, - onSizeSelected: (PcMouseMovementSize) -> Unit, - enabled: Boolean = true, - modifier: Modifier = Modifier -) { - val sizes = PcMouseMovementSize.entries - val colors = SegmentedButtonDefaults.colors( - activeContainerColor = MaterialTheme.colorScheme.primary, - activeContentColor = MaterialTheme.colorScheme.onPrimary, - activeBorderColor = MaterialTheme.colorScheme.primary, - inactiveContainerColor = MaterialTheme.colorScheme.surface, - inactiveContentColor = MaterialTheme.colorScheme.onSurface, - inactiveBorderColor = MaterialTheme.colorScheme.outline - ) - - SingleChoiceSegmentedButtonRow(modifier = modifier.fillMaxWidth()) { - sizes.forEachIndexed { index, size -> - SegmentedButton( - selected = selectedSize == size, - onClick = { onSizeSelected(size) }, - enabled = enabled, - shape = SegmentedButtonDefaults.itemShape(index = index, count = sizes.size), - colors = colors - ) { - Text( - text = stringResource(size.labelResId), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } -} - fun pcMouseControlSpecs(moveStep: Int): List { return pcMovementControlSpecs(moveStep) + pcClickControlSpecs() + pcScrollControlSpecs() } diff --git a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlActivity.kt b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlActivity.kt index 12833a11..a567b0d9 100644 --- a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlActivity.kt +++ b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlActivity.kt @@ -163,10 +163,8 @@ private fun PcMouseControlScreen( PcControlCommandGrid( enabled = surfaceEnabled, movementStep = uiState.movementStep, - selectedSize = uiState.selectedMovementSize, - onSizeSelected = viewModel::selectMovementSize, - onCommandSelected = viewModel::sendMouseCommand, - sizeSelectorEnabled = !uiState.isBusy + pointerSpeedLabel = uiState.pointerSpeedPercentLabel, + onCommandSelected = viewModel::sendMouseCommand ) } PcControlSurface.Typing -> PcTypingControlScreen( diff --git a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt index 405df677..1175c3b9 100644 --- a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt +++ b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt @@ -15,6 +15,7 @@ import com.enaboapps.switchify.pc.PcErrorReason import com.enaboapps.switchify.pc.PcKeyboardKey import com.enaboapps.switchify.pc.PcKeyboardModifierKey import com.enaboapps.switchify.pc.PcKeyboardShortcutKey +import com.enaboapps.switchify.pc.PcPointerMovementProfile import com.enaboapps.switchify.pc.PcMouseRepeatManager import com.enaboapps.switchify.pc.PcServiceConnectResult import com.enaboapps.switchify.pc.PcServiceConnectionController @@ -22,11 +23,13 @@ import com.enaboapps.switchify.pc.PcServiceConnectionState import com.enaboapps.switchify.pc.PcTextStreamItem import com.enaboapps.switchify.pc.isSafePcTypedText import com.enaboapps.switchify.pc.pcTextStreamItemsFor +import com.enaboapps.switchify.pc.pointerMoveStep import com.enaboapps.switchify.pc.supportsModifierToggle import com.enaboapps.switchify.pc.supportsTextStreams import com.enaboapps.switchify.pc.toShortcutKey import com.enaboapps.switchify.service.core.ServiceCore import java.util.UUID +import java.util.Locale import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -50,8 +53,9 @@ data class PcMouseControlUiState( val connectedDisplayName: String? = null, val switcherConnectedDisplayName: String? = null, val activeSurface: PcControlSurface = PcControlSurface.Mouse, - val selectedMovementSize: PcMouseMovementSize = PcMouseMovementSize.Small, - val movementStep: Int = PcMouseControlViewModel.FALLBACK_MOVEMENT_STEPS.small, + val movementStep: Int = PcMouseControlViewModel.FALLBACK_MOVEMENT_STEP, + val pointerSpeedSupported: Boolean = false, + val pointerSpeedPercentLabel: String = PcMouseControlViewModel.POINTER_SPEED_UNAVAILABLE_MESSAGE, val isDragging: Boolean = false, val isBusy: Boolean = false, val busyCommand: PcControlCommand? = null, @@ -71,40 +75,34 @@ data class PcMouseControlUiState( class PcMouseControlViewModel( private val serviceControllerProvider: () -> PcServiceConnectionController?, - private val movementSizeStore: PcMouseMovementSizeStore, private val controlSurfaceStore: PcControlSurfaceStore, private val mouseRepeatManager: PcMouseRepeatManager = PcMouseRepeatManager.instance ) : ViewModel() { constructor( - serviceControllerProvider: () -> PcServiceConnectionController?, - movementSizeStore: PcMouseMovementSizeStore + serviceControllerProvider: () -> PcServiceConnectionController? ) : this( serviceControllerProvider = serviceControllerProvider, - movementSizeStore = movementSizeStore, controlSurfaceStore = InMemoryControlSurfaceStore() ) constructor(context: Context) : this( serviceControllerProvider = { ServiceCore.getPcServiceConnectionController() }, - movementSizeStore = PcMouseMovementPreferenceStore(context.applicationContext), controlSurfaceStore = PcControlSurfacePreferenceStore(context.applicationContext), mouseRepeatManager = PcMouseRepeatManager.instance.also { it.init(context.applicationContext) } ) private val _uiState = MutableStateFlow(PcMouseControlUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private var movementSteps = FALLBACK_MOVEMENT_STEPS + private var movementStep = FALLBACK_MOVEMENT_STEP private var switchPcCandidates: List = emptyList() private var switchPcConnectionJob: Job? = null init { - val selectedSize = movementSizeStore.getSelectedSize() val selectedSurface = controlSurfaceStore.getSelectedSurface() _uiState.update { it.copy( activeSurface = selectedSurface, - selectedMovementSize = selectedSize, - movementStep = movementSteps.stepFor(selectedSize) + movementStep = movementStep ) } serviceControllerProvider()?.let { controller -> @@ -117,16 +115,6 @@ class PcMouseControlViewModel( } } - fun selectMovementSize(size: PcMouseMovementSize) { - movementSizeStore.setSelectedSize(size) - _uiState.update { - it.copy( - selectedMovementSize = size, - movementStep = movementSteps.stepFor(size) - ) - } - } - fun send(command: PcControlCommand) { val commandToSend = when (command) { is PcControlCommand.DragStart, @@ -868,14 +856,16 @@ class PcMouseControlViewModel( debugLog( "Connected PC profile displayName=${state.displayName}, modifierTogglesAdvertised=$supportsModifierToggles, supportedCommands=${pointerProfile?.capabilities?.supportedCommands.orEmpty()}" ) - movementSteps = pointerProfile?.toMouseMovementSteps() - ?: controller.currentPointerProfile()?.toMouseMovementSteps() - ?: FALLBACK_MOVEMENT_STEPS + movementStep = pointerProfile?.pointerMoveStep() + ?: controller.currentPointerProfile()?.pointerMoveStep() + ?: FALLBACK_MOVEMENT_STEP _uiState.update { it.copy( connectedDisplayName = state.displayName, switcherConnectedDisplayName = controller.currentControlDeviceName() ?: state.displayName, - movementStep = movementSteps.stepFor(it.selectedMovementSize), + movementStep = movementStep, + pointerSpeedSupported = pointerProfile?.capabilities?.pointerSpeed?.supported == true, + pointerSpeedPercentLabel = pointerSpeedLabel(pointerProfile), supportsTextStreamInput = pointerProfile?.supportsTextStreams() ?: false, supportsModifierToggles = supportsModifierToggles, message = if (it.message == RECONNECTING_MESSAGE || it.message == DISCONNECTED_MESSAGE || it.message == CONNECT_FIRST_MESSAGE) { @@ -906,6 +896,8 @@ class PcMouseControlViewModel( switcherConnectedDisplayName = controller.currentControlDeviceName() ?: state.displayName, isDragging = false, activeModifiers = emptySet(), + pointerSpeedSupported = false, + pointerSpeedPercentLabel = POINTER_SPEED_UNAVAILABLE_MESSAGE, supportsTextStreamInput = false, supportsModifierToggles = false, message = RECONNECTING_MESSAGE, @@ -922,12 +914,14 @@ class PcMouseControlViewModel( } PcServiceConnectionState.Disconnected -> { mouseRepeatManager.clearServiceState() - movementSteps = FALLBACK_MOVEMENT_STEPS + movementStep = FALLBACK_MOVEMENT_STEP _uiState.update { it.copy( connectedDisplayName = null, switcherConnectedDisplayName = null, - movementStep = movementSteps.stepFor(it.selectedMovementSize), + movementStep = movementStep, + pointerSpeedSupported = false, + pointerSpeedPercentLabel = POINTER_SPEED_UNAVAILABLE_MESSAGE, isDragging = false, activeModifiers = emptySet(), isBusy = false, @@ -945,12 +939,14 @@ class PcMouseControlViewModel( } is PcServiceConnectionState.Failed -> { mouseRepeatManager.clearServiceState() - movementSteps = FALLBACK_MOVEMENT_STEPS + movementStep = FALLBACK_MOVEMENT_STEP _uiState.update { it.copy( connectedDisplayName = null, switcherConnectedDisplayName = null, - movementStep = movementSteps.stepFor(it.selectedMovementSize), + movementStep = movementStep, + pointerSpeedSupported = false, + pointerSpeedPercentLabel = POINTER_SPEED_UNAVAILABLE_MESSAGE, isDragging = false, activeModifiers = emptySet(), isBusy = false, @@ -1033,12 +1029,14 @@ class PcMouseControlViewModel( } private fun showConnectFirst() { - movementSteps = FALLBACK_MOVEMENT_STEPS + movementStep = FALLBACK_MOVEMENT_STEP _uiState.update { it.copy( connectedDisplayName = null, switcherConnectedDisplayName = null, - movementStep = movementSteps.stepFor(it.selectedMovementSize), + movementStep = movementStep, + pointerSpeedSupported = false, + pointerSpeedPercentLabel = POINTER_SPEED_UNAVAILABLE_MESSAGE, isDragging = false, activeModifiers = emptySet(), isBusy = false, @@ -1093,17 +1091,26 @@ class PcMouseControlViewModel( } } + private fun pointerSpeedLabel(pointerProfile: PcPointerMovementProfile?): String { + val speed = pointerProfile?.capabilities?.pointerSpeed + if (speed?.supported != true) return POINTER_SPEED_UNAVAILABLE_MESSAGE + val percent = if (speed.scalePercent % 1.0 == 0.0) { + speed.scalePercent.toInt().toString() + } else { + String.format(Locale.ROOT, "%.1f", speed.scalePercent) + } + return "$POINTER_SPEED_PREFIX$percent%" + } + private fun debugLog(message: String) { runCatching { Log.d(TAG, message) } } companion object { private const val TAG = "PcMouseControlViewModel" - val FALLBACK_MOVEMENT_STEPS = PcMouseMovementSteps( - small = 40, - medium = 80, - large = 160 - ) + const val FALLBACK_MOVEMENT_STEP = 80 + const val POINTER_SPEED_PREFIX = "Set in Switchify PC: " + const val POINTER_SPEED_UNAVAILABLE_MESSAGE = "Update Switchify PC to view pointer speed." const val CONNECT_FIRST_MESSAGE = "Connect to PC from Switchify first." const val COMMAND_FAILED_MESSAGE = "Could not send command to PC." const val TYPING_FAILED_MESSAGE = "Could not send text to PC." diff --git a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseMovementSize.kt b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseMovementSize.kt deleted file mode 100644 index d7f56736..00000000 --- a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseMovementSize.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.enaboapps.switchify.screens.pc - -import android.content.Context -import androidx.annotation.StringRes -import com.enaboapps.switchify.R -import com.enaboapps.switchify.backend.preferences.PreferenceManager -import com.enaboapps.switchify.pc.PcPointerMovementProfile - -enum class PcMouseMovementSize( - val preferenceValue: String, - @param:StringRes val labelResId: Int -) { - Small("small", R.string.pc_mouse_movement_small), - Medium("medium", R.string.pc_mouse_movement_medium), - Large("large", R.string.pc_mouse_movement_large); - - companion object { - fun fromPreferenceValue(value: String): PcMouseMovementSize { - return entries.firstOrNull { it.preferenceValue == value } ?: Small - } - } -} - -data class PcMouseMovementSteps( - val small: Int, - val medium: Int, - val large: Int -) { - fun stepFor(size: PcMouseMovementSize): Int { - return when (size) { - PcMouseMovementSize.Small -> small - PcMouseMovementSize.Medium -> medium - PcMouseMovementSize.Large -> large - } - } -} - -interface PcMouseMovementSizeStore { - fun getSelectedSize(): PcMouseMovementSize - fun setSelectedSize(size: PcMouseMovementSize) -} - -class PcMouseMovementPreferenceStore(context: Context) : PcMouseMovementSizeStore { - private val preferenceManager = PreferenceManager(context.applicationContext) - - override fun getSelectedSize(): PcMouseMovementSize { - return PcMouseMovementSize.fromPreferenceValue( - preferenceManager.getStringValue(PreferenceManager.PREFERENCE_KEY_PC_MOUSE_MOVEMENT_SIZE) - ) - } - - override fun setSelectedSize(size: PcMouseMovementSize) { - preferenceManager.setStringValue( - PreferenceManager.PREFERENCE_KEY_PC_MOUSE_MOVEMENT_SIZE, - size.preferenceValue - ) - } -} - -fun PcPointerMovementProfile.toMouseMovementSteps(): PcMouseMovementSteps { - return PcMouseMovementSteps( - small = recommendedDeltas.small.coerceIn(1, maxDelta), - medium = recommendedDeltas.medium.coerceIn(1, maxDelta), - large = recommendedDeltas.large.coerceIn(1, maxDelta) - ) -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e60a09f0..f50130a4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -381,10 +381,7 @@ Typing Window Connected to %1$s. - Movement size - Small - Medium - Large + Pointer speed Movement Clicks Request rejected. diff --git a/app/src/test/java/com/enaboapps/switchify/pc/PcProtocolTest.kt b/app/src/test/java/com/enaboapps/switchify/pc/PcProtocolTest.kt index a72233da..93a53fa9 100644 --- a/app/src/test/java/com/enaboapps/switchify/pc/PcProtocolTest.kt +++ b/app/src/test/java/com/enaboapps/switchify/pc/PcProtocolTest.kt @@ -146,6 +146,36 @@ class PcProtocolTest { assertFalse(response.profile.capabilities.noAckMouseMove) assertEquals(emptySet(), response.profile.capabilities.noAckCommands) assertEquals(emptySet(), response.profile.capabilities.supportedCommands) + assertFalse(response.profile.capabilities.pointerSpeed.supported) + assertEquals(130, response.profile.pointerMoveStep()) + } + + @Test + fun parsesPointerProfileSpeedCapability() { + val response = PcProtocol.parseResponse( + validPointerProfileResponse( + capabilities = ""","capabilities":{"pointerSpeed":{"supported":true,"scalePercent":125,"minScalePercent":25,"maxScalePercent":225,"stepPercent":5,"baseMoveDelta":128,"effectiveMoveDelta":160}}""" + ) + ) as PcProtocolResponse.PointerProfile + + assertTrue(response.profile.capabilities.pointerSpeed.supported) + assertEquals(125.0, response.profile.capabilities.pointerSpeed.scalePercent, 0.0) + assertEquals(225.0, response.profile.capabilities.pointerSpeed.maxScalePercent, 0.0) + assertEquals(128, response.profile.capabilities.pointerSpeed.baseMoveDelta) + assertEquals(160, response.profile.capabilities.pointerSpeed.effectiveMoveDelta) + assertEquals(128, response.profile.pointerMoveStep()) + } + + @Test + fun rejectsMalformedPointerProfileSpeedCapability() { + assertEquals( + PcProtocolResponse.Invalid, + PcProtocol.parseResponse( + validPointerProfileResponse( + capabilities = ""","capabilities":{"pointerSpeed":{"supported":true,"scalePercent":300,"minScalePercent":25,"maxScalePercent":225,"stepPercent":5,"baseMoveDelta":128,"effectiveMoveDelta":160}}""" + ) + ) + ) } @Test diff --git a/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModelTest.kt b/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModelTest.kt index d621f2d0..059119ea 100644 --- a/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModelTest.kt +++ b/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModelTest.kt @@ -28,6 +28,7 @@ import com.enaboapps.switchify.pc.PcPointerBounds import com.enaboapps.switchify.pc.PcPointerCapabilities import com.enaboapps.switchify.pc.PcPointerDeltas import com.enaboapps.switchify.pc.PcPointerMovementProfile +import com.enaboapps.switchify.pc.PcPointerSpeedCapabilities import com.enaboapps.switchify.pc.PcServiceConnectionController import com.enaboapps.switchify.pc.PcStoredPairing import com.enaboapps.switchify.pc.PcWindowControlAction @@ -149,12 +150,26 @@ class PcMouseControlViewModelTest { @Test fun connectedServiceStateUsesPointerProfile() = runTest(dispatcher) { - val controller = connectedController(pointerProfile = pointerProfile(small = 52, medium = 130, large = 260)) - val viewModel = viewModel(controller, FakeMovementSizeStore(PcMouseMovementSize.Medium)) + val controller = connectedController( + pointerProfile = pointerProfile( + medium = 130, + capabilities = PcPointerCapabilities( + pointerSpeed = PcPointerSpeedCapabilities( + supported = true, + scalePercent = 125.0, + baseMoveDelta = 128, + effectiveMoveDelta = 160 + ) + ) + ) + ) + val viewModel = viewModel(controller) advanceUntilIdle() - assertEquals(130, viewModel.uiState.value.movementStep) + assertEquals(128, viewModel.uiState.value.movementStep) + assertEquals(true, viewModel.uiState.value.pointerSpeedSupported) + assertEquals("Set in Switchify PC: 125%", viewModel.uiState.value.pointerSpeedPercentLabel) } @Test @@ -393,13 +408,17 @@ class PcMouseControlViewModelTest { } @Test - fun firstRunDefaultsToSmallFallbackStep() = runTest(dispatcher) { + fun firstRunDefaultsToMediumFallbackStep() = runTest(dispatcher) { val viewModel = viewModel(null) advanceUntilIdle() - assertEquals(PcMouseMovementSize.Small, viewModel.uiState.value.selectedMovementSize) - assertEquals(40, viewModel.uiState.value.movementStep) + assertEquals(80, viewModel.uiState.value.movementStep) + assertEquals(false, viewModel.uiState.value.pointerSpeedSupported) + assertEquals( + "Update Switchify PC to view pointer speed.", + viewModel.uiState.value.pointerSpeedPercentLabel + ) } @Test @@ -411,18 +430,6 @@ class PcMouseControlViewModelTest { assertEquals(PcControlSurface.Typing, viewModel.uiState.value.activeSurface) } - @Test - fun selectingMediumPersistsPreference() = runTest(dispatcher) { - val movementSizeStore = FakeMovementSizeStore() - val viewModel = viewModel(null, movementSizeStore) - - viewModel.selectMovementSize(PcMouseMovementSize.Medium) - advanceUntilIdle() - - assertEquals(PcMouseMovementSize.Medium, movementSizeStore.getSelectedSize()) - assertEquals(80, viewModel.uiState.value.movementStep) - } - @Test fun controlSurfacePreferenceParserDefaultsUnknownValuesToMouse() { assertEquals(PcControlSurface.Mouse, PcControlSurface.fromPreferenceValue("")) @@ -1800,12 +1807,10 @@ class PcMouseControlViewModelTest { private fun viewModel( controller: PcServiceConnectionController?, - movementSizeStore: FakeMovementSizeStore = FakeMovementSizeStore(), controlSurfaceStore: FakeControlSurfaceStore = FakeControlSurfaceStore() ): PcMouseControlViewModel { return PcMouseControlViewModel( serviceControllerProvider = { controller }, - movementSizeStore = movementSizeStore, controlSurfaceStore = controlSurfaceStore ) } @@ -2024,16 +2029,6 @@ class PcMouseControlViewModelTest { ) } - private class FakeMovementSizeStore( - private var selectedSize: PcMouseMovementSize = PcMouseMovementSize.Small - ) : PcMouseMovementSizeStore { - override fun getSelectedSize(): PcMouseMovementSize = selectedSize - - override fun setSelectedSize(size: PcMouseMovementSize) { - selectedSize = size - } - } - private class FakeControlSurfaceStore( private var selectedSurface: PcControlSurface = PcControlSurface.Mouse ) : PcControlSurfaceStore { From 58d622ca09e0803373b78d3dd11002945ab2b871 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 6 Jul 2026 13:10:55 +0100 Subject: [PATCH 2/3] Let Android set PC pointer speed --- .../com/enaboapps/switchify/pc/PcConnector.kt | 1 + .../switchify/pc/PcPointerProfile.kt | 1 + .../com/enaboapps/switchify/pc/PcProtocol.kt | 24 ++++ .../pc/bluetooth/SwitchifyPcBleClient.kt | 2 + .../screens/pc/PcMouseCommandGrid.kt | 83 ++++++++++-- .../screens/pc/PcMouseControlActivity.kt | 7 + .../screens/pc/PcMouseControlViewModel.kt | 65 +++++++-- app/src/main/res/values/strings.xml | 2 + .../enaboapps/switchify/pc/PcProtocolTest.kt | 45 ++++++- .../pc/bluetooth/SwitchifyPcBleClientTest.kt | 19 +++ .../screens/pc/PcMouseControlViewModelTest.kt | 124 +++++++++++++++++- 11 files changed, 352 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/enaboapps/switchify/pc/PcConnector.kt b/app/src/main/java/com/enaboapps/switchify/pc/PcConnector.kt index 0f19668b..86f72e5a 100644 --- a/app/src/main/java/com/enaboapps/switchify/pc/PcConnector.kt +++ b/app/src/main/java/com/enaboapps/switchify/pc/PcConnector.kt @@ -31,6 +31,7 @@ sealed class PcControlCommand { data class ModifierDown(val key: PcKeyboardModifierKey) : PcControlCommand() data class ModifierUp(val key: PcKeyboardModifierKey) : PcControlCommand() data class WindowControl(val action: PcWindowControlAction) : PcControlCommand() + data class SetPointerSpeed(val scalePercent: Double) : PcControlCommand() data object LeftClick : PcControlCommand() data object DoubleClick : PcControlCommand() data object RightClick : PcControlCommand() diff --git a/app/src/main/java/com/enaboapps/switchify/pc/PcPointerProfile.kt b/app/src/main/java/com/enaboapps/switchify/pc/PcPointerProfile.kt index 8139f1fc..0d891ceb 100644 --- a/app/src/main/java/com/enaboapps/switchify/pc/PcPointerProfile.kt +++ b/app/src/main/java/com/enaboapps/switchify/pc/PcPointerProfile.kt @@ -19,6 +19,7 @@ data class PcPointerCapabilities( data class PcPointerSpeedCapabilities( val supported: Boolean = false, + val setSupported: Boolean = false, val scalePercent: Double = 100.0, val minScalePercent: Double = 25.0, val maxScalePercent: Double = 225.0, diff --git a/app/src/main/java/com/enaboapps/switchify/pc/PcProtocol.kt b/app/src/main/java/com/enaboapps/switchify/pc/PcProtocol.kt index 5a1f4c0b..e083d9e7 100644 --- a/app/src/main/java/com/enaboapps/switchify/pc/PcProtocol.kt +++ b/app/src/main/java/com/enaboapps/switchify/pc/PcProtocol.kt @@ -91,6 +91,23 @@ object PcProtocol { ) } + fun pointerSpeedSet( + id: String, + deviceId: String, + token: String, + timestamp: Long, + scalePercent: Double + ): String { + return authenticatedCommand( + id = id, + deviceId = deviceId, + token = token, + timestamp = timestamp, + type = "pointer.speed.set", + payload = JSONObject().put("scalePercent", jsonNumber(scalePercent)) + ) + } + fun authenticatedCommand( id: String, deviceId: String, @@ -114,6 +131,10 @@ object PcProtocol { return message.toString() } + private fun jsonNumber(value: Double): Number { + return if (value.isFinite() && value % 1.0 == 0.0) value.toInt() else value + } + fun mouseMove( id: String, deviceId: String, @@ -631,6 +652,7 @@ object PcProtocol { val speedJson = capabilitiesJson?.opt("pointerSpeed") ?: return PcPointerSpeedCapabilities() if (speedJson !is JSONObject) return null if (speedJson.has("supported") && speedJson.opt("supported") !is Boolean) return null + if (speedJson.has("setSupported") && speedJson.opt("setSupported") !is Boolean) return null val scalePercent = speedJson.optDouble("scalePercent", 100.0) val minScalePercent = speedJson.optDouble("minScalePercent", 25.0) @@ -662,6 +684,7 @@ object PcProtocol { return PcPointerSpeedCapabilities( supported = speedJson.optBoolean("supported", false), + setSupported = speedJson.optBoolean("setSupported", false), scalePercent = scalePercent, minScalePercent = minScalePercent, maxScalePercent = maxScalePercent, @@ -726,6 +749,7 @@ object PcProtocol { "connection.ping", "connection.disconnecting", "pointer.profile", + "pointer.speed.set", "mouse.repeat.start", "mouse.repeat.stop", "keyboard.textStream.open", diff --git a/app/src/main/java/com/enaboapps/switchify/pc/bluetooth/SwitchifyPcBleClient.kt b/app/src/main/java/com/enaboapps/switchify/pc/bluetooth/SwitchifyPcBleClient.kt index c11ce4e6..09b45720 100644 --- a/app/src/main/java/com/enaboapps/switchify/pc/bluetooth/SwitchifyPcBleClient.kt +++ b/app/src/main/java/com/enaboapps/switchify/pc/bluetooth/SwitchifyPcBleClient.kt @@ -315,6 +315,7 @@ class SwitchifyPcBleClient( is PcControlCommand.ModifierDown -> PcProtocol.keyboardModifierDown(id, deviceId, token, timestamp, key, responseMode) is PcControlCommand.ModifierUp -> PcProtocol.keyboardModifierUp(id, deviceId, token, timestamp, key, responseMode) is PcControlCommand.WindowControl -> PcProtocol.windowControl(id, deviceId, token, timestamp, action, responseMode) + is PcControlCommand.SetPointerSpeed -> PcProtocol.pointerSpeedSet(id, deviceId, token, timestamp, scalePercent) } } @@ -424,6 +425,7 @@ private fun PcControlCommand.protocolType(): String { is PcControlCommand.ModifierDown -> "keyboard.modifierDown" is PcControlCommand.ModifierUp -> "keyboard.modifierUp" is PcControlCommand.WindowControl -> "window.control" + is PcControlCommand.SetPointerSpeed -> "pointer.speed.set" } } diff --git a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGrid.kt b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGrid.kt index cf7979e8..a703842c 100644 --- a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGrid.kt +++ b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGrid.kt @@ -41,6 +41,7 @@ import androidx.compose.material.icons.filled.OpenWith import androidx.compose.material.icons.filled.TouchApp import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -67,6 +68,7 @@ import com.enaboapps.switchify.R import com.enaboapps.switchify.components.EqualHeightGridRow import com.enaboapps.switchify.components.Section import com.enaboapps.switchify.pc.PcControlCommand +import kotlin.math.roundToInt data class PcMouseControlSpec( @param:StringRes val labelResId: Int, @@ -184,14 +186,28 @@ fun PcTransientMessage( fun PcControlCommandGrid( enabled: Boolean, movementStep: Int, + pointerSpeedScalePercent: Double, + pointerSpeedMinScalePercent: Double, + pointerSpeedMaxScalePercent: Double, + pointerSpeedStepPercent: Double, + pointerSpeedSupported: Boolean, + pointerSpeedControlEnabled: Boolean, pointerSpeedLabel: String, + onPointerSpeedSelected: (Double) -> Unit, onCommandSelected: (PcControlCommand, Boolean) -> Unit, modifier: Modifier = Modifier ) { PcControlCommandSections( enabled = enabled, movementStep = movementStep, + pointerSpeedScalePercent = pointerSpeedScalePercent, + pointerSpeedMinScalePercent = pointerSpeedMinScalePercent, + pointerSpeedMaxScalePercent = pointerSpeedMaxScalePercent, + pointerSpeedStepPercent = pointerSpeedStepPercent, + pointerSpeedSupported = pointerSpeedSupported, + pointerSpeedControlEnabled = pointerSpeedControlEnabled, pointerSpeedLabel = pointerSpeedLabel, + onPointerSpeedSelected = onPointerSpeedSelected, onCommandSelected = onCommandSelected, modifier = modifier ) @@ -201,7 +217,14 @@ fun PcControlCommandGrid( fun PcControlCommandSections( enabled: Boolean, movementStep: Int, + pointerSpeedScalePercent: Double, + pointerSpeedMinScalePercent: Double, + pointerSpeedMaxScalePercent: Double, + pointerSpeedStepPercent: Double, + pointerSpeedSupported: Boolean, + pointerSpeedControlEnabled: Boolean, pointerSpeedLabel: String, + onPointerSpeedSelected: (Double) -> Unit, onCommandSelected: (PcControlCommand, Boolean) -> Unit, modifier: Modifier = Modifier ) { @@ -228,15 +251,37 @@ fun PcControlCommandSections( ) } } - PcPointerSpeedSection(pointerSpeedLabel = pointerSpeedLabel) + PcPointerSpeedSection( + scalePercent = pointerSpeedScalePercent, + minScalePercent = pointerSpeedMinScalePercent, + maxScalePercent = pointerSpeedMaxScalePercent, + stepPercent = pointerSpeedStepPercent, + supported = pointerSpeedSupported, + enabled = pointerSpeedControlEnabled, + label = pointerSpeedLabel, + onSpeedSelected = onPointerSpeedSelected + ) } } @Composable fun PcPointerSpeedSection( - pointerSpeedLabel: String, + scalePercent: Double, + minScalePercent: Double, + maxScalePercent: Double, + stepPercent: Double, + supported: Boolean, + enabled: Boolean, + label: String, + onSpeedSelected: (Double) -> Unit, modifier: Modifier = Modifier ) { + var displayedScale by remember(scalePercent) { mutableStateOf(scalePercent) } + val min = minScalePercent.toFloat() + val max = maxScalePercent.toFloat() + val step = stepPercent.takeIf { it > 0.0 } ?: 5.0 + val steps = (((maxScalePercent - minScalePercent) / step).roundToInt() - 1).coerceAtLeast(0) + Section(titleResId = R.string.pc_mouse_pointer_speed) { Surface( modifier = modifier @@ -245,12 +290,34 @@ fun PcPointerSpeedSection( shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh ) { - Text( - text = pointerSpeedLabel, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp) - ) + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + Text( + text = if (supported) label else stringResource(R.string.pc_mouse_pointer_speed_unavailable), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource( + if (supported) { + R.string.pc_mouse_pointer_speed_configured + } else { + R.string.pc_mouse_pointer_speed_unavailable + } + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp) + ) + Slider( + value = displayedScale.toFloat().coerceIn(min, max), + onValueChange = { displayedScale = it.toDouble() }, + onValueChangeFinished = { onSpeedSelected(displayedScale) }, + enabled = supported && enabled, + valueRange = min..max, + steps = steps, + modifier = Modifier.padding(top = 4.dp) + ) + } } } } diff --git a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlActivity.kt b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlActivity.kt index a567b0d9..b0b3c236 100644 --- a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlActivity.kt +++ b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlActivity.kt @@ -163,7 +163,14 @@ private fun PcMouseControlScreen( PcControlCommandGrid( enabled = surfaceEnabled, movementStep = uiState.movementStep, + pointerSpeedScalePercent = uiState.pointerSpeedScalePercent, + pointerSpeedMinScalePercent = uiState.pointerSpeedMinScalePercent, + pointerSpeedMaxScalePercent = uiState.pointerSpeedMaxScalePercent, + pointerSpeedStepPercent = uiState.pointerSpeedStepPercent, + pointerSpeedSupported = uiState.pointerSpeedSupported && uiState.pointerSpeedSetSupported, + pointerSpeedControlEnabled = uiState.pointerSpeedSupported && uiState.pointerSpeedSetSupported && !uiState.isBusy, pointerSpeedLabel = uiState.pointerSpeedPercentLabel, + onPointerSpeedSelected = viewModel::setPointerSpeed, onCommandSelected = viewModel::sendMouseCommand ) } diff --git a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt index 1175c3b9..f7d08a3a 100644 --- a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt +++ b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt @@ -55,6 +55,11 @@ data class PcMouseControlUiState( val activeSurface: PcControlSurface = PcControlSurface.Mouse, val movementStep: Int = PcMouseControlViewModel.FALLBACK_MOVEMENT_STEP, val pointerSpeedSupported: Boolean = false, + val pointerSpeedSetSupported: Boolean = false, + val pointerSpeedScalePercent: Double = 100.0, + val pointerSpeedMinScalePercent: Double = 25.0, + val pointerSpeedMaxScalePercent: Double = 225.0, + val pointerSpeedStepPercent: Double = 5.0, val pointerSpeedPercentLabel: String = PcMouseControlViewModel.POINTER_SPEED_UNAVAILABLE_MESSAGE, val isDragging: Boolean = false, val isBusy: Boolean = false, @@ -204,6 +209,30 @@ class PcMouseControlViewModel( send(command) } + fun setPointerSpeed(scalePercent: Double) { + val state = _uiState.value + if (!state.pointerSpeedSupported || !state.pointerSpeedSetSupported) { + _uiState.update { it.copy(message = POINTER_SPEED_UNAVAILABLE_MESSAGE) } + return + } + + val normalized = normalizePointerSpeed( + scalePercent, + state.pointerSpeedMinScalePercent, + state.pointerSpeedMaxScalePercent, + state.pointerSpeedStepPercent + ) + sendCommand(PcControlCommand.SetPointerSpeed(normalized)) { + it.copy( + pointerSpeedScalePercent = normalized, + pointerSpeedPercentLabel = pointerSpeedLabel(normalized), + isBusy = false, + busyCommand = null, + message = null + ) + } + } + private fun sendPcSideRepeatCommand(command: PcControlCommand) { val controller = serviceControllerProvider() if (controller == null || !mouseRepeatManager.armPcSideRepeat( @@ -865,7 +894,12 @@ class PcMouseControlViewModel( switcherConnectedDisplayName = controller.currentControlDeviceName() ?: state.displayName, movementStep = movementStep, pointerSpeedSupported = pointerProfile?.capabilities?.pointerSpeed?.supported == true, - pointerSpeedPercentLabel = pointerSpeedLabel(pointerProfile), + pointerSpeedSetSupported = pointerProfile?.capabilities?.pointerSpeed?.setSupported == true, + pointerSpeedScalePercent = pointerProfile?.capabilities?.pointerSpeed?.scalePercent ?: 100.0, + pointerSpeedMinScalePercent = pointerProfile?.capabilities?.pointerSpeed?.minScalePercent ?: 25.0, + pointerSpeedMaxScalePercent = pointerProfile?.capabilities?.pointerSpeed?.maxScalePercent ?: 225.0, + pointerSpeedStepPercent = pointerProfile?.capabilities?.pointerSpeed?.stepPercent ?: 5.0, + pointerSpeedPercentLabel = pointerSpeedLabel(pointerProfile?.capabilities?.pointerSpeed?.scalePercent), supportsTextStreamInput = pointerProfile?.supportsTextStreams() ?: false, supportsModifierToggles = supportsModifierToggles, message = if (it.message == RECONNECTING_MESSAGE || it.message == DISCONNECTED_MESSAGE || it.message == CONNECT_FIRST_MESSAGE) { @@ -897,6 +931,7 @@ class PcMouseControlViewModel( isDragging = false, activeModifiers = emptySet(), pointerSpeedSupported = false, + pointerSpeedSetSupported = false, pointerSpeedPercentLabel = POINTER_SPEED_UNAVAILABLE_MESSAGE, supportsTextStreamInput = false, supportsModifierToggles = false, @@ -921,6 +956,8 @@ class PcMouseControlViewModel( switcherConnectedDisplayName = null, movementStep = movementStep, pointerSpeedSupported = false, + pointerSpeedSetSupported = false, + pointerSpeedScalePercent = 100.0, pointerSpeedPercentLabel = POINTER_SPEED_UNAVAILABLE_MESSAGE, isDragging = false, activeModifiers = emptySet(), @@ -946,6 +983,8 @@ class PcMouseControlViewModel( switcherConnectedDisplayName = null, movementStep = movementStep, pointerSpeedSupported = false, + pointerSpeedSetSupported = false, + pointerSpeedScalePercent = 100.0, pointerSpeedPercentLabel = POINTER_SPEED_UNAVAILABLE_MESSAGE, isDragging = false, activeModifiers = emptySet(), @@ -1036,6 +1075,8 @@ class PcMouseControlViewModel( switcherConnectedDisplayName = null, movementStep = movementStep, pointerSpeedSupported = false, + pointerSpeedSetSupported = false, + pointerSpeedScalePercent = 100.0, pointerSpeedPercentLabel = POINTER_SPEED_UNAVAILABLE_MESSAGE, isDragging = false, activeModifiers = emptySet(), @@ -1091,15 +1132,20 @@ class PcMouseControlViewModel( } } - private fun pointerSpeedLabel(pointerProfile: PcPointerMovementProfile?): String { - val speed = pointerProfile?.capabilities?.pointerSpeed - if (speed?.supported != true) return POINTER_SPEED_UNAVAILABLE_MESSAGE - val percent = if (speed.scalePercent % 1.0 == 0.0) { - speed.scalePercent.toInt().toString() + private fun pointerSpeedLabel(scalePercent: Double?): String { + if (scalePercent == null) return POINTER_SPEED_UNAVAILABLE_MESSAGE + val percent = if (scalePercent % 1.0 == 0.0) { + scalePercent.toInt().toString() } else { - String.format(Locale.ROOT, "%.1f", speed.scalePercent) + String.format(Locale.ROOT, "%.1f", scalePercent) } - return "$POINTER_SPEED_PREFIX$percent%" + return "$percent%" + } + + private fun normalizePointerSpeed(value: Double, min: Double, max: Double, step: Double): Double { + val bounded = value.coerceIn(min, max) + val normalizedStep = if (step > 0.0) step else 5.0 + return kotlin.math.round(bounded / normalizedStep) * normalizedStep } private fun debugLog(message: String) { @@ -1109,8 +1155,7 @@ class PcMouseControlViewModel( companion object { private const val TAG = "PcMouseControlViewModel" const val FALLBACK_MOVEMENT_STEP = 80 - const val POINTER_SPEED_PREFIX = "Set in Switchify PC: " - const val POINTER_SPEED_UNAVAILABLE_MESSAGE = "Update Switchify PC to view pointer speed." + const val POINTER_SPEED_UNAVAILABLE_MESSAGE = "Update Switchify PC to set pointer speed." const val CONNECT_FIRST_MESSAGE = "Connect to PC from Switchify first." const val COMMAND_FAILED_MESSAGE = "Could not send command to PC." const val TYPING_FAILED_MESSAGE = "Could not send text to PC." diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f50130a4..b7c70212 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -382,6 +382,8 @@ Window Connected to %1$s. Pointer speed + Set in Switchify PC. + Update Switchify PC to set pointer speed. Movement Clicks Request rejected. diff --git a/app/src/test/java/com/enaboapps/switchify/pc/PcProtocolTest.kt b/app/src/test/java/com/enaboapps/switchify/pc/PcProtocolTest.kt index 93a53fa9..203808fe 100644 --- a/app/src/test/java/com/enaboapps/switchify/pc/PcProtocolTest.kt +++ b/app/src/test/java/com/enaboapps/switchify/pc/PcProtocolTest.kt @@ -133,6 +133,39 @@ class PcProtocolTest { assertFalse(json.toString().contains("shared-token")) } + @Test + fun buildsPointerSpeedSetCommandWithAuthProof() { + val json = JSONObject( + PcProtocol.pointerSpeedSet( + id = "speed-1", + deviceId = "device-1", + token = "shared-token", + timestamp = 1000L, + scalePercent = 125.0 + ) + ) + + assertEquals(1, json.getInt("version")) + assertEquals("speed-1", json.getString("id")) + assertEquals("device-1", json.getString("deviceId")) + assertEquals(1000L, json.getLong("timestamp")) + assertEquals("pointer.speed.set", json.getString("type")) + assertEquals(125.0, json.getJSONObject("payload").getDouble("scalePercent"), 0.0) + assertFalse(json.has("responseMode")) + assertEquals( + PcProtocol.authProof( + id = "speed-1", + deviceId = "device-1", + timestamp = 1000L, + type = "pointer.speed.set", + payload = json.getJSONObject("payload"), + token = "shared-token" + ), + json.getString("auth") + ) + assertFalse(json.toString().contains("shared-token")) + } + @Test fun parsesPointerProfileResponse() { val response = PcProtocol.parseResponse(validPointerProfileResponse()) as PcProtocolResponse.PointerProfile @@ -147,6 +180,7 @@ class PcProtocolTest { assertEquals(emptySet(), response.profile.capabilities.noAckCommands) assertEquals(emptySet(), response.profile.capabilities.supportedCommands) assertFalse(response.profile.capabilities.pointerSpeed.supported) + assertFalse(response.profile.capabilities.pointerSpeed.setSupported) assertEquals(130, response.profile.pointerMoveStep()) } @@ -154,11 +188,12 @@ class PcProtocolTest { fun parsesPointerProfileSpeedCapability() { val response = PcProtocol.parseResponse( validPointerProfileResponse( - capabilities = ""","capabilities":{"pointerSpeed":{"supported":true,"scalePercent":125,"minScalePercent":25,"maxScalePercent":225,"stepPercent":5,"baseMoveDelta":128,"effectiveMoveDelta":160}}""" + capabilities = ""","capabilities":{"pointerSpeed":{"supported":true,"setSupported":true,"scalePercent":125,"minScalePercent":25,"maxScalePercent":225,"stepPercent":5,"baseMoveDelta":128,"effectiveMoveDelta":160}}""" ) ) as PcProtocolResponse.PointerProfile assertTrue(response.profile.capabilities.pointerSpeed.supported) + assertTrue(response.profile.capabilities.pointerSpeed.setSupported) assertEquals(125.0, response.profile.capabilities.pointerSpeed.scalePercent, 0.0) assertEquals(225.0, response.profile.capabilities.pointerSpeed.maxScalePercent, 0.0) assertEquals(128, response.profile.capabilities.pointerSpeed.baseMoveDelta) @@ -176,6 +211,14 @@ class PcProtocolTest { ) ) ) + assertEquals( + PcProtocolResponse.Invalid, + PcProtocol.parseResponse( + validPointerProfileResponse( + capabilities = ""","capabilities":{"pointerSpeed":{"supported":true,"setSupported":"yes","scalePercent":125,"minScalePercent":25,"maxScalePercent":225,"stepPercent":5,"baseMoveDelta":128,"effectiveMoveDelta":160}}""" + ) + ) + ) } @Test diff --git a/app/src/test/java/com/enaboapps/switchify/pc/bluetooth/SwitchifyPcBleClientTest.kt b/app/src/test/java/com/enaboapps/switchify/pc/bluetooth/SwitchifyPcBleClientTest.kt index cb467f1a..4dbd6903 100644 --- a/app/src/test/java/com/enaboapps/switchify/pc/bluetooth/SwitchifyPcBleClientTest.kt +++ b/app/src/test/java/com/enaboapps/switchify/pc/bluetooth/SwitchifyPcBleClientTest.kt @@ -331,6 +331,25 @@ class SwitchifyPcBleClientTest { assertEquals("Z", messages[3].getJSONObject("payload").getJSONArray("keys").getString(2)) } + @Test + fun sendsPointerSpeedSetWithAckResponseMode() = runTest { + val tokens = FakeTokenStore(mutableMapOf("desktop-1" to "token"), mutableMapOf("desktop-1" to "Switchify PC")) + val messages = mutableListOf() + val transport = FakeTransportFactory { message -> + val json = JSONObject(message) + messages += json + ack(json.getString("id")) + } + val client: PcConnector = client(tokens, transport) + val session = PcAuthenticatedSession("desktop-1", "device-1", "AA:BB:CC:DD:EE:FF", PcTransport.Bluetooth) + + assertEquals(PcCommandResult.Ack, client.sendCommand(session, PcControlCommand.SetPointerSpeed(125.0))) + + assertEquals("pointer.speed.set", messages.single().getString("type")) + assertEquals(125.0, messages.single().getJSONObject("payload").getDouble("scalePercent"), 0.0) + assertTrue(!messages.single().has("responseMode")) + } + @Test fun realtimeMoveFallsBackWhenPointerProfileDoesNotAdvertiseNoAck() = runTest { val tokens = FakeTokenStore(mutableMapOf("desktop-1" to "token"), mutableMapOf("desktop-1" to "Switchify PC")) diff --git a/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModelTest.kt b/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModelTest.kt index 059119ea..690cf7b3 100644 --- a/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModelTest.kt +++ b/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModelTest.kt @@ -156,6 +156,7 @@ class PcMouseControlViewModelTest { capabilities = PcPointerCapabilities( pointerSpeed = PcPointerSpeedCapabilities( supported = true, + setSupported = true, scalePercent = 125.0, baseMoveDelta = 128, effectiveMoveDelta = 160 @@ -169,7 +170,126 @@ class PcMouseControlViewModelTest { assertEquals(128, viewModel.uiState.value.movementStep) assertEquals(true, viewModel.uiState.value.pointerSpeedSupported) - assertEquals("Set in Switchify PC: 125%", viewModel.uiState.value.pointerSpeedPercentLabel) + assertEquals("125%", viewModel.uiState.value.pointerSpeedPercentLabel) + } + + @Test + fun pointerSpeedSetSupportEnablesSpeedEditing() = runTest(dispatcher) { + val controller = connectedController( + pointerProfile = pointerProfile( + capabilities = PcPointerCapabilities( + pointerSpeed = PcPointerSpeedCapabilities( + supported = true, + setSupported = true, + scalePercent = 160.0, + minScalePercent = 25.0, + maxScalePercent = 225.0, + stepPercent = 5.0, + baseMoveDelta = 128, + effectiveMoveDelta = 205 + ) + ) + ) + ) + val viewModel = viewModel(controller) + + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.pointerSpeedSupported) + assertTrue(viewModel.uiState.value.pointerSpeedSetSupported) + assertEquals(160.0, viewModel.uiState.value.pointerSpeedScalePercent, 0.0) + assertEquals(25.0, viewModel.uiState.value.pointerSpeedMinScalePercent, 0.0) + assertEquals(225.0, viewModel.uiState.value.pointerSpeedMaxScalePercent, 0.0) + assertEquals(5.0, viewModel.uiState.value.pointerSpeedStepPercent, 0.0) + } + + @Test + fun settingPointerSpeedSendsNormalizedValueAndUpdatesAfterAck() = runTest(dispatcher) { + val connector = FakeConnector() + val controller = connectedController( + connector = connector, + pointerProfile = pointerProfile( + capabilities = PcPointerCapabilities( + pointerSpeed = PcPointerSpeedCapabilities( + supported = true, + setSupported = true, + scalePercent = 100.0, + minScalePercent = 25.0, + maxScalePercent = 225.0, + stepPercent = 5.0, + baseMoveDelta = 128, + effectiveMoveDelta = 128 + ) + ) + ) + ) + val viewModel = viewModel(controller) + advanceUntilIdle() + + viewModel.setPointerSpeed(127.0) + advanceUntilIdle() + + assertEquals(listOf(PcControlCommand.SetPointerSpeed(125.0)), connector.commands) + assertEquals(125.0, viewModel.uiState.value.pointerSpeedScalePercent, 0.0) + assertEquals("125%", viewModel.uiState.value.pointerSpeedPercentLabel) + assertEquals(128, viewModel.uiState.value.movementStep) + } + + @Test + fun failedPointerSpeedSetDoesNotUpdateDisplayedSpeed() = runTest(dispatcher) { + val connector = FakeConnector(commandResults = mutableListOf(PcCommandResult.Failed())) + val controller = connectedController( + connector = connector, + pointerProfile = pointerProfile( + capabilities = PcPointerCapabilities( + pointerSpeed = PcPointerSpeedCapabilities( + supported = true, + setSupported = true, + scalePercent = 100.0, + minScalePercent = 25.0, + maxScalePercent = 225.0, + stepPercent = 5.0, + baseMoveDelta = 128, + effectiveMoveDelta = 128 + ) + ) + ) + ) + val viewModel = viewModel(controller) + advanceUntilIdle() + + viewModel.setPointerSpeed(160.0) + advanceUntilIdle() + + assertEquals(listOf(PcControlCommand.SetPointerSpeed(160.0)), connector.commands) + assertEquals(100.0, viewModel.uiState.value.pointerSpeedScalePercent, 0.0) + assertEquals("100%", viewModel.uiState.value.pointerSpeedPercentLabel) + assertEquals(PcMouseControlViewModel.COMMAND_FAILED_MESSAGE, viewModel.uiState.value.message) + } + + @Test + fun unsupportedPointerSpeedSetDoesNotSendCommand() = runTest(dispatcher) { + val connector = FakeConnector() + val controller = connectedController( + connector = connector, + pointerProfile = pointerProfile( + capabilities = PcPointerCapabilities( + pointerSpeed = PcPointerSpeedCapabilities( + supported = true, + setSupported = false, + scalePercent = 100.0 + ) + ) + ) + ) + val viewModel = viewModel(controller) + advanceUntilIdle() + + viewModel.setPointerSpeed(160.0) + advanceUntilIdle() + + assertTrue(connector.commands.isEmpty()) + assertEquals(PcMouseControlViewModel.POINTER_SPEED_UNAVAILABLE_MESSAGE, viewModel.uiState.value.message) } @Test @@ -416,7 +536,7 @@ class PcMouseControlViewModelTest { assertEquals(80, viewModel.uiState.value.movementStep) assertEquals(false, viewModel.uiState.value.pointerSpeedSupported) assertEquals( - "Update Switchify PC to view pointer speed.", + "Update Switchify PC to set pointer speed.", viewModel.uiState.value.pointerSpeedPercentLabel ) } From ffa1af99c90da902fe1b9aac6abf715b4487226d Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 6 Jul 2026 14:30:05 +0100 Subject: [PATCH 3/3] Use picker for PC pointer speed --- .../enaboapps/switchify/components/Picker.kt | 6 +- .../switchify/pc/PcPointerProfile.kt | 2 +- .../com/enaboapps/switchify/pc/PcProtocol.kt | 2 +- .../screens/pc/PcMouseCommandGrid.kt | 134 ++++++++++++------ .../screens/pc/PcMouseControlViewModel.kt | 4 +- app/src/main/res/values/strings.xml | 1 + .../enaboapps/switchify/pc/PcProtocolTest.kt | 8 +- .../screens/pc/PcMouseCommandGridTest.kt | 34 +++++ .../screens/pc/PcMouseControlViewModelTest.kt | 39 ++++- 9 files changed, 177 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/com/enaboapps/switchify/components/Picker.kt b/app/src/main/java/com/enaboapps/switchify/components/Picker.kt index 99d4a0b5..5e2cf64c 100644 --- a/app/src/main/java/com/enaboapps/switchify/components/Picker.kt +++ b/app/src/main/java/com/enaboapps/switchify/components/Picker.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -44,7 +45,8 @@ fun Picker( items: List, onItemSelected: (T) -> Unit, itemToString: (T) -> String, - itemDescription: (T) -> String + itemDescription: (T) -> String, + enabled: Boolean = true ) { var expanded by remember { mutableStateOf(false) } @@ -69,10 +71,12 @@ fun Picker( .heightIn(min = 56.dp) .background(triggerColor) .clickable( + enabled = enabled, interactionSource = interactionSource, indication = null, onClick = { expanded = true } ) + .alpha(if (enabled) 1f else 0.55f) .padding(Dimens.spaceM), horizontalArrangement = Arrangement.spacedBy(Dimens.spaceM), verticalAlignment = Alignment.CenterVertically diff --git a/app/src/main/java/com/enaboapps/switchify/pc/PcPointerProfile.kt b/app/src/main/java/com/enaboapps/switchify/pc/PcPointerProfile.kt index 0d891ceb..aadc304d 100644 --- a/app/src/main/java/com/enaboapps/switchify/pc/PcPointerProfile.kt +++ b/app/src/main/java/com/enaboapps/switchify/pc/PcPointerProfile.kt @@ -21,7 +21,7 @@ data class PcPointerSpeedCapabilities( val supported: Boolean = false, val setSupported: Boolean = false, val scalePercent: Double = 100.0, - val minScalePercent: Double = 25.0, + val minScalePercent: Double = 5.0, val maxScalePercent: Double = 225.0, val stepPercent: Double = 5.0, val baseMoveDelta: Int = 128, diff --git a/app/src/main/java/com/enaboapps/switchify/pc/PcProtocol.kt b/app/src/main/java/com/enaboapps/switchify/pc/PcProtocol.kt index e083d9e7..fec3cfb0 100644 --- a/app/src/main/java/com/enaboapps/switchify/pc/PcProtocol.kt +++ b/app/src/main/java/com/enaboapps/switchify/pc/PcProtocol.kt @@ -655,7 +655,7 @@ object PcProtocol { if (speedJson.has("setSupported") && speedJson.opt("setSupported") !is Boolean) return null val scalePercent = speedJson.optDouble("scalePercent", 100.0) - val minScalePercent = speedJson.optDouble("minScalePercent", 25.0) + val minScalePercent = speedJson.optDouble("minScalePercent", 5.0) val maxScalePercent = speedJson.optDouble("maxScalePercent", 225.0) val stepPercent = speedJson.optDouble("stepPercent", 5.0) val baseMoveDeltaValue = speedJson.opt("baseMoveDelta") diff --git a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGrid.kt b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGrid.kt index a703842c..def78a97 100644 --- a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGrid.kt +++ b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGrid.kt @@ -41,7 +41,6 @@ import androidx.compose.material.icons.filled.OpenWith import androidx.compose.material.icons.filled.TouchApp import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -55,6 +54,7 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription @@ -66,9 +66,10 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.unit.dp import com.enaboapps.switchify.R import com.enaboapps.switchify.components.EqualHeightGridRow +import com.enaboapps.switchify.components.Picker import com.enaboapps.switchify.components.Section import com.enaboapps.switchify.pc.PcControlCommand -import kotlin.math.roundToInt +import kotlin.math.round data class PcMouseControlSpec( @param:StringRes val labelResId: Int, @@ -276,52 +277,103 @@ fun PcPointerSpeedSection( onSpeedSelected: (Double) -> Unit, modifier: Modifier = Modifier ) { - var displayedScale by remember(scalePercent) { mutableStateOf(scalePercent) } - val min = minScalePercent.toFloat() - val max = maxScalePercent.toFloat() - val step = stepPercent.takeIf { it > 0.0 } ?: 5.0 - val steps = (((maxScalePercent - minScalePercent) / step).roundToInt() - 1).coerceAtLeast(0) + val context = LocalContext.current + val speedOptions = remember(minScalePercent, maxScalePercent, stepPercent) { + pointerSpeedOptions(minScalePercent, maxScalePercent, stepPercent) + } + val selectedSpeed = remember(scalePercent, minScalePercent, maxScalePercent, stepPercent) { + normalizePointerSpeedForPicker(scalePercent, minScalePercent, maxScalePercent, stepPercent) + } Section(titleResId = R.string.pc_mouse_pointer_speed) { - Surface( - modifier = modifier - .fillMaxWidth() - .padding(12.dp), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh - ) { - Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { - Text( - text = if (supported) label else stringResource(R.string.pc_mouse_pointer_speed_unavailable), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = stringResource( - if (supported) { - R.string.pc_mouse_pointer_speed_configured - } else { - R.string.pc_mouse_pointer_speed_unavailable - } - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 2.dp) - ) - Slider( - value = displayedScale.toFloat().coerceIn(min, max), - onValueChange = { displayedScale = it.toDouble() }, - onValueChangeFinished = { onSpeedSelected(displayedScale) }, - enabled = supported && enabled, - valueRange = min..max, - steps = steps, - modifier = Modifier.padding(top = 4.dp) - ) + if (supported) { + Picker( + titleResId = R.string.pc_mouse_pointer_speed, + selectedItem = selectedSpeed, + items = speedOptions, + onItemSelected = onSpeedSelected, + itemToString = { "${formatPointerSpeedPercent(it)}%" }, + itemDescription = { speed -> + context.getString( + R.string.pc_mouse_pointer_speed_option_description, + "${formatPointerSpeedPercent(speed)}%" + ) + }, + enabled = enabled + ) + } else { + Surface( + modifier = modifier + .fillMaxWidth() + .padding(12.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.pc_mouse_pointer_speed_unavailable), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp) + ) + } } } } } +internal fun pointerSpeedOptions( + minScalePercent: Double, + maxScalePercent: Double, + stepPercent: Double +): List { + val step = if (stepPercent.isFinite() && stepPercent > 0.0) stepPercent else 5.0 + val min = if (minScalePercent.isFinite() && minScalePercent > 0.0) minScalePercent else 5.0 + val max = if (maxScalePercent.isFinite() && maxScalePercent >= min) maxScalePercent else min + val options = mutableListOf() + var current = normalizePointerSpeedForPicker(min, min, max, step) + while (current <= max + 0.0001) { + options += normalizePointerSpeedOption(current) + current += step + } + val normalizedMax = normalizePointerSpeedForPicker(max, min, max, step) + if (options.lastOrNull() != normalizedMax) { + options += normalizedMax + } + return options.distinct() +} + +internal fun normalizePointerSpeedForPicker( + value: Double, + minScalePercent: Double, + maxScalePercent: Double, + stepPercent: Double +): Double { + val step = if (stepPercent.isFinite() && stepPercent > 0.0) stepPercent else 5.0 + val min = if (minScalePercent.isFinite() && minScalePercent > 0.0) minScalePercent else 5.0 + val max = if (maxScalePercent.isFinite() && maxScalePercent >= min) maxScalePercent else min + val bounded = value.coerceIn(min, max) + return normalizePointerSpeedOption((round(bounded / step) * step).coerceIn(min, max)) +} + +private fun normalizePointerSpeedOption(value: Double): Double { + return round(value * 10.0) / 10.0 +} + +private fun formatPointerSpeedPercent(value: Double): String { + val normalized = normalizePointerSpeedOption(value) + return if (normalized % 1.0 == 0.0) { + normalized.toInt().toString() + } else { + java.lang.String.format(java.util.Locale.ROOT, "%.1f", normalized) + } +} + private fun PcMouseControlSpec?.toCommandCell( enabled: Boolean, onCommandSelected: (PcControlCommand, Boolean) -> Unit diff --git a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt index f7d08a3a..91e6370b 100644 --- a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt +++ b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt @@ -57,7 +57,7 @@ data class PcMouseControlUiState( val pointerSpeedSupported: Boolean = false, val pointerSpeedSetSupported: Boolean = false, val pointerSpeedScalePercent: Double = 100.0, - val pointerSpeedMinScalePercent: Double = 25.0, + val pointerSpeedMinScalePercent: Double = 5.0, val pointerSpeedMaxScalePercent: Double = 225.0, val pointerSpeedStepPercent: Double = 5.0, val pointerSpeedPercentLabel: String = PcMouseControlViewModel.POINTER_SPEED_UNAVAILABLE_MESSAGE, @@ -896,7 +896,7 @@ class PcMouseControlViewModel( pointerSpeedSupported = pointerProfile?.capabilities?.pointerSpeed?.supported == true, pointerSpeedSetSupported = pointerProfile?.capabilities?.pointerSpeed?.setSupported == true, pointerSpeedScalePercent = pointerProfile?.capabilities?.pointerSpeed?.scalePercent ?: 100.0, - pointerSpeedMinScalePercent = pointerProfile?.capabilities?.pointerSpeed?.minScalePercent ?: 25.0, + pointerSpeedMinScalePercent = pointerProfile?.capabilities?.pointerSpeed?.minScalePercent ?: 5.0, pointerSpeedMaxScalePercent = pointerProfile?.capabilities?.pointerSpeed?.maxScalePercent ?: 225.0, pointerSpeedStepPercent = pointerProfile?.capabilities?.pointerSpeed?.stepPercent ?: 5.0, pointerSpeedPercentLabel = pointerSpeedLabel(pointerProfile?.capabilities?.pointerSpeed?.scalePercent), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b7c70212..73e6b9bd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -384,6 +384,7 @@ Pointer speed Set in Switchify PC. Update Switchify PC to set pointer speed. + Set pointer speed to %1$s. Movement Clicks Request rejected. diff --git a/app/src/test/java/com/enaboapps/switchify/pc/PcProtocolTest.kt b/app/src/test/java/com/enaboapps/switchify/pc/PcProtocolTest.kt index 203808fe..701237d3 100644 --- a/app/src/test/java/com/enaboapps/switchify/pc/PcProtocolTest.kt +++ b/app/src/test/java/com/enaboapps/switchify/pc/PcProtocolTest.kt @@ -181,6 +181,7 @@ class PcProtocolTest { assertEquals(emptySet(), response.profile.capabilities.supportedCommands) assertFalse(response.profile.capabilities.pointerSpeed.supported) assertFalse(response.profile.capabilities.pointerSpeed.setSupported) + assertEquals(5.0, response.profile.capabilities.pointerSpeed.minScalePercent, 0.0) assertEquals(130, response.profile.pointerMoveStep()) } @@ -188,13 +189,14 @@ class PcProtocolTest { fun parsesPointerProfileSpeedCapability() { val response = PcProtocol.parseResponse( validPointerProfileResponse( - capabilities = ""","capabilities":{"pointerSpeed":{"supported":true,"setSupported":true,"scalePercent":125,"minScalePercent":25,"maxScalePercent":225,"stepPercent":5,"baseMoveDelta":128,"effectiveMoveDelta":160}}""" + capabilities = ""","capabilities":{"pointerSpeed":{"supported":true,"setSupported":true,"scalePercent":125,"minScalePercent":5,"maxScalePercent":225,"stepPercent":5,"baseMoveDelta":128,"effectiveMoveDelta":160}}""" ) ) as PcProtocolResponse.PointerProfile assertTrue(response.profile.capabilities.pointerSpeed.supported) assertTrue(response.profile.capabilities.pointerSpeed.setSupported) assertEquals(125.0, response.profile.capabilities.pointerSpeed.scalePercent, 0.0) + assertEquals(5.0, response.profile.capabilities.pointerSpeed.minScalePercent, 0.0) assertEquals(225.0, response.profile.capabilities.pointerSpeed.maxScalePercent, 0.0) assertEquals(128, response.profile.capabilities.pointerSpeed.baseMoveDelta) assertEquals(160, response.profile.capabilities.pointerSpeed.effectiveMoveDelta) @@ -207,7 +209,7 @@ class PcProtocolTest { PcProtocolResponse.Invalid, PcProtocol.parseResponse( validPointerProfileResponse( - capabilities = ""","capabilities":{"pointerSpeed":{"supported":true,"scalePercent":300,"minScalePercent":25,"maxScalePercent":225,"stepPercent":5,"baseMoveDelta":128,"effectiveMoveDelta":160}}""" + capabilities = ""","capabilities":{"pointerSpeed":{"supported":true,"scalePercent":300,"minScalePercent":5,"maxScalePercent":225,"stepPercent":5,"baseMoveDelta":128,"effectiveMoveDelta":160}}""" ) ) ) @@ -215,7 +217,7 @@ class PcProtocolTest { PcProtocolResponse.Invalid, PcProtocol.parseResponse( validPointerProfileResponse( - capabilities = ""","capabilities":{"pointerSpeed":{"supported":true,"setSupported":"yes","scalePercent":125,"minScalePercent":25,"maxScalePercent":225,"stepPercent":5,"baseMoveDelta":128,"effectiveMoveDelta":160}}""" + capabilities = ""","capabilities":{"pointerSpeed":{"supported":true,"setSupported":"yes","scalePercent":125,"minScalePercent":5,"maxScalePercent":225,"stepPercent":5,"baseMoveDelta":128,"effectiveMoveDelta":160}}""" ) ) ) diff --git a/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGridTest.kt b/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGridTest.kt index 5ef0a37d..f6ffd97f 100644 --- a/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGridTest.kt +++ b/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGridTest.kt @@ -158,4 +158,38 @@ class PcControlCommandGridTest { assertEquals(PcControlCommand.DragStart(), commands[13]) } + + @Test + fun pointerSpeedOptionsStartAtAdvertisedMinimumAndUseStep() { + val options = pointerSpeedOptions( + minScalePercent = 5.0, + maxScalePercent = 225.0, + stepPercent = 5.0 + ) + + assertEquals(5.0, options.first(), 0.0) + assertEquals(10.0, options[1], 0.0) + assertEquals(100.0, options[19], 0.0) + assertEquals(225.0, options.last(), 0.0) + } + + @Test + fun pointerSpeedOptionsHonorAdvertisedOlderMinimum() { + val options = pointerSpeedOptions( + minScalePercent = 25.0, + maxScalePercent = 225.0, + stepPercent = 5.0 + ) + + assertEquals(25.0, options.first(), 0.0) + assertEquals(225.0, options.last(), 0.0) + } + + @Test + fun normalizePointerSpeedForPickerClampsAndRounds() { + assertEquals(5.0, normalizePointerSpeedForPicker(1.0, 5.0, 225.0, 5.0), 0.0) + assertEquals(5.0, normalizePointerSpeedForPicker(7.0, 5.0, 225.0, 5.0), 0.0) + assertEquals(10.0, normalizePointerSpeedForPicker(8.0, 5.0, 225.0, 5.0), 0.0) + assertEquals(225.0, normalizePointerSpeedForPicker(300.0, 5.0, 225.0, 5.0), 0.0) + } } diff --git a/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModelTest.kt b/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModelTest.kt index 690cf7b3..7426519d 100644 --- a/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModelTest.kt +++ b/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModelTest.kt @@ -182,7 +182,7 @@ class PcMouseControlViewModelTest { supported = true, setSupported = true, scalePercent = 160.0, - minScalePercent = 25.0, + minScalePercent = 5.0, maxScalePercent = 225.0, stepPercent = 5.0, baseMoveDelta = 128, @@ -198,7 +198,7 @@ class PcMouseControlViewModelTest { assertTrue(viewModel.uiState.value.pointerSpeedSupported) assertTrue(viewModel.uiState.value.pointerSpeedSetSupported) assertEquals(160.0, viewModel.uiState.value.pointerSpeedScalePercent, 0.0) - assertEquals(25.0, viewModel.uiState.value.pointerSpeedMinScalePercent, 0.0) + assertEquals(5.0, viewModel.uiState.value.pointerSpeedMinScalePercent, 0.0) assertEquals(225.0, viewModel.uiState.value.pointerSpeedMaxScalePercent, 0.0) assertEquals(5.0, viewModel.uiState.value.pointerSpeedStepPercent, 0.0) } @@ -214,7 +214,7 @@ class PcMouseControlViewModelTest { supported = true, setSupported = true, scalePercent = 100.0, - minScalePercent = 25.0, + minScalePercent = 5.0, maxScalePercent = 225.0, stepPercent = 5.0, baseMoveDelta = 128, @@ -235,6 +235,37 @@ class PcMouseControlViewModelTest { assertEquals(128, viewModel.uiState.value.movementStep) } + @Test + fun settingPointerSpeedClampsToFivePercentMinimum() = runTest(dispatcher) { + val connector = FakeConnector() + val controller = connectedController( + connector = connector, + pointerProfile = pointerProfile( + capabilities = PcPointerCapabilities( + pointerSpeed = PcPointerSpeedCapabilities( + supported = true, + setSupported = true, + scalePercent = 100.0, + minScalePercent = 5.0, + maxScalePercent = 225.0, + stepPercent = 5.0, + baseMoveDelta = 128, + effectiveMoveDelta = 128 + ) + ) + ) + ) + val viewModel = viewModel(controller) + advanceUntilIdle() + + viewModel.setPointerSpeed(1.0) + advanceUntilIdle() + + assertEquals(listOf(PcControlCommand.SetPointerSpeed(5.0)), connector.commands) + assertEquals(5.0, viewModel.uiState.value.pointerSpeedScalePercent, 0.0) + assertEquals("5%", viewModel.uiState.value.pointerSpeedPercentLabel) + } + @Test fun failedPointerSpeedSetDoesNotUpdateDisplayedSpeed() = runTest(dispatcher) { val connector = FakeConnector(commandResults = mutableListOf(PcCommandResult.Failed())) @@ -246,7 +277,7 @@ class PcMouseControlViewModelTest { supported = true, setSupported = true, scalePercent = 100.0, - minScalePercent = 25.0, + minScalePercent = 5.0, maxScalePercent = 225.0, stepPercent = 5.0, baseMoveDelta = 128,