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")) + } + } +}