From b05fff5db5d340c6ed7ac1ee24f1a7464c468bdc Mon Sep 17 00:00:00 2001 From: xiwangly2 Date: Thu, 29 Oct 2026 20:07:16 +0800 Subject: [PATCH] fix: recover from occupied CarPlay control ports --- .../xcertplay/network/CarPlayVpnScopeTest.kt | 4 +- .../network/WirelessListenerIdentityTest.kt | 6 +- .../xcertplay/web/TeslaHttpLifecycleTest.kt | 4 +- .../xcertplay/airplay/AirPlayConfig.kt | 1 + .../shilapi/xcertplay/airplay/RtspMessage.kt | 2 +- .../xcertplay/network/AirPlayListener.kt | 44 ++++ .../xcertplay/network/CarPlayVpnService.kt | 21 +- .../orchestration/CarPlayController.kt | 34 +-- .../xcertplay/network/AirPlayListenerTest.kt | 170 ++++++++++++ .../network/CarPlayAirPlayPortTest.kt | 243 ++++++++++++++++++ 10 files changed, 495 insertions(+), 34 deletions(-) create mode 100644 shared/src/main/java/com/shilapi/xcertplay/network/AirPlayListener.kt create mode 100644 shared/src/test/java/com/shilapi/xcertplay/network/AirPlayListenerTest.kt create mode 100644 shared/src/test/java/com/shilapi/xcertplay/network/CarPlayAirPlayPortTest.kt diff --git a/common/src/test/java/com/shilapi/xcertplay/network/CarPlayVpnScopeTest.kt b/common/src/test/java/com/shilapi/xcertplay/network/CarPlayVpnScopeTest.kt index 337fdd53..093497bb 100644 --- a/common/src/test/java/com/shilapi/xcertplay/network/CarPlayVpnScopeTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/network/CarPlayVpnScopeTest.kt @@ -70,7 +70,7 @@ class CarPlayVpnScopeTest { @Test fun rejectedWiredReplacementClosesWirelessListenerAndAllowsFreshWirelessAttach() { withService { service -> - assertEquals(CarPlayVpnService.AttachResult.Started, attachWireless(service)) + assertTrue(attachWireless(service) is CarPlayVpnService.AttachResult.Started) assertTrue(service.isAttached()) assertTrue(ReflectionHelpers.getField(service, "serverSocket").localPort > 0) assertTrue("Wireless must not establish a VPN", VpnScopeBoundary.calls.isEmpty()) @@ -82,7 +82,7 @@ class CarPlayVpnScopeTest { assertEquals(listOf("allow:${service.packageName}"), VpnScopeBoundary.calls) assertReleased(service) - assertEquals(CarPlayVpnService.AttachResult.Started, attachWireless(service)) + assertTrue(attachWireless(service) is CarPlayVpnService.AttachResult.Started) assertTrue(service.isAttached()) val newServer = ReflectionHelpers.getField(service, "serverSocket") service.detach() diff --git a/common/src/test/java/com/shilapi/xcertplay/network/WirelessListenerIdentityTest.kt b/common/src/test/java/com/shilapi/xcertplay/network/WirelessListenerIdentityTest.kt index c3af3c23..c637c796 100644 --- a/common/src/test/java/com/shilapi/xcertplay/network/WirelessListenerIdentityTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/network/WirelessListenerIdentityTest.kt @@ -27,12 +27,14 @@ class WirelessListenerIdentityTest { try { repeat(2) { generation -> val identity = AirPlayListenerIdentity(generation) - assertEquals(CarPlayVpnService.AttachResult.Started, service.attachWireless( + val result = service.attachWireless( InetAddress.getLoopbackAddress(), AirPlayConfig("test", "00:00:00:00:00:01", "00:00:00:00:00:02", "1", AirPlayDisplayConfig(800, 480), port = 0), AirPlayIdentity(ByteArray(32), ByteArray(32), "test"), PairingStore(), null, - listener, object : AirPlayMediaHandler {}, listenerIdentity = identity)) + listener, object : AirPlayMediaHandler {}, listenerIdentity = identity) + assertTrue(result is CarPlayVpnService.AttachResult.Started) val server = ReflectionHelpers.getField(service, "serverSocket") + assertEquals(server.localPort, (result as CarPlayVpnService.AttachResult.Started).port) Socket(InetAddress.getLoopbackAddress(), server.localPort).use { val accepted = requireNotNull(events.poll(2, TimeUnit.SECONDS)) assertSame(identity, accepted.listener) diff --git a/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpLifecycleTest.kt b/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpLifecycleTest.kt index a2e3931e..830fc4aa 100644 --- a/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpLifecycleTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpLifecycleTest.kt @@ -107,12 +107,12 @@ class TeslaHttpLifecycleTest { val controller = Robolectric.buildService(RoutingVpnService::class.java).create() val service = controller.get() try { - assertEquals(CarPlayVpnService.AttachResult.Started, service.attachWireless( + assertTrue(service.attachWireless( java.net.InetAddress.getByName("127.0.0.1"), AirPlayConfig("Test", "00:11:22:33:44:55", "00:11:22:33:44:55", "1.0", AirPlayDisplayConfig(1280, 720), port = 0), AirPlayIdentity.generate(), PairingStore(), null, - object : AirPlaySessionListener {}, object : AirPlayMediaHandler {})) + object : AirPlaySessionListener {}, object : AirPlayMediaHandler {}) is CarPlayVpnService.AttachResult.Started) assertTrue(service.isAttached()) service.onRevoke() assertTrue("Wireless listener is independent of the wired VPN", service.isAttached()) diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayConfig.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayConfig.kt index 403d4860..66a91106 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayConfig.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayConfig.kt @@ -38,6 +38,7 @@ data class AirPlayConfig( val main: AirPlayDisplayConfig, val cluster: AirPlayDisplayConfig? = null, val rightHandDrive: Boolean = false, + /** Preferred listen port; conflicts fall back to an OS-assigned port. Zero requests one directly. */ val port: Int = 7000, val entertainmentSampleRate: Int = 48000, val hevc: Boolean = false, diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/RtspMessage.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/RtspMessage.kt index ad038134..c73937d1 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/airplay/RtspMessage.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/RtspMessage.kt @@ -5,7 +5,7 @@ import java.io.ByteArrayOutputStream /** * RTSP/HTTP-style request framing for the CarPlay control channel. * - * The control connection on TCP :7000 speaks a text request line plus headers plus an optional + * The control connection on the advertised TCP port speaks a text request line plus headers plus an optional * body, using the same framing RTSP and HTTP share. Encryption, once pair-verify completes, wraps * this framing in a separate layer. */ diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/AirPlayListener.kt b/shared/src/main/java/com/shilapi/xcertplay/network/AirPlayListener.kt new file mode 100644 index 00000000..9c934c90 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/network/AirPlayListener.kt @@ -0,0 +1,44 @@ +package com.shilapi.xcertplay.network + +import java.net.BindException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket + +/** Keeps the bound socket, so selecting an available port cannot race with another listener. */ +internal fun bindAirPlayListener( + address: InetAddress, + preferredPort: Int, + socketFactory: () -> ServerSocket = ::ServerSocket, +): ServerSocket { + require(preferredPort in 0..65535) { "AirPlay port must be in 0..65535" } + + fun bind(port: Int): ServerSocket { + val server = socketFactory() + try { + server.bind(InetSocketAddress(address, port)) + return server + } catch (error: Exception) { + try { + server.close() + } catch (closeError: Exception) { + if (closeError !== error) error.addSuppressed(closeError) + } + throw error + } + } + + return try { + bind(preferredPort) + } catch (original: BindException) { + if (preferredPort == 0) throw original + try { + // Keep the same interface/address. A missing address still fails instead of + // silently exposing the listener on every network interface. + bind(0) + } catch (fallback: Exception) { + if (fallback !== original) fallback.addSuppressed(original) + throw fallback + } + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayVpnService.kt b/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayVpnService.kt index 29745f6c..333a499b 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayVpnService.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayVpnService.kt @@ -23,7 +23,6 @@ import com.shilapi.xcertplay.transport.NcmUsbBridge import java.io.IOException import java.net.Inet6Address import java.net.InetAddress -import java.net.InetSocketAddress import java.net.NetworkInterface import java.net.ServerSocket import java.net.Socket @@ -42,7 +41,7 @@ open class CarPlayVpnService : VpnService() { } sealed class AttachResult { - data object Started : AttachResult() + data class Started(val port: Int) : AttachResult() data object AlreadyStarted : AttachResult() @@ -232,11 +231,11 @@ open class CarPlayVpnService : VpnService() { ipv6Bridge.start() bridge = ipv6Bridge - startAirPlayServer( + val port = startAirPlayServer( generation, AirPlayAttachment(address, config, identity, pairings, mfi, listener, media), ) - AttachResult.Started + AttachResult.Started(port) } catch (error: Exception) { releaseLocked() AttachResult.Failed(error.message ?: error.javaClass.simpleName) @@ -265,7 +264,7 @@ open class CarPlayVpnService : VpnService() { active.set(true) val generation = ++attachGeneration return try { - startAirPlayServer( + val port = startAirPlayServer( generation, AirPlayAttachment( bindAddress, @@ -278,7 +277,7 @@ open class CarPlayVpnService : VpnService() { listenerIdentity, ), ) - AttachResult.Started + AttachResult.Started(port) } catch (error: Exception) { releaseLocked() AttachResult.Failed(error.message ?: error.javaClass.simpleName) @@ -315,15 +314,17 @@ open class CarPlayVpnService : VpnService() { super.onDestroy() } - private fun startAirPlayServer(generation: Int, replacement: AirPlayAttachment) { - val server = ServerSocket() - server.bind(InetSocketAddress(replacement.address, replacement.config.port)) - attachment = replacement + private fun startAirPlayServer(generation: Int, replacement: AirPlayAttachment): Int { + val server = bindAirPlayListener(replacement.address, replacement.config.port) + val port = server.localPort + attachment = replacement.copy(config = replacement.config.copy(port = port)) serverSocket = server Thread({ acceptLoop(generation, server) }, "airplay-accept").apply { isDaemon = true start() } + Log.i(TAG, "AirPlay listener port=$port requestedPort=${replacement.config.port}") + return port } private fun acceptLoop(generation: Int, server: ServerSocket) { diff --git a/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayController.kt b/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayController.kt index 970187df..b3e9ba93 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayController.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayController.kt @@ -936,7 +936,7 @@ class CarPlayController( val service = awaitVpnService() ?: throw IOException("Could not bind the CarPlay AirPlay service") startedHotspot?.validateReady() - when ( + val boundAirPlayPort = when ( val result = service.attachWireless( bindAddress = hostAddress, config = wirelessAirPlayConfig, @@ -948,15 +948,16 @@ class CarPlayController( media = media, ) ) { - CarPlayVpnService.AttachResult.Started -> Unit + is CarPlayVpnService.AttachResult.Started -> result.port CarPlayVpnService.AttachResult.AlreadyStarted -> throw IOException("Wireless AirPlay transport is already attached") is CarPlayVpnService.AttachResult.Failed -> throw IOException(result.message) } + val boundAirPlayConfig = wirelessAirPlayConfig.copy(port = boundAirPlayPort) debugLog( "wireless AirPlay listener attached bind=$hostAddressText " + - "port=${airPlayConfig.port}", + "port=$boundAirPlayPort", ) if (isStaleWirelessRun(generation)) { closeWirelessStack() @@ -965,7 +966,7 @@ class CarPlayController( val bonjourClient = CarPlayBonjour( context = appContext, - config = wirelessAirPlayConfig, + config = boundAirPlayConfig, identity = identity, advertisedHost = hostAddress.hostAddress, // Bind discovery and its connect probe to the same AP/address family as AirPlay. @@ -1017,7 +1018,7 @@ class CarPlayController( channel = hotspotInfo.channel, security = hotspotInfo.security, ipAddresses = listOf(hostAddressText), - airPlayPort = airPlayConfig.port, + airPlayPort = boundAirPlayPort, deviceIdentifier = deviceIdentifier, publicKey = identity.publicKeyHex, sourceVersion = airPlayConfig.sourceVersion, @@ -1641,9 +1642,8 @@ class CarPlayController( val ncmHostMac = ncm.hostMac ?: config.hostMac debugLog("ncm using hostMac=${ncmHostMac.macString()}") - if (!attachVpn(ncm, ncmHostMac)) { - throw IphoneUsbException.DeviceUnavailable("Could not attach the NCM/VPN AirPlay transport") - } + val boundAirPlayPort = attachVpn(ncm, ncmHostMac) + ?: throw IphoneUsbException.DeviceUnavailable("Could not attach the NCM/VPN AirPlay transport") ncmOwnedLocally = false debugLog("wired NCM/VPN AirPlay transport attached") if (closed) { @@ -1655,7 +1655,7 @@ class CarPlayController( ?: throw IphoneUsbException.DeviceUnavailable("MFi coprocessor client is unavailable") val endpoint = Iap2WiredCarPlayEndpoint( ipv6Addresses = listOf(config.linkLocal), - airPlayPort = airPlayConfig.port, + airPlayPort = boundAirPlayPort, publicKey = identity.publicKeyHex, sourceVersion = airPlayConfig.sourceVersion, deviceIdentifier = ncmHostMac.macString(), @@ -1966,12 +1966,12 @@ class CarPlayController( else -> CONTROL_LOOP_TIMEOUT_MILLIS } - private fun attachVpn(ncm: NcmUsbBridge, hostMac: ByteArray): Boolean { + private fun attachVpn(ncm: NcmUsbBridge, hostMac: ByteArray): Int? { onStatus(CarPlayStatus.AttachingNetwork) val service = awaitVpnService() ?: run { debugLog("wired VPN service bind failed") ncm.close() - return false + return null } debugLog("wired VPN service bound; attaching NCM transport") val result = try { @@ -1989,23 +1989,23 @@ class CarPlayController( } catch (error: Throwable) { ncm.close() onStatus(CarPlayStatus.Failed(error.message ?: error.javaClass.simpleName)) - return false + return null } return when (result) { - CarPlayVpnService.AttachResult.Started -> { - debugLog("wired VPN/NCM transport attach result=started") - true + is CarPlayVpnService.AttachResult.Started -> { + debugLog("wired VPN/NCM transport attach result=started port=${result.port}") + result.port } CarPlayVpnService.AttachResult.AlreadyStarted -> { debugLog("wired VPN/NCM transport attach result=already-started") ncm.close() - false + null } is CarPlayVpnService.AttachResult.Failed -> { debugLog("wired VPN/NCM transport attach result=failed ${result.message}") ncm.close() onStatus(CarPlayStatus.Failed(result.message)) - false + null } } } diff --git a/shared/src/test/java/com/shilapi/xcertplay/network/AirPlayListenerTest.kt b/shared/src/test/java/com/shilapi/xcertplay/network/AirPlayListenerTest.kt new file mode 100644 index 00000000..929e2963 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/network/AirPlayListenerTest.kt @@ -0,0 +1,170 @@ +package com.shilapi.xcertplay.network + +import java.io.Closeable +import java.io.IOException +import java.net.BindException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.SocketAddress +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Test + +class AirPlayListenerTest { + private val address = InetAddress.getByName("127.0.0.1") + + @Test fun aSuccessfulPreferredBindReturnsTheSameOpenSocket() { + Sockets(null).use { sockets -> + val listener = bindAirPlayListener(address, 7000, sockets::create) + + assertSame(sockets[0], listener) + assertEquals(listOf(InetSocketAddress(address, 7000)), sockets[0].binds) + assertEquals(0, sockets[0].closeCalls) + assertEquals(1, sockets.created) + } + } + + @Test fun aCollisionClosesTheFirstSocketBeforeBindingDynamicallyOnTheSameAddress() { + Sockets(BindException("Address already in use"), null).use { sockets -> + val listener = bindAirPlayListener(address, 7000, sockets::create) + + assertSame(sockets[1], listener) + assertEquals(listOf(InetSocketAddress(address, 7000)), sockets[0].binds) + assertEquals(listOf(InetSocketAddress(address, 0)), sockets[1].binds) + assertEquals(1, sockets[0].closeCalls) + assertEquals(0, sockets[1].closeCalls) + assertEquals(2, sockets.created) + assertEquals( + listOf("create:0", "bind:0:7000", "close:0", "create:1", "bind:1:0"), + sockets.events, + ) + } + } + + @Test fun explicitlyRequestingAnEphemeralPortOnlyBindsOnce() { + Sockets(null).use { sockets -> + val listener = bindAirPlayListener(address, 0, sockets::create) + + assertSame(sockets[0], listener) + assertEquals(listOf(InetSocketAddress(address, 0)), sockets[0].binds) + assertEquals(0, sockets[0].closeCalls) + assertEquals(1, sockets.created) + } + } + + @Test fun anEphemeralBindFailureIsClosedWithoutAnotherAttempt() { + val failure = BindException("No dynamic port available") + Sockets(failure).use { sockets -> + val thrown = assertThrows(BindException::class.java) { + bindAirPlayListener(address, 0, sockets::create) + } + + assertSame(failure, thrown) + assertEquals(listOf(InetSocketAddress(address, 0)), sockets[0].binds) + assertEquals(1, sockets[0].closeCalls) + assertEquals(1, sockets.created) + } + } + + @Test fun anUnrelatedIoFailureIsClosedAndPropagatedWithoutFallback() { + assertNoFallback(IOException("Socket operation failed")) + } + + @Test fun aSecurityFailureIsClosedAndPropagatedWithoutFallback() { + assertNoFallback(SecurityException("Listen denied")) + } + + @Test fun aFailedFallbackClosesBothSocketsAndRetainsTheOriginalBindFailure() { + for (fallbackFailure in listOf( + BindException("No dynamic port available"), + IOException("Socket operation failed"), + SecurityException("Listen denied"), + )) { + val collision = BindException("Address already in use") + Sockets(collision, fallbackFailure).use { sockets -> + val thrown = assertThrows(Exception::class.java) { + bindAirPlayListener(address, 7000, sockets::create) + } + + assertSame(fallbackFailure, thrown) + assertSame(collision, thrown.suppressed.single()) + assertEquals(1, sockets[0].closeCalls) + assertEquals(1, sockets[1].closeCalls) + assertEquals(2, sockets.created) + assertEquals(listOf(InetSocketAddress(address, 0)), sockets[1].binds) + } + } + } + + @Test fun invalidPortsAreRejectedBeforeCreatingASocket() { + for (port in listOf(-1, 65_536)) { + var created = 0 + assertThrows(IllegalArgumentException::class.java) { + bindAirPlayListener(address, port) { + created++ + throw AssertionError("Invalid port must not allocate a socket") + } + } + assertEquals(0, created) + } + } + + private fun assertNoFallback(failure: Exception) { + Sockets(failure).use { sockets -> + val thrown = assertThrows(Exception::class.java) { + bindAirPlayListener(address, 7000, sockets::create) + } + + assertSame(failure, thrown) + assertEquals(listOf(InetSocketAddress(address, 7000)), sockets[0].binds) + assertEquals(1, sockets[0].closeCalls) + assertEquals(1, sockets.created) + } + } + + private class Sockets(vararg failures: Exception?) : Closeable { + val events = mutableListOf() + private val sockets = failures.mapIndexed { index, failure -> + FakeSocket(index, failure, events) + } + var created = 0 + private set + + operator fun get(index: Int): FakeSocket = sockets[index] + + fun create(): ServerSocket { + val index = created++ + events += "create:$index" + return sockets.getOrElse(index) { throw AssertionError("Unexpected socket retry") } + } + + override fun close() { + sockets.filter { it.closeCalls == 0 }.forEach { it.close() } + } + } + + private class FakeSocket( + private val id: Int, + private val failure: Exception?, + private val events: MutableList, + ) : ServerSocket() { + val binds = mutableListOf() + var closeCalls = 0 + private set + + override fun bind(endpoint: SocketAddress?) { + val target = endpoint as InetSocketAddress + binds += target + events += "bind:$id:${target.port}" + failure?.let { throw it } + } + + override fun close() { + closeCalls++ + events += "close:$id" + super.close() + } + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/network/CarPlayAirPlayPortTest.kt b/shared/src/test/java/com/shilapi/xcertplay/network/CarPlayAirPlayPortTest.kt new file mode 100644 index 00000000..e3a68ef6 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/network/CarPlayAirPlayPortTest.kt @@ -0,0 +1,243 @@ +package com.shilapi.xcertplay.network + +import android.content.Context +import android.net.nsd.NsdManager +import com.shilapi.xcertplay.airplay.AirPlayConfig +import com.shilapi.xcertplay.airplay.AirPlayDisplayConfig +import com.shilapi.xcertplay.airplay.AirPlayIdentity +import com.shilapi.xcertplay.airplay.AirPlayListenerIdentity +import com.shilapi.xcertplay.airplay.AirPlayMediaHandler +import com.shilapi.xcertplay.airplay.AirPlaySessionListener +import com.shilapi.xcertplay.airplay.AirPlayTcpAccepted +import com.shilapi.xcertplay.airplay.PairingStore +import com.shilapi.xcertplay.iap2.body.Iap2BodyReader +import com.shilapi.xcertplay.transport.Iap2WiredCarPlayEndpoint +import com.shilapi.xcertplay.transport.Iap2WiredControlClient +import com.shilapi.xcertplay.transport.Iap2WirelessCarPlayEndpoint +import com.shilapi.xcertplay.transport.Iap2WirelessControlClient +import com.shilapi.xcertplay.transport.Iap2WirelessSecurity +import java.io.IOException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import org.junit.Assert.* +import org.junit.Assume.assumeNoException +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class CarPlayAirPlayPortTest { + @Test fun occupiedPreferredPortFallsBackToAReachableListener() { + // Keep the competing listener open throughout attachment; no free-port probe race. + ServerSocket(0, 1, loopback).use { occupied -> + withService { service -> + val events = LinkedBlockingQueue() + val owner = AirPlayListenerIdentity(1) + val listener = object : AirPlaySessionListener { + override fun onTcpAccepted(event: AirPlayTcpAccepted) { events.add(event) } + } + val result = attach(service, occupied.localPort, listener, owner) + + assertStarted(service, result) + val server = listenerSocket(service) + assertTrue(server.localPort in 1..65535) + assertNotEquals(occupied.localPort, server.localPort) + assertEquals(loopback, server.inetAddress) + Socket().use { client -> + client.connect(InetSocketAddress(loopback, server.localPort), 2_000) + val accepted = events.poll(2, TimeUnit.SECONDS) + assertNotNull("The fallback listener must accept a real TCP connection", accepted) + assertSame(owner, accepted!!.listener) + } + + service.detach() + assertTrue(server.isClosed) + assertReleased(service) + assertFalse("The competing listener belongs to another owner", occupied.isClosed) + } + } + } + + @Test fun explicitEphemeralPortIsAllocatedAndReleasedOnDetach() { + withService { service -> + assertStarted(service, attach(service, 0)) + val server = listenerSocket(service) + val port = server.localPort + assertTrue(port in 1..65535) + + service.detach() + service.detach() + assertTrue(server.isClosed) + assertReleased(service) + ServerSocket().use { replacement -> + replacement.bind(InetSocketAddress(loopback, port)) + assertEquals(port, replacement.localPort) + } + } + } + + @Test fun occupiedIpv6PortFallsBackOnTheSameAddress() { + val ipv6 = InetAddress.getByName("::1") + val competitor = try { + ServerSocket(0, 1, ipv6) + } catch (error: IOException) { + assumeNoException("Host IPv6 loopback binding is unavailable", error) + return + } + competitor.use { occupied -> + withService { service -> + val result = assertStarted(service, + attach(service, occupied.localPort, bindAddress = ipv6)) + assertNotEquals(occupied.localPort, result.port) + val server = listenerSocket(service) + assertEquals(ipv6, server.inetAddress) + Socket().use { client -> + client.connect(InetSocketAddress(ipv6, result.port), 2_000) + } + service.detach() + assertTrue(server.isClosed) + assertReleased(service) + assertFalse(occupied.isClosed) + } + } + } + + @Test fun availablePreferredPortIsPreservedOnReattachment() { + withService { service -> + assertStarted(service, attach(service, 0)) + val first = listenerSocket(service) + val preferredPort = first.localPort + service.detach() + assertTrue(first.isClosed) + + assertStarted(service, attach(service, preferredPort)) + assertEquals(preferredPort, listenerSocket(service).localPort) + } + } + + @Test fun invalidPortFailsWithoutLeavingAttachmentAndAllowsAnotherAttempt() { + withService { service -> + assertTrue(attach(service, -1) is CarPlayVpnService.AttachResult.Failed) + assertReleased(service) + + assertStarted(service, attach(service, 0)) + assertTrue(listenerSocket(service).localPort > 0) + service.detach() + assertReleased(service) + } + } + + @Test fun boundPortIsEncodedInStartSessionAndNsdPublication() { + ServerSocket(0, 1, loopback).use { occupied -> + withService { service -> + val result = assertStarted(service, attach(service, occupied.localPort)) + assertNotEquals(occupied.localPort, result.port) + val boundConfig = attachmentConfig(service) + + // Exercise protocol conversion of the bound result, without physical USB/Bluetooth. + val wired = Iap2WiredControlClient.carPlayStartSession(Iap2WiredCarPlayEndpoint( + ipv6Addresses = listOf("fe80::2"), airPlayPort = result.port, + publicKey = identity.publicKeyHex, sourceVersion = boundConfig.sourceVersion, + )) + val wireless = Iap2WirelessControlClient.carPlayStartSession(Iap2WirelessCarPlayEndpoint( + ssid = "port-test", passphrase = "test-password", channel = 36, + security = Iap2WirelessSecurity.WPA_WPA2, ipAddresses = listOf("192.168.43.1"), + airPlayPort = result.port, deviceIdentifier = boundConfig.deviceId, + publicKey = identity.publicKeyHex, sourceVersion = boundConfig.sourceVersion, + )) + assertEquals(result.port.toLong(), Iap2BodyReader.of(wired).u32(2)) + assertEquals(result.port.toLong(), Iap2BodyReader.of(wireless).u32(2)) + + val context = RuntimeEnvironment.getApplication() + val nsd = shadowOf(context.getSystemService(Context.NSD_SERVICE) as NsdManager) + CarPlayBonjour(context, boundConfig, identity).use { bonjour -> + bonjour.start() + val registration = ReflectionHelpers.getField( + bonjour, "registrationListener", + ) + val published = nsd.getRegisteredServiceInfo(registration) + assertNotNull(published) + assertEquals(result.port, published!!.port) + } + } + } + } + + @Test fun oldAttachmentResultKeepsItsPortAfterRebinding() { + withService { service -> + val first = assertStarted(service, attach(service, 0)) + val firstPort = first.port + val firstServer = listenerSocket(service) + service.detach() + assertTrue(firstServer.isClosed) + + ServerSocket().use { occupied -> + occupied.bind(InetSocketAddress(loopback, firstPort)) + val replacement = assertStarted(service, attach(service, firstPort)) + assertNotEquals(firstPort, replacement.port) + assertEquals(firstPort, first.port) + assertFalse(occupied.isClosed) + } + } + } + + private fun withService(check: (CarPlayVpnService) -> Unit) { + val lifecycle = Robolectric.buildService(CarPlayVpnService::class.java).create() + try { check(lifecycle.get()) } finally { lifecycle.destroy() } + } + + private fun attach( + service: CarPlayVpnService, + port: Int, + listener: AirPlaySessionListener = object : AirPlaySessionListener {}, + owner: AirPlayListenerIdentity? = null, + bindAddress: InetAddress = loopback, + ): CarPlayVpnService.AttachResult = service.attachWireless( + bindAddress, + AirPlayConfig("port-test", "00:00:00:00:00:01", "00:00:00:00:00:02", "1", + AirPlayDisplayConfig(800, 480), port = port), + identity, + PairingStore(), null, listener, object : AirPlayMediaHandler {}, listenerIdentity = owner, + ) + + private fun listenerSocket(service: CarPlayVpnService): ServerSocket = + ReflectionHelpers.getField(service, "serverSocket") + + private fun attachmentConfig(service: CarPlayVpnService): AirPlayConfig = + ReflectionHelpers.getField(service, "attachment").let { + ReflectionHelpers.getField(it, "config") + } + + private fun assertStarted( + service: CarPlayVpnService, + result: CarPlayVpnService.AttachResult, + ): CarPlayVpnService.AttachResult.Started { + assertTrue("AirPlay attachment must succeed: $result", + result is CarPlayVpnService.AttachResult.Started) + return (result as CarPlayVpnService.AttachResult.Started).also { + assertEquals(listenerSocket(service).localPort, it.port) + assertEquals(it.port, attachmentConfig(service).port) + } + } + + private fun assertReleased(service: CarPlayVpnService) { + assertFalse(service.isAttached()) + assertFalse(ReflectionHelpers.getField(service, "active").get()) + assertNull(ReflectionHelpers.getField(service, "attachment")) + assertNull(ReflectionHelpers.getField(service, "serverSocket")) + } + + private val loopback = InetAddress.getByName("127.0.0.1") + private val identity = AirPlayIdentity(ByteArray(32), ByteArray(32), "port-test") +}