Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4f5ad32
test: cover coordination and persistence behavior
guaje Aug 27, 2026
230f9a2
test: return Unit from preference test methods
guaje Aug 27, 2026
377af65
test: cover coordination and persistence branches
guaje Sep 1, 2026
a208286
test: cover remaining coordination branches
guaje Sep 1, 2026
57cf58b
test: cover preference defaults and malformed records
guaje Sep 1, 2026
e966d41
test: retain Android preference constructor coverage
guaje Sep 1, 2026
7a9e6b8
test: keep Android dependencies out of JVM tests
guaje Sep 1, 2026
f1d89c4
ci: count executed JaCoCo partial lines as covered
guaje Sep 1, 2026
f68ccab
build: report JVM-covered coordinator and preferences, count JaCoCo p…
guaje Sep 3, 2026
b251be2
test: drive the connection lifecycle against a loopback server
guaje Sep 3, 2026
21129ea
fix: use the reuseAddress property in the loopback fixture
guaje Sep 3, 2026
c901223
test: wait for a real keepalive-timeout event in the silence test
guaje Sep 3, 2026
04b7234
test: require a real disconnect in the keepalive silence test
guaje Sep 3, 2026
de3595a
build: drop all Kover coverage exclusions
guaje Sep 3, 2026
cc728ea
docs: describe the exclusion-free coverage merge
guaje Sep 3, 2026
35cc116
test: rename ConnectionServiceConnectionTest to ConnectionServiceLife…
guaje Sep 4, 2026
9752b31
test: align coordinator expectations with hid enter and debounced leave
guaje Oct 1, 2026
6d35f92
test: drive leave, preference toggles, and idle detach in lifecycle s…
guaje Oct 1, 2026
1cbcf38
test: drive injector swap and HID dispatch in lifecycle suite
guaje Oct 1, 2026
8add030
test: route input and drive a mid-session QueryInfo in the lifecycle …
guaje Oct 1, 2026
e892eb7
refactor: evaluate connection gate before the bounds early-return in …
guaje Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
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 {
reuseAddress = 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)

/**
* 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). */
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)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Throwable>()
private val workers = CopyOnWriteArrayList<Thread>()
private val activeSockets = CopyOnWriteArrayList<Socket>()
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
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String> = Collections.synchronizedList(mutableListOf<String>())
val routedEvents: MutableList<InputLeapEvent> = Collections.synchronizedList(mutableListOf<InputLeapEvent>())

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"
}
}
Loading
Loading