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/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/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 745ee6b3..aadc304d 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,19 @@ 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 setSupported: Boolean = false, + val scalePercent: Double = 100.0, + val minScalePercent: Double = 5.0, + val maxScalePercent: Double = 225.0, + val stepPercent: Double = 5.0, + val baseMoveDelta: Int = 128, + val effectiveMoveDelta: Int = 128 ) data class PcMouseRepeatCapabilities( @@ -56,3 +68,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..fec3cfb0 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, @@ -568,7 +589,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 +648,52 @@ 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 + if (speedJson.has("setSupported") && speedJson.opt("setSupported") !is Boolean) return null + + val scalePercent = speedJson.optDouble("scalePercent", 100.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") + 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), + setSupported = speedJson.optBoolean("setSupported", 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") @@ -681,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 e99fa567..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,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 @@ -57,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 @@ -68,8 +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.round data class PcMouseControlSpec( @param:StringRes val labelResId: Int, @@ -183,43 +183,33 @@ 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, + pointerSpeedScalePercent: Double, + pointerSpeedMinScalePercent: Double, + pointerSpeedMaxScalePercent: Double, + pointerSpeedStepPercent: Double, + pointerSpeedSupported: Boolean, + pointerSpeedControlEnabled: Boolean, + pointerSpeedLabel: String, + onPointerSpeedSelected: (Double) -> Unit, onCommandSelected: (PcControlCommand, Boolean) -> Unit, - sizeSelectorEnabled: Boolean = true, modifier: Modifier = Modifier ) { PcControlCommandSections( enabled = enabled, movementStep = movementStep, - selectedSize = selectedSize, - onSizeSelected = onSizeSelected, + pointerSpeedScalePercent = pointerSpeedScalePercent, + pointerSpeedMinScalePercent = pointerSpeedMinScalePercent, + pointerSpeedMaxScalePercent = pointerSpeedMaxScalePercent, + pointerSpeedStepPercent = pointerSpeedStepPercent, + pointerSpeedSupported = pointerSpeedSupported, + pointerSpeedControlEnabled = pointerSpeedControlEnabled, + pointerSpeedLabel = pointerSpeedLabel, + onPointerSpeedSelected = onPointerSpeedSelected, onCommandSelected = onCommandSelected, - sizeSelectorEnabled = sizeSelectorEnabled, modifier = modifier ) } @@ -228,10 +218,15 @@ fun PcControlCommandGrid( fun PcControlCommandSections( enabled: Boolean, movementStep: Int, - selectedSize: PcMouseMovementSize, - onSizeSelected: (PcMouseMovementSize) -> Unit, + pointerSpeedScalePercent: Double, + pointerSpeedMinScalePercent: Double, + pointerSpeedMaxScalePercent: Double, + pointerSpeedStepPercent: Double, + pointerSpeedSupported: Boolean, + pointerSpeedControlEnabled: Boolean, + pointerSpeedLabel: String, + onPointerSpeedSelected: (Double) -> Unit, onCommandSelected: (PcControlCommand, Boolean) -> Unit, - sizeSelectorEnabled: Boolean = true, modifier: Modifier = Modifier ) { val specs = pcMouseCompactControlSpecs(movementStep) @@ -257,14 +252,128 @@ fun PcControlCommandSections( ) } } - PcMovementSizeSection( - selectedSize = selectedSize, - onSizeSelected = onSizeSelected, - enabled = sizeSelectorEnabled + PcPointerSpeedSection( + scalePercent = pointerSpeedScalePercent, + minScalePercent = pointerSpeedMinScalePercent, + maxScalePercent = pointerSpeedMaxScalePercent, + stepPercent = pointerSpeedStepPercent, + supported = pointerSpeedSupported, + enabled = pointerSpeedControlEnabled, + label = pointerSpeedLabel, + onSpeedSelected = onPointerSpeedSelected ) } } +@Composable +fun PcPointerSpeedSection( + scalePercent: Double, + minScalePercent: Double, + maxScalePercent: Double, + stepPercent: Double, + supported: Boolean, + enabled: Boolean, + label: String, + onSpeedSelected: (Double) -> Unit, + modifier: Modifier = Modifier +) { + 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) { + 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 @@ -498,42 +607,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..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,10 +163,15 @@ private fun PcMouseControlScreen( PcControlCommandGrid( enabled = surfaceEnabled, movementStep = uiState.movementStep, - selectedSize = uiState.selectedMovementSize, - onSizeSelected = viewModel::selectMovementSize, - onCommandSelected = viewModel::sendMouseCommand, - sizeSelectorEnabled = !uiState.isBusy + 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 ) } 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..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 @@ -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,14 @@ 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 pointerSpeedSetSupported: Boolean = false, + val pointerSpeedScalePercent: Double = 100.0, + val pointerSpeedMinScalePercent: Double = 5.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, val busyCommand: PcControlCommand? = null, @@ -71,40 +80,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 +120,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, @@ -216,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( @@ -868,14 +885,21 @@ 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, + pointerSpeedSetSupported = pointerProfile?.capabilities?.pointerSpeed?.setSupported == true, + pointerSpeedScalePercent = pointerProfile?.capabilities?.pointerSpeed?.scalePercent ?: 100.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), supportsTextStreamInput = pointerProfile?.supportsTextStreams() ?: false, supportsModifierToggles = supportsModifierToggles, message = if (it.message == RECONNECTING_MESSAGE || it.message == DISCONNECTED_MESSAGE || it.message == CONNECT_FIRST_MESSAGE) { @@ -906,6 +930,9 @@ class PcMouseControlViewModel( switcherConnectedDisplayName = controller.currentControlDeviceName() ?: state.displayName, isDragging = false, activeModifiers = emptySet(), + pointerSpeedSupported = false, + pointerSpeedSetSupported = false, + pointerSpeedPercentLabel = POINTER_SPEED_UNAVAILABLE_MESSAGE, supportsTextStreamInput = false, supportsModifierToggles = false, message = RECONNECTING_MESSAGE, @@ -922,12 +949,16 @@ 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, + pointerSpeedSetSupported = false, + pointerSpeedScalePercent = 100.0, + pointerSpeedPercentLabel = POINTER_SPEED_UNAVAILABLE_MESSAGE, isDragging = false, activeModifiers = emptySet(), isBusy = false, @@ -945,12 +976,16 @@ 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, + pointerSpeedSetSupported = false, + pointerSpeedScalePercent = 100.0, + pointerSpeedPercentLabel = POINTER_SPEED_UNAVAILABLE_MESSAGE, isDragging = false, activeModifiers = emptySet(), isBusy = false, @@ -1033,12 +1068,16 @@ 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, + pointerSpeedSetSupported = false, + pointerSpeedScalePercent = 100.0, + pointerSpeedPercentLabel = POINTER_SPEED_UNAVAILABLE_MESSAGE, isDragging = false, activeModifiers = emptySet(), isBusy = false, @@ -1093,17 +1132,30 @@ class PcMouseControlViewModel( } } + 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", scalePercent) + } + 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) { 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_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/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..73e6b9bd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -381,10 +381,10 @@ Typing Window Connected to %1$s. - Movement size - Small - Medium - Large + 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 a72233da..701237d3 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 @@ -146,6 +179,48 @@ 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) + assertFalse(response.profile.capabilities.pointerSpeed.setSupported) + assertEquals(5.0, response.profile.capabilities.pointerSpeed.minScalePercent, 0.0) + assertEquals(130, response.profile.pointerMoveStep()) + } + + @Test + fun parsesPointerProfileSpeedCapability() { + val response = PcProtocol.parseResponse( + validPointerProfileResponse( + 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) + assertEquals(128, response.profile.pointerMoveStep()) + } + + @Test + fun rejectsMalformedPointerProfileSpeedCapability() { + assertEquals( + PcProtocolResponse.Invalid, + PcProtocol.parseResponse( + validPointerProfileResponse( + capabilities = ""","capabilities":{"pointerSpeed":{"supported":true,"scalePercent":300,"minScalePercent":5,"maxScalePercent":225,"stepPercent":5,"baseMoveDelta":128,"effectiveMoveDelta":160}}""" + ) + ) + ) + assertEquals( + PcProtocolResponse.Invalid, + PcProtocol.parseResponse( + validPointerProfileResponse( + capabilities = ""","capabilities":{"pointerSpeed":{"supported":true,"setSupported":"yes","scalePercent":125,"minScalePercent":5,"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/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 d621f2d0..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 @@ -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,177 @@ 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, + setSupported = true, + scalePercent = 125.0, + baseMoveDelta = 128, + effectiveMoveDelta = 160 + ) + ) + ) + ) + val viewModel = viewModel(controller) + + advanceUntilIdle() + + assertEquals(128, viewModel.uiState.value.movementStep) + assertEquals(true, viewModel.uiState.value.pointerSpeedSupported) + 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 = 5.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(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) + } + + @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 = 5.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 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(130, viewModel.uiState.value.movementStep) + 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())) + 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(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 @@ -393,13 +559,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 set pointer speed.", + viewModel.uiState.value.pointerSpeedPercentLabel + ) } @Test @@ -411,18 +581,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 +1958,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 +2180,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 {