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
37 changes: 37 additions & 0 deletions common/src/main/assets/concentus-license.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
Copyright (c) by various holding parties, including (but not limited to):
Skype Limited, Xiph.Org Foundation, CSIRO, Microsoft Corporation,
Jean-Marc Valin, Gregory Maxwell, Mark Borgerding, Timothy B. Terriberry,
Logan Stromberg. All rights are reserved by their respective holders.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.

* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.

* Neither the name of Internet Society, IETF or IETF Trust, nor the
names of specific contributors, may be used to endorse or promote
products derived from this software without specific prior written
permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.


This repository and its redistributable packages contain independently compiled
versions of the Opus C reference library, which is maintained by Xiph.org and the
Opus open-source contributors. The source code for these libraries is freely available
at https://gitlab.xiph.org/xiph/opus/-/tags/v1.5.2, and all binaries are being
redistributed to you under the same terms of the general Opus license dictated above.
12 changes: 10 additions & 2 deletions common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ class CarPlayHostActivity : ComponentActivity() {
private var startAfterHandshakeReset = false
private var restartGeneration = 0
private var reconnectScheduled = false
private var automaticReconnectAllowed = true
private var sessionLog: SessionLogFile? = null
private var gestureSequenceActive = false
private var gestureTracking = false
Expand Down Expand Up @@ -2377,6 +2378,8 @@ class CarPlayHostActivity : ComponentActivity() {
hotspotStatus.copy(state = "Connecting Bluetooth")
CarPlayStatus.RunningWireless ->
hotspotStatus.copy(state = "Running")
CarPlayStatus.WirelessActiveFallback ->
hotspotStatus.copy(state = "Active (Bluetooth control)")
CarPlayStatus.WirelessActive ->
hotspotStatus.copy(state = "Active")
CarPlayStatus.AttachingNetwork ->
Expand Down Expand Up @@ -2623,6 +2626,7 @@ class CarPlayHostActivity : ComponentActivity() {
rightHandDrive = rightHandDrive,
hevc = hevcEnabled,
microphone = AirPlayPersistence.loadMicrophoneSource(this) != CarPlayMicrophoneSource.DISABLED,
microphoneOpus = com.shilapi.xcertplay.media.OpusEncoderSupport.isAvailable(),
manufacturer = normalizedManufacturer(),
model = normalizedModel(),
oemLabel = normalizedManufacturer(),
Expand Down Expand Up @@ -2881,6 +2885,7 @@ class CarPlayHostActivity : ComponentActivity() {
controllerGeneration: Int,
): (CarPlayStatus) -> Unit = { status ->
if (!menuOpen && controllerGeneration == restartGeneration) {
if (status is CarPlayStatus.Failed && !status.automaticRetry) automaticReconnectAllowed = false
updateHotspotStatus(status)
val description = status.describe()
setConnectionStage(description)
Expand All @@ -2889,7 +2894,7 @@ class CarPlayHostActivity : ComponentActivity() {
wifiRecoveryButton?.visibility = View.VISIBLE
} else {
wifiRecoveryButton?.visibility = View.GONE
reconnectAfterLoss(description)
if (status.automaticRetry) reconnectAfterLoss(description)
}
else -> Unit
}
Expand Down Expand Up @@ -2946,6 +2951,7 @@ class CarPlayHostActivity : ComponentActivity() {
private fun startCarPlay(size: DisplaySize) {
if (CarPlayBackgroundSession.hasSession() && !CarPlayBackgroundSession.isOwner(this)) return
if (shuttingDown.get() || menuOpen || handshakeResetInProgress || controller != null) return
automaticReconnectAllowed = true
if (DiPlayBootstrap.importing) {
setConnectionStage("认证资源正在导入,请完成后重新连接")
return
Expand Down Expand Up @@ -3136,6 +3142,7 @@ class CarPlayHostActivity : ComponentActivity() {
}

private fun reconnectAfterLoss(reason: String) {
if (!automaticReconnectAllowed) return
if (!CarPlayBackgroundSession.isOwner(this)) return
if (shuttingDown.get() || menuOpen || handshakeResetInProgress) return
if (reconnectScheduled) return
Expand All @@ -3152,7 +3159,7 @@ class CarPlayHostActivity : ComponentActivity() {
{
reconnectScheduled = false
if (
shuttingDown.get() ||
!automaticReconnectAllowed || shuttingDown.get() ||
menuOpen ||
handshakeResetInProgress ||
generation != restartGeneration
Expand Down Expand Up @@ -3462,6 +3469,7 @@ class CarPlayHostActivity : ComponentActivity() {
CarPlayStatus.WaitingForPairedIphone -> "Waiting for paired iPhone"
CarPlayStatus.ConnectingBluetooth -> "Connecting Bluetooth"
CarPlayStatus.RunningWireless -> "Wireless CarPlay control running"
CarPlayStatus.WirelessActiveFallback -> "Wireless CarPlay active with Bluetooth control"
CarPlayStatus.WirelessActive -> "Wireless CarPlay active"
CarPlayStatus.DiscoveringIphone -> "Discovering iPhone"
CarPlayStatus.WaitingForIphone -> "Waiting for iPhone over USB"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ class CarPlayVpnScopeTest {
ClassParameter.from(UsbEndpoint::class.java, endpoint(0x85)),
ClassParameter.from(UsbEndpoint::class.java, null),
ClassParameter.from(List::class.java, emptyList<android.hardware.usb.UsbInterface>()),
ClassParameter.from(ByteArray::class.java, null))
ClassParameter.from(ByteArray::class.java, null),
ClassParameter.from(kotlin.jvm.functions.Function1::class.java, { _: String -> Unit }))
}

private val config = AirPlayConfig("test", "00:00:00:00:00:01", "00:00:00:00:00:02",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.shilapi.xcertplay.network

import com.shilapi.xcertplay.airplay.*
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.util.ReflectionHelpers

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class WirelessListenerIdentityTest {
@Test fun localProbesAreMarkedInternalAndRebindingUsesTheNewListenerIdentity() {
val lifecycle = Robolectric.buildService(CarPlayVpnService::class.java).create()
val service = lifecycle.get()
val events = LinkedBlockingQueue<AirPlayTcpAccepted>()
val listener = object : AirPlaySessionListener {
override fun onTcpAccepted(event: AirPlayTcpAccepted) { events.add(event) }
}
try {
repeat(2) { generation ->
val identity = AirPlayListenerIdentity(generation)
assertEquals(CarPlayVpnService.AttachResult.Started, 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))
val server = ReflectionHelpers.getField<ServerSocket>(service, "serverSocket")
Socket(InetAddress.getLoopbackAddress(), server.localPort).use {
val accepted = requireNotNull(events.poll(2, TimeUnit.SECONDS))
assertSame(identity, accepted.listener)
assertTrue(accepted.internal)
assertTrue(accepted.acceptedAtNanos > 0)
}
}
assertTrue(events.isEmpty())
} finally { lifecycle.destroy() }
}
}
2 changes: 2 additions & 0 deletions docs/upstream/THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ CarPlay and the CarPlay icon are Apple Inc. marks/assets. This asset is not cove

## Runtime dependencies

- Concentus — pure Java Opus implementation by the Concentus and Opus contributors, BSD-style license. Vendored from `lostromb/concentus` commit `3885c4e46513ef0fc81fca100189e54f1714c6ca` via DiPlay. Full copyright and license notices are in `shared/src/main/java/org/concentus/LICENSE` and the APK asset `concentus-license.txt`.

- AndroidX and Jetpack Compose — Android Open Source Project; Apache License 2.0.
- Kotlin standard library — JetBrains; Apache License 2.0.
- Quickie 1.12.0 — Thomas Wirth; MIT License. Its bundled flavor includes the on-device ML Kit barcode model; the Quickie license text is in `docs/QUICKIE-LICENSE.txt`.
Expand Down
2 changes: 2 additions & 0 deletions shared/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ android {
ndkVersion = '28.2.13676358'
externalNativeBuild { ndkBuild { path = file('src/main/jni/Android.mk') } }
}
testOptions { unitTests.includeAndroidResources = true }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
Expand All @@ -34,4 +35,5 @@ dependencies {
implementation libs.bouncycastle
implementation libs.jmdns
testImplementation libs.junit
testImplementation "org.robolectric:robolectric:4.17"
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ data class AirPlayConfig(
val hevc: Boolean = false,
val disableAudioOutput: Boolean = false,
val microphone: Boolean = false,
val microphoneOpus: Boolean = true,
val manufacturer: String = "xcertplay",
val model: String = "xcertplay",
val oemLabel: String = "xcertplay",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ object AirPlayInfoPlist {
)
if (!config.disableAudioOutput) {
info["audioLatencies"] = audioLatencies()
info["audioFormats"] = audioFormats(config.entertainmentSampleRate, config.microphone)
info["audioFormats"] = audioFormats(config.entertainmentSampleRate, config.microphone, config.microphoneOpus)
}
info["extendedFeatures"] = listOf("vocoderInfo", "enhancedRequestCarUI")
info["displays"] = displays
Expand Down Expand Up @@ -110,6 +110,7 @@ object AirPlayInfoPlist {
private fun audioFormats(
entertainmentRate: Int,
microphone: Boolean,
microphoneOpus: Boolean,
): List<Map<String, Any?>> {
fun format(type: Int, audioType: String, outputFormats: Int, inputFormats: Int? = null): Map<String, Any?> {
val entry = linkedMapOf<String, Any?>(
Expand All @@ -128,7 +129,7 @@ object AirPlayInfoPlist {
val opus = 0x70000000
val aacLc = if (is48) 0x800000 else 0x400000
val pcmInput = if (microphone) pcmMono else null
val wirelessInput = if (microphone) pcmMono or opus else null
val wirelessInput = if (microphone) (if (microphoneOpus) pcmMono or opus else pcmMono) else null

return listOf(
format(100, "compatibility", pcm, pcmInput),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ data class AirPlayDeviceInfo(

/** Session lifecycle and command callbacks for the driver/UI layer. */
interface AirPlaySessionListener {
fun onTcpAccepted(event: AirPlayTcpAccepted) {}
fun onSessionActive(session: AirPlaySession) {}
fun onSessionEnded(session: AirPlaySession) {}
fun onVideoFrameRendered(session: AirPlaySession) {}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.shilapi.xcertplay.airplay

import java.net.InetAddress
import java.net.NetworkInterface
import java.util.concurrent.atomic.AtomicLong

/** 每次绑定生成独立身份,防止旧 accept 和旧清理作用于新 listener。 */
class AirPlayListenerIdentity(val generation: Int) {
val id: Long = nextId.incrementAndGet()
private companion object { val nextId = AtomicLong() }
}

data class AirPlayTcpAccepted(
val listener: AirPlayListenerIdentity,
val internal: Boolean,
val acceptedAtNanos: Long,
)

internal fun isInternalAirPlayPeer(peer: InetAddress, local: InetAddress): Boolean =
peer.isLoopbackAddress || peer == local ||
runCatching { NetworkInterface.getByInetAddress(peer) != null }.getOrDefault(true)
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.shilapi.xcertplay.media

import java.io.Closeable

/** Encodes 20 ms chunks of 48 kHz mono PCM into raw Opus access units for the CarPlay microphone uplink. */
internal interface MicrophoneOpusEncoder : Closeable {
/** Short implementation name for diagnostic reports. */
val implementation: String

val available: Boolean

/** Encodes one 20 ms little-endian 16-bit PCM frame and returns the Opus access units it produced. */
fun encode(pcm: ByteArray): List<ByteArray>
}

internal object MicrophoneOpusEncoders {
/**
* Android's MediaCodec Opus encoder exists only from Android 10 (API 29). Wireless CarPlay sends
* calls and Siri as Opus, so older head units fall back to the bundled software encoder.
*/
fun create(
bitrate: Int,
platform: (Int) -> MicrophoneOpusEncoder = { OpusEncoder(it) },
software: (Int) -> MicrophoneOpusEncoder = { SoftwareOpusEncoder(it) },
): MicrophoneOpusEncoder? {
val platformEncoder = platform(bitrate)
if (platformEncoder.available) return platformEncoder
platformEncoder.close()
val softwareEncoder = software(bitrate)
if (softwareEncoder.available) return softwareEncoder
softwareEncoder.close()
return null
}
}
Loading
Loading