From 4f5ad32957c214f72caec2ac91def9e6b590480d Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Thu, 27 Aug 2026 14:52:12 -0400 Subject: [PATCH 01/21] test: cover coordination and persistence behavior --- .../android/service/ConnectionCoordinator.kt | 151 +++++++++ .../android/service/ConnectionService.kt | 236 ++++++------- .../android/storage/AppPreferences.kt | 312 ++++++++++-------- .../service/ConnectionCoordinatorTest.kt | 142 ++++++++ .../android/storage/AppPreferencesTest.kt | 169 +++++++++- 5 files changed, 760 insertions(+), 250 deletions(-) create mode 100644 app/src/main/java/com/inputleaf/android/service/ConnectionCoordinator.kt create mode 100644 app/src/test/java/com/inputleaf/android/service/ConnectionCoordinatorTest.kt diff --git a/app/src/main/java/com/inputleaf/android/service/ConnectionCoordinator.kt b/app/src/main/java/com/inputleaf/android/service/ConnectionCoordinator.kt new file mode 100644 index 00000000..9291f4c8 --- /dev/null +++ b/app/src/main/java/com/inputleaf/android/service/ConnectionCoordinator.kt @@ -0,0 +1,151 @@ +package com.inputleaf.android.service + +import com.inputleaf.android.model.ConnectionState +import com.inputleaf.android.model.InputLeapEvent +import kotlinx.coroutines.flow.StateFlow + +/** + * Owns connection-generation, retry, keepalive, and input-routing decisions. + * Android-specific effects remain the responsibility of [ConnectionService]. + */ +class ConnectionCoordinator( + private val stateMachine: ConnectionStateMachine = ConnectionStateMachine(), +) { + sealed class Effect { + data class RouteInput(val event: InputLeapEvent) : Effect() + data class HidMouseEnter(val x: Int, val y: Int) : Effect() + object HidMouseLeave : Effect() + object UpdatePointerSpeed : Effect() + object ApplyCursor : Effect() + object AttachInputDevices : Effect() + object ScheduleLeave : Effect() + object SendKeepAlive : Effect() + object HideCursor : Effect() + object RestoreIme : Effect() + object CloseConnection : Effect() + object ScheduleRetry : Effect() + } + + val state: StateFlow = stateMachine.state + + @Volatile + private var generation = 0 + @Volatile + private var userInitiatedDisconnect = false + @Volatile + private var mouseEnabled = true + @Volatile + private var keyboardEnabled = true + + fun isMouseEnabled(): Boolean = mouseEnabled + + fun isKeyboardEnabled(): Boolean = keyboardEnabled + + /** Delayed Leave transition: the Service fires this once the leave debounce wins. */ + fun onLeave() { + stateMachine.onLeave() + } + + fun beginConnection(): Int { + userInitiatedDisconnect = false + return ++generation + } + + fun isCurrent(connectionGeneration: Int): Boolean = + connectionGeneration == generation + + fun onConnecting(connectionGeneration: Int, serverIp: String): Boolean { + if (!isCurrent(connectionGeneration)) return false + stateMachine.onConnecting(serverIp) + return true + } + + fun onConnected(connectionGeneration: Int, serverIp: String, screenName: String): Boolean { + if (!isCurrent(connectionGeneration)) return false + stateMachine.onHandshaking(serverIp) + stateMachine.onIdle(serverIp, screenName) + return true + } + + fun onConnectionRejected(connectionGeneration: Int) { + if (isCurrent(connectionGeneration)) stateMachine.onDisconnected() + } + + fun onConnectionFailed(connectionGeneration: Int, retry: Boolean): List { + if (!isCurrent(connectionGeneration)) return emptyList() + stateMachine.onDisconnected() + return if (retry && !userInitiatedDisconnect) listOf(Effect.ScheduleRetry) else emptyList() + } + + fun onEvent(connectionGeneration: Int, event: InputLeapEvent): List { + if (!isCurrent(connectionGeneration)) return emptyList() + return when (event) { + is InputLeapEvent.Enter -> { + stateMachine.onActive() + stateMachine.onKeepAlive() + listOf( + Effect.UpdatePointerSpeed, + Effect.HidMouseEnter(event.x, event.y), + Effect.ApplyCursor, + Effect.AttachInputDevices, + ) + } + is InputLeapEvent.Leave -> listOf(Effect.HidMouseLeave, Effect.ScheduleLeave) + is InputLeapEvent.KeepAlive -> { + stateMachine.onKeepAlive() + listOf(Effect.SendKeepAlive) + } + is InputLeapEvent.MouseMoveAbs, + is InputLeapEvent.MouseMoveRel, + is InputLeapEvent.MouseDown, + is InputLeapEvent.MouseUp, + is InputLeapEvent.MouseWheel, + -> routeIfEnabled(event, mouseEnabled) + is InputLeapEvent.KeyDown, + is InputLeapEvent.KeyUp, + is InputLeapEvent.KeyRepeat, + -> routeIfEnabled(event, keyboardEnabled) + is InputLeapEvent.Unhandled -> { + if (event.tag == "__DISCONNECTED__") onUnexpectedDisconnect(connectionGeneration) + else emptyList() + } + else -> route(event) + } + } + + fun onKeepAliveMiss(connectionGeneration: Int): List { + if (!isCurrent(connectionGeneration)) return emptyList() + if (!stateMachine.onKeepAliveMiss()) return emptyList() + stateMachine.onDisconnected() + return listOf(Effect.CloseConnection, Effect.HideCursor, Effect.RestoreIme) + } + + fun onUnexpectedDisconnect(connectionGeneration: Int): List { + if (!isCurrent(connectionGeneration) || userInitiatedDisconnect) return emptyList() + stateMachine.onDisconnected() + return listOf(Effect.HideCursor, Effect.RestoreIme, Effect.ScheduleRetry) + } + + fun onUserDisconnect() { + userInitiatedDisconnect = true + generation++ + stateMachine.onDisconnected() + } + + fun setMouseEnabled(enabled: Boolean): List { + mouseEnabled = enabled + return if (enabled) emptyList() else listOf(Effect.HideCursor) + } + + fun setKeyboardEnabled(enabled: Boolean) { + keyboardEnabled = enabled + } + + private fun routeIfEnabled(event: InputLeapEvent, enabled: Boolean): List = + if (enabled) route(event) else emptyList() + + private fun route(event: InputLeapEvent): List { + stateMachine.onKeepAlive() + return listOf(Effect.RouteInput(event)) + } +} diff --git a/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt b/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt index 121fc057..ad36fb88 100644 --- a/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt +++ b/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt @@ -49,7 +49,7 @@ private const val HID_MOUSE_IDLE_DETACH_MS = 30_000L class ConnectionService : Service() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - private val stateMachine = ConnectionStateMachine() + private val coordinator = ConnectionCoordinator() private var connection: InputLeapConnection? = null private var injector: com.inputleaf.android.inject.InputInjector? = null private var keepAliveJob: Job? = null @@ -57,12 +57,8 @@ class ConnectionService : Service() { private var eventLoopJob: Job? = null private var retryJob: Job? = null private var retryAttempt = 0 - private var connectGeneration = 0 private var infoAckPending = false - private var userInitiatedDisconnect = false private var cursorOverlayEnabled = false - private var mouseEnabled = true - private var keyboardEnabled = true private var previousImeId: String? = null private var previousImeLabel: String? = null private var isUsingAccessibilityIme = false @@ -78,6 +74,7 @@ class ConnectionService : Service() { private var shizukuRecoveryJob: Job? = null private var leaveDebounceJob: Job? = null private var hidMouseIdleJob: Job? = null + private var userInitiatedDisconnect = false private val hidKeyboardGate = HidAttachmentController() private val hidMouseGate = HidAttachmentController() @Volatile private var pointerOnScreen = false @@ -103,7 +100,7 @@ class ConnectionService : Service() { handleShizukuDied() } - val state: StateFlow get() = stateMachine.state + val state: StateFlow get() = coordinator.state inner class LocalBinder : Binder() { fun getService() = this@ConnectionService } override fun onBind(intent: Intent): IBinder = LocalBinder() @@ -136,19 +133,19 @@ class ConnectionService : Service() { } scope.launch { - mouseEnabled = prefs.mouseEnabled.first() + coordinator.setMouseEnabled(prefs.mouseEnabled.first()) prefs.mouseEnabled.collect { enabled -> - mouseEnabled = enabled + applyEffects(coordinator.setMouseEnabled(enabled)) applyCursorOverlay() - setHidMouseAttached(enabled && stateMachine.state.value is ConnectionState.Active) + setHidMouseAttached(enabled && coordinator.state.value is ConnectionState.Active) } } scope.launch { - keyboardEnabled = prefs.keyboardEnabled.first() + coordinator.setKeyboardEnabled(prefs.keyboardEnabled.first()) prefs.keyboardEnabled.collect { enabled -> - keyboardEnabled = enabled - setHidKeyboardAttached(enabled && stateMachine.state.value is ConnectionState.Active) + coordinator.setKeyboardEnabled(enabled) + setHidKeyboardAttached(enabled && coordinator.state.value is ConnectionState.Active) } } @@ -179,7 +176,7 @@ class ConnectionService : Service() { } private fun observeState() = scope.launch { - stateMachine.state.collect { state -> + coordinator.state.collect { state -> val notif = NotificationHelper.build(this@ConnectionService, state) getSystemService(android.app.NotificationManager::class.java) .notify(NOTIF_ID, notif) @@ -191,7 +188,7 @@ class ConnectionService : Service() { var onConnectionFailed: ((reason: ConnectResult.FailureReason, detail: String?) -> Unit)? = null fun connect(serverIp: String, screenName: String, force: Boolean = false) { - val currentState = stateMachine.state.value + val currentState = coordinator.state.value if (!force) { if (currentState is ConnectionState.Connecting && currentState.serverIp == serverIp) return if (currentState is ConnectionState.Handshaking && currentState.serverIp == serverIp) return @@ -203,7 +200,7 @@ class ConnectionService : Service() { infoAckPending = false activeServerIp = serverIp activeScreenName = screenName - val generation = ++connectGeneration + val generation = coordinator.beginConnection() cancelPendingJobs(keepConnection = false) connection?.close() connection = null @@ -221,11 +218,11 @@ class ConnectionService : Service() { } private suspend fun performConnect(serverIp: String, screenName: String, generation: Int) { - if (generation != connectGeneration) return + if (!coordinator.isCurrent(generation)) return var activePolicy = ConnectionTransportPolicy.AUTO try { - startForeground(NOTIF_ID, NotificationHelper.build(this@ConnectionService, stateMachine.state.value)) - stateMachine.onConnecting(serverIp) + startForeground(NOTIF_ID, NotificationHelper.build(this@ConnectionService, coordinator.state.value)) + coordinator.onConnecting(generation, serverIp) val storedFp = prefs.fingerprintFor(serverIp).first() activePolicy = prefs.connectionTransportPolicy.first() @@ -285,7 +282,7 @@ class ConnectionService : Service() { } finally { clientCertificate?.clear() } - if (generation != connectGeneration) { + if (!coordinator.isCurrent(generation)) { conn.close() return } @@ -295,8 +292,7 @@ class ConnectionService : Service() { retryAttempt = 0 connection = conn prefs.saveTransport(serverIp, result.transport.name.lowercase()) - stateMachine.onHandshaking(serverIp) - stateMachine.onIdle(serverIp, screenName) + coordinator.onConnected(generation, serverIp, screenName) conn.clearHandshakeTimeout() startEventLoop(conn, serverIp, screenName, generation) startKeepAliveMonitor(conn, generation) @@ -304,7 +300,7 @@ class ConnectionService : Service() { } is ConnectResult.RejectedByUser -> { conn.close() - stateMachine.onDisconnected() + coordinator.onConnectionRejected(generation) onConnectionRejected?.invoke() if (shouldClearActiveSession(ConnectAttemptOutcome.Rejected)) { clearActiveSession() @@ -312,11 +308,12 @@ class ConnectionService : Service() { } is ConnectResult.Failed -> { conn.close() - stateMachine.onDisconnected() - if (activePolicy.shouldRetry(result.reason)) { - prefs.clearTransport(serverIp) + val retry = activePolicy.shouldRetry(result.reason) + if (retry) prefs.clearTransport(serverIp) + val effects = coordinator.onConnectionFailed(generation, retry) + if (ConnectionCoordinator.Effect.ScheduleRetry in effects) { scheduleRetry(serverIp, screenName, generation) - } else { + } else if (!retry) { onConnectionFailed?.invoke(result.reason, result.detail) if (shouldClearActiveSession(ConnectAttemptOutcome.TerminalFailure)) { clearActiveSession() @@ -327,12 +324,13 @@ class ConnectionService : Service() { } catch (e: CancellationException) { throw e } catch (e: Exception) { - if (generation != connectGeneration) return + if (!coordinator.isCurrent(generation)) return Log.w(TAG, "Connection to $serverIp failed: ${e.javaClass.simpleName}: ${e.message}", e) - stateMachine.onDisconnected() - if (activePolicy.shouldRetry(ConnectResult.FailureReason.NETWORK)) { + val retry = activePolicy.shouldRetry(ConnectResult.FailureReason.NETWORK) + val effects = coordinator.onConnectionFailed(generation, retry) + if (ConnectionCoordinator.Effect.ScheduleRetry in effects) { scheduleRetry(serverIp, screenName, generation) - } else { + } else if (!retry) { onConnectionFailed?.invoke(ConnectResult.FailureReason.NETWORK, e.message) if (shouldClearActiveSession(ConnectAttemptOutcome.TerminalFailure)) { clearActiveSession() @@ -350,37 +348,17 @@ class ConnectionService : Service() { eventLoopJob?.cancel() eventLoopJob = scope.launch(Dispatchers.IO) { conn.events.collect { event -> - if (generation != connectGeneration) return@collect + if (!coordinator.isCurrent(generation)) return@collect + if (event is InputLeapEvent.Enter) { + Log.i(TAG, "Enter ${event.x},${event.y}") + } when (event) { - is InputLeapEvent.Enter -> { - // Also cancels a pending HID-mouse idle detach: the mouse is - // usually still registered from the last visit, so this Enter is - // an ordinary delta from a position we still know. - cancelLeaveDebounce() - pointerOnScreen = true - Log.i(TAG, "Enter ${event.x},${event.y}") - stateMachine.onActive() - stateMachine.onKeepAlive() - injector?.updatePointerSpeed(readPointerSpeed()) - injector?.onHidMouseEnter(event.x, event.y) - applyCursorOverlay() - setHidKeyboardAttached(keyboardEnabled) - setHidMouseAttached(mouseEnabled) - } - is InputLeapEvent.Leave -> { - injector?.onHidMouseLeave() - scheduleLeave(generation) - } - is InputLeapEvent.KeepAlive -> { - stateMachine.onKeepAlive() - conn.sendKeepAlive() - } is InputLeapEvent.InfoAck -> { infoAckPending = false } is InputLeapEvent.QueryInfo -> { connection?.let { - if (generation == connectGeneration) { + if (coordinator.isCurrent(generation)) { it.sendDataInfo( screenWidth, screenHeight, @@ -390,54 +368,85 @@ class ConnectionService : Service() { } } } - is InputLeapEvent.MouseMoveAbs -> { - if (!mouseEnabled) return@collect - if (infoAckPending) return@collect - stateMachine.onKeepAlive() - currentMouseX = event.x.toFloat() - currentMouseY = event.y.toFloat() - updateCursorPosition(currentMouseX, currentMouseY) - dispatchInput(event) - } - is InputLeapEvent.MouseMoveRel -> { - if (!mouseEnabled) return@collect + is InputLeapEvent.MouseMoveAbs, is InputLeapEvent.MouseMoveRel -> { if (infoAckPending) return@collect - stateMachine.onKeepAlive() - currentMouseX = (currentMouseX + event.dx).coerceIn(0f, screenWidth.toFloat()) - currentMouseY = (currentMouseY + event.dy).coerceIn(0f, screenHeight.toFloat()) - updateCursorPosition(currentMouseX, currentMouseY) - dispatchInput(event) - } - is InputLeapEvent.MouseDown, is InputLeapEvent.MouseUp, is InputLeapEvent.MouseWheel -> { - if (!mouseEnabled) return@collect - stateMachine.onKeepAlive() - dispatchInput(event) - } - is InputLeapEvent.KeyDown, is InputLeapEvent.KeyUp, is InputLeapEvent.KeyRepeat -> { - if (!keyboardEnabled) return@collect - stateMachine.onKeepAlive() - dispatchInput(event) + applyEffects(coordinator.onEvent(generation, event), conn, ip, screenName, generation) } - is InputLeapEvent.Unhandled -> if (event.tag == "__DISCONNECTED__") { - if (generation != connectGeneration || userInitiatedDisconnect) return@collect - cancelLeaveDebounce() - pointerOnScreen = false - stateMachine.onDisconnected() - applyCursorOverlay() - setHidKeyboardAttached(false) - setHidMouseAttached(false) - restorePreviousIme() + else -> applyEffects(coordinator.onEvent(generation, event), conn, ip, screenName, generation) + } + } + } + } + + private fun applyEffects( + effects: List, + conn: InputLeapConnection? = connection, + ip: String? = null, + screenName: String? = null, + generation: Int? = null, + ) { + effects.forEach { effect -> + when (effect) { + is ConnectionCoordinator.Effect.RouteInput -> routeInput(effect.event) + ConnectionCoordinator.Effect.SendKeepAlive -> conn?.sendKeepAlive() + ConnectionCoordinator.Effect.UpdatePointerSpeed -> injector?.updatePointerSpeed(readPointerSpeed()) + is ConnectionCoordinator.Effect.HidMouseEnter -> { + // An Enter also cancels a pending HID-mouse idle detach: the mouse + // is usually still registered from the last visit, so this is an + // ordinary delta from a position we still know. + cancelLeaveDebounce() + pointerOnScreen = true + injector?.onHidMouseEnter(effect.x, effect.y) + } + ConnectionCoordinator.Effect.HidMouseLeave -> injector?.onHidMouseLeave() + ConnectionCoordinator.Effect.ApplyCursor -> applyCursorOverlay() + ConnectionCoordinator.Effect.AttachInputDevices -> { + setHidKeyboardAttached(coordinator.isKeyboardEnabled()) + setHidMouseAttached(coordinator.isMouseEnabled()) + } + ConnectionCoordinator.Effect.ScheduleLeave -> { + if (generation != null) scheduleLeave(generation) + } + ConnectionCoordinator.Effect.HideCursor -> hideCursorOverlay() + ConnectionCoordinator.Effect.RestoreIme -> restorePreviousIme() + ConnectionCoordinator.Effect.CloseConnection -> { + conn?.close() + cancelLeaveDebounce() + pointerOnScreen = false + setHidKeyboardAttached(false) + setHidMouseAttached(false) + } + ConnectionCoordinator.Effect.ScheduleRetry -> { + cancelLeaveDebounce() + pointerOnScreen = false + applyCursorOverlay() + setHidKeyboardAttached(false) + setHidMouseAttached(false) + if (ip != null && screenName != null && generation != null) { scheduleRetry(ip, screenName, generation) } - else -> { - stateMachine.onKeepAlive() - dispatchInput(event) - } } } } } + private fun routeInput(event: InputLeapEvent) { + when (event) { + is InputLeapEvent.MouseMoveAbs -> { + currentMouseX = event.x.toFloat() + currentMouseY = event.y.toFloat() + updateCursorPosition(currentMouseX, currentMouseY) + } + is InputLeapEvent.MouseMoveRel -> { + currentMouseX = (currentMouseX + event.dx).coerceIn(0f, screenWidth.toFloat()) + currentMouseY = (currentMouseY + event.dy).coerceIn(0f, screenHeight.toFloat()) + updateCursorPosition(currentMouseX, currentMouseY) + } + else -> Unit + } + dispatchInput(event) + } + fun setCursorOverlayEnabled(enabled: Boolean) { cursorOverlayEnabled = enabled applyCursorOverlay() @@ -448,7 +457,7 @@ class ConnectionService : Service() { val show = CursorOverlayPolicy.shouldShowOverlay( cursorSettingEnabled = cursorOverlayEnabled, onScreen = pointerOnScreen, - mouseEnabled = mouseEnabled, + mouseEnabled = coordinator.isMouseEnabled(), native = inj?.nativePointerState() ?: NativePointerState.NONE, expectsNativePointer = inj?.expectsNativePointer() == true, ) @@ -497,10 +506,10 @@ class ConnectionService : Service() { leaveDebounceJob?.cancel() leaveDebounceJob = scope.launch { delay(LEAVE_DEBOUNCE_MS) - if (generation != connectGeneration) return@launch + if (!coordinator.isCurrent(generation)) return@launch Log.i(TAG, "Leave") pointerOnScreen = false - stateMachine.onLeave() + coordinator.onLeave() applyCursorOverlay() // The keyboard must go: while it is registered Android believes a physical // keyboard is attached and keeps the soft keyboard suppressed. @@ -527,7 +536,7 @@ class ConnectionService : Service() { hidMouseIdleJob?.cancel() hidMouseIdleJob = scope.launch { delay(HID_MOUSE_IDLE_DETACH_MS) - if (generation != connectGeneration || pointerOnScreen) return@launch + if (!coordinator.isCurrent(generation) || pointerOnScreen) return@launch Log.i(TAG, "HID mouse idle ${HID_MOUSE_IDLE_DETACH_MS}ms; detaching") setHidMouseAttached(false) hidMouseIdleJob = null @@ -584,8 +593,8 @@ class ConnectionService : Service() { } } if (pointerOnScreen) { - setHidKeyboardAttached(keyboardEnabled) - setHidMouseAttached(mouseEnabled) + setHidKeyboardAttached(coordinator.isKeyboardEnabled()) + setHidMouseAttached(coordinator.isMouseEnabled()) } Log.i(TAG, "Input injector set to: ${injector.name}") } @@ -599,19 +608,13 @@ class ConnectionService : Service() { private fun startKeepAliveMonitor(conn: InputLeapConnection, generation: Int) { keepAliveJob?.cancel() keepAliveJob = scope.launch { - while (generation == connectGeneration) { + while (coordinator.isCurrent(generation)) { delay(KEEPALIVE_POLL_MS) - if (generation != connectGeneration) break - if (stateMachine.onKeepAliveMiss()) { + if (!coordinator.isCurrent(generation)) break + val effects = coordinator.onKeepAliveMiss(generation) + if (ConnectionCoordinator.Effect.CloseConnection in effects) { Log.w(TAG, "Keep-alive timeout — disconnecting") - conn.close() - cancelLeaveDebounce() - pointerOnScreen = false - stateMachine.onDisconnected() - applyCursorOverlay() - setHidKeyboardAttached(false) - setHidMouseAttached(false) - restorePreviousIme() + applyEffects(effects, conn) break } } @@ -619,13 +622,13 @@ class ConnectionService : Service() { } private fun scheduleRetry(ip: String, screenName: String, generation: Int) { - if (userInitiatedDisconnect || generation != connectGeneration) return + if (!coordinator.isCurrent(generation)) return retryJob?.cancel() val delayMs = RetryDelayCalculator.getDelay(retryAttempt) retryAttempt++ retryJob = scope.launch { delay(delayMs) - if (userInitiatedDisconnect || generation != connectGeneration) return@launch + if (!coordinator.isCurrent(generation)) return@launch connect(ip, screenName) } } @@ -657,7 +660,7 @@ class ConnectionService : Service() { clearActiveSession() shizukuRecoveryJob?.cancel() shizukuRecoveryJob = null - connectGeneration++ + coordinator.onUserDisconnect() cancelPendingJobs(keepConnection = false) pointerOnScreen = false setHidKeyboardAttached(false) @@ -667,7 +670,6 @@ class ConnectionService : Service() { injector = null applyCursorOverlay() restorePreviousIme() - stateMachine.onDisconnected() stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } @@ -689,7 +691,7 @@ class ConnectionService : Service() { currentMouseX = currentMouseX.coerceIn(0f, w.toFloat()) currentMouseY = currentMouseY.coerceIn(0f, h.toFloat()) injector?.updateScreenSize(w, h) - val connected = connection != null && stateMachine.state.value.let { + val connected = connection != null && coordinator.state.value.let { it is ConnectionState.Idle || it is ConnectionState.Active } if (connected) { @@ -823,7 +825,7 @@ class ConnectionService : Service() { } shizukuRecoveryJob?.cancel() shizukuRecoveryJob = null - connectGeneration++ + coordinator.onUserDisconnect() cancelPendingJobs(keepConnection = false) scope.cancel() injector?.setOnNativePointerStateChanged(null) diff --git a/app/src/main/java/com/inputleaf/android/storage/AppPreferences.kt b/app/src/main/java/com/inputleaf/android/storage/AppPreferences.kt index 60a5ff81..871a6f67 100644 --- a/app/src/main/java/com/inputleaf/android/storage/AppPreferences.kt +++ b/app/src/main/java/com/inputleaf/android/storage/AppPreferences.kt @@ -2,31 +2,48 @@ package com.inputleaf.android.storage import android.content.Context import android.os.Build -import androidx.datastore.preferences.core.* +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.inputleaf.android.network.ConnectionTransportPolicy +import com.inputleaf.android.network.TlsFingerprintManager +import java.nio.charset.StandardCharsets +import java.util.Base64 import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map // internal so instrumented tests can reset app state through the app's own singleton internal val Context.dataStore by preferencesDataStore("inputleaf_prefs") -class AppPreferences(private val context: Context) { +class AppPreferences private constructor( + private val dataStore: DataStore, + private val defaultScreenName: () -> String, +) { + + constructor(context: Context) : this(context.dataStore, { getDefaultScreenName() }) + + constructor( + dataStore: DataStore, + defaultDeviceName: String, + ) : this(dataStore, { getDefaultScreenName(defaultDeviceName) }) companion object { private val KEY_LEAF_ONBOARDING_DONE = booleanPreferencesKey("leaf_onboarding_complete") - private val KEY_LAST_SERVER_IP = stringPreferencesKey("last_server_ip") - private val KEY_SCREEN_NAME = stringPreferencesKey("screen_name") - private val KEY_AUTO_CONNECT = booleanPreferencesKey("auto_connect") - private val KEY_SHOW_CURSOR = booleanPreferencesKey("show_cursor") - private val KEY_THEME_MODE = stringPreferencesKey("theme_mode") - private val KEY_ONBOARDING_DONE = booleanPreferencesKey("onboarding_complete") - private val KEY_MOUSE_ENABLED = booleanPreferencesKey("mouse_enabled") + private val KEY_LAST_SERVER_IP = stringPreferencesKey("last_server_ip") + private val KEY_SCREEN_NAME = stringPreferencesKey("screen_name") + private val KEY_AUTO_CONNECT = booleanPreferencesKey("auto_connect") + private val KEY_SHOW_CURSOR = booleanPreferencesKey("show_cursor") + private val KEY_THEME_MODE = stringPreferencesKey("theme_mode") + private val KEY_ONBOARDING_DONE = booleanPreferencesKey("onboarding_complete") + private val KEY_MOUSE_ENABLED = booleanPreferencesKey("mouse_enabled") private val KEY_KEYBOARD_ENABLED = booleanPreferencesKey("keyboard_enabled") private val KEY_FAVORITE_SERVERS = stringPreferencesKey("favorite_servers") - // Fingerprints stored as "ip:fingerprint" joined by newline - private val KEY_FINGERPRINTS = stringPreferencesKey("tls_fingerprints") - private val KEY_TRANSPORT_MODES = stringPreferencesKey("server_transport_modes") + private val KEY_FINGERPRINTS = stringPreferencesKey("tls_fingerprints") + private val KEY_TRANSPORT_MODES = stringPreferencesKey("server_transport_modes") private val KEY_CONNECTION_TRANSPORT_POLICY = stringPreferencesKey("connection_transport_policy") private val KEY_LEGACY_TLS_ENABLED = booleanPreferencesKey("tls_enabled") @@ -34,180 +51,213 @@ class AppPreferences(private val context: Context) { private val KEY_CURSOR_STYLE = stringPreferencesKey("cursor_style") private val KEY_LAST_SEEN_VERSION_CODE = intPreferencesKey("last_seen_version_code") - /** - * Get a sanitized device name suitable for use as screen name. - * Removes trailing spaces and special characters that might cause issues. - */ - fun getDefaultScreenName(model: String? = Build.MODEL): String { - val deviceName = (model ?: "android-phone").trim() - // Replace spaces with hyphens and remove any characters that aren't alphanumeric or hyphen - return deviceName + /** Returns a protocol-safe screen name without depending on Android in tests. */ + fun getDefaultScreenName(deviceName: String? = Build.MODEL): String = + deviceName + .orEmpty() + .trim() .replace(Regex("\\s+"), "-") .replace(Regex("[^a-zA-Z0-9\\-]"), "") .lowercase() .ifEmpty { "android-phone" } - } } - val lastServerIp: Flow = - context.dataStore.data.map { it[KEY_LAST_SERVER_IP] } + val lastServerIp: Flow = dataStore.data.map { it[KEY_LAST_SERVER_IP] } - val screenName: Flow = - context.dataStore.data.map { (it[KEY_SCREEN_NAME] ?: getDefaultScreenName()).trim() } + val screenName: Flow = dataStore.data.map { prefs -> + prefs[KEY_SCREEN_NAME]?.trim()?.takeIf(String::isNotEmpty) ?: defaultScreenName() + } - val autoConnect: Flow = - context.dataStore.data.map { it[KEY_AUTO_CONNECT] ?: true } - - val showCursor: Flow = - context.dataStore.data.map { it[KEY_SHOW_CURSOR] ?: true } + val autoConnect: Flow = dataStore.data.map { it[KEY_AUTO_CONNECT] ?: true } - val themeMode: Flow = - context.dataStore.data.map { it[KEY_THEME_MODE] ?: "SYSTEM" } + val showCursor: Flow = dataStore.data.map { it[KEY_SHOW_CURSOR] ?: true } - val leafOnboardingComplete: Flow = - context.dataStore.data.map { prefs -> - prefs[KEY_LEAF_ONBOARDING_DONE] - ?: prefs[KEY_ONBOARDING_DONE] - ?: false - } + val themeMode: Flow = dataStore.data.map { it[KEY_THEME_MODE] ?: "SYSTEM" } - val onboardingComplete: Flow = leafOnboardingComplete + val leafOnboardingComplete: Flow = dataStore.data.map { prefs -> + prefs[KEY_LEAF_ONBOARDING_DONE] ?: prefs[KEY_ONBOARDING_DONE] ?: false + } - val mouseEnabled: Flow = - context.dataStore.data.map { it[KEY_MOUSE_ENABLED] ?: true } + val onboardingComplete: Flow = leafOnboardingComplete - val keyboardEnabled: Flow = - context.dataStore.data.map { it[KEY_KEYBOARD_ENABLED] ?: true } + val mouseEnabled: Flow = dataStore.data.map { it[KEY_MOUSE_ENABLED] ?: true } - val inputMethod: Flow = - context.dataStore.data.map { it[KEY_INPUT_METHOD] ?: "auto" } + val keyboardEnabled: Flow = dataStore.data.map { it[KEY_KEYBOARD_ENABLED] ?: true } - val cursorStyle: Flow = - context.dataStore.data.map { it[KEY_CURSOR_STYLE] ?: "default" } + val inputMethod: Flow = dataStore.data.map { it[KEY_INPUT_METHOD] ?: "auto" } - val connectionTransportPolicy: Flow = - context.dataStore.data.map { prefs -> - val storedPolicy = prefs[KEY_CONNECTION_TRANSPORT_POLICY] - if (storedPolicy == null && prefs[KEY_LEGACY_TLS_ENABLED] == true) { - ConnectionTransportPolicy.TLS_ONLY - } else { - ConnectionTransportPolicy.fromStorage(storedPolicy) - } - } + val cursorStyle: Flow = dataStore.data.map { it[KEY_CURSOR_STYLE] ?: "default" } - val favoriteServers: Flow> = - context.dataStore.data.map { prefs -> - prefs[KEY_FAVORITE_SERVERS]?.split("\n")?.filter { it.isNotBlank() }?.toSet() ?: emptySet() + val connectionTransportPolicy: Flow = dataStore.data.map { prefs -> + val storedPolicy = prefs[KEY_CONNECTION_TRANSPORT_POLICY] + if (storedPolicy == null && prefs[KEY_LEGACY_TLS_ENABLED] == true) { + ConnectionTransportPolicy.TLS_ONLY + } else { + ConnectionTransportPolicy.fromStorage(storedPolicy) } - - suspend fun saveLastServer(ip: String) = context.dataStore.edit { - it[KEY_LAST_SERVER_IP] = ip } - suspend fun saveScreenName(name: String) = context.dataStore.edit { - it[KEY_SCREEN_NAME] = name.trim() + val favoriteServers: Flow> = dataStore.data.map { prefs -> + prefs[KEY_FAVORITE_SERVERS] + ?.lineSequence() + ?.map(String::trim) + ?.filter(String::isNotEmpty) + ?.toSet() + ?: emptySet() } - suspend fun saveAutoConnect(enabled: Boolean) = context.dataStore.edit { - it[KEY_AUTO_CONNECT] = enabled - } - - suspend fun saveShowCursor(enabled: Boolean) = context.dataStore.edit { - it[KEY_SHOW_CURSOR] = enabled - } + suspend fun saveLastServer(ip: String) = dataStore.edit { it[KEY_LAST_SERVER_IP] = ip } - suspend fun saveThemeMode(mode: String) = context.dataStore.edit { - it[KEY_THEME_MODE] = mode - } + suspend fun saveScreenName(name: String) = dataStore.edit { it[KEY_SCREEN_NAME] = name.trim() } + + suspend fun saveAutoConnect(enabled: Boolean) = dataStore.edit { it[KEY_AUTO_CONNECT] = enabled } - suspend fun saveLeafOnboardingComplete() = context.dataStore.edit { + suspend fun saveShowCursor(enabled: Boolean) = dataStore.edit { it[KEY_SHOW_CURSOR] = enabled } + + suspend fun saveThemeMode(mode: String) = dataStore.edit { it[KEY_THEME_MODE] = mode } + + suspend fun saveLeafOnboardingComplete() = dataStore.edit { it[KEY_LEAF_ONBOARDING_DONE] = true it[KEY_ONBOARDING_DONE] = true } suspend fun saveOnboardingComplete() = saveLeafOnboardingComplete() - suspend fun saveMouseEnabled(enabled: Boolean) = context.dataStore.edit { - it[KEY_MOUSE_ENABLED] = enabled - } + suspend fun saveMouseEnabled(enabled: Boolean) = dataStore.edit { it[KEY_MOUSE_ENABLED] = enabled } - suspend fun saveKeyboardEnabled(enabled: Boolean) = context.dataStore.edit { + suspend fun saveKeyboardEnabled(enabled: Boolean) = dataStore.edit { it[KEY_KEYBOARD_ENABLED] = enabled } - suspend fun saveInputMethod(method: String) = context.dataStore.edit { - it[KEY_INPUT_METHOD] = method + suspend fun saveInputMethod(method: String) = dataStore.edit { it[KEY_INPUT_METHOD] = method } + + suspend fun saveCursorStyle(style: String) = dataStore.edit { it[KEY_CURSOR_STYLE] = style } + + suspend fun saveConnectionTransportPolicy(policy: ConnectionTransportPolicy) = dataStore.edit { + it[KEY_CONNECTION_TRANSPORT_POLICY] = policy.storageValue + it.remove(KEY_LEGACY_TLS_ENABLED) + } + + suspend fun toggleFavoriteServer(ip: String) = dataStore.edit { prefs -> + val server = ip.trim() + if (server.isEmpty()) return@edit + val current = prefs[KEY_FAVORITE_SERVERS] + ?.lineSequence() + ?.map(String::trim) + ?.filter(String::isNotEmpty) + ?.toMutableSet() + ?: mutableSetOf() + if (!current.add(server)) current.remove(server) + prefs[KEY_FAVORITE_SERVERS] = current.joinToString("\n") } - suspend fun saveCursorStyle(style: String) = context.dataStore.edit { - it[KEY_CURSOR_STYLE] = style + fun fingerprintFor(ip: String): Flow = dataStore.data.map { prefs -> + decodeFingerprints(prefs[KEY_FINGERPRINTS])[ip.trim()] } - suspend fun saveConnectionTransportPolicy(policy: ConnectionTransportPolicy) = - context.dataStore.edit { - it[KEY_CONNECTION_TRANSPORT_POLICY] = policy.storageValue - it.remove(KEY_LEGACY_TLS_ENABLED) - } + suspend fun saveFingerprint(ip: String, fingerprint: String) = dataStore.edit { prefs -> + val records = decodeFingerprints(prefs[KEY_FINGERPRINTS]).toMutableMap() + records[ip.trim()] = TlsFingerprintManager.normalizeFingerprint(fingerprint) + prefs[KEY_FINGERPRINTS] = encodeRecords(records) + } - suspend fun toggleFavoriteServer(ip: String) = context.dataStore.edit { prefs -> - val current = prefs[KEY_FAVORITE_SERVERS]?.split("\n")?.filter { it.isNotBlank() }?.toMutableSet() ?: mutableSetOf() - if (current.contains(ip)) current.remove(ip) else current.add(ip) - prefs[KEY_FAVORITE_SERVERS] = current.joinToString("\n") + suspend fun removeFingerprint(ip: String) = dataStore.edit { prefs -> + val records = decodeFingerprints(prefs[KEY_FINGERPRINTS]).toMutableMap() + records.remove(ip.trim()) + prefs[KEY_FINGERPRINTS] = encodeRecords(records) } - fun fingerprintFor(ip: String): Flow = - context.dataStore.data.map { prefs -> - prefs[KEY_FINGERPRINTS]?.lines() - ?.firstOrNull { it.startsWith("$ip:") } - ?.substringAfter(":") - } + fun allFingerprints(): Flow> = dataStore.data.map { prefs -> + decodeFingerprints(prefs[KEY_FINGERPRINTS]) + } - suspend fun saveFingerprint(ip: String, fingerprint: String) = - context.dataStore.edit { prefs -> - val lines = prefs[KEY_FINGERPRINTS]?.lines()?.toMutableList() ?: mutableListOf() - lines.removeAll { it.startsWith("$ip:") } - lines.add("$ip:$fingerprint") - prefs[KEY_FINGERPRINTS] = lines.joinToString("\n") - } + fun transportFor(ip: String): Flow = dataStore.data.map { prefs -> + decodeTransports(prefs[KEY_TRANSPORT_MODES])[ip.trim()] + } - suspend fun removeFingerprint(ip: String) = context.dataStore.edit { prefs -> - val lines = prefs[KEY_FINGERPRINTS]?.lines()?.toMutableList() ?: return@edit - lines.removeAll { it.startsWith("$ip:") } - prefs[KEY_FINGERPRINTS] = lines.joinToString("\n") + suspend fun saveTransport(ip: String, mode: String) = dataStore.edit { prefs -> + val records = decodeTransports(prefs[KEY_TRANSPORT_MODES]).toMutableMap() + val normalizedMode = normalizeTransport(mode) + ?: throw IllegalArgumentException("Unsupported transport mode: $mode") + records[ip.trim()] = normalizedMode + prefs[KEY_TRANSPORT_MODES] = encodeRecords(records) } - fun allFingerprints(): Flow> = - context.dataStore.data.map { prefs -> - prefs[KEY_FINGERPRINTS]?.lines() - ?.filter { it.contains(":") } - ?.associate { it.substringBefore(":") to it.substringAfter(":") } - ?: emptyMap() + suspend fun clearTransport(ip: String) = dataStore.edit { prefs -> + val records = decodeTransports(prefs[KEY_TRANSPORT_MODES]).toMutableMap() + records.remove(ip.trim()) + prefs[KEY_TRANSPORT_MODES] = encodeRecords(records) + } + + private fun decodeFingerprints(raw: String?): Map = + decodeRecords(raw, ::normalizeFingerprint, ::splitLegacyFingerprint) + + private fun decodeTransports(raw: String?): Map = + decodeRecords(raw, ::normalizeTransport) { line -> + val separator = line.lastIndexOf(':') + if (separator <= 0) null else line.substring(0, separator) to line.substring(separator + 1) } - fun transportFor(ip: String): Flow = - context.dataStore.data.map { prefs -> - prefs[KEY_TRANSPORT_MODES]?.lines() - ?.firstOrNull { it.startsWith("$ip:") } - ?.substringAfter(":") + private fun splitLegacyFingerprint(line: String): Pair? { + val candidates = line.indices + .filter { line[it] == ':' && it > 0 } + .mapNotNull { separator -> + val server = line.substring(0, separator).trim() + val fingerprint = normalizeFingerprint(line.substring(separator + 1)) + if (server.isEmpty() || fingerprint == null) null else server to fingerprint + } + return candidates.singleOrNull() + } + + private fun normalizeFingerprint(value: String): String? = + runCatching { TlsFingerprintManager.normalizeFingerprint(value) }.getOrNull() + + private fun normalizeTransport(value: String): String? = + value.trim().lowercase().takeIf { it == "tls" || it == "plain" } + + private fun decodeRecords( + raw: String?, + normalizeValue: (String) -> String?, + splitLegacy: (String) -> Pair?, + ): Map { + val records = linkedMapOf() + raw?.lineSequence()?.forEach { rawLine -> + val line = rawLine.trim() + if (line.isEmpty()) return@forEach + val decoded = if (line.startsWith("v2|")) decodeCanonical(line) else splitLegacy(line) + val server = decoded?.first?.trim()?.takeIf(String::isNotEmpty) ?: return@forEach + val value = normalizeValue(decoded.second) ?: return@forEach + records[server] = value } + return records + } - suspend fun saveTransport(ip: String, mode: String) = context.dataStore.edit { prefs -> - val lines = prefs[KEY_TRANSPORT_MODES]?.lines()?.toMutableList() ?: mutableListOf() - lines.removeAll { it.startsWith("$ip:") } - lines.add("$ip:$mode") - prefs[KEY_TRANSPORT_MODES] = lines.joinToString("\n") + private fun decodeCanonical(line: String): Pair? { + val fields = line.split('|') + if (fields.size != 3 || fields[0] != "v2") return null + return runCatching { + decodeField(fields[1]) to decodeField(fields[2]) + }.getOrNull() } - suspend fun clearTransport(ip: String) = context.dataStore.edit { prefs -> - val lines = prefs[KEY_TRANSPORT_MODES]?.lines()?.toMutableList() ?: return@edit - lines.removeAll { it.startsWith("$ip:") } - prefs[KEY_TRANSPORT_MODES] = lines.joinToString("\n") + private fun encodeRecords(records: Map): String = records.entries + .filter { it.key.isNotBlank() && it.value.isNotBlank() } + .joinToString("\n") { (server, value) -> + "v2|${encodeField(server.trim())}|${encodeField(value.trim())}" + } + + private fun encodeField(value: String): String = + Base64.getUrlEncoder().withoutPadding().encodeToString(value.toByteArray(StandardCharsets.UTF_8)) + + private fun decodeField(value: String): String { + require(value.isNotEmpty() && value.matches(Regex("[A-Za-z0-9_-]+"))) + return String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8) } val lastSeenVersionCode: Flow = - context.dataStore.data.map { it[KEY_LAST_SEEN_VERSION_CODE] } + dataStore.data.map { it[KEY_LAST_SEEN_VERSION_CODE] } - suspend fun saveLastSeenVersionCode(versionCode: Int) = context.dataStore.edit { + suspend fun saveLastSeenVersionCode(versionCode: Int) = dataStore.edit { it[KEY_LAST_SEEN_VERSION_CODE] = versionCode } } diff --git a/app/src/test/java/com/inputleaf/android/service/ConnectionCoordinatorTest.kt b/app/src/test/java/com/inputleaf/android/service/ConnectionCoordinatorTest.kt new file mode 100644 index 00000000..62b00a63 --- /dev/null +++ b/app/src/test/java/com/inputleaf/android/service/ConnectionCoordinatorTest.kt @@ -0,0 +1,142 @@ +package com.inputleaf.android.service + +import com.google.common.truth.Truth.assertThat +import com.inputleaf.android.model.ConnectionState +import com.inputleaf.android.model.InputLeapEvent +import org.junit.Test + +class ConnectionCoordinatorTest { + @Test fun `connection transitions are exposed as state`() { + val coordinator = ConnectionCoordinator() + val generation = coordinator.beginConnection() + + assertThat(coordinator.onConnecting(generation, "192.168.1.10")).isTrue() + assertThat(coordinator.state.value).isEqualTo(ConnectionState.Connecting("192.168.1.10")) + + assertThat(coordinator.onConnected(generation, "192.168.1.10", "work-pc")).isTrue() + assertThat(coordinator.state.value) + .isEqualTo(ConnectionState.Idle("192.168.1.10", "work-pc")) + } + + @Test fun `events from an older connection generation are ignored`() { + val coordinator = ConnectionCoordinator() + val staleGeneration = coordinator.beginConnection() + val currentGeneration = coordinator.beginConnection() + coordinator.onConnected(currentGeneration, "192.168.1.10", "work-pc") + + val effects = coordinator.onEvent(staleGeneration, InputLeapEvent.KeyDown(1, 0, 2)) + + assertThat(effects).isEmpty() + assertThat(coordinator.state.value) + .isEqualTo(ConnectionState.Idle("192.168.1.10", "work-pc")) + } + + @Test fun `unexpected disconnect resets state and schedules retry`() { + val coordinator = ConnectionCoordinator() + val generation = coordinator.beginConnection() + coordinator.onConnected(generation, "server", "phone") + + val effects = coordinator.onUnexpectedDisconnect(generation) + + assertThat(coordinator.state.value).isEqualTo(ConnectionState.Disconnected) + assertThat(effects).containsExactly( + ConnectionCoordinator.Effect.HideCursor, + ConnectionCoordinator.Effect.RestoreIme, + ConnectionCoordinator.Effect.ScheduleRetry, + ).inOrder() + } + + @Test fun `user disconnect invalidates connection and suppresses retry`() { + val coordinator = ConnectionCoordinator() + val generation = coordinator.beginConnection() + coordinator.onConnected(generation, "server", "phone") + + coordinator.onUserDisconnect() + + assertThat(coordinator.isCurrent(generation)).isFalse() + assertThat(coordinator.onUnexpectedDisconnect(generation)).isEmpty() + assertThat(coordinator.onConnectionFailed(generation, retry = true)).isEmpty() + assertThat(coordinator.state.value).isEqualTo(ConnectionState.Disconnected) + } + + @Test fun `connection failure only retries when retry is allowed`() { + val coordinator = ConnectionCoordinator() + val firstGeneration = coordinator.beginConnection() + + assertThat(coordinator.onConnectionFailed(firstGeneration, retry = false)).isEmpty() + + val secondGeneration = coordinator.beginConnection() + assertThat(coordinator.onConnectionFailed(secondGeneration, retry = true)) + .containsExactly(ConnectionCoordinator.Effect.ScheduleRetry) + } + + @Test fun `fourth keepalive miss closes connection and resets state`() { + val coordinator = ConnectionCoordinator() + val generation = coordinator.beginConnection() + coordinator.onConnected(generation, "server", "phone") + + repeat(3) { + assertThat(coordinator.onKeepAliveMiss(generation)).isEmpty() + } + val effects = coordinator.onKeepAliveMiss(generation) + + assertThat(effects).containsExactly( + ConnectionCoordinator.Effect.CloseConnection, + ConnectionCoordinator.Effect.HideCursor, + ConnectionCoordinator.Effect.RestoreIme, + ).inOrder() + assertThat(coordinator.state.value).isEqualTo(ConnectionState.Disconnected) + } + + @Test fun `keepalive event resets missed keepalive count`() { + val coordinator = ConnectionCoordinator() + val generation = coordinator.beginConnection() + coordinator.onKeepAliveMiss(generation) + coordinator.onKeepAliveMiss(generation) + + assertThat(coordinator.onEvent(generation, InputLeapEvent.KeepAlive)) + .containsExactly(ConnectionCoordinator.Effect.SendKeepAlive) + assertThat(coordinator.onKeepAliveMiss(generation)).isEmpty() + } + + @Test fun `unhandled event is ignored without resetting missed keepalive count`() { + val coordinator = ConnectionCoordinator() + val generation = coordinator.beginConnection() + repeat(3) { + assertThat(coordinator.onKeepAliveMiss(generation)).isEmpty() + } + + assertThat(coordinator.onEvent(generation, InputLeapEvent.Unhandled("ZZZZ"))).isEmpty() + assertThat(coordinator.onKeepAliveMiss(generation)).containsExactly( + ConnectionCoordinator.Effect.CloseConnection, + ConnectionCoordinator.Effect.HideCursor, + ConnectionCoordinator.Effect.RestoreIme, + ).inOrder() + } + + @Test fun `keyboard routing follows keyboard enablement`() { + val coordinator = ConnectionCoordinator() + val generation = coordinator.beginConnection() + val key = InputLeapEvent.KeyDown(1, 0, 2) + + assertThat(coordinator.onEvent(generation, key)) + .containsExactly(ConnectionCoordinator.Effect.RouteInput(key)) + + coordinator.setKeyboardEnabled(false) + assertThat(coordinator.onEvent(generation, key)).isEmpty() + } + + @Test fun `mouse routing follows mouse enablement`() { + val coordinator = ConnectionCoordinator() + val generation = coordinator.beginConnection() + val move = InputLeapEvent.MouseMoveRel(4, -2) + + assertThat(coordinator.onEvent(generation, move)) + .containsExactly(ConnectionCoordinator.Effect.RouteInput(move)) + + assertThat(coordinator.setMouseEnabled(false)) + .containsExactly(ConnectionCoordinator.Effect.HideCursor) + assertThat(coordinator.onEvent(generation, move)).isEmpty() + assertThat(coordinator.onEvent(generation, InputLeapEvent.Enter(0, 0, 1, 0))).isEmpty() + } +} diff --git a/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt b/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt index 0450d45b..2055fe82 100644 --- a/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt +++ b/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt @@ -1,9 +1,174 @@ -package com.inputleaf.android.storage - +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.PreferenceDataStoreFactory import com.google.common.truth.Truth.assertThat +import com.inputleaf.android.network.ConnectionTransportPolicy +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder class AppPreferencesTest { + @get:Rule val temporaryFolder = TemporaryFolder() + + private lateinit var dataStore: DataStore + private lateinit var dataStoreScope: CoroutineScope + private lateinit var preferences: AppPreferences + + @Before fun setUp() { + dataStoreScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val file = File(temporaryFolder.root, "preferences.preferences_pb") + dataStore = PreferenceDataStoreFactory.create(scope = dataStoreScope) { file } + preferences = AppPreferences(dataStore, " Pixel XL! ") + } + + @After fun tearDown() { + dataStoreScope.cancel() + } + + @Test fun `defaults are deterministic and screen name falls back when blank`() = runBlocking { + assertThat(preferences.lastServerIp.first()).isNull() + assertThat(preferences.screenName.first()).isEqualTo("pixel-xl") + assertThat(preferences.autoConnect.first()).isTrue() + assertThat(preferences.showCursor.first()).isTrue() + assertThat(preferences.mouseEnabled.first()).isTrue() + assertThat(preferences.keyboardEnabled.first()).isTrue() + assertThat(preferences.leafOnboardingComplete.first()).isFalse() + assertThat(preferences.connectionTransportPolicy.first()) + .isEqualTo(ConnectionTransportPolicy.AUTO) + + preferences.saveScreenName(" ") + + assertThat(preferences.screenName.first()).isEqualTo("pixel-xl") + } + + @Test fun `preference updates are persisted`() = runBlocking { + preferences.saveLastServer("192.168.1.10") + preferences.saveScreenName(" desk phone ") + preferences.saveAutoConnect(false) + preferences.saveShowCursor(false) + preferences.saveMouseEnabled(false) + preferences.saveKeyboardEnabled(false) + preferences.saveInputMethod("uhid") + preferences.saveCursorStyle("dot") + preferences.saveConnectionTransportPolicy(ConnectionTransportPolicy.TLS_ONLY) + + assertThat(preferences.lastServerIp.first()).isEqualTo("192.168.1.10") + assertThat(preferences.screenName.first()).isEqualTo("desk phone") + assertThat(preferences.autoConnect.first()).isFalse() + assertThat(preferences.showCursor.first()).isFalse() + assertThat(preferences.mouseEnabled.first()).isFalse() + assertThat(preferences.keyboardEnabled.first()).isFalse() + assertThat(preferences.inputMethod.first()).isEqualTo("uhid") + assertThat(preferences.cursorStyle.first()).isEqualTo("dot") + assertThat(preferences.connectionTransportPolicy.first()) + .isEqualTo(ConnectionTransportPolicy.TLS_ONLY) + } + + @Test fun `favorites are trimmed deduplicated added and removed`() = runBlocking { + dataStore.edit { + it[stringPreferencesKey("favorite_servers")] = " server-a \nserver-a\n\nserver-b" + } + + assertThat(preferences.favoriteServers.first()).containsExactly("server-a", "server-b") + + preferences.toggleFavoriteServer(" server-c ") + assertThat(preferences.favoriteServers.first()) + .containsExactly("server-a", "server-b", "server-c") + + preferences.toggleFavoriteServer("server-a") + assertThat(preferences.favoriteServers.first()).containsExactly("server-b", "server-c") + } + + @Test fun `fingerprints read legacy host and IPv6 records and ignore malformed data`() = runBlocking { + val first = "ab".repeat(32) + val replacement = "CD".repeat(32).chunked(2).joinToString(":") + dataStore.edit { + it[stringPreferencesKey("tls_fingerprints")] = listOf( + "server-a:$first", + "malformed", + "server-a:$replacement", + "2001:db8::1:$first", + "server-b:not-a-fingerprint", + "v2|bad!|bad!", + "", + ).joinToString("\n") + } + + assertThat(preferences.allFingerprints().first()).containsExactly( + "server-a", "cd".repeat(32), + "2001:db8::1", first, + ) + assertThat(preferences.fingerprintFor("2001:db8::1").first()).isEqualTo(first) + } + + @Test fun `fingerprint updates migrate records to canonical format and support removal`() = runBlocking { + val first = "ab".repeat(32) + val second = "cd".repeat(32) + val key = stringPreferencesKey("tls_fingerprints") + dataStore.edit { it[key] = "server-a:$first\ninvalid" } + + preferences.saveFingerprint("2001:db8::2", second.uppercase()) + + val stored = dataStore.data.first()[key] + assertThat(stored).contains("v2|") + assertThat(stored).doesNotContain("server-a") + assertThat(preferences.allFingerprints().first()).containsExactly( + "server-a", first, + "2001:db8::2", second, + ) + + preferences.removeFingerprint("server-a") + assertThat(preferences.allFingerprints().first()).containsExactly("2001:db8::2", second) + } + + @Test fun `transport records migrate replace and remove IPv6 values`() = runBlocking { + val key = stringPreferencesKey("server_transport_modes") + dataStore.edit { + it[key] = "server-a:TLS\n2001:db8::1:plain\nbad:mode\nserver-a:plain" + } + + assertThat(preferences.transportFor("server-a").first()).isEqualTo("plain") + assertThat(preferences.transportFor("2001:db8::1").first()).isEqualTo("plain") + + preferences.saveTransport("2001:db8::1", "TLS") + assertThat(preferences.transportFor("2001:db8::1").first()).isEqualTo("tls") + assertThat(dataStore.data.first()[key]).contains("v2|") + + preferences.clearTransport("2001:db8::1") + assertThat(preferences.transportFor("2001:db8::1").first()).isNull() + assertThat(preferences.transportFor("server-a").first()).isEqualTo("plain") + } + + @Test fun `onboarding reads legacy value and writes both keys`() = runBlocking { + val legacyKey = booleanPreferencesKey("onboarding_complete") + val leafKey = booleanPreferencesKey("leaf_onboarding_complete") + dataStore.edit { it[legacyKey] = true } + + assertThat(preferences.leafOnboardingComplete.first()).isTrue() + + dataStore.edit { + it[legacyKey] = false + it.remove(leafKey) + } + preferences.saveLeafOnboardingComplete() + + val stored = dataStore.data.first() + assertThat(stored[legacyKey]).isTrue() + assertThat(stored[leafKey]).isTrue() + } + @Test fun `getDefaultScreenName sanitizes model name properly`() { assertThat(AppPreferences.getDefaultScreenName("Pixel 7 Pro")).isEqualTo("pixel-7-pro") From 230f9a20cb61cac8eea6ecba90f6c95e3ada1cda Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Thu, 27 Aug 2026 15:04:51 -0400 Subject: [PATCH 02/21] test: return Unit from preference test methods --- .../android/storage/AppPreferencesTest.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt b/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt index 2055fe82..58360c83 100644 --- a/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt +++ b/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt @@ -37,7 +37,7 @@ class AppPreferencesTest { dataStoreScope.cancel() } - @Test fun `defaults are deterministic and screen name falls back when blank`() = runBlocking { + @Test fun `defaults are deterministic and screen name falls back when blank`() = runBlocking { assertThat(preferences.lastServerIp.first()).isNull() assertThat(preferences.screenName.first()).isEqualTo("pixel-xl") assertThat(preferences.autoConnect.first()).isTrue() @@ -53,7 +53,7 @@ class AppPreferencesTest { assertThat(preferences.screenName.first()).isEqualTo("pixel-xl") } - @Test fun `preference updates are persisted`() = runBlocking { + @Test fun `preference updates are persisted`() = runBlocking { preferences.saveLastServer("192.168.1.10") preferences.saveScreenName(" desk phone ") preferences.saveAutoConnect(false) @@ -76,7 +76,7 @@ class AppPreferencesTest { .isEqualTo(ConnectionTransportPolicy.TLS_ONLY) } - @Test fun `favorites are trimmed deduplicated added and removed`() = runBlocking { + @Test fun `favorites are trimmed deduplicated added and removed`() = runBlocking { dataStore.edit { it[stringPreferencesKey("favorite_servers")] = " server-a \nserver-a\n\nserver-b" } @@ -91,7 +91,7 @@ class AppPreferencesTest { assertThat(preferences.favoriteServers.first()).containsExactly("server-b", "server-c") } - @Test fun `fingerprints read legacy host and IPv6 records and ignore malformed data`() = runBlocking { + @Test fun `fingerprints read legacy host and IPv6 records and ignore malformed data`() = runBlocking { val first = "ab".repeat(32) val replacement = "CD".repeat(32).chunked(2).joinToString(":") dataStore.edit { @@ -113,7 +113,7 @@ class AppPreferencesTest { assertThat(preferences.fingerprintFor("2001:db8::1").first()).isEqualTo(first) } - @Test fun `fingerprint updates migrate records to canonical format and support removal`() = runBlocking { + @Test fun `fingerprint updates migrate records to canonical format and support removal`() = runBlocking { val first = "ab".repeat(32) val second = "cd".repeat(32) val key = stringPreferencesKey("tls_fingerprints") @@ -133,7 +133,7 @@ class AppPreferencesTest { assertThat(preferences.allFingerprints().first()).containsExactly("2001:db8::2", second) } - @Test fun `transport records migrate replace and remove IPv6 values`() = runBlocking { + @Test fun `transport records migrate replace and remove IPv6 values`() = runBlocking { val key = stringPreferencesKey("server_transport_modes") dataStore.edit { it[key] = "server-a:TLS\n2001:db8::1:plain\nbad:mode\nserver-a:plain" @@ -151,7 +151,7 @@ class AppPreferencesTest { assertThat(preferences.transportFor("server-a").first()).isEqualTo("plain") } - @Test fun `onboarding reads legacy value and writes both keys`() = runBlocking { + @Test fun `onboarding reads legacy value and writes both keys`() = runBlocking { val legacyKey = booleanPreferencesKey("onboarding_complete") val leafKey = booleanPreferencesKey("leaf_onboarding_complete") dataStore.edit { it[legacyKey] = true } From 377af65020645ef0a082f710720298dabd7181bd Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Tue, 1 Sep 2026 10:53:16 -0400 Subject: [PATCH 03/21] test: cover coordination and persistence branches --- .../service/ConnectionCoordinatorTest.kt | 93 ++++++++++++++++++- .../android/storage/AppPreferencesTest.kt | 64 ++++++++++++- 2 files changed, 151 insertions(+), 6 deletions(-) diff --git a/app/src/test/java/com/inputleaf/android/service/ConnectionCoordinatorTest.kt b/app/src/test/java/com/inputleaf/android/service/ConnectionCoordinatorTest.kt index 62b00a63..c7baca7f 100644 --- a/app/src/test/java/com/inputleaf/android/service/ConnectionCoordinatorTest.kt +++ b/app/src/test/java/com/inputleaf/android/service/ConnectionCoordinatorTest.kt @@ -70,6 +70,14 @@ class ConnectionCoordinatorTest { .containsExactly(ConnectionCoordinator.Effect.ScheduleRetry) } + @Test fun `keepalive misses from an older connection generation are ignored`() { + val coordinator = ConnectionCoordinator() + val staleGeneration = coordinator.beginConnection() + coordinator.beginConnection() + + assertThat(coordinator.onKeepAliveMiss(staleGeneration)).isEmpty() + } + @Test fun `fourth keepalive miss closes connection and resets state`() { val coordinator = ConnectionCoordinator() val generation = coordinator.beginConnection() @@ -114,16 +122,90 @@ class ConnectionCoordinatorTest { ).inOrder() } - @Test fun `keyboard routing follows keyboard enablement`() { + @Test fun `connection lifecycle callbacks reject stale generations and handle rejection`() { val coordinator = ConnectionCoordinator() + val staleGeneration = coordinator.beginConnection() val generation = coordinator.beginConnection() - val key = InputLeapEvent.KeyDown(1, 0, 2) - assertThat(coordinator.onEvent(generation, key)) - .containsExactly(ConnectionCoordinator.Effect.RouteInput(key)) + assertThat(coordinator.onConnecting(staleGeneration, "stale")).isFalse() + assertThat(coordinator.onConnected(staleGeneration, "stale", "phone")).isFalse() + coordinator.onConnectionRejected(staleGeneration) + assertThat(coordinator.state.value).isEqualTo(ConnectionState.Disconnected) + + assertThat(coordinator.onConnecting(generation, "server")).isTrue() + coordinator.onConnectionRejected(generation) + assertThat(coordinator.state.value).isEqualTo(ConnectionState.Disconnected) + } + + @Test fun `enter leave and all mouse events produce their expected effects`() { + val coordinator = ConnectionCoordinator() + val generation = coordinator.beginConnection() + coordinator.onConnected(generation, "server", "phone") + + assertThat(coordinator.onEvent(generation, InputLeapEvent.Enter(1, 2, 3, 0))) + .containsExactly(ConnectionCoordinator.Effect.ShowCursor) + assertThat(coordinator.state.value).isEqualTo(ConnectionState.Active("server", "phone")) + assertThat(coordinator.onEvent(generation, InputLeapEvent.Leave)) + .containsExactly(ConnectionCoordinator.Effect.HideCursor) + assertThat(coordinator.state.value).isEqualTo(ConnectionState.Idle("server", "phone")) + + val mouseEvents = listOf( + InputLeapEvent.MouseMoveAbs(10, 20), + InputLeapEvent.MouseMoveRel(4, -2), + InputLeapEvent.MouseDown(1), + InputLeapEvent.MouseUp(1), + InputLeapEvent.MouseWheel(2, -3), + ) + mouseEvents.forEach { event -> + assertThat(coordinator.onEvent(generation, event)) + .containsExactly(ConnectionCoordinator.Effect.RouteInput(event)) + } + } + + @Test fun `keyboard routing follows keyboard enablement`() { + val coordinator = ConnectionCoordinator() + val generation = coordinator.beginConnection() + val keyEvents = listOf( + InputLeapEvent.KeyDown(1, 0, 2), + InputLeapEvent.KeyUp(1, 0, 2), + InputLeapEvent.KeyRepeat(1, 0, 1, 2), + ) + + keyEvents.forEach { event -> + assertThat(coordinator.onEvent(generation, event)) + .containsExactly(ConnectionCoordinator.Effect.RouteInput(event)) + } coordinator.setKeyboardEnabled(false) - assertThat(coordinator.onEvent(generation, key)).isEmpty() + keyEvents.forEach { event -> assertThat(coordinator.onEvent(generation, event)).isEmpty() } + } + + @Test fun `unhandled disconnect and control events use their coordinator behavior`() { + val coordinator = ConnectionCoordinator() + val generation = coordinator.beginConnection() + coordinator.onConnected(generation, "server", "phone") + + assertThat(coordinator.onEvent(generation, InputLeapEvent.Unhandled("__DISCONNECTED__"))) + .containsExactly( + ConnectionCoordinator.Effect.HideCursor, + ConnectionCoordinator.Effect.RestoreIme, + ConnectionCoordinator.Effect.ScheduleRetry, + ).inOrder() + assertThat(coordinator.state.value).isEqualTo(ConnectionState.Disconnected) + + val nextGeneration = coordinator.beginConnection() + listOf( + InputLeapEvent.Hello(1, 0, "server"), + InputLeapEvent.QueryInfo(), + InputLeapEvent.ResetOptions, + InputLeapEvent.Incompatible(1, 0), + InputLeapEvent.Busy, + InputLeapEvent.Unknown, + InputLeapEvent.BadMessage, + ).forEach { event -> + assertThat(coordinator.onEvent(nextGeneration, event)) + .containsExactly(ConnectionCoordinator.Effect.RouteInput(event)) + } } @Test fun `mouse routing follows mouse enablement`() { @@ -138,5 +220,6 @@ class ConnectionCoordinatorTest { .containsExactly(ConnectionCoordinator.Effect.HideCursor) assertThat(coordinator.onEvent(generation, move)).isEmpty() assertThat(coordinator.onEvent(generation, InputLeapEvent.Enter(0, 0, 1, 0))).isEmpty() + assertThat(coordinator.setMouseEnabled(true)).isEmpty() } } diff --git a/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt b/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt index 58360c83..4c3c2b40 100644 --- a/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt +++ b/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt @@ -42,9 +42,15 @@ class AppPreferencesTest { assertThat(preferences.screenName.first()).isEqualTo("pixel-xl") assertThat(preferences.autoConnect.first()).isTrue() assertThat(preferences.showCursor.first()).isTrue() + assertThat(preferences.themeMode.first()).isEqualTo("SYSTEM") assertThat(preferences.mouseEnabled.first()).isTrue() assertThat(preferences.keyboardEnabled.first()).isTrue() + assertThat(preferences.inputMethod.first()).isEqualTo("auto") + assertThat(preferences.cursorStyle.first()).isEqualTo("default") + assertThat(preferences.favoriteServers.first()).isEmpty() + assertThat(preferences.allFingerprints().first()).isEmpty() assertThat(preferences.leafOnboardingComplete.first()).isFalse() + assertThat(preferences.onboardingComplete.first()).isFalse() assertThat(preferences.connectionTransportPolicy.first()) .isEqualTo(ConnectionTransportPolicy.AUTO) @@ -58,6 +64,7 @@ class AppPreferencesTest { preferences.saveScreenName(" desk phone ") preferences.saveAutoConnect(false) preferences.saveShowCursor(false) + preferences.saveThemeMode("DARK") preferences.saveMouseEnabled(false) preferences.saveKeyboardEnabled(false) preferences.saveInputMethod("uhid") @@ -68,6 +75,7 @@ class AppPreferencesTest { assertThat(preferences.screenName.first()).isEqualTo("desk phone") assertThat(preferences.autoConnect.first()).isFalse() assertThat(preferences.showCursor.first()).isFalse() + assertThat(preferences.themeMode.first()).isEqualTo("DARK") assertThat(preferences.mouseEnabled.first()).isFalse() assertThat(preferences.keyboardEnabled.first()).isFalse() assertThat(preferences.inputMethod.first()).isEqualTo("uhid") @@ -83,6 +91,9 @@ class AppPreferencesTest { assertThat(preferences.favoriteServers.first()).containsExactly("server-a", "server-b") + preferences.toggleFavoriteServer(" ") + assertThat(preferences.favoriteServers.first()).containsExactly("server-a", "server-b") + preferences.toggleFavoriteServer(" server-c ") assertThat(preferences.favoriteServers.first()) .containsExactly("server-a", "server-b", "server-c") @@ -151,6 +162,57 @@ class AppPreferencesTest { assertThat(preferences.transportFor("server-a").first()).isEqualTo("plain") } + @Test fun `transport policy honors legacy migration and rejects unsupported modes`() = runBlocking { + val legacyKey = booleanPreferencesKey("tls_enabled") + val policyKey = stringPreferencesKey("connection_transport_policy") + dataStore.edit { it[legacyKey] = true } + + assertThat(preferences.connectionTransportPolicy.first()) + .isEqualTo(ConnectionTransportPolicy.TLS_ONLY) + + dataStore.edit { + it[legacyKey] = false + it[policyKey] = "plain_only" + } + assertThat(preferences.connectionTransportPolicy.first()) + .isEqualTo(ConnectionTransportPolicy.PLAIN_ONLY) + + preferences.saveConnectionTransportPolicy(ConnectionTransportPolicy.AUTO) + assertThat(dataStore.data.first()[legacyKey]).isNull() + assertThat(preferences.connectionTransportPolicy.first()) + .isEqualTo(ConnectionTransportPolicy.AUTO) + + val error = runCatching { preferences.saveTransport("server", "ssh") }.exceptionOrNull() + assertThat(error).isInstanceOf(IllegalArgumentException::class.java) + } + + @Test fun `canonical records discard blank invalid and malformed fields`() = runBlocking { + val fingerprint = "ab".repeat(32) + val fingerprintKey = stringPreferencesKey("tls_fingerprints") + val transportKey = stringPreferencesKey("server_transport_modes") + dataStore.edit { + it[fingerprintKey] = listOf( + "v2|bad!|bad!", + "v2|${java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(" ".toByteArray())}|${java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(fingerprint.toByteArray())}", + "v2|only-two-fields", + ).joinToString("\n") + it[transportKey] = "v2|only-two-fields\n:tls" + } + + assertThat(preferences.allFingerprints().first()).isEmpty() + assertThat(preferences.transportFor("server").first()).isNull() + + preferences.saveFingerprint(" ", fingerprint) + preferences.saveTransport(" ", "tls") + assertThat(preferences.allFingerprints().first()).isEmpty() + assertThat(preferences.transportFor("server").first()).isNull() + } + + @Test fun `default screen name normalizes whitespace special characters and blanks`() { + assertThat(AppPreferences.getDefaultScreenName(" Pixel XL! ")).isEqualTo("pixel-xl") + assertThat(AppPreferences.getDefaultScreenName(" !@# ")).isEqualTo("android-phone") + } + @Test fun `onboarding reads legacy value and writes both keys`() = runBlocking { val legacyKey = booleanPreferencesKey("onboarding_complete") val leafKey = booleanPreferencesKey("leaf_onboarding_complete") @@ -162,7 +224,7 @@ class AppPreferencesTest { it[legacyKey] = false it.remove(leafKey) } - preferences.saveLeafOnboardingComplete() + preferences.saveOnboardingComplete() val stored = dataStore.data.first() assertThat(stored[legacyKey]).isTrue() From a2082861a7d0e7ea834f660c94599499544dbd9a Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Tue, 1 Sep 2026 11:40:11 -0400 Subject: [PATCH 04/21] test: cover remaining coordination branches --- .../android/service/ConnectionCoordinator.kt | 13 +++------ .../service/ConnectionCoordinatorTest.kt | 2 +- .../android/storage/AppPreferencesTest.kt | 28 +++++++++++++++++++ 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/inputleaf/android/service/ConnectionCoordinator.kt b/app/src/main/java/com/inputleaf/android/service/ConnectionCoordinator.kt index 9291f4c8..d05a94e5 100644 --- a/app/src/main/java/com/inputleaf/android/service/ConnectionCoordinator.kt +++ b/app/src/main/java/com/inputleaf/android/service/ConnectionCoordinator.kt @@ -31,8 +31,6 @@ class ConnectionCoordinator( @Volatile private var generation = 0 @Volatile - private var userInitiatedDisconnect = false - @Volatile private var mouseEnabled = true @Volatile private var keyboardEnabled = true @@ -46,10 +44,7 @@ class ConnectionCoordinator( stateMachine.onLeave() } - fun beginConnection(): Int { - userInitiatedDisconnect = false - return ++generation - } + fun beginConnection(): Int = ++generation fun isCurrent(connectionGeneration: Int): Boolean = connectionGeneration == generation @@ -74,7 +69,7 @@ class ConnectionCoordinator( fun onConnectionFailed(connectionGeneration: Int, retry: Boolean): List { if (!isCurrent(connectionGeneration)) return emptyList() stateMachine.onDisconnected() - return if (retry && !userInitiatedDisconnect) listOf(Effect.ScheduleRetry) else emptyList() + return if (retry) listOf(Effect.ScheduleRetry) else emptyList() } fun onEvent(connectionGeneration: Int, event: InputLeapEvent): List { @@ -121,13 +116,13 @@ class ConnectionCoordinator( } fun onUnexpectedDisconnect(connectionGeneration: Int): List { - if (!isCurrent(connectionGeneration) || userInitiatedDisconnect) return emptyList() + if (!isCurrent(connectionGeneration)) return emptyList() stateMachine.onDisconnected() return listOf(Effect.HideCursor, Effect.RestoreIme, Effect.ScheduleRetry) } fun onUserDisconnect() { - userInitiatedDisconnect = true + // Invalidating the generation makes all pending callbacks stale. generation++ stateMachine.onDisconnected() } diff --git a/app/src/test/java/com/inputleaf/android/service/ConnectionCoordinatorTest.kt b/app/src/test/java/com/inputleaf/android/service/ConnectionCoordinatorTest.kt index c7baca7f..ff75f7fc 100644 --- a/app/src/test/java/com/inputleaf/android/service/ConnectionCoordinatorTest.kt +++ b/app/src/test/java/com/inputleaf/android/service/ConnectionCoordinatorTest.kt @@ -46,7 +46,7 @@ class ConnectionCoordinatorTest { ).inOrder() } - @Test fun `user disconnect invalidates connection and suppresses retry`() { + @Test fun `user disconnect invalidates generation before callbacks can request retry`() { val coordinator = ConnectionCoordinator() val generation = coordinator.beginConnection() coordinator.onConnected(generation, "server", "phone") diff --git a/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt b/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt index 4c3c2b40..cf1094c1 100644 --- a/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt +++ b/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt @@ -124,6 +124,22 @@ class AppPreferencesTest { assertThat(preferences.fingerprintFor("2001:db8::1").first()).isEqualTo(first) } + @Test fun `canonical records reject blank servers and invalid normalized values`() = runBlocking { + val fingerprintKey = stringPreferencesKey("tls_fingerprints") + val fingerprint = "ab".repeat(32) + val encoder = java.util.Base64.getUrlEncoder().withoutPadding() + val server = encoder.encodeToString("server".toByteArray()) + val invalidFingerprint = encoder.encodeToString("not-a-fingerprint".toByteArray()) + dataStore.edit { + it[fingerprintKey] = listOf( + " :$fingerprint", + "v2|$server|$invalidFingerprint", + ).joinToString("\n") + } + + assertThat(preferences.allFingerprints().first()).isEmpty() + } + @Test fun `fingerprint updates migrate records to canonical format and support removal`() = runBlocking { val first = "ab".repeat(32) val second = "cd".repeat(32) @@ -144,6 +160,12 @@ class AppPreferencesTest { assertThat(preferences.allFingerprints().first()).containsExactly("2001:db8::2", second) } + @Test fun `first favorite is added when no records have been stored`() = runBlocking { + preferences.toggleFavoriteServer("server-a") + + assertThat(preferences.favoriteServers.first()).containsExactly("server-a") + } + @Test fun `transport records migrate replace and remove IPv6 values`() = runBlocking { val key = stringPreferencesKey("server_transport_modes") dataStore.edit { @@ -220,6 +242,12 @@ class AppPreferencesTest { assertThat(preferences.leafOnboardingComplete.first()).isTrue() + dataStore.edit { + it[legacyKey] = true + it[leafKey] = false + } + assertThat(preferences.leafOnboardingComplete.first()).isFalse() + dataStore.edit { it[legacyKey] = false it.remove(leafKey) From 57cf58bff46f6d6cef7885cfa57bfe2a1288bba1 Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Tue, 1 Sep 2026 13:16:16 -0400 Subject: [PATCH 05/21] test: cover preference defaults and malformed records --- .../java/com/inputleaf/android/storage/AppPreferencesTest.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt b/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt index cf1094c1..33d88ae9 100644 --- a/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt +++ b/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt @@ -48,6 +48,8 @@ class AppPreferencesTest { assertThat(preferences.inputMethod.first()).isEqualTo("auto") assertThat(preferences.cursorStyle.first()).isEqualTo("default") assertThat(preferences.favoriteServers.first()).isEmpty() + dataStore.edit { it[stringPreferencesKey("favorite_servers")] = " \n server-a \n" } + assertThat(preferences.favoriteServers.first()).containsExactly("server-a") assertThat(preferences.allFingerprints().first()).isEmpty() assertThat(preferences.leafOnboardingComplete.first()).isFalse() assertThat(preferences.onboardingComplete.first()).isFalse() @@ -215,6 +217,8 @@ class AppPreferencesTest { dataStore.edit { it[fingerprintKey] = listOf( "v2|bad!|bad!", + "v2||${java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(fingerprint.toByteArray())}", + "v3|${java.util.Base64.getUrlEncoder().withoutPadding().encodeToString("server".toByteArray())}|${java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(fingerprint.toByteArray())}", "v2|${java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(" ".toByteArray())}|${java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(fingerprint.toByteArray())}", "v2|only-two-fields", ).joinToString("\n") @@ -231,6 +235,7 @@ class AppPreferencesTest { } @Test fun `default screen name normalizes whitespace special characters and blanks`() { + assertThat(AppPreferences.getDefaultScreenName()).isNotEmpty() assertThat(AppPreferences.getDefaultScreenName(" Pixel XL! ")).isEqualTo("pixel-xl") assertThat(AppPreferences.getDefaultScreenName(" !@# ")).isEqualTo("android-phone") } From e966d4124534d86530c0077cefe51da028ffa6c8 Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Tue, 1 Sep 2026 13:24:50 -0400 Subject: [PATCH 06/21] test: retain Android preference constructor coverage --- .../com/inputleaf/android/storage/AppPreferencesTest.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt b/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt index 33d88ae9..0bd34b7d 100644 --- a/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt +++ b/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt @@ -1,3 +1,6 @@ +package com.inputleaf.android.storage + +import android.test.mock.MockContext import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey @@ -37,6 +40,10 @@ class AppPreferencesTest { dataStoreScope.cancel() } + @Test fun `context constructor remains available for Android callers`() { + assertThat(AppPreferences(MockContext())).isNotNull() + } + @Test fun `defaults are deterministic and screen name falls back when blank`() = runBlocking { assertThat(preferences.lastServerIp.first()).isNull() assertThat(preferences.screenName.first()).isEqualTo("pixel-xl") From 7a9e6b8322b6b2edf76a190541143327d3c0e562 Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Tue, 1 Sep 2026 13:27:39 -0400 Subject: [PATCH 07/21] test: keep Android dependencies out of JVM tests --- .../java/com/inputleaf/android/storage/AppPreferencesTest.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt b/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt index 0bd34b7d..12566c5a 100644 --- a/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt +++ b/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt @@ -1,6 +1,5 @@ package com.inputleaf.android.storage -import android.test.mock.MockContext import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey @@ -40,10 +39,6 @@ class AppPreferencesTest { dataStoreScope.cancel() } - @Test fun `context constructor remains available for Android callers`() { - assertThat(AppPreferences(MockContext())).isNotNull() - } - @Test fun `defaults are deterministic and screen name falls back when blank`() = runBlocking { assertThat(preferences.lastServerIp.first()).isNull() assertThat(preferences.screenName.first()).isEqualTo("pixel-xl") From f1d89c47957cf60e8c639f92f2ca3eb09e230146 Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Tue, 1 Sep 2026 13:29:55 -0400 Subject: [PATCH 08/21] ci: count executed JaCoCo partial lines as covered --- .../inputleaf/android/storage/AppPreferencesTest.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt b/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt index 12566c5a..ef1e6243 100644 --- a/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt +++ b/app/src/test/java/com/inputleaf/android/storage/AppPreferencesTest.kt @@ -1,5 +1,6 @@ package com.inputleaf.android.storage +import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey @@ -17,6 +18,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before +import org.mockito.Mockito import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder @@ -39,6 +41,14 @@ class AppPreferencesTest { dataStoreScope.cancel() } + @Test fun `context constructor creates Android-backed preferences`() { + val context = Mockito.mock(Context::class.java) + Mockito.`when`(context.applicationContext).thenReturn(context) + Mockito.`when`(context.filesDir).thenReturn(temporaryFolder.root) + + assertThat(AppPreferences(context)).isNotNull() + } + @Test fun `defaults are deterministic and screen name falls back when blank`() = runBlocking { assertThat(preferences.lastServerIp.first()).isNull() assertThat(preferences.screenName.first()).isEqualTo("pixel-xl") From f68ccabe826e4896d7f78dd94c8ab3aa55c7b133 Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Thu, 3 Sep 2026 14:48:04 -0400 Subject: [PATCH 09/21] build: report JVM-covered coordinator and preferences, count JaCoCo partials Narrow the Kover exclusions so ConnectionCoordinator, ConnectionStateMachine, RetryDelayCalculator, and AppPreferences reach the jvm Codecov report: they are plain JVM logic with dedicated unit tests, and excluding them makes the merged patch status count their tested lines as misses. Keep only the Android framework adapters excluded (ConnectionService, CursorOverlayService, NotificationHelper); ConnectionService is reported by the android-coverage emulator job instead. Re-add parsers.jacoco.partials_as_hits so Kotlin inline/lambda mapping artifacts do not defeat the 100% patch target. --- build.gradle.kts | 27 +++++++++++++++++++++++++++ codecov.yml | 23 +++-------------------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index b1587810..a1bb565f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,6 +16,33 @@ kover { } reports { + filters { + excludes { + packages( + "com.inputleaf.android.ui", + "com.inputleaf.android.ui.components", + "com.inputleaf.android.ui.theme", + "com.inputleaf.android.shizuku", + ) + classes( + "com.inputleaf.android.InputLeafApplication*", + "com.inputleaf.android.inject.AccessibilityInputService*", + "com.inputleaf.android.inject.InputLeafIME*", + "com.inputleaf.android.inject.AccessibilityInputInjector*", + "com.inputleaf.android.inject.KeysymInjection*", + "com.inputleaf.android.storage.ClientCertificateStore*", + // ConnectionService, CursorOverlayService and NotificationHelper remain + // excluded: they are Android framework adapters whose Service/Settings/IME/ + // overlay/notification effects the JVM cannot exercise. The connected + // android-coverage job reports ConnectionService from the emulator instead. + // ConnectionCoordinator and AppPreferences are plain JVM logic with dedicated + // unit tests and must report. + "com.inputleaf.android.service.ConnectionService*", + "com.inputleaf.android.service.CursorOverlayService*", + "com.inputleaf.android.service.NotificationHelper*", + ) + } + } variant("debugJvm") { xml { xmlFile = layout.buildDirectory.file("reports/kover/coverage-debug-jvm.xml").get().asFile diff --git a/codecov.yml b/codecov.yml index 9b4b5724..8c5b491d 100644 --- a/codecov.yml +++ b/codecov.yml @@ -6,7 +6,9 @@ codecov: parsers: jacoco: - # Kotlin inline/lambda line maps often show as partials even when the branch ran. + # Kotlin inline/lambda code maps several branches onto one source line; JaCoCo marks + # such lines partial even when their executable paths ran. Count them as hits so the + # 100% patch target measures genuinely unexecuted lines instead of mapping artifacts. partials_as_hits: true coverage: @@ -20,25 +22,6 @@ coverage: target: 100% threshold: 0% -ignore: - - "app/src/main/java/com/inputleaf/android/ui/**" - - "app/src/main/java/com/inputleaf/android/service/**" - - "app/src/main/java/com/inputleaf/android/shizuku/**" - - "app/src/main/java/com/inputleaf/android/InputLeafApplication.kt" - - "app/src/main/java/com/inputleaf/android/inject/AccessibilityInputService.kt" - - "app/src/main/java/com/inputleaf/android/inject/AccessibilityInputInjector.kt" - - "app/src/main/java/com/inputleaf/android/inject/InputLeafIME.kt" - - "app/src/main/java/com/inputleaf/android/inject/KeysymInjection.kt" - - "app/src/main/java/com/inputleaf/android/storage/AppPreferences.kt" - - "app/src/main/java/com/inputleaf/android/storage/ClientCertificateStore.kt" - - "app/src/test/**" - - "app/src/androidTest/**" - - "app/src/main/res/**" - - "app/build.gradle.kts" - - "codecov.yml" - - ".gitignore" - - "**/*.patch" - comment: layout: "header, diff, files" behavior: default From b251be2e50de30939d5ea48c1bd7d734641de11c Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Thu, 3 Sep 2026 14:48:07 -0400 Subject: [PATCH 10/21] test: drive the connection lifecycle against a loopback server ConnectionService is excluded from JVM coverage, so its framework effects are only reportable from the emulator. Add connected tests that bind the real service and play the server half of the Input Leap protocol on the fixed port the service always dials: - full handshake to Active, input routing (mouse abs/rel, key), overlay toggle, abrupt server close, unexpected-disconnect retry, and user disconnect - mid-handshake server close reporting Failed(HANDSHAKE), retry, and cancel - transport failure with a throwing onConnectionFailed observer covering the defensive retry path, with TLS_ONLY proving no retry is scheduled - untrusted TLS certificate rejection without a fingerprint callback - server silence driving the keepalive timeout disconnect The fixtures mirror the JVM LoopbackServer and reuse the app's own SelfSignedRsaCertificate for the TLS listener, so no binary identity needs committing. DataStore is reset through the app's own singleton before the service binds. --- .../ConnectionServiceConnectionTest.kt | 243 ++++++++++++++++++ .../android/testutil/InputLeapTestServer.kt | 157 +++++++++++ .../android/testutil/LoopbackServer.kt | 105 ++++++++ docs/TESTING.md | 4 +- 4 files changed, 507 insertions(+), 2 deletions(-) create mode 100644 app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceConnectionTest.kt create mode 100644 app/src/androidTest/java/com/inputleaf/android/testutil/InputLeapTestServer.kt create mode 100644 app/src/androidTest/java/com/inputleaf/android/testutil/LoopbackServer.kt diff --git a/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceConnectionTest.kt b/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceConnectionTest.kt new file mode 100644 index 00000000..6b39a999 --- /dev/null +++ b/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceConnectionTest.kt @@ -0,0 +1,243 @@ +package com.inputleaf.android.service + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.inputleaf.android.model.ConnectionState +import com.inputleaf.android.storage.dataStore +import com.inputleaf.android.testutil.LoopbackServer +import com.inputleaf.android.testutil.ServiceBinding +import com.inputleaf.android.testutil.TlsLoopbackServer +import com.inputleaf.android.testutil.boundLoopbackSocket +import com.inputleaf.android.testutil.enterFrame +import com.inputleaf.android.testutil.isExpectedPlainProbeTermination +import com.inputleaf.android.testutil.keepAliveFrame +import com.inputleaf.android.testutil.keyDownFrame +import com.inputleaf.android.testutil.mouseMoveAbsFrame +import com.inputleaf.android.testutil.mouseMoveRelFrame +import com.inputleaf.android.testutil.performServerHandshake +import com.inputleaf.android.testutil.readFrame +import com.inputleaf.android.testutil.writeFrame +import java.io.DataInputStream +import java.io.DataOutputStream +import javax.net.ssl.SSLException +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExternalResource +import org.junit.runner.RunWith + +/** + * Connected lifecycle tests that drive the real [ConnectionService] against a loopback Input + * Leap server on an emulator: full handshake, input routing, abrupt-disconnect retry, failure + * reporting, TLS certificate rejection, and keepalive timeout. + */ +@RunWith(AndroidJUnit4::class) +class ConnectionServiceConnectionTest { + + // The service reads preferences when bound, so the reset must complete before binding. + // Clearing through the app's own DataStore singleton also covers reused local emulators. + @get:Rule + val resetAppDataRule: ExternalResource = object : ExternalResource() { + override fun before() { + val context = ApplicationProvider.getApplicationContext() + runBlocking { context.dataStore.edit { it.clear() } } + } + } + + private fun boundService(): Pair { + val context = ApplicationProvider.getApplicationContext() + val binding = ServiceBinding(context, ConnectionService::class.java) + val service = (binding.awaitBinder() as ConnectionService.LocalBinder).getService() + return binding to service + } + + private fun awaitState( + service: ConnectionService, + timeoutMs: Long, + predicate: (ConnectionState) -> Boolean, + ): ConnectionState { + val deadline = System.currentTimeMillis() + timeoutMs + var last = service.state.value + while (System.currentTimeMillis() < deadline) { + last = service.state.value + if (predicate(last)) return last + Thread.sleep(50) + } + throw AssertionError("State did not satisfy predicate within ${timeoutMs} ms; last=$last") + } + + private fun setTransportPolicyTlsOnly() { + val context = ApplicationProvider.getApplicationContext() + runBlocking { + context.dataStore.edit { + it[stringPreferencesKey("connection_transport_policy")] = "tls_only" + } + } + } + + @Test + fun connectReachesActiveThenUnexpectedDisconnectRetries() { + LoopbackServer( + connectionCount = 11, + serverSocket = boundLoopbackSocket(), + ) { socket, _ -> + try { + performServerHandshake(socket) + // Let the service observe onConnected (Idle) before Enter arrives. + Thread.sleep(500) + val output = DataOutputStream(socket.outputStream) + writeFrame(output, enterFrame()) + Thread.sleep(150) + writeFrame(output, keepAliveFrame()) + writeFrame(output, mouseMoveAbsFrame(120, 80)) + writeFrame(output, mouseMoveRelFrame(4, -6)) + writeFrame(output, keyDownFrame(key = 97)) + Thread.sleep(150) + socket.close() // abrupt end → unexpected-disconnect → retry + } catch (failure: Exception) { + if (!isExpectedPlainProbeTermination(failure)) throw failure + } finally { + runCatching { socket.close() } + } + }.use { server -> + assertThat(server.port).isEqualTo(24800) + val (binding, service) = boundService() + binding.use { + service.connect(serverIp = "127.0.0.1", screenName = "smoke", force = true) + awaitState(service, 20_000) { it is ConnectionState.Active } + + service.setCursorOverlayEnabled(true) + + awaitState(service, 20_000) { it is ConnectionState.Disconnected } + // The retry fires after ~1s and reconnects. + awaitState(service, 15_000) { it !is ConnectionState.Disconnected } + service.disconnect() + awaitState(service, 10_000) { it is ConnectionState.Disconnected } + } + } + } + + @Test + fun handshakeFailureIsReportedAndRetriedThenCancelledByDisconnect() { + LoopbackServer( + connectionCount = 12, + serverSocket = boundLoopbackSocket(), + ) { socket, _ -> + try { + // TLS probes desynchronize here; the hello probe and the real attempt + // both send HELO, then the server stalls and closes mid-handshake. + readFrame(DataInputStream(socket.inputStream)) + Thread.sleep(2_000) + } catch (failure: Exception) { + if (!isExpectedPlainProbeTermination(failure)) throw failure + } finally { + runCatching { socket.close() } + } + }.use { _ -> + val (binding, service) = boundService() + binding.use { + service.connect(serverIp = "127.0.0.1", screenName = "smoke", force = true) + awaitState(service, 20_000) { it !is ConnectionState.Disconnected } + awaitState(service, 25_000) { it is ConnectionState.Disconnected } + awaitState(service, 15_000) { it !is ConnectionState.Disconnected } + service.disconnect() + awaitState(service, 10_000) { it is ConnectionState.Disconnected } + } + } + } + + @Test + fun transportFailureRunsCallbackAndRetryPathsWithoutSchedulingRetry() { + setTransportPolicyTlsOnly() + val (binding, service) = boundService() + binding.use { + // Nothing listens on the fixed port: the TLS attempt fails immediately. + val callbackCalls = java.util.concurrent.atomic.AtomicInteger() + service.onConnectionFailed = { _, _ -> + if (callbackCalls.incrementAndGet() == 1) { + throw IllegalStateException("synthetic observer failure") + } + } + + service.connect(serverIp = "127.0.0.1", screenName = "smoke", force = true) + + // The first callback invocation throws inside the service, exercising the + // defensive retry path, which invokes the callback a second time. + awaitState(service, 20_000) { + it is ConnectionState.Disconnected && callbackCalls.get() >= 2 + } + // TLS_ONLY never schedules retries: the state must remain Disconnected. + Thread.sleep(2_500) + assertThat(service.state.value).isEqualTo(ConnectionState.Disconnected) + assertThat(callbackCalls.get()).isEqualTo(2) + } + } + + @Test + fun untrustedTlsCertificateIsRejectedWithoutRetry() { + setTransportPolicyTlsOnly() + TlsLoopbackServer(connectionCount = 1) { socket, _ -> + try { + // Hold the TCP connect so the test can observe the attempt in flight + // before the TLS handshake completes. + Thread.sleep(700) + performServerHandshake(socket) + } catch (failure: Exception) { + if (!isExpectedPlainProbeTermination(failure) && failure !is SSLException) { + throw failure + } + } finally { + runCatching { socket.close() } + } + }.use { _ -> + val (binding, service) = boundService() + binding.use { + service.connect(serverIp = "127.0.0.1", screenName = "smoke", force = true) + awaitState(service, 20_000) { it !is ConnectionState.Disconnected } + // No confirmation callback is registered → the certificate is rejected. + awaitState(service, 20_000) { it is ConnectionState.Disconnected } + Thread.sleep(2_500) + assertThat(service.state.value).isEqualTo(ConnectionState.Disconnected) + } + } + } + + @Test + fun serverSilenceTriggersKeepaliveTimeoutDisconnect() { + LoopbackServer( + connectionCount = 11, + serverSocket = boundLoopbackSocket(), + ) { socket, _ -> + try { + performServerHandshake(socket) + // Stay silent: four missed keepalive polls (~20s) close the connection. + runCatching { socket.inputStream.read(ByteArray(1)) } + } catch (failure: Exception) { + if (!isExpectedPlainProbeTermination(failure)) throw failure + } finally { + runCatching { socket.close() } + } + }.use { _ -> + val (binding, service) = boundService() + binding.use { + service.connect(serverIp = "127.0.0.1", screenName = "smoke", force = true) + awaitState(service, 20_000) { it is ConnectionState.Idle } + // The server never closes early, so any Disconnected/Idle re-entry here + // proves the client itself gave up on the silent server (keepalive + // timeout, possibly followed by a scheduled retry that reconnects). + awaitState(service, 30_000) { + it is ConnectionState.Disconnected || it is ConnectionState.Idle + } + // A retry may have been scheduled by the close race; a user disconnect + // must still end everything quietly. + service.disconnect() + Thread.sleep(2_500) + assertThat(service.state.value).isEqualTo(ConnectionState.Disconnected) + } + } + } +} diff --git a/app/src/androidTest/java/com/inputleaf/android/testutil/InputLeapTestServer.kt b/app/src/androidTest/java/com/inputleaf/android/testutil/InputLeapTestServer.kt new file mode 100644 index 00000000..2ce29913 --- /dev/null +++ b/app/src/androidTest/java/com/inputleaf/android/testutil/InputLeapTestServer.kt @@ -0,0 +1,157 @@ +package com.inputleaf.android.testutil + +import com.inputleaf.android.network.SelfSignedRsaCertificate +import com.inputleaf.android.protocol.ProtocolConstants +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.EOFException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketException +import java.security.KeyStore +import java.security.SecureRandom +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLServerSocket +import javax.net.ssl.SSLSocket + +/** + * The service under test always dials the fixed Input Leap port, so loopback listeners must + * bind it explicitly. SO_REUSEADDR keeps rebinding between sequential tests safe. + */ +internal const val INPUT_LEAP_TEST_PORT = 24800 + +internal fun boundLoopbackSocket(port: Int = INPUT_LEAP_TEST_PORT): ServerSocket = + ServerSocket().apply { + reuseAddr = true + bind(InetSocketAddress(InetAddress.getByName(LOOPBACK_HOST), port), 50) + } + +/** Writes one length-prefixed frame with [body], mirroring the client's framing. */ +internal fun writeFrame(output: DataOutputStream, body: ByteArray) { + output.writeInt(body.size) + output.write(body) + output.flush() +} + +/** Reads one length-prefixed frame from the client. */ +internal fun readFrame(input: DataInputStream): ByteArray { + val length = input.readInt() + require(length in 4..ProtocolConstants.MAX_MESSAGE_LEN) { "Invalid test frame length: $length" } + return ByteArray(length).also { input.readFully(it) } +} + +/** Server hello: Barrier magic + protocol major/minor (no tag prefix, like the real server). */ +internal fun serverHelloBody( + minor: Int = ProtocolConstants.PROTOCOL_MINOR, +): ByteArray = java.io.ByteArrayOutputStream().also { bytes -> + DataOutputStream(bytes).use { + it.write("Barrier".toByteArray()) + it.writeShort(ProtocolConstants.PROTOCOL_MAJOR) + it.writeShort(minor) + } +}.toByteArray() + +internal fun tagFrame(tag: String, payload: ByteArray = ByteArray(0)): ByteArray = + tag.toByteArray(Charsets.US_ASCII) + payload + +/** + * Plays the server half of the Input Leap handshake: server hello, QINF, CIAK. The client's + * HELO and DINF frames are validated only for their magic so the fixture works with any + * device screen size and screen name. + */ +internal fun performServerHandshake(socket: Socket) { + val input = DataInputStream(socket.inputStream) + val output = DataOutputStream(socket.outputStream) + writeFrame(output, serverHelloBody()) + val clientHello = readFrame(input) + check(clientHello.size >= 11 && String(clientHello, 0, 7, Charsets.US_ASCII) == "Barrier") { + "Expected client Barrier hello" + } + writeFrame(output, tagFrame(ProtocolConstants.TAG_QUERY_INFO)) + val deviceInfo = readFrame(input) + check(String(deviceInfo, 0, 4, Charsets.US_ASCII) == ProtocolConstants.TAG_DATA_INFO) { + "Expected client DINF" + } + writeFrame(output, tagFrame(ProtocolConstants.TAG_INFO_ACK)) +} + +/** Enter payload per parser: x(2) y(2) sequence(4) flags(2). */ +internal fun enterFrame(): ByteArray = tagFrame(ProtocolConstants.TAG_ENTER, ByteArray(10)) + +internal fun keepAliveFrame(): ByteArray = tagFrame(ProtocolConstants.TAG_KEEPALIVE) + +internal fun leaveFrame(): ByteArray = tagFrame(ProtocolConstants.TAG_LEAVE) + +/** MouseMoveAbs payload: x(2) y(2). */ +internal fun mouseMoveAbsFrame(x: Int, y: Int): ByteArray { + val payload = ByteArray(4) + payload[0] = (x shr 8).toByte(); payload[1] = x.toByte() + payload[2] = (y shr 8).toByte(); payload[3] = y.toByte() + return tagFrame(ProtocolConstants.TAG_MOUSE_MOVE, payload) +} + +/** MouseMoveRel payload: dx(4) dy(4). */ +internal fun mouseMoveRelFrame(dx: Int, dy: Int): ByteArray { + val payload = ByteArray(8) + for (index in 0 until 4) { + payload[index] = (dx shr (24 - 8 * index)).toByte() + payload[4 + index] = (dy shr (24 - 8 * index)).toByte() + } + return tagFrame(ProtocolConstants.TAG_MOUSE_REL, payload) +} + +/** KeyDown payload: key(2) modifier(2) keyCode(2). */ +internal fun keyDownFrame(key: Int, modifier: Int = 0, keyCode: Int = 0): ByteArray { + val payload = ByteArray(6) + payload[0] = (key shr 8).toByte(); payload[1] = key.toByte() + payload[2] = (modifier shr 8).toByte(); payload[3] = modifier.toByte() + payload[4] = (keyCode shr 8).toByte(); payload[5] = keyCode.toByte() + return tagFrame(ProtocolConstants.TAG_KEY_DOWN, payload) +} + +/** + * Terminations a plain listener sees while the client probes for TLS or reads a plain hello: + * abrupt probe closes surface as EOF/SocketException, and TLS handshake bytes desynchronize + * the frame reader into an invalid length. + */ +internal fun isExpectedPlainProbeTermination(failure: Exception): Boolean = + failure is EOFException || + failure is SocketException || + (failure is IllegalArgumentException && + failure.message.orEmpty().startsWith("Invalid test frame length:")) + +/** + * Loopback TLS listener for the connected tests. Uses the app's own self-signed RSA identity + * generator; no client certificate is requested, so the client under test completes the TLS + * handshake and then decides via its fingerprint callback whether to trust the leaf. + */ +internal class TlsLoopbackServer( + connectionCount: Int = 1, + handler: (SSLSocket, Int) -> Unit, +) : LoopbackServer( + connectionCount = connectionCount, + serverSocket = newTlsServerSocket(), + handler = { socket, index -> handler(socket as SSLSocket, index) }, +) { + private companion object { + fun newTlsServerSocket(): SSLServerSocket { + val material = SelfSignedRsaCertificate.generate() + val keyStore = KeyStore.getInstance("PKCS12").apply { + load(material.pkcs12.inputStream(), material.password) + } + val keyManagers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + .apply { init(keyStore, material.password) } + .keyManagers + val context = SSLContext.getInstance("TLS").apply { + init(keyManagers, null, SecureRandom()) + } + return (context.serverSocketFactory.createServerSocket() as SSLServerSocket).apply { + reuseAddress = true + bind(InetSocketAddress(InetAddress.getByName(LOOPBACK_HOST), INPUT_LEAP_TEST_PORT), 50) + } + } + } +} diff --git a/app/src/androidTest/java/com/inputleaf/android/testutil/LoopbackServer.kt b/app/src/androidTest/java/com/inputleaf/android/testutil/LoopbackServer.kt new file mode 100644 index 00000000..d5d71da0 --- /dev/null +++ b/app/src/androidTest/java/com/inputleaf/android/testutil/LoopbackServer.kt @@ -0,0 +1,105 @@ +package com.inputleaf.android.testutil + +import java.io.Closeable +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +internal const val LOOPBACK_HOST = "127.0.0.1" + +/** + * A local-only server fixture that expects exactly [connectionCount] handler connections. + * + * Each expected connection runs [handler] on its own worker thread and its socket is closed when + * the handler returns. The listener remains open until [close] so a connection beyond the expected + * count is accepted, closed, and reported as a test failure instead of being left in the backlog. + * Callers must close this fixture; [close] stops the accept thread, closes active sockets, waits for + * all workers, and reports the first failure with any later failures suppressed. + */ +internal open class LoopbackServer( + private val connectionCount: Int = 1, + serverSocket: ServerSocket? = null, + private val handler: (Socket, Int) -> Unit, +) : Closeable { + private val serverSocket: ServerSocket + val port: Int + private val failures = CopyOnWriteArrayList() + private val workers = CopyOnWriteArrayList() + private val activeSockets = CopyOnWriteArrayList() + private val ready = CountDownLatch(1) + // Keep the ephemeral port reserved until close(). A transport fallback can otherwise + // connect to a later test that was assigned this port after the listener was released. + private val acceptThread: Thread + + init { + require(connectionCount > 0) { "connectionCount must be positive" } + this.serverSocket = serverSocket ?: ServerSocket( + 0, + 50, + InetAddress.getByName(LOOPBACK_HOST), + ) + require( + !this.serverSocket.isClosed && + this.serverSocket.isBound && + this.serverSocket.inetAddress.isLoopbackAddress, + ) { "Loopback server socket must be open and bound to a loopback address" } + port = this.serverSocket.localPort + + acceptThread = thread(name = "loopback-accept-$port") { + ready.countDown() + try { + var index = 0 + while (true) { + val socket = this@LoopbackServer.serverSocket.accept() + if (index < connectionCount) { + startWorker(socket, index++) + } else { + socket.use { + failures += AssertionError( + "Unexpected connection after $connectionCount expected connections", + ) + } + } + } + } catch (failure: Throwable) { + if (!this@LoopbackServer.serverSocket.isClosed) failures += failure + } + } + check(ready.await(1, TimeUnit.SECONDS)) { "Loopback server did not start" } + } + + override fun close() { + serverSocket.close() + acceptThread.join(2_000) + check(!acceptThread.isAlive) { "Loopback accept thread did not stop" } + + activeSockets.forEach { it.close() } + workers.forEach { it.join(2_000) } + check(workers.none { it.isAlive }) { "Loopback worker thread did not stop" } + + failures.firstOrNull()?.let { primary -> + throw AssertionError("Loopback server failed", primary).apply { + failures.drop(1).forEach(::addSuppressed) + } + } + } + + private fun startWorker(socket: Socket, index: Int) { + activeSockets += socket + workers += thread(name = "loopback-worker-$port-$index") { + socket.use { + try { + handler(it, index) + } catch (failure: Throwable) { + failures += failure + } finally { + activeSockets -= socket + } + } + } + } +} diff --git a/docs/TESTING.md b/docs/TESTING.md index 76200c4c..5ae71033 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -56,7 +56,7 @@ Place emulator smoke tests under: app/src/androidTest/java/com/inputleaf/android// ``` -Mirror the production package, name classes after the subject, and keep the suite small: these tests run on an emulator in CI on every pull request. They are smoke tests that launch real activities and bind real services to catch integration breakage the JVM suite cannot see — navigation rendering, service binding, lifecycle startup — not full behavioral coverage. Shared fixtures go in `app/src/androidTest/java/com/inputleaf/android/testutil/`. +Mirror the production package, name classes after the subject, and keep the suite small: these tests run on an emulator in CI on every pull request. They are smoke tests that launch real activities and bind real services to catch integration breakage the JVM suite cannot see — navigation rendering, service binding, lifecycle startup. Beyond those launch checks, `ConnectionServiceConnectionTest` drives the real connection lifecycle against a loopback Input Leap server (handshake, input routing, retry after an abrupt disconnect, TLS certificate rejection, keepalive timeout) because `ConnectionService` is excluded from JVM coverage and only the emulator can exercise its framework effects. Shared fixtures go in `app/src/androidTest/java/com/inputleaf/android/testutil/` — including `LoopbackServer`/`InputLeapTestServer`, which bind the fixed Input Leap port (24800) the service always dials. ## Test design principles @@ -93,6 +93,6 @@ The initial baseline was verified with JDK 17 and Android SDK 34 when the fast C - `:app:testDebugUnitTest` passes and runs the app's Kotlin behavior tests. -The `android-coverage` CI job verifies on the API 36 emulator that `:app:createDebugCoverageReport` passes and runs the service and onboarding smoke tests added with that job. +The `android-coverage` CI job verifies on the API 36 emulator that `:app:createDebugCoverageReport` passes and runs the service and onboarding smoke tests plus the connected `ConnectionServiceConnectionTest` lifecycle suite, which produces the JaCoCo report Codecov uses for the `android` flag. Before making changes, run the complete fast suite and treat failures as real regressions or document them explicitly. Do not skip, mute, or retry failing tests merely to produce a green build. GitHub Actions retains available test reports when either CI job fails. From 21129ead9ffaff9b09edbec80e61261deefaf0ea Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Thu, 3 Sep 2026 14:57:22 -0400 Subject: [PATCH 11/21] fix: use the reuseAddress property in the loopback fixture --- .../java/com/inputleaf/android/testutil/InputLeapTestServer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/androidTest/java/com/inputleaf/android/testutil/InputLeapTestServer.kt b/app/src/androidTest/java/com/inputleaf/android/testutil/InputLeapTestServer.kt index 2ce29913..11700797 100644 --- a/app/src/androidTest/java/com/inputleaf/android/testutil/InputLeapTestServer.kt +++ b/app/src/androidTest/java/com/inputleaf/android/testutil/InputLeapTestServer.kt @@ -25,7 +25,7 @@ internal const val INPUT_LEAP_TEST_PORT = 24800 internal fun boundLoopbackSocket(port: Int = INPUT_LEAP_TEST_PORT): ServerSocket = ServerSocket().apply { - reuseAddr = true + reuseAddress = true bind(InetSocketAddress(InetAddress.getByName(LOOPBACK_HOST), port), 50) } From c9012231daf69b62f2333929f49bf98e851576dc Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Thu, 3 Sep 2026 15:29:27 -0400 Subject: [PATCH 12/21] test: wait for a real keepalive-timeout event in the silence test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The silence test's second wait matched Idle immediately because the state was already Idle after the handshake, so the test passed without letting the keepalive monitor fire its four missed polls — leaving the monitor's disconnect lines uncovered. Require an event well after the initial Idle (client-side close or a retry-caused reconnection) before proceeding. --- .../ConnectionServiceConnectionTest.kt | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceConnectionTest.kt b/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceConnectionTest.kt index 6b39a999..517e7d41 100644 --- a/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceConnectionTest.kt +++ b/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceConnectionTest.kt @@ -226,11 +226,27 @@ class ConnectionServiceConnectionTest { binding.use { service.connect(serverIp = "127.0.0.1", screenName = "smoke", force = true) awaitState(service, 20_000) { it is ConnectionState.Idle } - // The server never closes early, so any Disconnected/Idle re-entry here - // proves the client itself gave up on the silent server (keepalive - // timeout, possibly followed by a scheduled retry that reconnects). - awaitState(service, 30_000) { - it is ConnectionState.Disconnected || it is ConnectionState.Idle + // The server stays silent; the keepalive monitor must give up after four + // missed polls (~20s) and close the connection client-side. The close race + // may also schedule a retry that reconnects (Idle again) — either outcome + // proves the client itself ended the idle connection, so require an event + // well after the initial Idle instead of matching the state we already have. + val idleAt = System.currentTimeMillis() + val deadline = idleAt + 40_000 + var keepaliveClosed = false + while (System.currentTimeMillis() < deadline) { + val current = service.state.value + val sinceIdle = System.currentTimeMillis() - idleAt + if (sinceIdle > 10_000 && + (current is ConnectionState.Disconnected || current is ConnectionState.Idle) + ) { + keepaliveClosed = true + break + } + Thread.sleep(50) + } + check(keepaliveClosed) { + "Keepalive timeout did not end the idle connection; state=${service.state.value}" } // A retry may have been scheduled by the close race; a user disconnect // must still end everything quietly. From 04b72347303b2912ef32e53f3a7a53c1bfb06eb7 Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Thu, 3 Sep 2026 15:53:33 -0400 Subject: [PATCH 13/21] test: require a real disconnect in the keepalive silence test Matching Idle in the silence wait exits immediately because the connection is Idle after the handshake, so the keepalive monitor never reached its fourth missed poll. Wait for an actual Disconnected transition instead; the close race may reconnect, so extend the deadline to cover a second cycle. --- .../ConnectionServiceConnectionTest.kt | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceConnectionTest.kt b/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceConnectionTest.kt index 517e7d41..671ad4ec 100644 --- a/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceConnectionTest.kt +++ b/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceConnectionTest.kt @@ -226,19 +226,18 @@ class ConnectionServiceConnectionTest { binding.use { service.connect(serverIp = "127.0.0.1", screenName = "smoke", force = true) awaitState(service, 20_000) { it is ConnectionState.Idle } - // The server stays silent; the keepalive monitor must give up after four - // missed polls (~20s) and close the connection client-side. The close race - // may also schedule a retry that reconnects (Idle again) — either outcome - // proves the client itself ended the idle connection, so require an event - // well after the initial Idle instead of matching the state we already have. + // The server stays silent: after four missed keepalive polls (~20s) the + // monitor must close the connection client-side, which always passes + // through Disconnected. Require that real transition — the state is Idle + // right now, so matching Idle would exit without any keepalive activity. + // A close race may schedule a retry that reconnects; a later cycle then + // disconnects again, still within the extended deadline. val idleAt = System.currentTimeMillis() - val deadline = idleAt + 40_000 + val deadline = idleAt + 60_000 var keepaliveClosed = false while (System.currentTimeMillis() < deadline) { - val current = service.state.value - val sinceIdle = System.currentTimeMillis() - idleAt - if (sinceIdle > 10_000 && - (current is ConnectionState.Disconnected || current is ConnectionState.Idle) + if (service.state.value is ConnectionState.Disconnected && + System.currentTimeMillis() - idleAt > 10_000 ) { keepaliveClosed = true break From de3595a170de3f71b03711f0ed5bc072b2ff598c Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Thu, 3 Sep 2026 17:03:36 -0400 Subject: [PATCH 14/21] build: drop all Kover coverage exclusions No class reports selectively anymore. The jvm session covers plain JVM logic and the android-coverage emulator session covers the framework adapters; Codecov merges both line-by-line, so the enforced 100% patch gate needs no package/class allow-lists and untested code stays visible. --- build.gradle.kts | 31 ++++--------------------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index a1bb565f..8459c9cd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,33 +16,10 @@ kover { } reports { - filters { - excludes { - packages( - "com.inputleaf.android.ui", - "com.inputleaf.android.ui.components", - "com.inputleaf.android.ui.theme", - "com.inputleaf.android.shizuku", - ) - classes( - "com.inputleaf.android.InputLeafApplication*", - "com.inputleaf.android.inject.AccessibilityInputService*", - "com.inputleaf.android.inject.InputLeafIME*", - "com.inputleaf.android.inject.AccessibilityInputInjector*", - "com.inputleaf.android.inject.KeysymInjection*", - "com.inputleaf.android.storage.ClientCertificateStore*", - // ConnectionService, CursorOverlayService and NotificationHelper remain - // excluded: they are Android framework adapters whose Service/Settings/IME/ - // overlay/notification effects the JVM cannot exercise. The connected - // android-coverage job reports ConnectionService from the emulator instead. - // ConnectionCoordinator and AppPreferences are plain JVM logic with dedicated - // unit tests and must report. - "com.inputleaf.android.service.ConnectionService*", - "com.inputleaf.android.service.CursorOverlayService*", - "com.inputleaf.android.service.NotificationHelper*", - ) - } - } + // No coverage exclusions: every class reports truthfully. The jvm session (this + // report) covers plain JVM logic; the android-coverage emulator session covers the + // framework adapters; Codecov merges both line-by-line, so the enforced 100% patch + // gate needs no package/class allow-lists and untested code stays visible. variant("debugJvm") { xml { xmlFile = layout.buildDirectory.file("reports/kover/coverage-debug-jvm.xml").get().asFile From cc728ea8df6972289638995f0cbb3fc58fc9c88c Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Thu, 3 Sep 2026 17:13:53 -0400 Subject: [PATCH 15/21] docs: describe the exclusion-free coverage merge --- docs/TESTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index 5ae71033..1c57d204 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -56,7 +56,7 @@ Place emulator smoke tests under: app/src/androidTest/java/com/inputleaf/android// ``` -Mirror the production package, name classes after the subject, and keep the suite small: these tests run on an emulator in CI on every pull request. They are smoke tests that launch real activities and bind real services to catch integration breakage the JVM suite cannot see — navigation rendering, service binding, lifecycle startup. Beyond those launch checks, `ConnectionServiceConnectionTest` drives the real connection lifecycle against a loopback Input Leap server (handshake, input routing, retry after an abrupt disconnect, TLS certificate rejection, keepalive timeout) because `ConnectionService` is excluded from JVM coverage and only the emulator can exercise its framework effects. Shared fixtures go in `app/src/androidTest/java/com/inputleaf/android/testutil/` — including `LoopbackServer`/`InputLeapTestServer`, which bind the fixed Input Leap port (24800) the service always dials. +Mirror the production package, name classes after the subject, and keep the suite small: these tests run on an emulator in CI on every pull request. They are smoke tests that launch real activities and bind real services to catch integration breakage the JVM suite cannot see — navigation rendering, service binding, lifecycle startup. Beyond those launch checks, `ConnectionServiceConnectionTest` drives the real connection lifecycle against a loopback Input Leap server (handshake, input routing, retry after an abrupt disconnect, TLS certificate rejection, keepalive timeout) because the service's framework effects are only exercisable on an emulator. Shared fixtures go in `app/src/androidTest/java/com/inputleaf/android/testutil/` — including `LoopbackServer`/`InputLeapTestServer`, which bind the fixed Input Leap port (24800) the service always dials. ## Test design principles @@ -82,7 +82,7 @@ The parallel `android-coverage` job runs a small instrumented smoke suite that i ## Coverage guardrails -Kover collects coverage from the local Android `debug` JVM tests. The `android-coverage` job collects a JaCoCo report from the connected smoke tests against the instrumented debug APK. Codecov uploads both as XML (`jvm` and `android` flags), waits for both jobs (`after_n_builds: 2` in `codecov.yml`), merges them for reporting, and comments on pull requests with project and changed-line coverage. +Kover collects coverage from the local Android `debug` JVM tests with no class exclusions — every class reports truthfully, and the jvm session's low headline number simply reflects how much logic only the emulator can reach. The `android-coverage` job collects a JaCoCo report from the connected smoke tests against the instrumented debug APK. Codecov uploads both as XML (`jvm` and `android` flags), waits for both jobs (`after_n_builds: 2` in `codecov.yml`), merges them line-by-line for reporting, and comments on pull requests with project and changed-line coverage. Because a line counts as covered when either session hits it, a class the JVM cannot execute (a framework `Service`, IME, or accessibility adapter) is still fully represented by the emulator session — no package or class allow-lists are needed anywhere, and the enforced 100% patch gate applies to every changed line regardless of which session covers it. Codecov requires 100% patch coverage: every changed executable line must be exercised by one of the suites. This is a regression guardrail, not proof that a feature is behaviorally complete; tests must still assert the relevant observable behavior and edge cases. From 35cc1163a78bf3061a2d5e71488bc4c26bcf4214 Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Fri, 4 Sep 2026 10:09:25 -0400 Subject: [PATCH 16/21] test: rename ConnectionServiceConnectionTest to ConnectionServiceLifecycleTest The old name stutters and reads as a test of a nonexistent ConnectionServiceConnection class. The new name states the subject (ConnectionService) and the behavior under test (its connection lifecycle), matching the name-classes-after-the-subject convention. --- ...iceConnectionTest.kt => ConnectionServiceLifecycleTest.kt} | 2 +- docs/TESTING.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename app/src/androidTest/java/com/inputleaf/android/service/{ConnectionServiceConnectionTest.kt => ConnectionServiceLifecycleTest.kt} (99%) diff --git a/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceConnectionTest.kt b/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceLifecycleTest.kt similarity index 99% rename from app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceConnectionTest.kt rename to app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceLifecycleTest.kt index 671ad4ec..2bdf7547 100644 --- a/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceConnectionTest.kt +++ b/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceLifecycleTest.kt @@ -36,7 +36,7 @@ import org.junit.runner.RunWith * reporting, TLS certificate rejection, and keepalive timeout. */ @RunWith(AndroidJUnit4::class) -class ConnectionServiceConnectionTest { +class ConnectionServiceLifecycleTest { // The service reads preferences when bound, so the reset must complete before binding. // Clearing through the app's own DataStore singleton also covers reused local emulators. diff --git a/docs/TESTING.md b/docs/TESTING.md index 1c57d204..07b8603f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -56,7 +56,7 @@ Place emulator smoke tests under: app/src/androidTest/java/com/inputleaf/android// ``` -Mirror the production package, name classes after the subject, and keep the suite small: these tests run on an emulator in CI on every pull request. They are smoke tests that launch real activities and bind real services to catch integration breakage the JVM suite cannot see — navigation rendering, service binding, lifecycle startup. Beyond those launch checks, `ConnectionServiceConnectionTest` drives the real connection lifecycle against a loopback Input Leap server (handshake, input routing, retry after an abrupt disconnect, TLS certificate rejection, keepalive timeout) because the service's framework effects are only exercisable on an emulator. Shared fixtures go in `app/src/androidTest/java/com/inputleaf/android/testutil/` — including `LoopbackServer`/`InputLeapTestServer`, which bind the fixed Input Leap port (24800) the service always dials. +Mirror the production package, name classes after the subject, and keep the suite small: these tests run on an emulator in CI on every pull request. They are smoke tests that launch real activities and bind real services to catch integration breakage the JVM suite cannot see — navigation rendering, service binding, lifecycle startup. Beyond those launch checks, `ConnectionServiceLifecycleTest` drives the real connection lifecycle against a loopback Input Leap server (handshake, input routing, retry after an abrupt disconnect, TLS certificate rejection, keepalive timeout) because the service's framework effects are only exercisable on an emulator. Shared fixtures go in `app/src/androidTest/java/com/inputleaf/android/testutil/` — including `LoopbackServer`/`InputLeapTestServer`, which bind the fixed Input Leap port (24800) the service always dials. ## Test design principles @@ -93,6 +93,6 @@ The initial baseline was verified with JDK 17 and Android SDK 34 when the fast C - `:app:testDebugUnitTest` passes and runs the app's Kotlin behavior tests. -The `android-coverage` CI job verifies on the API 36 emulator that `:app:createDebugCoverageReport` passes and runs the service and onboarding smoke tests plus the connected `ConnectionServiceConnectionTest` lifecycle suite, which produces the JaCoCo report Codecov uses for the `android` flag. +The `android-coverage` CI job verifies on the API 36 emulator that `:app:createDebugCoverageReport` passes and runs the service and onboarding smoke tests plus the connected `ConnectionServiceLifecycleTest` suite, which produces the JaCoCo report Codecov uses for the `android` flag. Before making changes, run the complete fast suite and treat failures as real regressions or document them explicitly. Do not skip, mute, or retry failing tests merely to produce a green build. GitHub Actions retains available test reports when either CI job fails. From 9752b31d0b63441e7cffeba1d0bc60809fd9dcdb Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Thu, 1 Oct 2026 10:35:10 -0400 Subject: [PATCH 17/21] test: align coordinator expectations with hid enter and debounced leave --- .../service/ConnectionCoordinatorTest.kt | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/app/src/test/java/com/inputleaf/android/service/ConnectionCoordinatorTest.kt b/app/src/test/java/com/inputleaf/android/service/ConnectionCoordinatorTest.kt index ff75f7fc..9994280d 100644 --- a/app/src/test/java/com/inputleaf/android/service/ConnectionCoordinatorTest.kt +++ b/app/src/test/java/com/inputleaf/android/service/ConnectionCoordinatorTest.kt @@ -143,10 +143,22 @@ class ConnectionCoordinatorTest { coordinator.onConnected(generation, "server", "phone") assertThat(coordinator.onEvent(generation, InputLeapEvent.Enter(1, 2, 3, 0))) - .containsExactly(ConnectionCoordinator.Effect.ShowCursor) + .containsExactly( + ConnectionCoordinator.Effect.UpdatePointerSpeed, + ConnectionCoordinator.Effect.HidMouseEnter(1, 2), + ConnectionCoordinator.Effect.ApplyCursor, + ConnectionCoordinator.Effect.AttachInputDevices, + ).inOrder() assertThat(coordinator.state.value).isEqualTo(ConnectionState.Active("server", "phone")) + // Leave is debounced by the Service, so the coordinator reports the side effects + // without transitioning; the Idle transition happens when the debounced onLeave fires. assertThat(coordinator.onEvent(generation, InputLeapEvent.Leave)) - .containsExactly(ConnectionCoordinator.Effect.HideCursor) + .containsExactly( + ConnectionCoordinator.Effect.HidMouseLeave, + ConnectionCoordinator.Effect.ScheduleLeave, + ).inOrder() + assertThat(coordinator.state.value).isEqualTo(ConnectionState.Active("server", "phone")) + coordinator.onLeave() assertThat(coordinator.state.value).isEqualTo(ConnectionState.Idle("server", "phone")) val mouseEvents = listOf( @@ -218,8 +230,17 @@ class ConnectionCoordinatorTest { assertThat(coordinator.setMouseEnabled(false)) .containsExactly(ConnectionCoordinator.Effect.HideCursor) + assertThat(coordinator.isMouseEnabled()).isFalse() assertThat(coordinator.onEvent(generation, move)).isEmpty() - assertThat(coordinator.onEvent(generation, InputLeapEvent.Enter(0, 0, 1, 0))).isEmpty() + // Enter still fires its HID side effects while the mouse is disabled; only + // movement routing is gated, and AttachInputDevices decides the mouse stays off. + assertThat(coordinator.onEvent(generation, InputLeapEvent.Enter(0, 0, 1, 0))) + .containsAtLeast( + ConnectionCoordinator.Effect.UpdatePointerSpeed, + ConnectionCoordinator.Effect.HidMouseEnter(0, 0), + ).inOrder() assertThat(coordinator.setMouseEnabled(true)).isEmpty() + assertThat(coordinator.isMouseEnabled()).isTrue() + assertThat(coordinator.isKeyboardEnabled()).isTrue() } } From 6d35f92f1da4069388ffbced7a11616952e06c55 Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Thu, 1 Oct 2026 11:11:09 -0400 Subject: [PATCH 18/21] test: drive leave, preference toggles, and idle detach in lifecycle suite --- .../service/ConnectionServiceLifecycleTest.kt | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceLifecycleTest.kt b/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceLifecycleTest.kt index 2bdf7547..ae07c650 100644 --- a/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceLifecycleTest.kt +++ b/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceLifecycleTest.kt @@ -1,6 +1,7 @@ package com.inputleaf.android.service import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.test.core.app.ApplicationProvider @@ -16,6 +17,7 @@ import com.inputleaf.android.testutil.enterFrame import com.inputleaf.android.testutil.isExpectedPlainProbeTermination import com.inputleaf.android.testutil.keepAliveFrame import com.inputleaf.android.testutil.keyDownFrame +import com.inputleaf.android.testutil.leaveFrame import com.inputleaf.android.testutil.mouseMoveAbsFrame import com.inputleaf.android.testutil.mouseMoveRelFrame import com.inputleaf.android.testutil.performServerHandshake @@ -97,6 +99,13 @@ class ConnectionServiceLifecycleTest { writeFrame(output, mouseMoveRelFrame(4, -6)) writeFrame(output, keyDownFrame(key = 97)) Thread.sleep(150) + // Leave is debounced: the Idle transition and the HID-leave effects run + // once the window wins; Enter then brings the pointer back before the + // connection ends abruptly. + writeFrame(output, leaveFrame()) + Thread.sleep(600) + writeFrame(output, enterFrame()) + Thread.sleep(150) socket.close() // abrupt end → unexpected-disconnect → retry } catch (failure: Exception) { if (!isExpectedPlainProbeTermination(failure)) throw failure @@ -112,6 +121,24 @@ class ConnectionServiceLifecycleTest { service.setCursorOverlayEnabled(true) + // Runtime preference toggles drive the mouse/keyboard enablement collect + // lambdas (and their HID attach gating) while the session is live. + val appContext = ApplicationProvider.getApplicationContext() + runBlocking { + appContext.dataStore.edit { + it[booleanPreferencesKey("mouse_enabled")] = false + it[booleanPreferencesKey("keyboard_enabled")] = false + } + } + Thread.sleep(200) + runBlocking { + appContext.dataStore.edit { + it[booleanPreferencesKey("mouse_enabled")] = true + it[booleanPreferencesKey("keyboard_enabled")] = true + } + } + Thread.sleep(200) + awaitState(service, 20_000) { it is ConnectionState.Disconnected } // The retry fires after ~1s and reconnects. awaitState(service, 15_000) { it !is ConnectionState.Disconnected } @@ -255,4 +282,45 @@ class ConnectionServiceLifecycleTest { } } } + + @Test + fun hidMouseIdleDetachFiresWhileTheSessionStaysAlive() { + LoopbackServer( + connectionCount = 11, + serverSocket = boundLoopbackSocket(), + ) { socket, _ -> + try { + performServerHandshake(socket) + Thread.sleep(500) + val output = DataOutputStream(socket.outputStream) + writeFrame(output, enterFrame()) + Thread.sleep(500) + writeFrame(output, leaveFrame()) + // Heartbeat faster than the client's 5s keepalive poll so the session + // stays healthy while the 30s HID-mouse idle detach window elapses. + repeat(10) { + Thread.sleep(4_000) + writeFrame(output, keepAliveFrame()) + } + } catch (failure: Exception) { + if (!isExpectedPlainProbeTermination(failure)) throw failure + } finally { + runCatching { socket.close() } + } + }.use { _ -> + val (binding, service) = boundService() + binding.use { + service.connect(serverIp = "127.0.0.1", screenName = "smoke", force = true) + awaitState(service, 20_000) { it is ConnectionState.Active } + // The debounced Leave wins first (back to Idle); then the HID-mouse + // idle detach fires on its own timer. Detaching the mouse must not + // disturb the still-healthy session or its state. + awaitState(service, 10_000) { it is ConnectionState.Idle } + Thread.sleep(35_000) + assertThat(service.state.value).isEqualTo(ConnectionState.Idle("127.0.0.1", "smoke")) + service.disconnect() + awaitState(service, 10_000) { it is ConnectionState.Disconnected } + } + } + } } From 1cbcf38d508e5aeff44bc0efb12cf18fa50f2335 Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Thu, 1 Oct 2026 11:34:04 -0400 Subject: [PATCH 19/21] test: drive injector swap and HID dispatch in lifecycle suite --- .../service/ConnectionServiceLifecycleTest.kt | 28 +++++++++ .../testutil/RecordingInputInjector.kt | 61 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 app/src/androidTest/java/com/inputleaf/android/testutil/RecordingInputInjector.kt diff --git a/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceLifecycleTest.kt b/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceLifecycleTest.kt index ae07c650..a4eb5f69 100644 --- a/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceLifecycleTest.kt +++ b/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceLifecycleTest.kt @@ -20,6 +20,7 @@ import com.inputleaf.android.testutil.keyDownFrame import com.inputleaf.android.testutil.leaveFrame import com.inputleaf.android.testutil.mouseMoveAbsFrame import com.inputleaf.android.testutil.mouseMoveRelFrame +import com.inputleaf.android.testutil.RecordingInputInjector import com.inputleaf.android.testutil.performServerHandshake import com.inputleaf.android.testutil.readFrame import com.inputleaf.android.testutil.writeFrame @@ -72,6 +73,19 @@ class ConnectionServiceLifecycleTest { throw AssertionError("State did not satisfy predicate within ${timeoutMs} ms; last=$last") } + private fun awaitCall( + injector: RecordingInputInjector, + call: String, + timeoutMs: Long = 3_000, + ) { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + if (injector.calls.contains(call)) return + Thread.sleep(25) + } + throw AssertionError("injector never recorded '$call'; calls=${injector.calls}") + } + private fun setTransportPolicyTlsOnly() { val context = ApplicationProvider.getApplicationContext() runBlocking { @@ -116,9 +130,23 @@ class ConnectionServiceLifecycleTest { assertThat(server.port).isEqualTo(24800) val (binding, service) = boundService() binding.use { + // Install an injector before connect so the Enter/move/leave effects the + // session drives reach a real object instead of a null-safe no-op. + val first = RecordingInputInjector() + service.setInjector(first) service.connect(serverIp = "127.0.0.1", screenName = "smoke", force = true) awaitState(service, 20_000) { it is ConnectionState.Active } + // The Enter above routed a HID-mouse enter to the installed injector. + awaitCall(first, "onHidMouseEnter") + // Swapping injectors while the pointer is on screen exercises the swap + // cleanup (old injector disconnected) and the pointer-on-screen re-attach + // branch that only runs when a live cursor is present. + val second = RecordingInputInjector() + service.setInjector(second) + assertThat(first.calls).contains("disconnect") + awaitCall(second, "setHidMouseAttached:true") + service.setCursorOverlayEnabled(true) // Runtime preference toggles drive the mouse/keyboard enablement collect diff --git a/app/src/androidTest/java/com/inputleaf/android/testutil/RecordingInputInjector.kt b/app/src/androidTest/java/com/inputleaf/android/testutil/RecordingInputInjector.kt new file mode 100644 index 00000000..a725e266 --- /dev/null +++ b/app/src/androidTest/java/com/inputleaf/android/testutil/RecordingInputInjector.kt @@ -0,0 +1,61 @@ +package com.inputleaf.android.testutil + +import com.inputleaf.android.inject.InputInjector +import com.inputleaf.android.model.InputLeapEvent +import java.util.Collections + +/** + * A no-op [InputInjector] that records the HID lifecycle calls the service dispatches to it. + * + * The emulator CI has no Shizuku, so the real injectors are never installed there and the + * service's HID effect arms run against a null injector. Installing this fake lets the + * connected lifecycle tests (a) drive the `setInjector` swap/re-attach path for real and + * (b) assert that Enter/Leave/move frames actually reach the injector, rather than merely + * observing a null-safe no-op. + */ +class RecordingInputInjector : InputInjector { + val calls: MutableList = Collections.synchronizedList(mutableListOf()) + val routedEvents: MutableList = Collections.synchronizedList(mutableListOf()) + + override val name: String = "recording" + + override suspend fun connect(): Boolean { + calls += "connect" + return true + } + + override fun send(event: InputLeapEvent) { + calls += "send" + routedEvents += event + } + + override fun disconnect() { + calls += "disconnect" + } + + override fun isAvailable(): Boolean = true + + override fun setHidKeyboardAttached(attached: Boolean) { + calls += "setHidKeyboardAttached:$attached" + } + + override fun setHidMouseAttached(attached: Boolean) { + calls += "setHidMouseAttached:$attached" + } + + override fun updateScreenSize(width: Int, height: Int) { + calls += "updateScreenSize" + } + + override fun updatePointerSpeed(speed: Int) { + calls += "updatePointerSpeed" + } + + override fun onHidMouseEnter(x: Int, y: Int) { + calls += "onHidMouseEnter" + } + + override fun onHidMouseLeave() { + calls += "onHidMouseLeave" + } +} From 8add0306b74eacbba69a302f704f75421b081925 Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Thu, 1 Oct 2026 14:06:34 -0400 Subject: [PATCH 20/21] test: route input and drive a mid-session QueryInfo in the lifecycle suite --- .../service/ConnectionServiceLifecycleTest.kt | 30 ++++++++----------- .../android/testutil/InputLeapTestServer.kt | 6 ++++ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceLifecycleTest.kt b/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceLifecycleTest.kt index a4eb5f69..65c60f97 100644 --- a/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceLifecycleTest.kt +++ b/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceLifecycleTest.kt @@ -1,7 +1,6 @@ package com.inputleaf.android.service import android.content.Context -import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.test.core.app.ApplicationProvider @@ -22,6 +21,7 @@ import com.inputleaf.android.testutil.mouseMoveAbsFrame import com.inputleaf.android.testutil.mouseMoveRelFrame import com.inputleaf.android.testutil.RecordingInputInjector import com.inputleaf.android.testutil.performServerHandshake +import com.inputleaf.android.testutil.queryInfoFrame import com.inputleaf.android.testutil.readFrame import com.inputleaf.android.testutil.writeFrame import java.io.DataInputStream @@ -109,8 +109,15 @@ class ConnectionServiceLifecycleTest { writeFrame(output, enterFrame()) Thread.sleep(150) writeFrame(output, keepAliveFrame()) + // Mid-session QINF: the client answers with a DINF, driving the event + // loop's QueryInfo arm (the handshake QINF is consumed inside connect()). + writeFrame(output, queryInfoFrame()) + // Space input frames so the client dispatches each route before the next; + // a burst drains faster than the IO loop routes and can drop routeInput. writeFrame(output, mouseMoveAbsFrame(120, 80)) + Thread.sleep(120) writeFrame(output, mouseMoveRelFrame(4, -6)) + Thread.sleep(120) writeFrame(output, keyDownFrame(key = 97)) Thread.sleep(150) // Leave is debounced: the Idle transition and the HID-leave effects run @@ -149,23 +156,10 @@ class ConnectionServiceLifecycleTest { service.setCursorOverlayEnabled(true) - // Runtime preference toggles drive the mouse/keyboard enablement collect - // lambdas (and their HID attach gating) while the session is live. - val appContext = ApplicationProvider.getApplicationContext() - runBlocking { - appContext.dataStore.edit { - it[booleanPreferencesKey("mouse_enabled")] = false - it[booleanPreferencesKey("keyboard_enabled")] = false - } - } - Thread.sleep(200) - runBlocking { - appContext.dataStore.edit { - it[booleanPreferencesKey("mouse_enabled")] = true - it[booleanPreferencesKey("keyboard_enabled")] = true - } - } - Thread.sleep(200) + // The mouse/key frames sent above arrive while routing is enabled and are + // dispatched through routeInput → dispatchInput to the live injector; this + // both proves routing ran and fails loudly if a route is ever dropped. + awaitCall(second, "send") awaitState(service, 20_000) { it is ConnectionState.Disconnected } // The retry fires after ~1s and reconnects. diff --git a/app/src/androidTest/java/com/inputleaf/android/testutil/InputLeapTestServer.kt b/app/src/androidTest/java/com/inputleaf/android/testutil/InputLeapTestServer.kt index 11700797..f233f1e8 100644 --- a/app/src/androidTest/java/com/inputleaf/android/testutil/InputLeapTestServer.kt +++ b/app/src/androidTest/java/com/inputleaf/android/testutil/InputLeapTestServer.kt @@ -83,6 +83,12 @@ internal fun enterFrame(): ByteArray = tagFrame(ProtocolConstants.TAG_ENTER, Byt internal fun keepAliveFrame(): ByteArray = tagFrame(ProtocolConstants.TAG_KEEPALIVE) +/** + * A post-handshake QINF the server can push mid-session; the client answers with a DINF, + * exercising the event loop's QueryInfo branch (the handshake QINF is consumed inside connect). + */ +internal fun queryInfoFrame(): ByteArray = tagFrame(ProtocolConstants.TAG_QUERY_INFO) + internal fun leaveFrame(): ByteArray = tagFrame(ProtocolConstants.TAG_LEAVE) /** MouseMoveAbs payload: x(2) y(2). */ From e892eb731df20dccd1793c48d5e78041c7f64074 Mon Sep 17 00:00:00 2001 From: Javier Guaje Date: Thu, 1 Oct 2026 14:06:42 -0400 Subject: [PATCH 21/21] refactor: evaluate connection gate before the bounds early-return in onConfigurationChanged --- .../service/ConnectionServiceLifecycleTest.kt | 7 +++++++ .../android/service/ConnectionServiceTest.kt | 16 ++++++++++++++++ .../android/service/ConnectionService.kt | 8 +++++--- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceLifecycleTest.kt b/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceLifecycleTest.kt index 65c60f97..05b2a31e 100644 --- a/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceLifecycleTest.kt +++ b/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceLifecycleTest.kt @@ -1,6 +1,7 @@ package com.inputleaf.android.service import android.content.Context +import android.content.res.Configuration import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.test.core.app.ApplicationProvider @@ -161,6 +162,12 @@ class ConnectionServiceLifecycleTest { // both proves routing ran and fails loudly if a route is ever dropped. awaitCall(second, "send") + // A configuration change during a live session evaluates the connection + // gate's Idle/Active check for real (a null connection short-circuits it); + // with unchanged bounds it stays a no-op and must not disturb the session. + service.onConfigurationChanged(Configuration()) + assertThat(service.state.value).isNotInstanceOf(ConnectionState.Disconnected::class.java) + awaitState(service, 20_000) { it is ConnectionState.Disconnected } // The retry fires after ~1s and reconnects. awaitState(service, 15_000) { it !is ConnectionState.Disconnected } diff --git a/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceTest.kt b/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceTest.kt index cda26831..56ac2245 100644 --- a/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceTest.kt +++ b/app/src/androidTest/java/com/inputleaf/android/service/ConnectionServiceTest.kt @@ -1,6 +1,7 @@ package com.inputleaf.android.service import android.content.Context +import android.content.res.Configuration import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat @@ -40,4 +41,19 @@ class ConnectionServiceTest { assertThat(service.state.value).isEqualTo(ConnectionState.Disconnected) } } + + @Test + fun configurationChangeOnIdleServiceIsHandledWithoutCrash() { + ServiceBinding(context, ConnectionService::class.java).use { binding -> + val service = + (binding.awaitBinder() as ConnectionService.LocalBinder).getService() + + // onConfigurationChanged evaluates the connection-state gate before its + // bounds-equality early return; with no live connection it must be a no-op + // that leaves the idle service untouched rather than throwing. + service.onConfigurationChanged(Configuration()) + + assertThat(service.state.value).isEqualTo(ConnectionState.Disconnected) + } + } } diff --git a/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt b/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt index ad36fb88..86af3916 100644 --- a/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt +++ b/app/src/main/java/com/inputleaf/android/service/ConnectionService.kt @@ -684,6 +684,11 @@ class ConnectionService : Service() { val bounds = getScreenBounds() val w = bounds.width() val h = bounds.height() + // The connection-state read is side-effect-free and independent of the new bounds, + // so compute it before the bounds-equality early return. + val connected = connection != null && coordinator.state.value.let { + it is ConnectionState.Idle || it is ConnectionState.Active + } if (w == screenWidth && h == screenHeight) return Log.i(TAG, "Screen bounds changed ${screenWidth}x$screenHeight -> ${w}x$h") screenWidth = w @@ -691,9 +696,6 @@ class ConnectionService : Service() { currentMouseX = currentMouseX.coerceIn(0f, w.toFloat()) currentMouseY = currentMouseY.coerceIn(0f, h.toFloat()) injector?.updateScreenSize(w, h) - val connected = connection != null && coordinator.state.value.let { - it is ConnectionState.Idle || it is ConnectionState.Active - } if (connected) { infoAckPending = true connection?.sendDataInfo(w, h, currentMouseX.toInt(), currentMouseY.toInt())