From b894ca964f64cbf1dd6793c9fa73c86d318c5272 Mon Sep 17 00:00:00 2001 From: Siubeng Date: Tue, 13 Oct 2026 04:05:39 +0000 Subject: [PATCH] fix: backport priority DiPlay audio and connection fixes Correct CarPlay audio clocks, retry decoder input under backpressure, deduplicate MediaSession metadata, scope the wired VPN to WheelPlay, and retain legacy Bluetooth permissions for MIUI. --- .../media/CarPlayMediaSessionBridge.kt | 35 +++- .../airplay/CarPlayAudioFeedbackClockTest.kt | 84 +++++++++ .../media/CarPlayMediaSessionBridgeTest.kt | 76 ++++++++ .../media/MediaSessionTestShadows.kt | 5 +- .../xcertplay/network/CarPlayVpnScopeTest.kt | 173 ++++++++++++++++++ .../xcertplay/web/TeslaHttpLifecycleTest.kt | 5 + shared/src/main/AndroidManifest.xml | 9 +- .../shilapi/xcertplay/airplay/AudioStream.kt | 10 +- .../xcertplay/airplay/CarPlayMediaEngine.kt | 10 +- .../xcertplay/airplay/MicrophonePacketizer.kt | 17 +- .../xcertplay/media/AndroidMediaSink.kt | 12 +- .../xcertplay/media/MicrophoneUplink.kt | 2 +- .../xcertplay/media/VideoDecodeQueue.kt | 3 +- .../xcertplay/network/CarPlayVpnService.kt | 2 + .../airplay/CarPlayAudioClockTest.kt | 41 +++++ .../xcertplay/media/VideoDecodeQueueTest.kt | 32 +++- .../network/BluetoothManifestTest.kt | 21 +++ 17 files changed, 504 insertions(+), 33 deletions(-) create mode 100644 common/src/test/java/com/shilapi/xcertplay/airplay/CarPlayAudioFeedbackClockTest.kt create mode 100644 common/src/test/java/com/shilapi/xcertplay/network/CarPlayVpnScopeTest.kt create mode 100644 shared/src/test/java/com/shilapi/xcertplay/airplay/CarPlayAudioClockTest.kt create mode 100644 shared/src/test/java/com/shilapi/xcertplay/network/BluetoothManifestTest.kt diff --git a/common/src/main/java/com/shilapi/xcertplay/media/CarPlayMediaSessionBridge.kt b/common/src/main/java/com/shilapi/xcertplay/media/CarPlayMediaSessionBridge.kt index 145b891..905d39b 100644 --- a/common/src/main/java/com/shilapi/xcertplay/media/CarPlayMediaSessionBridge.kt +++ b/common/src/main/java/com/shilapi/xcertplay/media/CarPlayMediaSessionBridge.kt @@ -45,6 +45,11 @@ internal class CarPlayMediaSessionBridge( private var pauseRequest = 0L private var artwork: MediaArtwork? = null private var cover: Bitmap? = null + private data class PublishedMetadata( + val connection: Long, val mediaId: String, val title: String?, val artist: String?, + val album: String?, val duration: Long?, val artwork: Bitmap, + ) + private var publishedMetadata: PublishedMetadata? = null private var closed = false private val localRequestQueued = AtomicBoolean(false) private val attributes = AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA) @@ -195,20 +200,30 @@ internal class CarPlayMediaSessionBridge( private fun render() { session.isActive = state.available if (!state.available) { - session.setMetadata(null) + if (publishedMetadata != null) session.setMetadata(null) + publishedMetadata = null session.setPlaybackState(PlaybackState.Builder().setState(PlaybackState.STATE_NONE, 0, 0f).build()) changed(state, null, null) return } - val metadata = MediaMetadata.Builder() - .putString(MediaMetadata.METADATA_KEY_MEDIA_ID, "${epoch}:${state.trackRevision}:${state.persistentId}") - .putString(MediaMetadata.METADATA_KEY_TITLE, state.title?.takeIf { it.isNotBlank() } ?: state.appName) - .putString(MediaMetadata.METADATA_KEY_ARTIST, state.artist) - .putString(MediaMetadata.METADATA_KEY_ALBUM, state.album) - .putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, cover ?: defaultArtwork) - .putBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON, cover ?: defaultArtwork) - state.durationMs?.takeIf { it > 0 }?.let { metadata.putLong(MediaMetadata.METADATA_KEY_DURATION, it) } - session.setMetadata(metadata.build()) + val nextMetadata = PublishedMetadata( + state.connectionGeneration, "${epoch}:${state.trackRevision}:${state.persistentId}", + state.title?.takeIf { it.isNotBlank() } ?: state.appName, state.artist, state.album, + state.durationMs?.takeIf { it > 0 }, cover ?: defaultArtwork, + ) + // DiPlay #162: position-only updates must not repeatedly parcel album bitmaps. + if (nextMetadata != publishedMetadata) { + val metadata = MediaMetadata.Builder() + .putString(MediaMetadata.METADATA_KEY_MEDIA_ID, nextMetadata.mediaId) + .putString(MediaMetadata.METADATA_KEY_TITLE, nextMetadata.title) + .putString(MediaMetadata.METADATA_KEY_ARTIST, nextMetadata.artist) + .putString(MediaMetadata.METADATA_KEY_ALBUM, nextMetadata.album) + .putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, nextMetadata.artwork) + .putBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON, nextMetadata.artwork) + nextMetadata.duration?.let { metadata.putLong(MediaMetadata.METADATA_KEY_DURATION, it) } + session.setMetadata(metadata.build()) + publishedMetadata = nextMetadata + } val playback = when (state.status) { PhonePlaybackStatus.PLAYING -> PlaybackState.STATE_PLAYING PhonePlaybackStatus.PAUSED -> PlaybackState.STATE_PAUSED diff --git a/common/src/test/java/com/shilapi/xcertplay/airplay/CarPlayAudioFeedbackClockTest.kt b/common/src/test/java/com/shilapi/xcertplay/airplay/CarPlayAudioFeedbackClockTest.kt new file mode 100644 index 0000000..91ebba6 --- /dev/null +++ b/common/src/test/java/com/shilapi/xcertplay/airplay/CarPlayAudioFeedbackClockTest.kt @@ -0,0 +1,84 @@ +package com.shilapi.xcertplay.airplay + +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29]) +class CarPlayAudioFeedbackClockTest { + @Test fun realAudioSetupAndEncryptedRtpReportTheNegotiatedClockThroughFeedbackAndMicrophone() { + for ((bits, rate) in listOf(0x10000000L to 16000, 0x20000000L to 24000, + 0x40000000L to 48000, 0x4000L to 48000, 0x800000L to 48000)) { + val loopback = InetAddress.getLoopbackAddress() + ServerSocket(0, 1, loopback).use { server -> + Socket(loopback, server.localPort).use { client -> + val peer = server.accept() + DatagramSocket(0, loopback).use { sender -> + val started = CountDownLatch(1) + val microphoneStarted = CountDownLatch(1) + var microphone: MicrophoneConfig? = null + var decodedFormat: AudioFormat? = null + val clock = AtomicLong(1000000000L) + val engine = CarPlayMediaEngine(object : MediaSink { + override fun onAudioStarted(type: Int, format: AudioFormat, firstSample: Int) { + decodedFormat = format; started.countDown() + } + override fun onMicrophoneStarted(type: Int, config: MicrophoneConfig) { + microphone = config; microphoneStarted.countDown() + } + }, microphoneEnabled = true, nanoTime = clock::get) + val session = AirPlaySession(peer, + AirPlayConfig("test", "02:00:00:00:00:02", "02:00:00:00:00:01", "1", + AirPlayDisplayConfig(800, 480)), + AirPlayIdentity.generate(), PairingStore(), null, + object : AirPlaySessionListener {}, engine) + val shared = ByteArray(32) { it.toByte() } + ReflectionHelpers.setField(ReflectionHelpers.getField(session, "pairVerify"), "sharedSecret", shared) + try { + val audioType = if (bits == 0x800000L) "media" else "telephony" + val setup = engine.onAudio(session, 100, mapOf("streamConnectionID" to 1L, + "audioFormat" to bits, "audioType" to audioType, + "dataPort" to sender.localPort))!! + val key = AirPlayCrypto.hkdfSha512(shared, "DataStream-Salt1".toByteArray(), + "DataStream-Output-Encryption-Key".toByteArray(), 32) + val firstSample = -16 + val packet = MicrophonePacketizer.sealPacket(key, 100, + MicrophoneCounters(timestamp = firstSample), byteArrayOf(1, 2, 3), 1) + sender.send(DatagramPacket(packet, packet.size, loopback, setup["dataPort"] as Int)) + assertTrue("Encrypted audio must reach the media engine", started.await(3, TimeUnit.SECONDS)) + assertEquals(48000, decodedFormat!!.sampleRate) + clock.addAndGet(20000000000L) + val feedback = (engine.onFeedback(session)!!["streams"] as List<*>).single() as Map<*, *> + assertEquals(rate, feedback["sampleRate"]) + assertEquals((0xfffffff0L + 20L * rate) and 0xffffffffL, feedback["sampleTime"]) + if (audioType == "telephony") { + assertTrue(microphoneStarted.await(3, TimeUnit.SECONDS)) + assertEquals(rate, microphone!!.opusClockRate) + if (decodedFormat!!.codec == AudioCodecKind.OPUS) { + assertEquals(1920, microphone!!.frameBytes) + assertEquals(rate / 50, microphone!!.rtpSamplesPerPacket) + } + } else { + assertNull(microphone) + } + } finally { + engine.onSessionClosed(session); session.close() + } + } + } + } + } + } +} diff --git a/common/src/test/java/com/shilapi/xcertplay/media/CarPlayMediaSessionBridgeTest.kt b/common/src/test/java/com/shilapi/xcertplay/media/CarPlayMediaSessionBridgeTest.kt index 9833cb8..09e8057 100644 --- a/common/src/test/java/com/shilapi/xcertplay/media/CarPlayMediaSessionBridgeTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/media/CarPlayMediaSessionBridgeTest.kt @@ -17,6 +17,82 @@ import java.util.concurrent.CompletableFuture @RunWith(RobolectricTestRunner::class) @Config(sdk = [29], shadows = [RecordingMediaSession::class, LinkedMediaController::class, LinkedTransportControls::class]) class CarPlayMediaSessionBridgeTest { + @Test fun positionStatusAndOutputRouteChangesDoNotRepublishArtworkMetadata() { + val context = RuntimeEnvironment.getApplication() + val source = Source(); val output = Output() + val bridge = CarPlayMediaSessionBridge(context) { _, _, _ -> } + try { + bridge.bind(source, output); source.update(playing()); idle() + val recorded = org.robolectric.shadow.api.Shadow.extract(bridge.session) + val first = recorded.recordedMetadata + val publications = recorded.metadataPublications + repeat(20) { + source.update(playing().copy(positionMs = 3000L + it, positionAtMs = 2000L + it)); idle() + } + source.update(source.state.copy(status = PhonePlaybackStatus.PAUSED)); idle() + output.route!!(true); idle() + assertEquals(publications, recorded.metadataPublications) + assertSame(first, recorded.recordedMetadata) + assertEquals(3019L, recorded.playback!!.position) + assertEquals(PlaybackState.STATE_PAUSED, recorded.playback!!.state) + source.update(source.state.copy(title = "New title")); idle() + assertEquals(publications + 1, recorded.metadataPublications) + assertEquals("New title", recorded.recordedMetadata!!.getString(MediaMetadata.METADATA_KEY_TITLE)) + source.update(source.state.copy(durationMs = 200000)); idle() + assertEquals(publications + 2, recorded.metadataPublications) + } finally { bridge.close() } + } + + @Test fun disconnectAndIdenticalSongFromReplacementSourceRepublishMetadata() { + val context = RuntimeEnvironment.getApplication() + val old = Source(); val next = Source() + val bridge = CarPlayMediaSessionBridge(context) { _, _, _ -> } + try { + bridge.bind(old, Output()); old.update(playing()); idle() + val recorded = org.robolectric.shadow.api.Shadow.extract(bridge.session) + val firstId = recorded.recordedMetadata!!.getString(MediaMetadata.METADATA_KEY_MEDIA_ID) + old.update(NowPlayingState()); idle() + assertNull(recorded.recordedMetadata) + val count = recorded.metadataPublications + old.update(NowPlayingState()); idle() + assertEquals(count, recorded.metadataPublications) + bridge.bind(next, Output()); next.update(playing()); idle() + assertEquals(count + 1, recorded.metadataPublications) + assertNotEquals(firstId, recorded.recordedMetadata!!.getString(MediaMetadata.METADATA_KEY_MEDIA_ID)) + val afterBind = recorded.metadataPublications + next.update(playing().copy(connectionGeneration = 1)); idle() + assertEquals(afterBind + 1, recorded.metadataPublications) + } finally { bridge.close() } + } + + @Test fun completedArtworkDecodePublishesOnceAndPositionUpdatesReuseTheBitmap() { + val context = RuntimeEnvironment.getApplication() + val source = Source() + val bridge = CarPlayMediaSessionBridge(context) { _, _, _ -> } + try { + bridge.bind(source, Output()); source.update(playing()); idle() + val recorded = org.robolectric.shadow.api.Shadow.extract(bridge.session) + val fallback = recorded.recordedMetadata!!.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART) + val bitmap = android.graphics.Bitmap.createBitmap(2, 2, android.graphics.Bitmap.Config.ARGB_8888) + val bytes = java.io.ByteArrayOutputStream().use { + bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it); it.toByteArray() + } + bitmap.recycle() + source.update(playing().copy(artwork = MediaArtwork(bytes))); idle() + val deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(3) + while (recorded.recordedMetadata!!.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART) === fallback && + System.nanoTime() < deadline) { + Thread.sleep(10); idle() + } + val cover = recorded.recordedMetadata!!.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART) + assertNotSame(fallback, cover) + val count = recorded.metadataPublications + source.update(source.state.copy(positionMs = 4000)); idle() + assertEquals(count, recorded.metadataPublications) + assertSame(cover, recorded.recordedMetadata!!.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)) + } finally { bridge.close() } + } + private class Source : CarPlayMediaSource { var state = NowPlayingState() val listeners = mutableListOf<(NowPlayingState) -> Unit>() diff --git a/common/src/test/java/com/shilapi/xcertplay/media/MediaSessionTestShadows.kt b/common/src/test/java/com/shilapi/xcertplay/media/MediaSessionTestShadows.kt index 092646b..ed3e1b6 100644 --- a/common/src/test/java/com/shilapi/xcertplay/media/MediaSessionTestShadows.kt +++ b/common/src/test/java/com/shilapi/xcertplay/media/MediaSessionTestShadows.kt @@ -25,6 +25,7 @@ import org.robolectric.shadows.ShadowTransportControls class RecordingMediaSession : ShadowMediaSession() { @RealObject lateinit var real: MediaSession var recordedMetadata: MediaMetadata? = null + var metadataPublications = 0 var playback: PlaybackState? = null var callback: MediaSession.Callback? = null var handler: Handler? = null @@ -35,7 +36,9 @@ class RecordingMediaSession : ShadowMediaSession() { var volume: VolumeProvider? = null private fun register() { sessions[real.sessionToken] = this } - @Implementation fun setMetadata(value: MediaMetadata?) { register(); recordedMetadata = value } + @Implementation fun setMetadata(value: MediaMetadata?) { + register(); recordedMetadata = value; metadataPublications++ + } @Implementation fun setPlaybackState(value: PlaybackState?) { register(); playback = value } @Implementation fun setCallback(value: MediaSession.Callback?, handler: Handler?) { register(); callback = value; this.handler = handler diff --git a/common/src/test/java/com/shilapi/xcertplay/network/CarPlayVpnScopeTest.kt b/common/src/test/java/com/shilapi/xcertplay/network/CarPlayVpnScopeTest.kt new file mode 100644 index 0000000..1c072ae --- /dev/null +++ b/common/src/test/java/com/shilapi/xcertplay/network/CarPlayVpnScopeTest.kt @@ -0,0 +1,173 @@ +package com.shilapi.xcertplay.network + +import android.content.pm.PackageManager +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.net.VpnService +import android.os.ParcelFileDescriptor +import com.shilapi.xcertplay.airplay.AirPlayConfig +import com.shilapi.xcertplay.airplay.AirPlayDisplayConfig +import com.shilapi.xcertplay.airplay.AirPlayIdentity +import com.shilapi.xcertplay.airplay.AirPlayMediaHandler +import com.shilapi.xcertplay.airplay.AirPlaySessionListener +import com.shilapi.xcertplay.airplay.PairingStore +import com.shilapi.xcertplay.transport.NcmUsbBridge +import java.net.InetAddress +import java.net.ServerSocket +import java.util.concurrent.atomic.AtomicBoolean +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +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.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.annotation.RealObject +import org.robolectric.util.ReflectionHelpers +import org.robolectric.util.ReflectionHelpers.ClassParameter + +/** Adapted from DiPlay #168: exercise the attachment transaction at the Android VPN boundary. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28, 33], manifest = Config.NONE, shadows = [VpnScopeBuilderShadow::class]) +class CarPlayVpnScopeTest { + @Before fun resetVpnBoundary() { VpnScopeBoundary.reset() } + + @Test fun allowlistRejectionNeverEstablishesAnUnrestrictedTunnel() { + VpnScopeBoundary.allowFailure = PackageManager.NameNotFoundException("scope rejected") + withService { service -> + val result = attachWired(service) + + assertEquals(CarPlayVpnService.AttachResult.Failed("scope rejected"), result) + assertEquals(listOf("allow:${service.packageName}"), VpnScopeBoundary.calls) + assertReleased(service) + } + } + + @Test fun runtimePackageIsAllowedBeforeEstablishAndRevokedConsentCleansUp() { + withService { service -> + val result = attachWired(service) + + assertEquals(CarPlayVpnService.AttachResult.Failed("CarPlay VPN 未获授权"), result) + assertEquals(listOf("allow:${service.packageName}", "establish"), VpnScopeBoundary.calls) + assertReleased(service) + } + } + + @Test fun platformEstablishmentFailureDoesNotRetryOrKeepAttachmentState() { + VpnScopeBoundary.establishFailure = SecurityException("consent revoked") + withService { service -> + assertEquals(CarPlayVpnService.AttachResult.Failed("consent revoked"), attachWired(service)) + assertEquals(listOf("allow:${service.packageName}", "establish"), VpnScopeBoundary.calls) + assertReleased(service) + } + } + + @Test fun rejectedWiredReplacementClosesWirelessListenerAndAllowsFreshWirelessAttach() { + withService { service -> + assertEquals(CarPlayVpnService.AttachResult.Started, attachWireless(service)) + assertTrue(service.isAttached()) + assertTrue(ReflectionHelpers.getField(service, "serverSocket").localPort > 0) + assertTrue("Wireless must not establish a VPN", VpnScopeBoundary.calls.isEmpty()) + val oldServer = ReflectionHelpers.getField(service, "serverSocket") + + VpnScopeBoundary.allowFailure = PackageManager.NameNotFoundException("scope rejected") + assertEquals(CarPlayVpnService.AttachResult.Failed("scope rejected"), attachWired(service)) + assertTrue("Replacement releases the previous listener", oldServer.isClosed) + assertEquals(listOf("allow:${service.packageName}"), VpnScopeBoundary.calls) + assertReleased(service) + + assertEquals(CarPlayVpnService.AttachResult.Started, attachWireless(service)) + assertTrue(service.isAttached()) + val newServer = ReflectionHelpers.getField(service, "serverSocket") + service.detach() + service.detach() + assertTrue(newServer.isClosed) + assertReleased(service) + } + } + + private fun withService(check: (CarPlayVpnService) -> Unit) { + val controller = Robolectric.buildService(CarPlayVpnService::class.java).create() + try { check(controller.get()) } finally { controller.destroy() } + } + + private fun attachWired(service: CarPlayVpnService): CarPlayVpnService.AttachResult = + service.attach(ncm(), "fe80::1234", ByteArray(6), config, identity, PairingStore(), null, + object : AirPlaySessionListener {}, object : AirPlayMediaHandler {}) + + private fun attachWireless(service: CarPlayVpnService): CarPlayVpnService.AttachResult = + service.attachWireless(InetAddress.getLoopbackAddress(), config, identity, PairingStore(), null, + object : AirPlaySessionListener {}, object : AirPlayMediaHandler {}) + + private fun assertReleased(service: CarPlayVpnService) { + assertFalse(service.isAttached()) + assertFalse(ReflectionHelpers.getField(service, "active").get()) + assertNull(ReflectionHelpers.getField(service, "attachment")) + assertNull(ReflectionHelpers.getField(service, "serverSocket")) + assertNull(ReflectionHelpers.getField(service, "bridge")) + assertNull(ReflectionHelpers.getField(service, "tun")) + } + + private fun ncm(): NcmUsbBridge { + val connection = ReflectionHelpers.callConstructor(UsbDeviceConnection::class.java, + ClassParameter.from(UsbDevice::class.java, null)) + fun endpoint(address: Int): UsbEndpoint = ReflectionHelpers.callConstructor( + UsbEndpoint::class.java, ClassParameter.from(Int::class.javaPrimitiveType, address), + ClassParameter.from(Int::class.javaPrimitiveType, 2), + ClassParameter.from(Int::class.javaPrimitiveType, 512), + ClassParameter.from(Int::class.javaPrimitiveType, 0)) + return ReflectionHelpers.callConstructor(NcmUsbBridge::class.java, + ClassParameter.from(UsbDeviceConnection::class.java, connection), + ClassParameter.from(UsbEndpoint::class.java, endpoint(0x04)), + ClassParameter.from(UsbEndpoint::class.java, endpoint(0x85)), + ClassParameter.from(UsbEndpoint::class.java, null), + ClassParameter.from(List::class.java, emptyList()), + ClassParameter.from(ByteArray::class.java, null)) + } + + private val config = AirPlayConfig("test", "00:00:00:00:00:01", "00:00:00:00:00:02", + "1", AirPlayDisplayConfig(800, 480), port = 0) + private val identity = AirPlayIdentity(ByteArray(32), ByteArray(32), "test") +} + +/** No TUN can be created here: rejected scoping must stop before this boundary is reached. */ +object VpnScopeBoundary { + val calls = mutableListOf() + var allowFailure: Exception? = null + var establishFailure: Exception? = null + + fun reset() { + calls.clear() + allowFailure = null + establishFailure = null + } +} + +@Implements(VpnService.Builder::class) +class VpnScopeBuilderShadow { + @RealObject private lateinit var builder: VpnService.Builder + + // The Android implementation calls InetAddress.parseNumericAddress, which the host JVM lacks. + // Control that platform plumbing while keeping scope/establish ordering in the real service. + @Implementation fun addAddress(address: String, prefixLength: Int): VpnService.Builder = builder + + @Implementation fun addRoute(address: String, prefixLength: Int): VpnService.Builder = builder + + @Implementation fun addAllowedApplication(packageName: String): VpnService.Builder { + VpnScopeBoundary.calls += "allow:$packageName" + VpnScopeBoundary.allowFailure?.let { throw it } + return builder + } + + @Implementation fun establish(): ParcelFileDescriptor? { + VpnScopeBoundary.calls += "establish" + VpnScopeBoundary.establishFailure?.let { throw it } + return null + } +} 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 ba07a5a..4748576 100644 --- a/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpLifecycleTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpLifecycleTest.kt @@ -32,6 +32,7 @@ class TeslaHttpLifecycleTest { val addresses = mutableListOf>() val routes = mutableListOf>() val families = mutableSetOf() + val allowedApplications = mutableListOf() @Implementation fun addAddress(address: String, prefix: Int): android.net.VpnService.Builder { addresses.add(address to prefix); return builder } @@ -41,6 +42,9 @@ class TeslaHttpLifecycleTest { @Implementation fun allowFamily(family: Int): android.net.VpnService.Builder { families.add(family); return builder } + @Implementation fun addAllowedApplication(packageName: String): android.net.VpnService.Builder { + allowedApplications.add(packageName); return builder + } @Implementation fun establish(): ParcelFileDescriptor { plans.add(this) val pipe = ParcelFileDescriptor.createPipe() @@ -64,6 +68,7 @@ class TeslaHttpLifecycleTest { assertEquals(listOf("fe80::1" to 64), plan.addresses) assertEquals(listOf("fe80::" to 64), plan.routes) assertEquals(setOf(android.system.OsConstants.AF_INET, android.system.OsConstants.AF_INET6), plan.families) + assertEquals(listOf(controller.get().packageName), plan.allowedApplications) } finally { controller.destroy() AllocatingBuilderShadow.peers.forEach { it.close() } diff --git a/shared/src/main/AndroidManifest.xml b/shared/src/main/AndroidManifest.xml index 9135a37..aff8a0c 100644 --- a/shared/src/main/AndroidManifest.xml +++ b/shared/src/main/AndroidManifest.xml @@ -12,12 +12,9 @@ - - + + + AudioFormat(AudioCodecKind.OPUS, 48_000, 1, payloadType, audioType) + isOpus -> AudioFormat(AudioCodecKind.OPUS, 48_000, 1, payloadType, audioType, + rtpClockRate = MicrophoneConfig.opusClockRate(bits)) isAacLc -> AudioFormat( AudioCodecKind.AAC_LC, if ((bits and AAC_LC_48K_STEREO) != 0L) 48_000 else 44_100, diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayMediaEngine.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayMediaEngine.kt index 99da162..2c8ae45 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayMediaEngine.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayMediaEngine.kt @@ -43,6 +43,7 @@ class CarPlayMediaEngine( private val sink: MediaSink, private val microphoneEnabled: Boolean = false, private val audioCaptureDirectory: File? = null, + private val nanoTime: () -> Long = System::nanoTime, ) : AirPlayMediaHandler { internal data class StreamKey( val session: AirPlaySession, @@ -146,7 +147,7 @@ class CarPlayMediaEngine( object : AudioStream.Listener { override fun onStarted(firstSample: Int) { meta.firstSample = firstSample - meta.originNs = System.nanoTime() + meta.originNs = nanoTime() sink.onAudioStarted(type, format, firstSample) microphone?.let { sink.onMicrophoneStarted(type, it) } } @@ -258,18 +259,18 @@ class CarPlayMediaEngine( val streams = active.map { meta -> val entry = linkedMapOf( "type" to meta.type, - "sampleRate" to meta.format.sampleRate, + "sampleRate" to meta.format.rtpClockRate, ) val firstSample = meta.firstSample val originNs = meta.originNs if (firstSample != null && originNs != null) { - val nowNs = System.nanoTime() + val nowNs = nanoTime() val elapsedSec = Math.max( 0.0, (nowNs - originNs) / 1e9 - meta.playoutLatencyMs / 1000.0, ) val firstUnsigned = firstSample.toLong() and 0xffff_ffffL - val sampleTime = (firstUnsigned + Math.round(elapsedSec * meta.format.sampleRate)) and + val sampleTime = (firstUnsigned + Math.round(elapsedSec * meta.format.rtpClockRate)) and 0xffff_ffffL entry["streamConnectionID"] = unsignedPlistInteger(meta.connectionId ?: 0L) entry["timestamp"] = session.syncedNtp() @@ -360,6 +361,7 @@ class CarPlayMediaEngine( key = key, codec = format.codec, bitrate = if (format.codec == AudioCodecKind.OPUS) opusBitrate else null, + opusClockRate = MicrophoneConfig.opusClockRate(formatBits), ) } diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/MicrophonePacketizer.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/MicrophonePacketizer.kt index 3ef77f8..693e70e 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/airplay/MicrophonePacketizer.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/MicrophonePacketizer.kt @@ -14,6 +14,8 @@ data class MicrophoneConfig( val key: ByteArray, val codec: AudioCodecKind = AudioCodecKind.LPCM, val bitrate: Int? = null, + /** Negotiated CarPlay RTP clock; Opus capture/encoding remains at 48 kHz. */ + val opusClockRate: Int = 48_000, ) { val samplesPerPacket: Int get() = if (codec == AudioCodecKind.OPUS) { @@ -25,8 +27,19 @@ data class MicrophoneConfig( val frameBytes: Int get() = samplesPerPacket * channels * 2 - private companion object { - const val OPUS_SAMPLES_PER_PACKET = 960 + val rtpSamplesPerPacket: Int + get() = if (codec == AudioCodecKind.OPUS) opusClockRate * 20 / 1000 else samplesPerPacket + + companion object { + private const val OPUS_SAMPLES_PER_PACKET = 960 + + /** DiPlay #295/#547: CarPlay timestamps follow its 16/24/48 kHz format bits. */ + fun opusClockRate(formatBits: Long): Int = when { + formatBits and 0x40000000L != 0L -> 48_000 + formatBits and 0x20000000L != 0L -> 24_000 + formatBits and 0x10000000L != 0L -> 16_000 + else -> 48_000 + } } } diff --git a/shared/src/main/java/com/shilapi/xcertplay/media/AndroidMediaSink.kt b/shared/src/main/java/com/shilapi/xcertplay/media/AndroidMediaSink.kt index 7e22dcf..6dd54ac 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/media/AndroidMediaSink.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/media/AndroidMediaSink.kt @@ -510,7 +510,7 @@ private class VideoDecoder( "head=${annexB.take(16).joinToString("") { "%02x".format(it.toInt() and 0xff) }}", ) } - val index = VideoInputPump.acquire( + val index = DecoderInputPump.acquire( running = { running }, drain = { drainOutput(codec) }, dequeue = { codec.dequeueInputBuffer(INPUT_TIMEOUT_US) }, ) @@ -917,11 +917,17 @@ private class AudioRenderer( } private fun sampleTimestampUs(sample: Int): Long = - (sample.toLong() and 0xffff_ffffL) * 1_000_000L / format.sampleRate + format.timestampMicros(sample) private fun feedCodec(payload: ByteArray, presentationTimeUs: Long) { val codec = codec ?: return - val index = codec.dequeueInputBuffer(INPUT_TIMEOUT_US) + // DiPlay #618/#644: drain queued output and retry this packet instead of dropping it + // after one 10 ms wait. Keep the audio budget below the video's 500 ms budget. + val index = DecoderInputPump.acquire( + running = { running }, drain = { drainCodec(codec) }, + dequeue = { codec.dequeueInputBuffer(INPUT_TIMEOUT_US) }, + timeoutNs = DecoderInputPump.AUDIO_TIMEOUT_NS, + ) if (index < 0) { inputDropped++ if (inputDropped == 1) { diff --git a/shared/src/main/java/com/shilapi/xcertplay/media/MicrophoneUplink.kt b/shared/src/main/java/com/shilapi/xcertplay/media/MicrophoneUplink.kt index 89173ca..2f0d68e 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/media/MicrophoneUplink.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/media/MicrophoneUplink.kt @@ -177,7 +177,7 @@ internal class MicrophoneUplink( socket = socket, counters = counters, body = body, - samples = config.samplesPerPacket, + samples = config.rtpSamplesPerPacket, ) } } diff --git a/shared/src/main/java/com/shilapi/xcertplay/media/VideoDecodeQueue.kt b/shared/src/main/java/com/shilapi/xcertplay/media/VideoDecodeQueue.kt index 024b452..563fe20 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/media/VideoDecodeQueue.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/media/VideoDecodeQueue.kt @@ -51,7 +51,8 @@ internal class VideoDecodeQueue( } /** Drain output while waiting for input: full output buffers can otherwise starve input forever. */ -internal object VideoInputPump { +internal object DecoderInputPump { + const val AUDIO_TIMEOUT_NS = 100_000_000L fun acquire( running: () -> Boolean, drain: () -> Unit, 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 bb45169..ad34646 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayVpnService.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayVpnService.kt @@ -69,6 +69,8 @@ open class CarPlayVpnService : VpnService() { .setBlocking(true) .addAddress(linkLocal, LINK_PREFIX) .addRoute(LINK_LOCAL_ROUTE, LINK_PREFIX) + // DiPlay #168: reject failed scoping instead of establishing a tunnel for every UID. + .addAllowedApplication(packageName) .establish() ?: throw IOException("CarPlay VPN 未获授权") override fun onBind(intent: Intent?): IBinder? = diff --git a/shared/src/test/java/com/shilapi/xcertplay/airplay/CarPlayAudioClockTest.kt b/shared/src/test/java/com/shilapi/xcertplay/airplay/CarPlayAudioClockTest.kt new file mode 100644 index 0000000..7d6da18 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/airplay/CarPlayAudioClockTest.kt @@ -0,0 +1,41 @@ +package com.shilapi.xcertplay.airplay + +import java.net.InetAddress +import org.junit.Assert.* +import org.junit.Test + +class CarPlayAudioClockTest { + @Test fun opusKeeps48KhzPcmButUsesTheNegotiatedClockOnTheWire() { + for ((bits, rate) in listOf(0x10000000L to 16000, 0x20000000L to 24000, 0x40000000L to 48000)) { + val format = AudioStreamCodec.fromFormatBits(bits, 100, "telephony") + assertEquals(48000, format.sampleRate) + assertEquals(rate, format.rtpClockRate) + assertEquals(20000L, format.timestampMicros(rate / 50)) + val config = MicrophoneConfig("telephony", 48000, 1, 100, 20, + InetAddress.getLoopbackAddress(), 1234, ByteArray(32), AudioCodecKind.OPUS, + opusClockRate = format.rtpClockRate) + assertEquals(1920, config.frameBytes) + assertEquals(960, config.samplesPerPacket) + val counters = MicrophoneCounters() + repeat(2) { + MicrophonePacketizer.sealPacket(config.key, 100, counters, byteArrayOf(1), + config.rtpSamplesPerPacket) + } + assertEquals(rate * 40 / 1000, counters.timestamp) + assertEquals(2L, counters.nonce) + } + } + + @Test fun aacAndPcmRetainTheirClockAndUnsignedRtpWrapIsHandled() { + for (bits in listOf(0x400000L, 0x800000L, 0x4000L)) { + val format = AudioStreamCodec.fromFormatBits(bits, 100) + assertEquals(format.sampleRate, format.rtpClockRate) + assertEquals(1000000L, format.timestampMicros(format.sampleRate)) + assertEquals(0xffffffffL * 1000000L / format.rtpClockRate, format.timestampMicros(-1)) + } + val pcm = MicrophoneConfig("telephony", 16000, 1, 100, 20, + InetAddress.getLoopbackAddress(), 1234, ByteArray(32)) + assertEquals(640, pcm.frameBytes) + assertEquals(320, pcm.rtpSamplesPerPacket) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/media/VideoDecodeQueueTest.kt b/shared/src/test/java/com/shilapi/xcertplay/media/VideoDecodeQueueTest.kt index df9a6ea..37f206c 100644 --- a/shared/src/test/java/com/shilapi/xcertplay/media/VideoDecodeQueueTest.kt +++ b/shared/src/test/java/com/shilapi/xcertplay/media/VideoDecodeQueueTest.kt @@ -5,6 +5,32 @@ import org.junit.Assert.* import org.junit.Test class VideoDecodeQueueTest { + @Test fun audioRetriesTheSamePacketAfterOutputBackpressureWithin100Milliseconds() { + var now = 0L + var heldOutput = true + var drains = 0 + val index = DecoderInputPump.acquire( + running = { true }, + drain = { drains++; if (now >= 30_000_000L) heldOutput = false }, + dequeue = { now += 10_000_000L; if (heldOutput) -1 else 3 }, + nanoTime = { now }, timeoutNs = DecoderInputPump.AUDIO_TIMEOUT_NS, + ) + assertEquals(3, index) + assertEquals(4, drains) + assertEquals(40_000_000L, now) + } + + @Test fun stalledAudioStopsAfter100MillisecondsInsteadOfTheVideoBudget() { + var now = 0L + var attempts = 0 + assertEquals(-1, DecoderInputPump.acquire( + running = { true }, drain = {}, dequeue = { attempts++; now += 10_000_000L; -1 }, + nanoTime = { now }, timeoutNs = DecoderInputPump.AUDIO_TIMEOUT_NS, + )) + assertEquals(10, attempts) + assertEquals(100_000_000L, now) + } + @Test fun lostReferenceChainWaitsForSuccessfullyQueuedKeyframe() { val chain = VideoReferenceChain() val predicted = byteArrayOf(0, 0, 0, 1, 0x41, 1) @@ -50,7 +76,7 @@ class VideoDecodeQueueTest { var dequeues = 0 val submitted = mutableListOf() for (frame in 1..3) { - val index = VideoInputPump.acquire( + val index = DecoderInputPump.acquire( running = { true }, drain = { if (heldOutputs > 0) heldOutputs-- }, dequeue = { dequeues++; if (heldOutputs > 0) -1 else 0 }, @@ -66,11 +92,11 @@ class VideoDecodeQueueTest { @Test fun stalledDecoderHasFiniteWaitAndShutdownCancelsImmediately() { var time = 0L var attempts = 0 - assertEquals(-1, VideoInputPump.acquire( + assertEquals(-1, DecoderInputPump.acquire( running = { true }, drain = {}, dequeue = { attempts++; -1 }, nanoTime = { time.also { time += 10 } }, timeoutNs = 30, )) assertEquals(3, attempts) - assertEquals(-1, VideoInputPump.acquire(running = { false }, drain = { fail() }, dequeue = { fail(); 0 })) + assertEquals(-1, DecoderInputPump.acquire(running = { false }, drain = { fail() }, dequeue = { fail(); 0 })) } } diff --git a/shared/src/test/java/com/shilapi/xcertplay/network/BluetoothManifestTest.kt b/shared/src/test/java/com/shilapi/xcertplay/network/BluetoothManifestTest.kt new file mode 100644 index 0000000..d88262b --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/network/BluetoothManifestTest.kt @@ -0,0 +1,21 @@ +package com.shilapi.xcertplay.network + +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import org.junit.Assert.* +import org.junit.Test + +class BluetoothManifestTest { + @Test fun legacyBluetoothPermissionsRemainDeclaredAboveAndroid11AlongsideConnect() { + val factory = DocumentBuilderFactory.newInstance().apply { isNamespaceAware = true } + val permissions = factory.newDocumentBuilder().parse(File("src/main/AndroidManifest.xml")) + .getElementsByTagName("uses-permission") + val android = "http://schemas.android.com/apk/res/android" + val entries = (0 until permissions.length).map { permissions.item(it) as org.w3c.dom.Element } + for (permission in listOf("BLUETOOTH", "BLUETOOTH_ADMIN", "BLUETOOTH_CONNECT")) { + val entry = entries.single { it.getAttributeNS(android, "name") == "android.permission.$permission" } + assertFalse("MIUI 13 checks legacy Bluetooth permissions on Android 12", + entry.hasAttributeNS(android, "maxSdkVersion")) + } + } +}