Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerSocket>(service, "serverSocket").localPort > 0)
assertTrue("Wireless must not establish a VPN", VpnScopeBoundary.calls.isEmpty())
Expand All @@ -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<ServerSocket>(service, "serverSocket")
service.detach()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerSocket>(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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -265,7 +264,7 @@ open class CarPlayVpnService : VpnService() {
active.set(true)
val generation = ++attachGeneration
return try {
startAirPlayServer(
val port = startAirPlayServer(
generation,
AirPlayAttachment(
bindAddress,
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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(),
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
}
}
Expand Down
Loading
Loading