From c669c89eae4a3e0ed1232d4ff0bc6e6a0c68152c Mon Sep 17 00:00:00 2001 From: Siubeng Date: Fri, 23 Oct 2026 12:56:08 +0000 Subject: [PATCH 1/4] feat: add experimental Android Auto USB and TCP protocol source --- README.md | 2 +- README.zh-CN.md | 2 + .../com/shilapi/xcertplay/DiPlayActivity.kt | 34 ++- .../shilapi/xcertplay/DiPlaySessionService.kt | 3 +- .../xcertplay/projection/IccoaSource.kt | 16 ++ .../projection/ProjectionSessions.kt | 6 + .../projection/androidauto/AapAudio.kt | 122 ++++++++ .../projection/androidauto/AapConnection.kt | 123 ++++++++ .../projection/androidauto/AapTls.kt | 99 +++++++ .../projection/androidauto/AapVideo.kt | 52 ++++ .../androidauto/AndroidAutoSource.kt | 263 ++++++++++++++++++ .../com/shilapi/xcertplay/web/WebSession.kt | 4 +- .../projection/androidauto/AapVideoTest.kt | 40 +++ .../androidauto/AndroidAutoSessionTest.kt | 146 ++++++++++ .../androidauto/IccoaAvailabilityTest.kt | 33 +++ docs/ANDROID_AUTO.md | 48 ++++ docs/PROJECTION_IMPLEMENTATION.md | 2 + docs/README.md | 1 + docs/upstream/THIRD_PARTY_NOTICES.md | 4 + .../xcertplay/androidauto/AapFraming.kt | 49 ++++ .../shilapi/xcertplay/androidauto/AapProto.kt | 46 +++ .../xcertplay/androidauto/AapServices.kt | 31 +++ .../shilapi/xcertplay/androidauto/AapTouch.kt | 36 +++ .../xcertplay/projection/ProjectionRuntime.kt | 10 +- .../xcertplay/projection/ProjectionSource.kt | 11 +- .../res/values-zh-rCN/projection_strings.xml | 6 +- .../res/values-zh-rTW/projection_strings.xml | 6 +- .../main/res/values/projection_strings.xml | 6 +- .../xcertplay/androidauto/AapProtocolTest.kt | 80 ++++++ .../projection/ProjectionRuntimeTest.kt | 28 ++ 30 files changed, 1295 insertions(+), 14 deletions(-) create mode 100644 common/src/main/java/com/shilapi/xcertplay/projection/IccoaSource.kt create mode 100644 common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapAudio.kt create mode 100644 common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapConnection.kt create mode 100644 common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapTls.kt create mode 100644 common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapVideo.kt create mode 100644 common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSource.kt create mode 100644 common/src/test/java/com/shilapi/xcertplay/projection/androidauto/AapVideoTest.kt create mode 100644 common/src/test/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSessionTest.kt create mode 100644 common/src/test/java/com/shilapi/xcertplay/projection/androidauto/IccoaAvailabilityTest.kt create mode 100644 docs/ANDROID_AUTO.md create mode 100644 shared/src/main/java/com/shilapi/xcertplay/androidauto/AapFraming.kt create mode 100644 shared/src/main/java/com/shilapi/xcertplay/androidauto/AapProto.kt create mode 100644 shared/src/main/java/com/shilapi/xcertplay/androidauto/AapServices.kt create mode 100644 shared/src/main/java/com/shilapi/xcertplay/androidauto/AapTouch.kt create mode 100644 shared/src/test/java/com/shilapi/xcertplay/androidauto/AapProtocolTest.kt diff --git a/README.md b/README.md index a812d8d9..fdc46b8c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Install the app on an Android device and open the displayed address in your car' ## Features -- **Multiple-source framework**: A single active projection session routes video, touch and media controls independently of CarPlay. Three diagnostic sources exercise H.264, Surface and framebuffer output; Android Auto and ICCOA adapters are follow-up work. See the [implementation and device test guide](docs/PROJECTION_IMPLEMENTATION.md). +- **Multiple-source framework**: A single active projection session routes video, touch and media controls independently of CarPlay. Three diagnostic sources exercise H.264, Surface and framebuffer output; Experimental Android Auto USB AOA and explicit Head Unit Server TCP support is available; ICCOA is blocked on its proprietary SDK. See the [Android Auto setup and limitations](docs/ANDROID_AUTO.md). See the [implementation and device test guide](docs/PROJECTION_IMPLEMENTATION.md). - **Browser display and touch control**: CarPlay streaming, touch input, and fullscreen display. - **Selectable streaming modes**: Choose WebRTC video passthrough (H.264 / HEVC) or image compatibility mode in settings, with JPEG / WebP format and image quality from 1 to 100. Image streaming retains JPEG compatibility fallback and overlaps up to two frames while audio and touch keep their existing routes. - **Optional browser audio**: After pairing, the browser can play CarPlay audio. The browser microphone can provide CarPlay voice input through a trusted HTTPS page; Android handles audio and microphone input by default. diff --git a/README.zh-CN.md b/README.zh-CN.md index c659461a..1ce7e10f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,6 +8,8 @@ WheelPlay 是一款 Android 局域网 CarPlay 服务端应用,可将 iPhone ## 功能 +- **多输入源与实验性 Android Auto**:支持 CarPlay、三类诊断源,以及 Android Auto USB AOA/显式 Head Unit Server TCP 接入。AA 使用 720p H.264、浏览器触控和宿主音频/麦克风,仍需真机验收;ICCOA 因缺少专有 SDK 暂不可用。详见 [Android Auto 使用与限制](docs/ANDROID_AUTO.md)。 + - **浏览器显示与触控**:支持 CarPlay 画面串流、触摸操作和全屏显示。 - **可选串流技术**:设置中可选 WebRTC 视频直通(H.264 / HEVC)或图片兼容模式,并调整 JPEG / WebP 格式和 1–100 的图片质量。图片串流保留 JPEG 兼容回退,最多两帧并行传输,音频与触控保持原有路径。详见[图片串流与多缓冲](docs/IMAGE_STREAM.md)。 - **可选浏览器音频**:配对后可改由浏览器播放 CarPlay 音频;浏览器麦克风可通过可信 HTTPS 页面接入 CarPlay 语音输入,默认仍由 Android 处理。 diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt index 4444156d..f817f659 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt @@ -567,14 +567,38 @@ class DiPlayActivity : AppCompatActivity() { val sources = projectionSessions.sources() android.app.AlertDialog.Builder(this).setTitle(getString(UiR.string.projection_choose_demo)) .setItems(sources.map { it.descriptor.displayName }.toTypedArray()) { _, index -> - startWebServiceIfReady() - projectionSessions.switchTo(sources[index].descriptor.id).whenComplete { _, failure -> runOnUiThread { - if (failure != null) toast(getString(UiR.string.projection_failed, failure.cause?.message ?: failure.message.orEmpty())) - refreshStatus(); refreshService() - } } + val id = sources[index].descriptor.id + if (id == "android-auto-tcp") { + val host = android.widget.EditText(this).apply { + setSingleLine(true) + hint = "192.168.1.2" + setText(getSharedPreferences("projection", 0).getString("aa_host", "")) + } + android.app.AlertDialog.Builder(this) + .setTitle(getString(UiR.string.aa_tcp_title)) + .setMessage(getString(UiR.string.aa_tcp_hint)) + .setView(host) + .setPositiveButton(android.R.string.ok) { _, _ -> + val address = host.text.toString().trim() + if (!com.shilapi.xcertplay.projection.androidauto.AapTcpConnection.validAddress(address)) { + toast(getString(UiR.string.aa_invalid_address)) + } else { + getSharedPreferences("projection", 0).edit().putString("aa_host", address).putInt("aa_port", 5277).apply() + startProjectionSource(id) + } + }.setNegativeButton(android.R.string.cancel, null).show() + } else startProjectionSource(id) }.setNegativeButton(android.R.string.cancel, null).show() } + private fun startProjectionSource(id: String) { + startWebServiceIfReady() + projectionSessions.switchTo(id).whenComplete { _, failure -> runOnUiThread { + if (failure != null) toast(getString(UiR.string.projection_failed, failure.cause?.message ?: failure.message.orEmpty())) + refreshStatus(); refreshService() + } } + } + private fun refreshAuthentication() { setupError = if (DiPlayBootstrap.importing) getString(UiR.string.ui_importing_authentication_resources_please_wait) else runCatching { DiPlayBootstrap.ensure(this) }.exceptionOrNull()?.let { diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt index 7b73eda5..316614bd 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt @@ -239,7 +239,8 @@ class DiPlaySessionService : Service() { val notification = builder.build() if (Build.VERSION.SDK_INT >= 29) { var types = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE - if (currentState.available && currentState.playing) + if (currentState.available && currentState.playing || + projectionSessions.snapshot()?.let { it.capabilities.audioPlayback && !it.capabilities.browserAudio } == true) types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK if ( Build.VERSION.SDK_INT >= 30 && diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/IccoaSource.kt b/common/src/main/java/com/shilapi/xcertplay/projection/IccoaSource.kt new file mode 100644 index 00000000..5df0d85c --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/IccoaSource.kt @@ -0,0 +1,16 @@ +package com.shilapi.xcertplay.projection + +import android.content.Context +import java.util.concurrent.CompletableFuture + +/** No proprietary SDK is bundled. Do not start BLE/P2P or claim a usable CarLink session. */ +internal class IccoaSource(private val context: Context) : ProjectionSourceProvider { + override val descriptor = SourceDescriptor("iccoa-carlink", "ICCOA CarLink") + override val resources = setOf(ProjectionResource.USB, ProjectionResource.WIRELESS, + ProjectionResource.DISPLAY, ProjectionResource.MICROPHONE) + override fun probe(): SourceAvailability = SourceAvailability.Unavailable( + context.getString(com.shilapi.xcertplay.shared.R.string.iccoa_sdk_unavailable)) + override fun open(): CompletableFuture = CompletableFuture().also { + it.completeExceptionally(IllegalStateException((probe() as SourceAvailability.Unavailable).reason)) + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSessions.kt b/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSessions.kt index 0857fc63..86393ad5 100644 --- a/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSessions.kt +++ b/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSessions.kt @@ -6,6 +6,12 @@ internal val projectionSessions = ProjectionRuntime() @Synchronized internal fun registerProjectionSources(context: android.content.Context) { if (projectionSessions.sources().isNotEmpty()) return val app = context.applicationContext + listOf(true, false).forEach { usb -> + projectionSessions.register(com.shilapi.xcertplay.projection.androidauto.AndroidAutoSource(app, usb) { + BrowserProjectionVideo(app, 1280, 720, VideoIngressKind.ENCODED) + }) + } + projectionSessions.register(IccoaSource(app)) VideoIngressKind.entries.forEach { kind -> projectionSessions.register(SyntheticSource(kind, { ingress, width, height -> BrowserProjectionVideo(app, width, height, ingress) }, diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapAudio.kt b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapAudio.kt new file mode 100644 index 00000000..511d6d60 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapAudio.kt @@ -0,0 +1,122 @@ +package com.shilapi.xcertplay.projection.androidauto + +import android.content.Context +import android.media.* +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** PCM endpoints are bounded independently of AAP acknowledgements and browser video. */ +internal class AapAudio(context: Context, private val sendMic: (ByteArray) -> Boolean, + private val focusChanged: (Int) -> Unit, private val failed: (Throwable) -> Unit, + private val microphoneAllowed: () -> Boolean) : AutoCloseable { + private val manager = context.getSystemService(AudioManager::class.java) + private val closed = AtomicBoolean() + private val queue = ArrayBlockingQueue>(32) + private val tracks = mutableMapOf() + @Volatile private var focusState = 1 + @Volatile private var focus: AudioFocusRequest? = null + private val focusListener = AudioManager.OnAudioFocusChangeListener { value -> + focusState = when (value) { + AudioManager.AUDIOFOCUS_GAIN -> 1 + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> 4 + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> 5 + else -> 3 + } + if (focusState == 3 || focusState == 5) queue.clear() + focusChanged(focusState) + } + private val stoppingChannels = java.util.concurrent.ConcurrentLinkedQueue() + private val player = Thread({ + try { + while (!closed.get()) { + while (true) { + val stop = stoppingChannels.poll() ?: break + tracks.remove(stop)?.release() + } + val (channel, bytes) = queue.poll(100, TimeUnit.MILLISECONDS) ?: continue + if (focusState == 3 || focusState == 5) continue + val track = tracks.getOrPut(channel) { + val rate = if (channel == 6) 48000 else 16000 + val mask = if (channel == 6) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO + val minimum = AudioTrack.getMinBufferSize(rate, mask, AudioFormat.ENCODING_PCM_16BIT) + check(minimum > 0) + AudioTrack.Builder().setAudioAttributes(AudioAttributes.Builder() + .setUsage(if (channel == 6) AudioAttributes.USAGE_MEDIA else AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE) + .setContentType(if (channel == 6) AudioAttributes.CONTENT_TYPE_MUSIC else AudioAttributes.CONTENT_TYPE_SPEECH).build()) + .setAudioFormat(AudioFormat.Builder().setSampleRate(rate).setChannelMask(mask).setEncoding(AudioFormat.ENCODING_PCM_16BIT).build()) + .setBufferSizeInBytes(maxOf(minimum, rate / 5 * if (channel == 6) 4 else 2)).build().apply { play() } + } + // Never let a slow audio device block the protocol reader. Dropped PCM has no prediction dependency. + track.setVolume(if (focusState == 4) 0.2f else 1f) + track.write(bytes, 0, bytes.size, AudioTrack.WRITE_NON_BLOCKING) + } + } catch (error: Throwable) { if (!closed.get()) failed(error) } + finally { tracks.values.forEach { it.release() }; tracks.clear() } + }, "aa-pcm").apply { start() } + @Volatile private var recorder: AudioRecord? = null + private var micThread: Thread? = null + fun offer(channel: Int, bytes: ByteArray) { + if (closed.get()) return + if (!queue.offer(channel to bytes)) { queue.poll(); queue.offer(channel to bytes) } + } + fun stopChannel(channel: Int) { + queue.removeIf { it.first == channel } + stoppingChannels.offer(channel) + } + @Synchronized fun requestFocus(request: Int): Int { + focus?.let { manager.abandonAudioFocusRequest(it) } + focus = null + if (request == 4 || closed.get()) { focusState = 3; queue.clear(); return 3 } + val gain = when (request) { + 2 -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT + 3 -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK + else -> AudioManager.AUDIOFOCUS_GAIN + } + val next = AudioFocusRequest.Builder(gain) + .setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build()) + .setOnAudioFocusChangeListener(focusListener).build() + focus = next + val state = if (manager.requestAudioFocus(next) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + if (request == 1) 1 else 2 + } else 3 + focusState = state + return state + } + @android.annotation.SuppressLint("MissingPermission") // Provider gates microphone capability on RECORD_AUDIO. + @Synchronized fun microphone(open: Boolean) { + stopMicrophone() + if (!open || closed.get() || !microphoneAllowed()) return + val minimum = AudioRecord.getMinBufferSize(16000, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) + check(minimum > 0) + val record = AudioRecord(MediaRecorder.AudioSource.VOICE_RECOGNITION, 16000, + AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, maxOf(minimum, 6400)) + try { check(record.state == AudioRecord.STATE_INITIALIZED); record.startRecording() } + catch (t: Throwable) { record.release(); throw t } + recorder = record + micThread = Thread({ + val buffer = ByteArray(640) + try { + while (recorder === record && !closed.get() && microphoneAllowed()) { + val count = record.read(buffer, 0, buffer.size) + if (count <= 0) break + if (!sendMic(buffer.copyOf(count))) break + } + } catch (error: Throwable) { if (!closed.get()) failed(error) } + finally { if (recorder === record) recorder = null; record.release() } + }, "aa-mic").apply { start() } + } + private fun stopMicrophone() { + val record = recorder; recorder = null + if (record != null) runCatching { record.stop() } + micThread?.join(3000) + check(micThread?.isAlive != true) { "AA microphone did not stop" } + micThread = null + } + @Synchronized override fun close() { + closed.set(true); stopMicrophone(); player.join(3000) + check(!player.isAlive) { "AA audio did not stop" } + queue.clear(); focus?.let { manager.abandonAudioFocusRequest(it) }; focus = null + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapConnection.kt b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapConnection.kt new file mode 100644 index 00000000..da104783 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapConnection.kt @@ -0,0 +1,123 @@ +package com.shilapi.xcertplay.projection.androidauto + +import android.app.PendingIntent +import android.content.* +import android.hardware.usb.* +import androidx.core.content.ContextCompat +import java.io.* +import java.net.InetSocketAddress +import java.net.Socket +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +internal interface AapConnection : AutoCloseable { + fun connect() + val input: InputStream + val output: OutputStream +} +internal class AapTcpConnection(private val host: String, private val port: Int) : AapConnection { + private val socket = Socket() + override fun connect() { require(validAddress(host)) { "Enter a numeric IP address" }; socket.connect(InetSocketAddress(host, port), 10000); socket.tcpNoDelay = true; socket.soTimeout = 15000 } + override val input get() = socket.getInputStream() + override val output get() = socket.getOutputStream() + override fun close() = socket.close() + companion object { + fun validAddress(host: String): Boolean = host.split('.').let { parts -> + parts.size == 4 && parts.all { it.isNotEmpty() && it.length <= 3 && it.all(Char::isDigit) && (it.toIntOrNull() ?: -1) in 0..255 } + } + } +} + +/** Android USB host -> phone in Android Open Accessory mode; no USB accessory permission on the host. */ +internal class AapUsbConnection(private val context: Context, private val device: UsbDevice) : AapConnection { + private val manager = context.getSystemService(UsbManager::class.java) + private val closed = AtomicBoolean() + @Volatile private var connection: UsbDeviceConnection? = null + @Volatile private var permissionWait: CountDownLatch? = null + private lateinit var incoming: UsbEndpoint + private lateinit var outgoing: UsbEndpoint + companion object { + fun isAccessory(device: UsbDevice) = device.vendorId == 0x18d1 && device.productId in setOf(0x2d00, 0x2d01, 0x2d04, 0x2d05) + fun candidates(context: Context) = context.getSystemService(UsbManager::class.java).deviceList.values.filter { + it.vendorId != 0x05ac && (isAccessory(it) || it.deviceClass == 0 || it.deviceClass == 0xef) + }.sortedBy { it.deviceName } + } + private fun checkOpen() { check(!closed.get()) { "Android Auto stopped" } } + private fun open(device: UsbDevice): UsbDeviceConnection { + checkOpen() + if (!manager.hasPermission(device)) { + val action = context.packageName + ".AA_USB." + UUID.randomUUID() + val wait = CountDownLatch(1); permissionWait = wait + val receiver = object : BroadcastReceiver() { override fun onReceive(c: Context, i: Intent) { if (i.action == action) wait.countDown() } } + ContextCompat.registerReceiver(context, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED) + val pending = PendingIntent.getBroadcast(context, 0, Intent(action).setPackage(context.packageName), PendingIntent.FLAG_IMMUTABLE) + try { manager.requestPermission(device, pending); check(wait.await(45, TimeUnit.SECONDS)) { "USB permission timed out" } } + finally { context.unregisterReceiver(receiver); pending.cancel(); permissionWait = null } + checkOpen(); check(manager.hasPermission(device)) { "USB permission denied" } + } + return checkNotNull(manager.openDevice(device)) { "Cannot open USB phone" }.also { + connection = it + if (closed.get()) { it.close(); error("Android Auto stopped") } + } + } + override fun connect() { + var selected = device + var link = open(selected) + if (!isAccessory(selected)) { + val version = ByteArray(2) + check(link.controlTransfer(0xc0, 51, 0, 0, version, 2, 2000) == 2 && version[0].toInt() > 0) { "Phone does not support Android Open Accessory" } + listOf("Android", "Android Auto", "WheelPlay", "1.0", "https://github.com/fython/wheelplay", "WheelPlay").forEachIndexed { index, value -> + val bytes = (value + '\u0000').toByteArray(Charsets.UTF_8) + check(link.controlTransfer(0x40, 52, 0, index, bytes, bytes.size, 2000) == bytes.size) + } + check(link.controlTransfer(0x40, 53, 0, 0, null, 0, 2000) >= 0) { "AOA mode switch failed" } + link.close(); connection = null + val deadline = android.os.SystemClock.elapsedRealtime() + 15000 + while (true) { + checkOpen() + val devices = manager.deviceList.values.filter { isAccessory(it) } + check(devices.size <= 1) { "Connect only one Android Auto phone" } + if (devices.size == 1) { selected = devices.single(); break } + check(android.os.SystemClock.elapsedRealtime() < deadline) { "Phone did not re-enumerate in accessory mode" } + Thread.sleep(100) + } + link = open(selected) + } + val iface = (0 until selected.interfaceCount).map(selected::getInterface).firstOrNull { i -> + (0 until i.endpointCount).map(i::getEndpoint).count { it.type == UsbConstants.USB_ENDPOINT_XFER_BULK } == 2 + } ?: error("Missing AOA bulk interface") + incoming = (0 until iface.endpointCount).map(iface::getEndpoint).first { it.direction == UsbConstants.USB_DIR_IN } + outgoing = (0 until iface.endpointCount).map(iface::getEndpoint).first { it.direction == UsbConstants.USB_DIR_OUT } + check(link.claimInterface(iface, true)) { "USB interface is busy" } + } + // Buffer whole USB transfers: reading just the four-byte AAP header would discard the rest of a USB packet. + override val input = object : InputStream() { + private val buffer = ByteArray(16384) + private var offset = 0; private var size = 0 + override fun read(): Int { val b = ByteArray(1); return if (read(b, 0, 1) < 0) -1 else b[0].toInt() and 255 } + override fun read(bytes: ByteArray, start: Int, length: Int): Int { + if (length == 0) return 0 + checkOpen() + if (offset == size) { + size = connection!!.bulkTransfer(incoming, buffer, buffer.size, 15000); offset = 0 + if (size <= 0) throw IOException("USB disconnected or timed out") + } + val n = minOf(length, size - offset); buffer.copyInto(bytes, start, offset, offset + n); offset += n; return n + } + } + override val output = object : OutputStream() { + override fun write(value: Int) = write(byteArrayOf(value.toByte())) + override fun write(bytes: ByteArray, start: Int, length: Int) { + var offset = start + while (offset < start + length) { + checkOpen() + val n = connection!!.bulkTransfer(outgoing, bytes, offset, minOf(16384, start + length - offset), 2000) + if (n <= 0) throw IOException("USB write failed") + offset += n + } + } + } + override fun close() { closed.set(true); permissionWait?.countDown(); connection?.close() } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapTls.kt b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapTls.kt new file mode 100644 index 00000000..fa591ebd --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapTls.kt @@ -0,0 +1,99 @@ +package com.shilapi.xcertplay.projection.androidauto + +import com.shilapi.xcertplay.androidauto.AapFraming +import java.io.DataInputStream +import java.io.OutputStream +import java.math.BigInteger +import java.nio.ByteBuffer +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.SecureRandom +import java.security.cert.X509Certificate +import java.util.Date +import javax.net.ssl.* +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder + +/** AAP is TLS carried in channel-zero envelopes, not TLS over the underlying socket directly. */ +internal class AapTls(clientMode: Boolean = true) { + internal val engine: SSLEngine + init { + val pair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + val name = X500Name("CN=WheelPlay Android Auto") + val certificate = JcaX509CertificateConverter().getCertificate(JcaX509v3CertificateBuilder( + name, BigInteger(128, SecureRandom()), Date(System.currentTimeMillis() - 86400000), + Date(System.currentTimeMillis() + 365L * 86400000), name, pair.public + ).build(JcaContentSignerBuilder("SHA256withRSA").build(pair.private))) + val password = CharArray(0) + val keys = KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(null); setKeyEntry("headunit", pair.private, password, arrayOf(certificate)) + } + val km = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply { init(keys, password) } + // AAP peers use protocol-specific/self-signed certificates; this is not HTTPS trust. + // Access is limited to an explicitly selected USB device or phone endpoint. + val trust = object : X509TrustManager { + override fun getAcceptedIssuers() = emptyArray() + override fun checkClientTrusted(chain: Array?, authType: String?) = Unit + override fun checkServerTrusted(chain: Array?, authType: String?) = Unit + } + engine = SSLContext.getInstance("TLSv1.2").apply { init(km.keyManagers, arrayOf(trust), SecureRandom()) } + .createSSLEngine().apply { useClientMode = clientMode; enabledProtocols = arrayOf("TLSv1.2") } + } + private fun tasks() { while (true) (engine.delegatedTask ?: break).run() } + fun handshake(input: DataInputStream, output: OutputStream) { + AapFraming.write(output, 0, 3, AapFraming.message(1, byteArrayOf(0, 1, 0, 2))) + val version = AapFraming.read(input) + require(version.channel == 0 && version.flags == 3 && AapFraming.type(version.payload) == 2) + require(version.payload.size >= 8 && version.payload[6] == 0.toByte() && version.payload[7] == 0.toByte()) { "Unsupported AAP version" } + engine.beginHandshake() + var pending = ByteArray(0) + while (true) { + when (engine.handshakeStatus) { + SSLEngineResult.HandshakeStatus.NEED_TASK -> tasks() + SSLEngineResult.HandshakeStatus.NEED_WRAP -> { + val buffer = ByteBuffer.allocate(65535) + val result = engine.wrap(ByteBuffer.allocate(0), buffer) + check(result.status == SSLEngineResult.Status.OK) + if (buffer.position() > 0) AapFraming.write(output, 0, 3, AapFraming.message(3, buffer.array().copyOf(buffer.position()))) + } + SSLEngineResult.HandshakeStatus.NEED_UNWRAP -> { + if (pending.isEmpty()) pending = handshakePacket(input) + val source = ByteBuffer.wrap(pending) + val result = engine.unwrap(source, ByteBuffer.allocate(65535)) + pending = pending.copyOfRange(source.position(), pending.size) + when (result.status) { + SSLEngineResult.Status.BUFFER_UNDERFLOW -> { pending += handshakePacket(input); require(pending.size <= 262144) } + SSLEngineResult.Status.OK -> Unit + else -> error("AAP TLS handshake: ${result.status}") + } + } + SSLEngineResult.HandshakeStatus.FINISHED, SSLEngineResult.HandshakeStatus.NOT_HANDSHAKING -> break + else -> error("Unexpected TLS handshake state") + } + } + require(pending.isEmpty()) + AapFraming.write(output, 0, 3, AapFraming.message(4, byteArrayOf(8, 0))) + } + private fun handshakePacket(input: DataInputStream): ByteArray { + val packet = AapFraming.read(input) + require(packet.channel == 0 && packet.flags == 3 && AapFraming.type(packet.payload) == 3) + return packet.payload.copyOfRange(2, packet.payload.size) + } + @Synchronized fun encrypt(bytes: ByteArray): ByteArray { + val src = ByteBuffer.wrap(bytes); val dst = ByteBuffer.allocate(65535) + val result = engine.wrap(src, dst) + check(result.status == SSLEngineResult.Status.OK && !src.hasRemaining()) + return dst.array().copyOf(dst.position()) + } + @Synchronized fun decrypt(bytes: ByteArray): ByteArray { + val src = ByteBuffer.wrap(bytes); val dst = ByteBuffer.allocate(65535) + while (src.hasRemaining()) { + val result = engine.unwrap(src, dst) + check(result.status == SSLEngineResult.Status.OK && result.bytesConsumed() > 0) { "Invalid AAP TLS record" } + tasks() + } + return dst.array().copyOf(dst.position()) + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapVideo.kt b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapVideo.kt new file mode 100644 index 00000000..e1d327f6 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapVideo.kt @@ -0,0 +1,52 @@ +package com.shilapi.xcertplay.projection.androidauto + +import com.shilapi.xcertplay.androidauto.AapMediaAssembler +import com.shilapi.xcertplay.media.HeapVideoFrame +import com.shilapi.xcertplay.media.VideoCodec +import com.shilapi.xcertplay.projection.ProjectionVideoOutput +import java.io.ByteArrayOutputStream + +/** Keep SPS/PPS out of the transport; convert Annex-B config to the host's avcC port. */ +internal class AapVideo(private val output: ProjectionVideoOutput, private val keyframe: () -> Unit) { + private var sps: ByteArray? = null + private var pps: ByteArray? = null + private var config: ByteArray? = null + private var revision = 0L + fun accept(unit: AapMediaAssembler.AccessUnit) { + val nals = annexB(unit.data) + nals.forEach { nal -> when (nal[0].toInt() and 31) { 7 -> sps = nal; 8 -> pps = nal } } + val s = sps; val p = pps + if (s != null && p != null) { + val next = avcC(s, p) + if (config?.contentEquals(next) != true) { revision = output.configure(VideoCodec.H264, next, keyframe); config = next } + } + if (revision != 0L && nals.any { it[0].toInt() and 31 in 1..5 }) output.frame(revision, HeapVideoFrame(unit.data), unit.ptsUs) + } + companion object { + fun annexB(data: ByteArray): List { + val starts = ArrayList>() + var i = 0 + while (i + 3 <= data.size) { + val size = when { + i + 4 <= data.size && data[i] == 0.toByte() && data[i+1] == 0.toByte() && data[i+2] == 0.toByte() && data[i+3] == 1.toByte() -> 4 + data[i] == 0.toByte() && data[i+1] == 0.toByte() && data[i+2] == 1.toByte() -> 3 + else -> 0 + } + if (size != 0) { starts += i to size; i += size } else i++ + } + require(starts.isNotEmpty() && starts.first().first == 0) { "Expected Annex-B H.264" } + return starts.mapIndexedNotNull { index, (start, size) -> + val end = starts.getOrNull(index + 1)?.first ?: data.size + if (end > start + size) data.copyOfRange(start + size, end) else null + } + } + fun avcC(sps: ByteArray, pps: ByteArray): ByteArray { + require(sps.size in 4..65535 && pps.size in 1..65535) + return ByteArrayOutputStream().apply { + write(byteArrayOf(1, sps[1], sps[2], sps[3], 0xff.toByte(), 0xe1.toByte())) + write(sps.size ushr 8); write(sps.size); write(sps); write(1) + write(pps.size ushr 8); write(pps.size); write(pps) + }.toByteArray() + } + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSource.kt b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSource.kt new file mode 100644 index 00000000..81fd6a25 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSource.kt @@ -0,0 +1,263 @@ +package com.shilapi.xcertplay.projection.androidauto + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Handler +import android.os.Looper +import com.shilapi.xcertplay.AirPlayPersistence +import com.shilapi.xcertplay.CarPlayMicrophoneSource +import com.shilapi.xcertplay.androidauto.* +import com.shilapi.xcertplay.projection.* +import java.io.DataInputStream +import java.nio.ByteBuffer +import java.util.concurrent.* +import java.util.concurrent.atomic.AtomicBoolean + +internal class AndroidAutoSource(private val context: Context, private val usb: Boolean, + private val outputFactory: () -> ProjectionVideoOutput) : ProjectionSourceProvider { + override val descriptor = SourceDescriptor(if (usb) "android-auto-usb" else "android-auto-tcp", if (usb) "Android Auto · USB" else "Android Auto · Head Unit Server (TCP)") + override val resources = setOf(ProjectionResource.DISPLAY, ProjectionResource.MICROPHONE, + if (usb) ProjectionResource.USB else ProjectionResource.WIRELESS) + override fun probe(): SourceAvailability { + if (usb && AapUsbConnection.candidates(context).size != 1) return SourceAvailability.Unavailable("Connect exactly one Android phone by USB") + if (!usb && context.getSharedPreferences("projection", 0).getString("aa_host", "").isNullOrBlank()) + return SourceAvailability.Unavailable("Configure the phone's Head Unit Server address first") + return SourceAvailability.Ready + } + override fun open(): CompletableFuture = try { + val connection = if (usb) AapUsbConnection(context, AapUsbConnection.candidates(context).single()) else { + val prefs = context.getSharedPreferences("projection", 0) + AapTcpConnection(prefs.getString("aa_host", "")!!, prefs.getInt("aa_port", 5277)) + } + val microphone = context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED && + AirPlayPersistence.loadMicrophoneSource(context) == CarPlayMicrophoneSource.ANDROID + val session = AndroidAutoSession(context, descriptor, connection, microphone, outputFactory) + // The runtime admits this session immediately, so Stop can interrupt connect/USB permission/handshake. + CompletableFuture.completedFuture(session) + } catch (failure: Throwable) { CompletableFuture().also { it.completeExceptionally(failure) } } +} + +internal class AndroidAutoSession(private val context: Context, override val descriptor: SourceDescriptor, + private val connection: AapConnection, private val micEnabled: Boolean, + private val outputFactory: () -> ProjectionVideoOutput) : ProjectionSession { + override val capabilities = ProjectionCapabilities(setOf(VideoIngressKind.ENCODED), 10, nightMode = true, + audioPlayback = true, microphone = micEnabled, browserAudio = false, browserMicrophone = false) + @Volatile override var statusText = "Android Auto: connecting" + private set + @Volatile private var ready = false + private var inputBound = false + private var videoFocusMode = 1 + override val inputReady get() = ready && !stopping.get() + private val stopping = AtomicBoolean() + private val finished = CompletableFuture() + private val outbound = ArrayBlockingQueue>(256) + private val frames = ArrayBlockingQueue(8) + private val touches = AapTouch(AapServices.WIDTH, AapServices.HEIGHT) + private val sessions = HashMap() + private val opened = HashSet() + private val assemblers = HashMap() + private var audio: AapAudio? = null + private var output: ProjectionVideoOutput? = null + private var writer: Thread? = null + private var renderer: Thread? = null + private var reader: Thread? = null + @Volatile private var waitForIdr = true + @Volatile private var night = false + @Volatile private var nightSubscribed = false + private var keyframeAt = Long.MIN_VALUE / 2 + private val micCredits = java.util.concurrent.atomic.AtomicInteger(0) + @Volatile private var micWindow = 0 + override fun activate() = start() + @Synchronized fun start() { + if (reader != null || stopping.get()) { if (reader == null) finished.complete(Unit); return } + reader = Thread({ run() }, "aa-session").apply { start() } + } + private fun state(value: String) { statusText = value; projectionSessions.changed(this) } + private fun run() { + var failure: Throwable? = null + try { + connection.connect() + state("Android Auto: negotiating") + val input = DataInputStream(connection.input) + val tls = AapTls() + // Overall handshake deadline also interrupts a peer that slowly drips bytes. + val timer = Executors.newSingleThreadScheduledExecutor() + val timeout = timer.schedule({ connection.close() }, 20, TimeUnit.SECONDS) + try { tls.handshake(input, connection.output) } finally { timeout.cancel(false); timer.shutdownNow() } + check(!stopping.get()) + output = outputFactory() + val video = AapVideo(output!!) { requestKeyframe() } + audio = AapAudio(context, { bytes -> + if (!microphoneAllowed()) false + else if (micCredits.getAndUpdate { if (it > 0) it - 1 else 0 } <= 0) true + else send(7, 0, ByteBuffer.allocate(8 + bytes.size).putLong(android.os.SystemClock.elapsedRealtimeNanos() / 1000).put(bytes).array()) + }, { focus -> send(0, 19, AapProto.build { number(1, focus.toLong()); number(2, 1) }) }, { error -> + state("Android Auto audio failed: ${error.message}"); connection.close() + }, ::microphoneAllowed) + writer = Thread({ + try { + while (!stopping.get()) { + val (channel, type, body) = outbound.poll(200, TimeUnit.MILLISECONDS) ?: continue + if (channel == 7 && type == 0 && !microphoneAllowed()) continue + val encrypted = tls.encrypt(AapFraming.message(type, body)) + AapFraming.write(connection.output, channel, if (channel != 0 && type in 2..31) 15 else 11, encrypted) + if (channel == 0 && type == 16) { stopping.set(true); connection.close() } + } + } catch (error: Throwable) { if (!stopping.get()) { state("Android Auto write failed: ${error.message}"); connection.close() } } + }, "aa-send").apply { start() } + renderer = Thread({ + try { + while (!stopping.get()) { + val frame = frames.poll(200, TimeUnit.MILLISECONDS) ?: continue + val types = AapVideo.annexB(frame.data).map { it[0].toInt() and 31 } + if (waitForIdr && 5 !in types && 7 !in types && 8 !in types) continue + if (5 in types) waitForIdr = false + video.accept(frame) + } + } catch (error: Throwable) { if (!stopping.get()) { state("Android Auto video failed: ${error.message}"); connection.close() } } + }, "aa-video").apply { start() } + state("Android Auto: waiting for phone projection") + while (!stopping.get()) { + val packet = AapFraming.read(input) + require(packet.flags and 8 != 0) { "Expected encrypted AAP packet" } + val payload = tls.decrypt(packet.payload) + if (packet.channel in setOf(2, 4, 5, 6) && (packet.flags and 3 in 0..2 || AapFraming.type(payload) in 0..1)) { + val channel = packet.channel + require(channel in opened && sessions.containsKey(channel)) + val unit = assemblers.getOrPut(channel) { AapMediaAssembler() }.accept(packet.flags, payload) + // ACK per transport fragment, independently of browser speed and decoder queue. + send(channel, 0x8004, AapProto.build { number(1, sessions[channel]!!); number(2, 1) }) + if (unit != null) { + if (channel == 2) { + if (!frames.offer(unit)) { frames.clear(); waitForIdr = true; requestKeyframe() } + } else { + require(unit.data.size % (if (channel == 6) 4 else 2) == 0) + audio?.offer(channel, unit.data) + } + } + continue + } + require(packet.flags and 3 == 3) { "Fragmented control message is unsupported" } + dispatch(packet.channel, AapFraming.type(payload), payload.copyOfRange(2, payload.size)) + } + } catch (error: Throwable) { + if (!stopping.get()) { + failure = error + state("Android Auto: ${error.message ?: error.javaClass.simpleName}") + Handler(Looper.getMainLooper()).post { android.widget.Toast.makeText(context, statusText, android.widget.Toast.LENGTH_LONG).show() } + } + } finally { + ready = false; stopping.set(true) + val cleanup = runCatching { + connection.close() + writer?.join(5000); renderer?.join(5000) + check(writer?.isAlive != true && renderer?.isAlive != true) { "AA workers did not stop" } + audio?.close(); output?.close() + outbound.clear(); frames.clear() + } + if (cleanup.isFailure) { + finished.completeExceptionally(ProjectionCleanupException("Android Auto cleanup failed", cleanup.exceptionOrNull())) + } else { + finished.complete(Unit) + projectionSessions.ended(this) + } + failure?.let { android.util.Log.w("WheelPlay-AA", "Protocol session ended", it) } + } + } + private fun dispatch(channel: Int, type: Int, body: ByteArray) { + when { + type == 7 -> { + val id = AapProto.number(body, 2).toInt() + val supported = channel == id && (id in AapServices.channels || id == 7 && micEnabled) + if (supported) opened += id + send(channel, 8, AapProto.build { number(1, if (supported) 0 else -4) }) + } + channel == 0 -> when (type) { + 5 -> send(0, 6, AapServices.discovery(micEnabled)) + 11 -> send(0, 12, body) + 13 -> send(0, 14, AapProto.build { number(1, 1) }) + 15 -> { ready = false; send(0, 16, byteArrayOf()) } + 18 -> send(0, 19, AapProto.build { number(1, audio!!.requestFocus(AapProto.number(body, 1).toInt()).toLong()); number(2, 0) }) + } + channel !in opened -> error("AAP message on unopened channel") + channel == 3 && type == 0x8002 -> { + send(3, 0x8003, AapProto.build { number(1, 0) }); inputBound = true; ready = videoFocusMode == 1; state("Android Auto connected · audio on host") + } + channel == 1 && type == 0x8001 -> { + val supported = AapProto.number(body, 1) == 10L + send(1, 0x8002, AapProto.build { number(1, if (supported) 0 else -9) }) + if (supported) { nightSubscribed = true; send(1, 0x8003, AapServices.night(night)) } + } + channel in 2..7 && channel != 3 -> when (type) { + 0x8000 -> { + send(channel, 0x8003, AapProto.build { number(1, 2); number(2, 16); number(3, 0) }) + if (channel == 2) requestKeyframe() + if (channel in 4..6) send(0, 19, AapProto.build { number(1, audio!!.requestFocus(1).toLong()); number(2, 1) }) + } + 0x8001 -> { + require(AapProto.number(body, 2) == 0L) + sessions[channel] = AapProto.number(body, 1) + assemblers.remove(channel) + } + 0x8002 -> { + sessions.remove(channel); assemblers.remove(channel) + if (channel == 7) audio?.microphone(false) + if (channel in 4..6) audio?.stopChannel(channel) + } + 0x8007 -> if (channel == 2) { + val mode = AapProto.number(body, 2).toInt() + require(mode in 1..4) + if (mode != 1) touch(emptyList()) + videoFocusMode = mode + ready = inputBound && mode == 1 + send(2, 0x8008, AapProto.build { number(1, mode.toLong()); number(2, 0) }) + if (mode == 2) stop() + } + 0x8005 -> if (channel == 7 && micEnabled) { + val requested = AapProto.number(body, 1) != 0L + val open = requested && microphoneAllowed() + micWindow = AapProto.number(body, 4, 16).toInt().coerceIn(1, 32) + micCredits.set(if (open) micWindow else 0) + send(7, 0x8006, AapProto.build { number(1, if (requested && !open) 1 else 0); number(2, 0) }) + audio?.microphone(open) + } + 0x8004 -> if (channel == 7) { + val count = AapProto.number(body, 2).toInt() + require(count in 0..32) + micCredits.getAndUpdate { minOf(micWindow, it + count) } + } + + } + } + } + private fun microphoneAllowed(): Boolean = micEnabled && !stopping.get() && + context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED && + AirPlayPersistence.loadMicrophoneSource(context) == CarPlayMicrophoneSource.ANDROID + + private fun send(channel: Int, type: Int, body: ByteArray): Boolean { + if (stopping.get()) return false + if (outbound.offer(Triple(channel, type, body))) return true + // Never drop a boundary event while keeping the session alive with a stuck finger. + state("Android Auto control queue overflow"); connection.close(); return false + } + @Synchronized private fun requestKeyframe() { + val now = android.os.SystemClock.elapsedRealtime() + if (now - keyframeAt < 500) return + keyframeAt = now + send(2, 0x8008, AapProto.build { number(1, 1); number(2, 1) }) + } + @Synchronized override fun touch(contacts: List): Boolean { + if (!inputReady) return false + return touches.update(contacts).all { send(3, 0x8001, touches.encode(it, android.os.SystemClock.elapsedRealtimeNanos())) } + } + override fun setNightMode(night: Boolean): ProjectionCommandResult { + this.night = night + return if (nightSubscribed && send(1, 0x8003, AapServices.night(night))) ProjectionCommandResult.SENT else ProjectionCommandResult.UNAVAILABLE + } + @Synchronized override fun stop(): CompletableFuture { + stopping.set(true); ready = false; connection.close() + if (reader == null) finished.complete(Unit) + return finished + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt b/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt index 4993ee98..317428ce 100644 --- a/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt +++ b/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt @@ -223,8 +223,8 @@ internal object WebSession { val source = AirPlayPersistence.loadMicrophoneSource(context) val capabilities = projectionSessions.snapshot()?.capabilities audio.configure( - capabilities?.audioPlayback == true && AirPlayPersistence.loadBrowserAudioPlayback(context), - capabilities?.microphone == true && source == CarPlayMicrophoneSource.BROWSER, + capabilities?.browserAudio == true && AirPlayPersistence.loadBrowserAudioPlayback(context), + capabilities?.browserMicrophone == true && source == CarPlayMicrophoneSource.BROWSER, AirPlayPersistence.loadMediaBufferMillis(context), capabilities?.microphone == true && source != CarPlayMicrophoneSource.DISABLED, ) diff --git a/common/src/test/java/com/shilapi/xcertplay/projection/androidauto/AapVideoTest.kt b/common/src/test/java/com/shilapi/xcertplay/projection/androidauto/AapVideoTest.kt new file mode 100644 index 00000000..75e26d44 --- /dev/null +++ b/common/src/test/java/com/shilapi/xcertplay/projection/androidauto/AapVideoTest.kt @@ -0,0 +1,40 @@ +package com.shilapi.xcertplay.projection.androidauto + +import android.view.Surface +import com.shilapi.xcertplay.androidauto.AapMediaAssembler +import com.shilapi.xcertplay.media.* +import com.shilapi.xcertplay.projection.* +import org.junit.Assert.* +import org.junit.Test + +class AapVideoTest { + private class Output : ProjectionVideoOutput { + override val surface: Surface get() = error("encoded") + val configs = mutableListOf() + var frames = 0 + override fun configure(codec: VideoCodec, codecData: ByteArray, requestKeyframe: () -> Unit): Long { + configs += codecData; return configs.size.toLong() + } + override fun frame(formatRevision: Long, frame: CompressedVideoFrame, ptsUs: Long): Boolean { + assertEquals(configs.size.toLong(), formatRevision); frames++; return true + } + override fun framebuffer(snapshot: ProjectionFramebuffer.Snapshot) = false + override fun close() = Unit + } + @Test fun annexBConfigBecomesAvcCBeforeFramesAndReconfiguresOnChange() { + val out = Output(); val video = AapVideo(out) {} + val sps = byteArrayOf(0x67, 0x42, 0, 0x1e, 1) + val pps = byteArrayOf(0x68, 2) + fun nal(bytes: ByteArray) = byteArrayOf(0, 0, 0, 1) + bytes + video.accept(AapMediaAssembler.AccessUnit(nal(byteArrayOf(0x65, 4)), 42)) + assertEquals(0, out.frames) + video.accept(AapMediaAssembler.AccessUnit(nal(sps) + nal(pps), 0)) + assertArrayEquals(byteArrayOf(1, 0x42, 0, 0x1e, -1, -31, 0, 5) + sps + byteArrayOf(1, 0, 2) + pps, out.configs.single()) + video.accept(AapMediaAssembler.AccessUnit(nal(byteArrayOf(0x65, 4)), 43)) + assertEquals(1, out.frames) + video.accept(AapMediaAssembler.AccessUnit(nal(sps) + nal(pps), 0)) + assertEquals(1, out.configs.size) + video.accept(AapMediaAssembler.AccessUnit(nal(sps.copyOf().also { it[4] = 3 }) + nal(pps), 0)) + assertEquals(2, out.configs.size) + } +} diff --git a/common/src/test/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSessionTest.kt b/common/src/test/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSessionTest.kt new file mode 100644 index 00000000..27d6099a --- /dev/null +++ b/common/src/test/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSessionTest.kt @@ -0,0 +1,146 @@ +package com.shilapi.xcertplay.projection.androidauto + +import android.content.Context +import android.view.Surface +import com.shilapi.xcertplay.androidauto.* +import com.shilapi.xcertplay.media.* +import com.shilapi.xcertplay.projection.* +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.DataInputStream +import java.net.ServerSocket +import java.nio.ByteBuffer +import java.util.concurrent.* +import javax.net.ssl.SSLEngineResult + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class AndroidAutoSessionTest { + @Test fun realTlsPeerDiscoversChannelsStreamsFragmentedVideoAndReceivesTouch() { + val server = ServerSocket(0) + val control = Executors.newSingleThreadExecutor() + val ready = CountDownLatch(1) + val frame = CountDownLatch(1) + val outputClosed = CountDownLatch(1) + val output = object : ProjectionVideoOutput { + override val surface: Surface get() = error("encoded") + override fun configure(codec: VideoCodec, codecData: ByteArray, requestKeyframe: () -> Unit): Long = 1 + override fun frame(formatRevision: Long, frameData: CompressedVideoFrame, ptsUs: Long): Boolean { + assertEquals(42L, ptsUs); assertEquals(1L, formatRevision); frame.countDown(); return true + } + override fun framebuffer(snapshot: ProjectionFramebuffer.Snapshot) = false + override fun close() { outputClosed.countDown() } + } + val peer = control.submit { + server.accept().use { socket -> + socket.soTimeout = 10000 + val input = DataInputStream(socket.getInputStream()); val out = socket.getOutputStream() + assertEquals(1, AapFraming.type(AapFraming.read(input).payload)) + AapFraming.write(out, 0, 3, AapFraming.message(2, byteArrayOf(0, 1, 0, 2, 0, 0))) + val tls = AapTls(false); val engine = tls.engine + engine.needClientAuth = true + engine.beginHandshake() + while (engine.handshakeStatus != SSLEngineResult.HandshakeStatus.NOT_HANDSHAKING) { + when (engine.handshakeStatus) { + SSLEngineResult.HandshakeStatus.NEED_TASK -> while (true) (engine.delegatedTask ?: break).run() + SSLEngineResult.HandshakeStatus.NEED_WRAP -> { + val dst = ByteBuffer.allocate(65535) + val result = engine.wrap(ByteBuffer.allocate(0), dst) + assertEquals(SSLEngineResult.Status.OK, result.status) + if (dst.position() > 0) AapFraming.write(out, 0, 3, AapFraming.message(3, dst.array().copyOf(dst.position()))) + } + SSLEngineResult.HandshakeStatus.NEED_UNWRAP -> { + val packet = AapFraming.read(input) + assertEquals(3, AapFraming.type(packet.payload)) + val src = ByteBuffer.wrap(packet.payload, 2, packet.payload.size - 2) + while (src.hasRemaining()) assertEquals(SSLEngineResult.Status.OK, engine.unwrap(src, ByteBuffer.allocate(65535)).status) + } + else -> error("Unexpected handshake state") + } + } + assertEquals(4, AapFraming.type(AapFraming.read(input).payload)) + fun send(channel: Int, type: Int, body: ByteArray) = AapFraming.write(out, channel, if (channel > 0 && type in 2..31) 15 else 11, tls.encrypt(AapFraming.message(type, body))) + fun receive(): Pair { + val p = AapFraming.read(input); return p.channel to tls.decrypt(p.payload) + } + send(0, 5, AapProto.build { text(4, "Phone"); text(5, "Test") }) + val discovery = receive(); assertEquals(6, AapFraming.type(discovery.second)) + assertEquals(6, AapProto.parse(discovery.second.copyOfRange(2, discovery.second.size)).count { it.number == 1 }) + for (id in listOf(2, 3)) { + send(id, 7, AapProto.build { number(1, 0); number(2, id.toLong()) }) + assertEquals(8, AapFraming.type(receive().second)) + } + send(2, 0x8000, AapProto.build { number(1, 3) }) + assertEquals(0x8003, AapFraming.type(receive().second)) + assertEquals(0x8008, AapFraming.type(receive().second)) + send(2, 0x8001, AapProto.build { number(1, 9); number(2, 0) }) + send(2, 1, byteArrayOf(0, 0, 0, 1, 0x67, 0x42, 0, 0x1e, 1, 0, 0, 0, 1, 0x68, 2)) + assertEquals(0x8004, AapFraming.type(receive().second)) + val first = tls.encrypt(ByteBuffer.allocate(14).putShort(0).putLong(42).put(byteArrayOf(0, 0, 0, 1)).array()) + out.write(byteArrayOf(2, 9, (first.size ushr 8).toByte(), first.size.toByte())) + out.write(ByteBuffer.allocate(4).putInt(100).array()); out.write(first); out.flush() + // This ACK must arrive before the final fragment; waiting for a complete frame deadlocks the phone window. + assertEquals(0x8004, AapFraming.type(receive().second)) + val last = tls.encrypt(byteArrayOf(0x65, 0x12, 0x34)) + out.write(byteArrayOf(2, 10, (last.size ushr 8).toByte(), last.size.toByte())); out.write(last); out.flush() + assertEquals(0x8004, AapFraming.type(receive().second)) + send(6, 7, AapProto.build { number(1, 0); number(2, 6) }) + assertEquals(8, AapFraming.type(receive().second)) + send(6, 0x8000, AapProto.build { number(1, 1) }) + assertEquals(0x8003, AapFraming.type(receive().second)) + assertEquals(19, AapFraming.type(receive().second)) + send(6, 0x8001, AapProto.build { number(1, 12); number(2, 0) }) + send(6, 0, ByteBuffer.allocate(648).putLong(42).put(ByteArray(640)).array()) + assertEquals(0x8004, AapFraming.type(receive().second)) + send(6, 0x8002, byteArrayOf()) + send(3, 0x8002, byteArrayOf()) + assertEquals(0x8003, AapFraming.type(receive().second)); ready.countDown() + val touch = receive() + assertEquals(3, touch.first); assertEquals(0x8001, AapFraming.type(touch.second)) + val report = AapProto.parse(touch.second.copyOfRange(2, touch.second.size)) + assertEquals(0L, AapProto.number(report.first { it.number == 3 }.bytes!!, 3)) + // Transient native focus releases held fingers but must not disconnect the protocol. + send(2, 0x8007, AapProto.build { number(2, 3) }) + assertEquals(0x8001, AapFraming.type(receive().second)) + val lostFocus = receive().second + assertEquals(0x8008, AapFraming.type(lostFocus)) + assertEquals(3L, AapProto.number(lostFocus.copyOfRange(2, lostFocus.size), 1)) + send(2, 0x8007, AapProto.build { number(2, 1) }) + assertEquals(0x8008, AapFraming.type(receive().second)) + send(0, 15, AapProto.build { number(1, 1) }) + assertEquals(16, AapFraming.type(receive().second)) + } + } + val context: Context = RuntimeEnvironment.getApplication() + val session = AndroidAutoSession(context, SourceDescriptor("test-aa", "Test AA"), AapTcpConnection("127.0.0.1", server.localPort), false) { output } + assertTrue(projectionSessions.adopt(session, emptySet())) + try { + session.start() + val bound = ready.await(15, TimeUnit.SECONDS) + if (!bound && peer.isDone) peer.get() + assertTrue("Peer did not bind input: ${session.statusText}", bound) + assertTrue("Frame did not reach generic output", frame.await(5, TimeUnit.SECONDS)) + assertTrue(session.touch(listOf(ProjectionContact(0, .5, .5, true)))) + peer.get(10, TimeUnit.SECONDS) + session.stop().get(5, TimeUnit.SECONDS) + assertTrue(outputClosed.await(5, TimeUnit.SECONDS)) + assertFalse(session.inputReady) + } finally { session.stop().get(10, TimeUnit.SECONDS); projectionSessions.ended(session); server.close(); control.shutdownNow() } + } + + @Test fun stopBeforeStartupNeverOpensTransportOrVideo() { + val connection = object : AapConnection { + override fun connect() { error("must not connect") } + override val input get() = error("must not read") + override val output get() = error("must not write") + override fun close() = Unit + } + val session = AndroidAutoSession(RuntimeEnvironment.getApplication(), SourceDescriptor("test", "Test"), connection, false) { error("must not create output") } + session.stop().get(1, TimeUnit.SECONDS); session.start() + assertFalse(session.inputReady) + } +} diff --git a/common/src/test/java/com/shilapi/xcertplay/projection/androidauto/IccoaAvailabilityTest.kt b/common/src/test/java/com/shilapi/xcertplay/projection/androidauto/IccoaAvailabilityTest.kt new file mode 100644 index 00000000..a2acfd49 --- /dev/null +++ b/common/src/test/java/com/shilapi/xcertplay/projection/androidauto/IccoaAvailabilityTest.kt @@ -0,0 +1,33 @@ +package com.shilapi.xcertplay.projection.androidauto + +import com.shilapi.xcertplay.projection.* +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.concurrent.CompletableFuture + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class IccoaAvailabilityTest { + @Test fun missingSdkCannotDisconnectCurrentProjection() { + val runtime = ProjectionRuntime() + val current = object : ProjectionSession { + override val descriptor = SourceDescriptor("playing", "Playing") + override val capabilities = ProjectionCapabilities(emptySet(), audioPlayback = true, browserAudio = false) + override val inputReady = false + override fun touch(contacts: List) = false + override fun stop(): CompletableFuture = error("Must keep current source") + } + runtime.adopt(current, setOf(ProjectionResource.WIRELESS)) + val source = IccoaSource(RuntimeEnvironment.getApplication()) + runtime.register(source) + assertTrue(source.probe() is SourceAvailability.Unavailable) + assertTrue(runtime.switchTo(source.descriptor.id).isCompletedExceptionally) + assertSame(current, runtime.snapshot()) + assertTrue(current.capabilities.audioPlayback) + assertFalse(current.capabilities.browserAudio) + } +} diff --git a/docs/ANDROID_AUTO.md b/docs/ANDROID_AUTO.md new file mode 100644 index 00000000..accf8337 --- /dev/null +++ b/docs/ANDROID_AUTO.md @@ -0,0 +1,48 @@ +# Android Auto 协议接入(实验版) + +基线:main `4a68a2e`(#33)。本阶段增加真正的 AAP 会话,不使用诊断视频代替手机画面。代码已实现 USB AOA 和显式 TCP 连接,仍需 Android Auto 手机真机验收;模拟端测试不能证明所有手机版本兼容。 + +## 连接 + +手机首页 → 投影输入源 → 选择投影输入源: + +- **Android Auto · USB**:用数据线将 Android Auto 手机接到 WheelPlay 宿主的 USB Host/OTG 端口。只连接一台候选手机,确认系统 USB 权限弹窗。宿主发送 AOA GET_PROTOCOL / SEND_STRING / START,等待手机重新枚举,再申请新设备权限并使用 bulk IN/OUT 通道。两个 Android 设备必须能分别承担 USB host 和 accessory 角色。不会自动接管 iPhone 或开启 Android Auto 无线热点。 +- **Android Auto · Head Unit Server (TCP)**:在手机 Android Auto 开发者设置启动 Head Unit Server,填写宿主能访问的 IPv4 地址,端口固定为 5277。同设备实验可填 `127.0.0.1`;跨设备必须确认服务实际监听可访问地址。只监听 loopback 的手机需要另行建立端口转发,连接同一 Wi-Fi 本身并不足够。此入口不是 Bluetooth/Wi-Fi 自动发现与无线配对。 +- 浏览器使用原有 WheelPlay 地址和配对流程。切换源后需重新连接浏览器。 +- 分辨率固定 1280×720、30 fps、H.264。WebRTC 直通和图像回退沿用公共视频输出。 +- 音乐/提示音/语音 PCM 播放在 **WheelPlay 宿主设备**。尚不转发到浏览器。 +- 麦克风仅在设置为 Android 麦克风且 RECORD_AUDIO 已授权时向手机声明;浏览器麦克风和禁用设置均不声明 AA 麦克风。关闭或切走 Android 麦克风会终止本机录音并丢弃排队的麦克风包;重新启用能力需要重新连接 AA。 + +启动失败会显示原因并清理会话;USB 权限等待、模式切换、TCP 连接和 TLS 握手都有超时。停止可打断启动。当前连接断开后由用户重新选择源,不包含自动重连策略。 + +## 已实现的协议路径 + +1. 明文 AAP 版本交换;channel 0 封装 TLS 1.2 握手;认证完成消息。每个会话生成自己的自签名 RSA 身份,没有复制外部项目的证书私钥。AAP 的自签名证书互信仅用于用户选定的手机连接,不能用于 HTTPS。 +2. 服务发现,通道开放、ping、导航焦点、音频焦点、结束会话。只发布实现的传感器、视频、触控、三路 PCM 和可选麦克风通道。 +3. 媒体 setup/config/start/stop;视频逐片解密、有界重组,**每片 ACK**,不等待完整帧或浏览器消费。完整 Annex-B 访问单元进入独立有界视频队列,SPS/PPS 转换为 avcC 后配置公共输出。溢出丢弃积压并请求 IDR。 +4. 触控快照转换为 DOWN / POINTER_DOWN / MOVE / POINTER_UP / UP;UP 包包含正在抬起的手指,稳定 ID 与 actionIndex 对应;重复快照不制造新 DOWN。队列溢出断开会话,防止漏发抬手后继续运行。 +5. 16 kHz 单声道语音/系统声音、48 kHz 双声道媒体 PCM;独立有界播放队列及 Android 音频焦点。16 kHz PCM 麦克风按手机请求开关,发送窗口由 ACK 归还。 +6. 夜间模式传感器订阅;切源/停止先封闭输入、关闭传输,等待音视频线程退出后释放输出。清理失败保留资源归属。 + +`ProjectionSession.activate()` 在运行时接纳会话后启动异步连接,避免快速连接失败通知先于会话入场。`browserAudio` / `browserMicrophone` 与本机能力分开,阻止浏览器启用尚未实现的 AA 音频通道。 + +## 验证 + +自动化测试包含 proto2 边界与未知字段、AAP 字节流拆包、视频分片、SPS/PPS 转换、多指触控,以及使用实际 SSLEngine 的模拟手机端:版本 → TLS → 发现 → 开通通道 → 视频配置与分片 ACK → 公共画面输出 → 浏览器触控格式消息 → 退出。另测停止先于启动时不能连接或创建输出。 + +待真机验收:至少一台 Android Auto 手机通过 USB 完成首次授权、投屏、触控、媒体/导航声音、语音助手和拔插重连;TCP 路径实际连接 Head Unit Server;切换 CarPlay/AA/诊断源、多次进入退出、慢浏览器回退、静止画面恢复、关闭麦克风后的无录音验证。测试需记录宿主/手机型号、Android/AA 版本、线材/OTG 和浏览器。 + +未实现:自动无线发现/配对、浏览器音频和麦克风、Now Playing 元数据、车载通话 HFP、AA 导航元数据、动态分辨率、HEVC、自动重连。不能据此将 #28 的完整 Android Auto 验收标为完成。 + +## ICCOA CarLink 阻塞 + +**此版本没有 ICCOA 协议实现。** 列表中的 ICCOA 项只返回依赖缺失原因;probe 发生在停止当前源之前,点击它不会拆掉现有会话。没有 SDK 时不会启动 BLE/P2P、绑定服务或创建假投屏。 + +MiAuto 使用专有 `icar-sdk.dex`,公开仓库仅有 `icar-sdk-stubs.jar` 签名桩和 `IccoaManager` 包装层,CI 构建不含运行库。其 SDK 移植还涉及固定名称的 Service、Room 依赖、权限、初始化一次的生命周期、Surface 投屏、触控和 SDK 退出完成语义。当前未获得 SDK,无法验证这些行为,也没有复制编译桩冒充协议实现或给出未经验证的运行库注入流程。 + +取得适用 SDK 后,应独立 PR 完成真实适配与离屏 Surface、触控、断开/资源释放验证。部分 SDK 网络权限需要系统签名或特定车机环境,不能因为 MiAuto 在某台车机运行就推断普通 Android 手机可用。 + +## 参考版本 + +- [DiAuto](https://github.com/shihabal3amri/DiAuto/tree/f3046721814bff289a78e58f597b45d895efbb95):AAP 的 control/media/input/sensors 协议字段、AOA 和消息处理研究参考。WheelPlay 使用自己的小型 wire codec、连接生命周期和公共视频端口,没有引入 DiAuto 的 Activity/Service 或证书私钥。 +- [MiAuto SDK 依赖说明](https://github.com/PiggyBackBayonet/MiAuto/blob/059b2bb6b4d7fd3b506e7c29bbf153a8e38a0c4d/app/libs/README.md) 与 [集成说明](https://github.com/PiggyBackBayonet/MiAuto/blob/059b2bb6b4d7fd3b506e7c29bbf153a8e38a0c4d/docs/ICCOA_SDK_INTEGRATION.md)。 diff --git a/docs/PROJECTION_IMPLEMENTATION.md b/docs/PROJECTION_IMPLEMENTATION.md index 469d5935..ddd73edd 100644 --- a/docs/PROJECTION_IMPLEMENTATION.md +++ b/docs/PROJECTION_IMPLEMENTATION.md @@ -1,5 +1,7 @@ # 投影输入源框架:当前实现 +后续 Android Auto USB/TCP 协议实现、使用方法和 ICCOA SDK 阻塞见 [Android Auto 接入](ANDROID_AUTO.md)。下文记录 #33 框架阶段的范围。 + 对应 [#28](https://github.com/fython/wheelplay/issues/28),实现基线为 `66d0455`(PR #31 合入后的 main)。继续使用 Kotlin/C++,没有引入 Rust 后端或承诺普通 Linux 宿主支持。 本次落实公共框架、CarPlay 兼容适配及三种诊断源,即 P0–P2 的代码基础。P1 的 CarPlay 真机回归、P2 的设备性能验收,以及 P3–P5 的真实新协议接入仍需单独验收;不能据此关闭整个跟踪 Issue。完整研究背景保存在 [设计草案](INPUT_SOURCE_FRAMEWORK.md)。 diff --git a/docs/README.md b/docs/README.md index f792edfe..af9c5f5e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ - [使用与构建说明](../README.md):安装、配对、认证资产和构建方式。 - [架构与协议](ARCHITECTURE.md):离屏解码、Web 服务、触摸及生命周期。 +- [Android Auto 协议接入与 ICCOA 依赖边界](ANDROID_AUTO.md):USB/TCP、宿主音频、协议测试及真机验收清单。 - [投影输入源实现与设备验收](PROJECTION_IMPLEMENTATION.md):当前框架、CarPlay 适配、三类诊断源及后续协议接入范围。 - [通用输入源框架设计草案](INPUT_SOURCE_FRAMEWORK.md):CarPlay、Android Auto、ICCOA、桌面与 VNC 的接口边界、研究证据及分阶段迁移建议(研究时点的方案)。 - [WebRTC 稳定性与卡顿恢复](WEBRTC_RECOVERY.md):回退原因、可配置容忍时间及真机排查方法。 diff --git a/docs/upstream/THIRD_PARTY_NOTICES.md b/docs/upstream/THIRD_PARTY_NOTICES.md index 656440f6..6d2b3e04 100644 --- a/docs/upstream/THIRD_PARTY_NOTICES.md +++ b/docs/upstream/THIRD_PARTY_NOTICES.md @@ -43,3 +43,7 @@ The static site layout, CSS and generator adapt DiAuto (AGPL-3.0). The AGPL lice Required Notice: Copyright AndyShaman (https://github.com/AndyShaman/BYDMate) The maneuver PNGs under `shared/src/main/assets/byd-hud-icons` were imported from BYDMate. Its PolyForm Noncommercial 1.0.0 terms and required notice are included alongside the assets. These files are separate from the project code license; upstream describes them as donor assets and their original provenance is not independently established. The validated DiLink5.1 windshield path uses factory turn codes rather than these images. + +## Android Auto protocol research + +The AAP wire field definitions and transport behavior were studied in [DiAuto at f304672](https://github.com/shihabal3amri/DiAuto/tree/f3046721814bff289a78e58f597b45d895efbb95) (AGPL-3.0). WheelPlay's adapter uses its own bounded wire codec and projection lifecycle; no upstream private keys or proprietary ICCOA runtime are included. MiAuto's public SDK documentation was used to identify the CarLink runtime dependency. See `docs/ANDROID_AUTO.md`. diff --git a/shared/src/main/java/com/shilapi/xcertplay/androidauto/AapFraming.kt b/shared/src/main/java/com/shilapi/xcertplay/androidauto/AapFraming.kt new file mode 100644 index 00000000..5af30b94 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/androidauto/AapFraming.kt @@ -0,0 +1,49 @@ +package com.shilapi.xcertplay.androidauto + +import java.io.DataInputStream +import java.io.OutputStream +import java.io.ByteArrayOutputStream + +/** AAP header is outside TLS. First fragmented record includes a four-byte big-endian total. */ +object AapFraming { + data class Packet(val channel: Int, val flags: Int, val payload: ByteArray, val total: Int? = null) + fun read(input: DataInputStream): Packet { + val channel = input.readUnsignedByte(); val flags = input.readUnsignedByte(); val length = input.readUnsignedShort() + require(flags and 0xf0 == 0 && length > 0) + val total = if (flags and 3 == 1) input.readInt().also { require(it in 1..4 * 1024 * 1024) } else null + return Packet(channel, flags, ByteArray(length).also(input::readFully), total) + } + fun write(output: OutputStream, channel: Int, flags: Int, payload: ByteArray) { + require(channel in 0..255 && payload.size in 1..65535 && flags and 3 == 3) + output.write(byteArrayOf(channel.toByte(), flags.toByte(), (payload.size ushr 8).toByte(), payload.size.toByte())) + output.write(payload); output.flush() + } + fun message(type: Int, body: ByteArray) = byteArrayOf((type ushr 8).toByte(), type.toByte()) + body + fun type(data: ByteArray): Int { require(data.size >= 2); return ((data[0].toInt() and 255) shl 8) or (data[1].toInt() and 255) } +} + +/** Continuations carry raw media bytes, not a repeated message type or timestamp. */ +class AapMediaAssembler { + data class AccessUnit(val data: ByteArray, val ptsUs: Long) + private var pending: ByteArrayOutputStream? = null + private var ptsUs = 0L + fun accept(flags: Int, payload: ByteArray): AccessUnit? { + val boundary = flags and 3 + if (boundary == 1 || boundary == 3) { + check(pending == null) { "Truncated video access unit" } + val type = AapFraming.type(payload) + require(type == 0 || type == 1) + val offset = if (type == 0) 10 else 2 + require(payload.size > offset) + ptsUs = if (type == 0) java.nio.ByteBuffer.wrap(payload, 2, 8).long else 0L + if (boundary == 3) return AccessUnit(payload.copyOfRange(offset, payload.size), ptsUs) + pending = ByteArrayOutputStream().apply { write(payload, offset, payload.size - offset) } + } else { + val buffer = checkNotNull(pending) { "Orphan video fragment" } + require(buffer.size() + payload.size <= 4 * 1024 * 1024) + buffer.write(payload) + if (boundary == 2) { pending = null; return AccessUnit(buffer.toByteArray(), ptsUs) } + } + return null + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/androidauto/AapProto.kt b/shared/src/main/java/com/shilapi/xcertplay/androidauto/AapProto.kt new file mode 100644 index 00000000..1e74b78f --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/androidauto/AapProto.kt @@ -0,0 +1,46 @@ +package com.shilapi.xcertplay.androidauto + +import java.io.ByteArrayOutputStream + +/** Small bounded proto2 wire codec. Field numbers follow AAP control/media/input/sensor schemas. */ +object AapProto { + class Builder { + private val out = ByteArrayOutputStream() + private fun varint(value: Long) { var v = value; while (v and -128L != 0L) { out.write((v.toInt() and 127) or 128); v = v ushr 7 }; out.write(v.toInt()) } + fun number(field: Int, value: Long): Builder { require(field > 0); varint((field.toLong() shl 3)); varint(value); return this } + fun bytes(field: Int, value: ByteArray): Builder { require(field > 0); varint((field.toLong() shl 3) or 2); varint(value.size.toLong()); out.write(value); return this } + fun text(field: Int, value: String) = bytes(field, value.toByteArray(Charsets.UTF_8)) + fun build(): ByteArray = out.toByteArray() + } + fun build(block: Builder.() -> Unit): ByteArray = Builder().apply(block).build() + data class Field(val number: Int, val value: Long = 0, val bytes: ByteArray? = null) + fun parse(data: ByteArray): List { + require(data.size <= 4 * 1024 * 1024) + var offset = 0 + fun varint(): Long { + var value = 0L + for (i in 0..9) { + require(offset < data.size) { "Truncated protobuf" } + val b = data[offset++].toInt() and 255 + require(i != 9 || b <= 1) { "Varint overflow" } + value = value or ((b and 127).toLong() shl (i * 7)) + if (b and 128 == 0) return value + } + error("Malformed varint") + } + val result = ArrayList() + while (offset < data.size) { + require(result.size < 4096) + val tag = varint(); require(tag ushr 3 in 1L..536870911L) + val number = (tag ushr 3).toInt() + when ((tag and 7).toInt()) { + 0 -> result.add(Field(number, varint())) + 2 -> { val count = varint(); require(count in 0..(data.size - offset).toLong()); result.add(Field(number, bytes = data.copyOfRange(offset, offset + count.toInt()))); offset += count.toInt() } + 1, 5 -> { val count = if (tag and 7 == 1L) 8 else 4; require(count <= data.size - offset); offset += count } + else -> error("Unsupported wire type") + } + } + return result + } + fun number(data: ByteArray, field: Int, default: Long = 0): Long = parse(data).firstOrNull { it.number == field && it.bytes == null }?.value ?: default +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/androidauto/AapServices.kt b/shared/src/main/java/com/shilapi/xcertplay/androidauto/AapServices.kt new file mode 100644 index 00000000..4122053a --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/androidauto/AapServices.kt @@ -0,0 +1,31 @@ +package com.shilapi.xcertplay.androidauto + +/** Fixed 720p H.264 profile. Only channels implemented by the receiver are advertised. */ +object AapServices { + const val WIDTH = 1280 + const val HEIGHT = 720 + val channels = setOf(1, 2, 3, 4, 5, 6) + fun discovery(microphone: Boolean) = AapProto.build { + bytes(1, AapProto.build { number(1, 1); bytes(2, AapProto.build { + bytes(1, AapProto.build { number(1, 10) }) + }) }) + bytes(1, AapProto.build { number(1, 2); bytes(3, AapProto.build { + number(1, 3); number(5, 1) + bytes(4, AapProto.build { number(1, 2); number(2, 2); number(3, 0); number(4, 0); number(5, 160); number(8, 10000); number(10, 3) }) + }) }) + bytes(1, AapProto.build { number(1, 3); bytes(4, AapProto.build { + bytes(2, AapProto.build { number(1, WIDTH.toLong()); number(2, HEIGHT.toLong()) }) + }) }) + for (channel in 4..6) bytes(1, AapProto.build { number(1, channel.toLong()); bytes(3, AapProto.build { + number(1, 1); number(2, (channel - 3).toLong()); bytes(3, audioConfig(channel)) + }) }) + if (microphone) bytes(1, AapProto.build { number(1, 7); bytes(5, AapProto.build { number(1, 1); bytes(2, audioConfig(7)) }) }) + text(2, "WheelPlay"); text(3, "Projection"); text(4, "2026"); text(5, "WheelPlay") + number(6, 0); text(7, "WheelPlay"); text(8, "Android"); text(9, "1"); text(10, "1"); number(11, 0) + text(14, "WheelPlay") + } + fun audioConfig(channel: Int) = AapProto.build { + number(1, if (channel == 6) 48000 else 16000); number(2, 16); number(3, if (channel == 6) 2 else 1) + } + fun night(night: Boolean) = AapProto.build { bytes(10, AapProto.build { number(1, if (night) 1 else 0) }) } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/androidauto/AapTouch.kt b/shared/src/main/java/com/shilapi/xcertplay/androidauto/AapTouch.kt new file mode 100644 index 00000000..abe0d5b0 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/androidauto/AapTouch.kt @@ -0,0 +1,36 @@ +package com.shilapi.xcertplay.androidauto + +import com.shilapi.xcertplay.projection.ProjectionContact + +/** Converts snapshots into ordered Android pointer transitions. Released pointers stay in UP packets. */ +class AapTouch(private val width: Int, private val height: Int) { + data class Event(val action: Int, val index: Int, val pointers: List) + private var active = linkedMapOf() + fun update(contacts: List): List { + require(contacts.size <= 10 && contacts.map { it.id }.distinct().size == contacts.size) + require(contacts.all { it.id in 0..31 && it.x.isFinite() && it.y.isFinite() && it.x in 0.0..1.0 && it.y in 0.0..1.0 }) + val next = contacts.filter { it.down }.associateBy { it.id } + val events = mutableListOf() + val previous = active.toMap() + contacts.forEach { if (active.containsKey(it.id)) active[it.id] = it } + active.keys.filter { it !in next }.forEach { id -> + events += Event(if (active.size == 1) 1 else 6, active.keys.indexOf(id), active.values.toList()) + active.remove(id) + } + next.forEach { (id, point) -> if (id !in active) { + active[id] = point + events += Event(if (active.size == 1) 0 else 5, active.keys.indexOf(id), active.values.toList()) + } } + if (events.isEmpty() && active.isNotEmpty() && previous != active) events += Event(2, 0, active.values.toList()) + return events + } + fun encode(event: Event, nanos: Long): ByteArray = AapProto.build { + number(1, nanos) + bytes(3, AapProto.build { + event.pointers.forEach { point -> bytes(1, AapProto.build { + number(1, (point.x * (width - 1)).toLong()); number(2, (point.y * (height - 1)).toLong()); number(3, point.id.toLong()) + }) } + number(2, event.index.toLong()); number(3, event.action.toLong()) + }) + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionRuntime.kt b/shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionRuntime.kt index b075d9a3..81ae9f18 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionRuntime.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionRuntime.kt @@ -77,7 +77,15 @@ class ProjectionRuntime { } } publish() - if (failure == null) result.complete(session) else result.completeExceptionally(failure) + if (failure == null) { + try { session.activate(); result.complete(session) } + catch (activationFailure: Throwable) { + stop(session).whenComplete { _, cleanupFailure -> + if (cleanupFailure != null) activationFailure.addSuppressed(cleanupFailure) + result.completeExceptionally(activationFailure) + } + } + } else result.completeExceptionally(failure) } } catch (failure: Throwable) { synchronized(lock) { diff --git a/shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionSource.kt b/shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionSource.kt index a4bea336..f719e387 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionSource.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionSource.kt @@ -16,8 +16,15 @@ data class ProjectionCapabilities( val nightMode: Boolean = false, val audioPlayback: Boolean = false, val microphone: Boolean = false, + /** Source-managed audio may be available locally without a browser route. */ + val browserAudio: Boolean = audioPlayback, + val browserMicrophone: Boolean = microphone, ) { - init { require(maxTouchContacts in 0..10) } + init { + require(maxTouchContacts in 0..10) + require(!browserAudio || audioPlayback) + require(!browserMicrophone || microphone) + } } data class SourceDescriptor(val id: String, val displayName: String) @@ -34,6 +41,8 @@ interface ProjectionSession { val statusText: String? get() = null val media: MediaControlSource? get() = null val mediaOutput: MediaOutputControl? get() = null + /** Called after runtime admission. Start asynchronous producers here; stop may race activation. */ + fun activate() = Unit /** Nonblocking admission; true means accepted, not acknowledged by the remote device. */ fun touch(contacts: List): Boolean fun setNightMode(night: Boolean): ProjectionCommandResult = ProjectionCommandResult.UNSUPPORTED diff --git a/shared/src/main/res/values-zh-rCN/projection_strings.xml b/shared/src/main/res/values-zh-rCN/projection_strings.xml index 30204eeb..041b0be2 100644 --- a/shared/src/main/res/values-zh-rCN/projection_strings.xml +++ b/shared/src/main/res/values-zh-rCN/projection_strings.xml @@ -2,10 +2,14 @@ 投影输入源 诊断源无需 CarPlay 认证。Surface 和 framebuffer 演示支持触控反馈,H.264 测试流为只读画面。 - 选择诊断输入源 + 选择投影输入源 停止投影 无法切换输入源:%1$s 等待连接投影输入源 输入源:%1$s 已有投影输入源运行,请在 WheelPlay 中切换输入源。 + Android Auto 手机地址 + 在手机 Android Auto 开发者设置中启动 Head Unit Server,输入宿主可访问的手机地址(TCP 5277)。音频由宿主播放;麦克风使用 Android 设置,不支持浏览器麦克风。 + 请输入有效的手机 IPv4 地址。 + ICCOA CarLink 需要专有 SDK。MiAuto 公开仓库及本安装包均不包含该运行库,目前无法接入 CarLink 协议。 diff --git a/shared/src/main/res/values-zh-rTW/projection_strings.xml b/shared/src/main/res/values-zh-rTW/projection_strings.xml index 89dda47d..dd4bf296 100644 --- a/shared/src/main/res/values-zh-rTW/projection_strings.xml +++ b/shared/src/main/res/values-zh-rTW/projection_strings.xml @@ -2,10 +2,14 @@ 投影輸入源 診斷源無需 CarPlay 認證。Surface 和 framebuffer 示範支援觸控回饋,H.264 測試串流為唯讀畫面。 - 選擇診斷輸入源 + 選擇投影輸入源 停止投影 無法切換輸入源:%1$s 等待連接投影輸入源 輸入源:%1$s 已有投影輸入源運行,請在 WheelPlay 中切換輸入源。 + Android Auto 手機位址 + 在手機 Android Auto 開發者設定中啟動 Head Unit Server,輸入宿主可存取的手機位址(TCP 5277)。音訊由宿主播放;麥克風使用 Android 設定,不支援瀏覽器麥克風。 + 請輸入有效的手機 IPv4 位址。 + ICCOA CarLink 需要專有 SDK。MiAuto 公開倉庫及本安裝套件均不包含該執行庫,目前無法接入 CarLink 協議。 diff --git a/shared/src/main/res/values/projection_strings.xml b/shared/src/main/res/values/projection_strings.xml index cd639b05..d98bc322 100644 --- a/shared/src/main/res/values/projection_strings.xml +++ b/shared/src/main/res/values/projection_strings.xml @@ -2,10 +2,14 @@ Projection sources Diagnostic sources work without CarPlay authentication. Surface and framebuffer demos show touch feedback; the H.264 clip is read-only. - Choose a diagnostic source + Choose projection source Stop projection Could not change source: %1$s Waiting for a projection source Source: %1$s A projection source is already active. Switch sources in WheelPlay. + Android Auto phone address + Enable Android Auto developer mode and start its Head Unit Server. Enter the phone address reachable from this host (TCP 5277). Audio plays on the host. Microphone uses the Android setting; browser microphone is unavailable. + Enter a valid phone IPv4 address. + ICCOA CarLink needs the proprietary SDK. It is not included in the public MiAuto repository or this build; CarLink protocol integration is unavailable. diff --git a/shared/src/test/java/com/shilapi/xcertplay/androidauto/AapProtocolTest.kt b/shared/src/test/java/com/shilapi/xcertplay/androidauto/AapProtocolTest.kt new file mode 100644 index 00000000..e37fff82 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/androidauto/AapProtocolTest.kt @@ -0,0 +1,80 @@ +package com.shilapi.xcertplay.androidauto + +import com.shilapi.xcertplay.projection.ProjectionContact +import org.junit.Assert.* +import org.junit.Test +import java.io.* +import java.nio.ByteBuffer + +class AapProtocolTest { + @Test fun protoParsesUnknownFixedFieldsAndRejectsTruncationAndOverflow() { + assertEquals(300L, AapProto.number(byteArrayOf(8, 0xac.toByte(), 2), 1)) + assertEquals(-4L, AapProto.number(AapProto.build { number(1, -4) }, 1)) + assertEquals(7L, AapProto.number(byteArrayOf(0x15, 0, 0, 0, 0, 8, 7), 1)) + listOf(byteArrayOf(8, 0x80.toByte()), byteArrayOf(10, 5, 1), byteArrayOf(0), + byteArrayOf(8) + ByteArray(11) { 0xff.toByte() }).forEach { bytes -> + assertThrows(IllegalArgumentException::class.java) { AapProto.parse(bytes) } + } + } + @Test fun headerReadsSplitStreamAndFirstFragmentTotalWithoutConsumingNextPacket() { + val wire = byteArrayOf(2, 9, 0, 3, 0, 0, 0, 20, 1, 2, 3, 0, 3, 0, 2, 0, 2) + val input = DataInputStream(object : ByteArrayInputStream(wire) { + override fun read(b: ByteArray, off: Int, len: Int) = super.read(b, off, minOf(1, len)) + }) + val first = AapFraming.read(input) + assertEquals(20, first.total); assertArrayEquals(byteArrayOf(1, 2, 3), first.payload) + assertEquals(2, AapFraming.type(AapFraming.read(input).payload)) + assertThrows(EOFException::class.java) { AapFraming.read(input) } + assertThrows(IllegalArgumentException::class.java) { AapFraming.read(DataInputStream(ByteArrayInputStream(byteArrayOf(2, 9, 0, 1, 0x7f, -1, -1, -1)))) } + } + @Test fun transportFramingHasWireCompatibleControlMessageBytes() { + val out = ByteArrayOutputStream() + AapFraming.write(out, 0, 3, AapFraming.message(1, byteArrayOf(0, 1, 0, 2))) + assertArrayEquals(byteArrayOf(0, 3, 0, 6, 0, 1, 0, 1, 0, 2), out.toByteArray()) + } + @Test fun reassemblesTimestampedVideoAndKeepsContinuationBytesIncludingApparentTypes() { + val assembler = AapMediaAssembler() + val first = ByteBuffer.allocate(14).putShort(0).putLong(123456).put(byteArrayOf(0, 0, 0, 1)).array() + assertNull(assembler.accept(9, first)) + assertNull(assembler.accept(8, byteArrayOf(0x65, 9, 8))) + val frame = assembler.accept(10, byteArrayOf(0, 1, 0x11))!! + assertEquals(123456L, frame.ptsUs) + assertArrayEquals(byteArrayOf(0, 0, 0, 1, 0x65, 9, 8, 0, 1, 0x11), frame.data) + assertThrows(IllegalStateException::class.java) { assembler.accept(10, byteArrayOf(1)) } + assembler.accept(9, first) + assertThrows(IllegalStateException::class.java) { assembler.accept(11, first) } + } + @Test fun touchPreservesPointerIdsAndUpIncludesReleasedPointer() { + val touch = AapTouch(1280, 720) + val first = ProjectionContact(2, .1, .2, true) + val second = ProjectionContact(8, .3, .4, true) + assertEquals(0, touch.update(listOf(first)).single().action) + assertTrue(touch.update(listOf(first)).isEmpty()) + val down = touch.update(listOf(second, first)).single() + assertEquals(5, down.action); assertEquals(1, down.index) + assertEquals(listOf(2, 8), down.pointers.map { it.id }) + val up = touch.update(listOf(second)).single() + assertEquals(6, up.action); assertEquals(0, up.index) + assertEquals(listOf(2, 8), up.pointers.map { it.id }) + val end = touch.update(emptyList()).single() + assertEquals(1, end.action); assertEquals(listOf(8), end.pointers.map { it.id }) + assertTrue(touch.update(emptyList()).isEmpty()) + } + @Test fun simultaneousChangesProduceOrderedTransitionsAndWireCoordinates() { + val touch = AapTouch(1280, 720) + val events = touch.update(listOf(ProjectionContact(0, 1.0, 1.0, true), ProjectionContact(1, 0.0, 0.0, true))) + assertEquals(listOf(0, 5), events.map { it.action }) + val report = AapProto.parse(touch.encode(events.last(), 123)) + assertEquals(123L, report.first { it.number == 1 }.value) + val event = AapProto.parse(report.first { it.number == 3 }.bytes!!) + val pointer = event.first { it.number == 1 }.bytes!! + assertEquals(1279L, AapProto.number(pointer, 1)); assertEquals(719L, AapProto.number(pointer, 2)) + assertEquals(listOf(6, 1), touch.update(emptyList()).map { it.action }) + } + @Test fun discoveryOnlyAdvertisesImplementedChannelsAndOptionalMicrophone() { + fun channels(mic: Boolean) = AapProto.parse(AapServices.discovery(mic)).filter { it.number == 1 } + .map { AapProto.number(it.bytes!!, 1).toInt() }.toSet() + assertEquals(AapServices.channels, channels(false)) + assertEquals(AapServices.channels + 7, channels(true)) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/projection/ProjectionRuntimeTest.kt b/shared/src/test/java/com/shilapi/xcertplay/projection/ProjectionRuntimeTest.kt index 3cd53fd0..11cb9cea 100644 --- a/shared/src/test/java/com/shilapi/xcertplay/projection/ProjectionRuntimeTest.kt +++ b/shared/src/test/java/com/shilapi/xcertplay/projection/ProjectionRuntimeTest.kt @@ -116,4 +116,32 @@ class ProjectionRuntimeTest { assertFalse(runtime.touch(old, listOf(finger.copy(y = 1.1)))) assertFalse(runtime.touch(old, listOf(finger.copy(id = -1)))) } + @Test fun activationRunsAfterAdmissionAndFailureWaitsForCleanup() { + val runtime = ProjectionRuntime() + val closed = CompletableFuture() + val session = object : ProjectionSession { + override val descriptor = SourceDescriptor("activate", "Activate") + override val capabilities = ProjectionCapabilities(emptySet()) + override val inputReady = false + override fun activate() { + assertSame(this, runtime.snapshot()) + throw IllegalStateException("failed after admission") + } + override fun touch(contacts: List) = false + override fun stop() = closed + } + runtime.register(object : ProjectionSourceProvider { + override val descriptor = session.descriptor + override val resources = setOf(ProjectionResource.USB) + override fun probe() = SourceAvailability.Ready + override fun open() = CompletableFuture.completedFuture(session) + }) + val result = runtime.open("activate") + assertFalse(result.isDone) + assertSame(session, runtime.snapshot()) + closed.complete(Unit) + assertTrue(result.isCompletedExceptionally) + assertNull(runtime.snapshot()) + } + } From 7e6a9b69cd4ca1c1093ed31eadcfbd518c3f50cb Mon Sep 17 00:00:00 2001 From: Siubeng Date: Fri, 23 Oct 2026 13:13:00 +0000 Subject: [PATCH 2/4] feat: add guided local Android Auto connection shortcut --- README.md | 2 +- README.zh-CN.md | 2 +- common/src/main/AndroidManifest.xml | 4 + .../com/shilapi/xcertplay/DiPlayActivity.kt | 58 +++++++++++- .../projection/ProjectionSessions.kt | 4 +- .../androidauto/AndroidAutoSource.kt | 43 ++++++--- .../androidauto/LocalAndroidAuto.kt | 25 ++++++ .../androidauto/LocalAndroidAutoTest.kt | 89 +++++++++++++++++++ docs/ANDROID_AUTO.md | 6 ++ .../res/values-zh-rCN/projection_strings.xml | 9 ++ .../res/values-zh-rTW/projection_strings.xml | 9 ++ .../main/res/values/projection_strings.xml | 9 ++ 12 files changed, 244 insertions(+), 16 deletions(-) create mode 100644 common/src/main/java/com/shilapi/xcertplay/projection/androidauto/LocalAndroidAuto.kt create mode 100644 common/src/test/java/com/shilapi/xcertplay/projection/androidauto/LocalAndroidAutoTest.kt diff --git a/README.md b/README.md index fdc46b8c..e8738e5c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Install the app on an Android device and open the displayed address in your car' ## Features -- **Multiple-source framework**: A single active projection session routes video, touch and media controls independently of CarPlay. Three diagnostic sources exercise H.264, Surface and framebuffer output; Experimental Android Auto USB AOA and explicit Head Unit Server TCP support is available; ICCOA is blocked on its proprietary SDK. See the [Android Auto setup and limitations](docs/ANDROID_AUTO.md). See the [implementation and device test guide](docs/PROJECTION_IMPLEMENTATION.md). +- **Multiple-source framework**: A single active projection session routes video, touch and media controls independently of CarPlay. Three diagnostic sources exercise H.264, Surface and framebuffer output; Experimental Android Auto USB AOA and explicit Head Unit Server TCP support is available, including a guided same-phone shortcut; ICCOA is blocked on its proprietary SDK. See the [Android Auto setup and limitations](docs/ANDROID_AUTO.md). See the [implementation and device test guide](docs/PROJECTION_IMPLEMENTATION.md). - **Browser display and touch control**: CarPlay streaming, touch input, and fullscreen display. - **Selectable streaming modes**: Choose WebRTC video passthrough (H.264 / HEVC) or image compatibility mode in settings, with JPEG / WebP format and image quality from 1 to 100. Image streaming retains JPEG compatibility fallback and overlaps up to two frames while audio and touch keep their existing routes. - **Optional browser audio**: After pairing, the browser can play CarPlay audio. The browser microphone can provide CarPlay voice input through a trusted HTTPS page; Android handles audio and microphone input by default. diff --git a/README.zh-CN.md b/README.zh-CN.md index 1ce7e10f..c14b1916 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,7 +8,7 @@ WheelPlay 是一款 Android 局域网 CarPlay 服务端应用,可将 iPhone ## 功能 -- **多输入源与实验性 Android Auto**:支持 CarPlay、三类诊断源,以及 Android Auto USB AOA/显式 Head Unit Server TCP 接入。AA 使用 720p H.264、浏览器触控和宿主音频/麦克风,仍需真机验收;ICCOA 因缺少专有 SDK 暂不可用。详见 [Android Auto 使用与限制](docs/ANDROID_AUTO.md)。 +- **多输入源与实验性 Android Auto**:支持 CarPlay、三类诊断源,以及 Android Auto USB AOA/显式 Head Unit Server TCP 接入,提供带首次设置引导的本机快捷入口。AA 使用 720p H.264、浏览器触控和宿主音频/麦克风,仍需真机验收;ICCOA 因缺少专有 SDK 暂不可用。详见 [Android Auto 使用与限制](docs/ANDROID_AUTO.md)。 - **浏览器显示与触控**:支持 CarPlay 画面串流、触摸操作和全屏显示。 - **可选串流技术**:设置中可选 WebRTC 视频直通(H.264 / HEVC)或图片兼容模式,并调整 JPEG / WebP 格式和 1–100 的图片质量。图片串流保留 JPEG 兼容回退,最多两帧并行传输,音频与触控保持原有路径。详见[图片串流与多缓冲](docs/IMAGE_STREAM.md)。 diff --git a/common/src/main/AndroidManifest.xml b/common/src/main/AndroidManifest.xml index 4c6aec49..bba77960 100644 --- a/common/src/main/AndroidManifest.xml +++ b/common/src/main/AndroidManifest.xml @@ -1,6 +1,10 @@ + + + + diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt index f817f659..87d35582 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt @@ -4,6 +4,7 @@ package com.shilapi.xcertplay import com.shilapi.xcertplay.projection.projectionSessions import com.shilapi.xcertplay.projection.registerProjectionSources +import com.shilapi.xcertplay.projection.androidauto.LocalAndroidAuto import com.shilapi.xcertplay.shared.R as UiR import com.shilapi.xcertplay.i18n.* @@ -63,6 +64,10 @@ class DiPlayActivity : AppCompatActivity() { private var webInterface: TextView? = null private var webBusy = false private var awaitingTeslaVpnConsent = false + private var localAndroidAutoDialog: android.app.AlertDialog? = null + private val localAndroidAutoSettings = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (!isFinishing && !isDestroyed) showLocalAndroidAutoSetup() + } private val teslaVpnConsent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val requested = awaitingTeslaVpnConsent awaitingTeslaVpnConsent = false @@ -282,7 +287,7 @@ class DiPlayActivity : AppCompatActivity() { } } override fun onPause() { WebSession.setPhoneConnectHandler(null); handler.removeCallbacks(tick); super.onPause() } - override fun onDestroy() { settingsDialogs.cancel(); pendingPemCertificate?.fill(0); pendingPemCertificate = null; super.onDestroy() } + override fun onDestroy() { localAndroidAutoDialog?.dismiss(); settingsDialogs.cancel(); pendingPemCertificate?.fill(0); pendingPemCertificate = null; super.onDestroy() } private fun normalizedPage(value: String) = when (value) { "home", "wireless-recovery" -> "phone" @@ -523,6 +528,11 @@ class DiPlayActivity : AppCompatActivity() { private fun home(content: LinearLayout) { content.addView(label(getString(UiR.string.projection_sources), 30, TEXT, true)) + ui.card(content, getString(UiR.string.aa_local_title), R.drawable.ic_server_phone) { body -> + body.addView(label(getString(UiR.string.aa_local_summary), 15, MUTED)) + body.addView(button(getString(UiR.string.aa_local_connect), false) { connectLocalAndroidAuto() }, matchButton(12)) + body.addView(button(getString(UiR.string.aa_local_help), false) { showLocalAndroidAutoSetup() }, ui.secondaryButtonLayout(12)) + } ui.card(content, getString(UiR.string.projection_sources), R.drawable.ic_server_phone) { body -> body.addView(label(getString(UiR.string.projection_demo_hint), 15, MUTED)) body.addView(button(getString(UiR.string.projection_choose_demo), false) { chooseProjectionSource() }, matchButton(12)) @@ -568,7 +578,8 @@ class DiPlayActivity : AppCompatActivity() { android.app.AlertDialog.Builder(this).setTitle(getString(UiR.string.projection_choose_demo)) .setItems(sources.map { it.descriptor.displayName }.toTypedArray()) { _, index -> val id = sources[index].descriptor.id - if (id == "android-auto-tcp") { + if (id == LocalAndroidAuto.SOURCE_ID) connectLocalAndroidAuto() + else if (id == "android-auto-tcp") { val host = android.widget.EditText(this).apply { setSingleLine(true) hint = "192.168.1.2" @@ -591,6 +602,49 @@ class DiPlayActivity : AppCompatActivity() { }.setNegativeButton(android.R.string.cancel, null).show() } + private fun connectLocalAndroidAuto() { + projectionSessions.snapshot()?.takeIf { it.descriptor.id == LocalAndroidAuto.SOURCE_ID }?.let { + toast(it.statusText ?: getString(UiR.string.projection_active, it.descriptor.displayName)) + return + } + if (!LocalAndroidAuto.isInstalledAndEnabled(this) || + !getSharedPreferences("projection", 0).getBoolean(LocalAndroidAuto.SETUP_SEEN, false)) { + showLocalAndroidAutoSetup() + return + } + registerProjectionSources(this) + startProjectionSource(LocalAndroidAuto.SOURCE_ID) + } + + private fun showLocalAndroidAutoSetup() { + localAndroidAutoDialog?.dismiss() + val available = LocalAndroidAuto.isInstalledAndEnabled(this) + val builder = android.app.AlertDialog.Builder(this) + .setTitle(getString(UiR.string.aa_local_title)) + .setMessage(getString(if (available) UiR.string.aa_local_setup else UiR.string.aa_local_unavailable)) + .setNeutralButton(UiR.string.aa_local_settings) { _, _ -> openLocalAndroidAutoSettings() } + .setNegativeButton(android.R.string.cancel, null) + if (available) builder.setPositiveButton(UiR.string.aa_local_connect) { _, _ -> + getSharedPreferences("projection", 0).edit().putBoolean(LocalAndroidAuto.SETUP_SEEN, true).apply() + connectLocalAndroidAuto() + } + localAndroidAutoDialog = builder.show() + } + + private fun openLocalAndroidAutoSettings() { + for (intent in LocalAndroidAuto.settingsIntents()) { + try { + localAndroidAutoSettings.launch(intent) + return + } catch (_: android.content.ActivityNotFoundException) { + // AA versions expose different settings entry points. + } catch (_: SecurityException) { + // Fall back when an AA activity is not exported. + } + } + toast(getString(UiR.string.aa_local_settings_unavailable)) + } + private fun startProjectionSource(id: String) { startWebServiceIfReady() projectionSessions.switchTo(id).whenComplete { _, failure -> runOnUiThread { diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSessions.kt b/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSessions.kt index 86393ad5..7e68a9b6 100644 --- a/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSessions.kt +++ b/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSessions.kt @@ -6,8 +6,8 @@ internal val projectionSessions = ProjectionRuntime() @Synchronized internal fun registerProjectionSources(context: android.content.Context) { if (projectionSessions.sources().isNotEmpty()) return val app = context.applicationContext - listOf(true, false).forEach { usb -> - projectionSessions.register(com.shilapi.xcertplay.projection.androidauto.AndroidAutoSource(app, usb) { + com.shilapi.xcertplay.projection.androidauto.AndroidAutoMode.entries.forEach { mode -> + projectionSessions.register(com.shilapi.xcertplay.projection.androidauto.AndroidAutoSource(app, mode) { BrowserProjectionVideo(app, 1280, 720, VideoIngressKind.ENCODED) }) } diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSource.kt b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSource.kt index 81fd6a25..cb16a74b 100644 --- a/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSource.kt +++ b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSource.kt @@ -13,22 +13,40 @@ import java.io.DataInputStream import java.nio.ByteBuffer import java.util.concurrent.* import java.util.concurrent.atomic.AtomicBoolean +import com.shilapi.xcertplay.shared.R as UiR -internal class AndroidAutoSource(private val context: Context, private val usb: Boolean, +internal enum class AndroidAutoMode { LOCAL, USB, TCP } + +internal class AndroidAutoSource(private val context: Context, private val mode: AndroidAutoMode, private val outputFactory: () -> ProjectionVideoOutput) : ProjectionSourceProvider { - override val descriptor = SourceDescriptor(if (usb) "android-auto-usb" else "android-auto-tcp", if (usb) "Android Auto · USB" else "Android Auto · Head Unit Server (TCP)") - override val resources = setOf(ProjectionResource.DISPLAY, ProjectionResource.MICROPHONE, - if (usb) ProjectionResource.USB else ProjectionResource.WIRELESS) + override val descriptor = when (mode) { + AndroidAutoMode.LOCAL -> SourceDescriptor(LocalAndroidAuto.SOURCE_ID, context.getString(UiR.string.aa_local_title)) + AndroidAutoMode.USB -> SourceDescriptor("android-auto-usb", "Android Auto · USB") + AndroidAutoMode.TCP -> SourceDescriptor("android-auto-tcp", "Android Auto · Head Unit Server (TCP)") + } + override val resources = buildSet { + add(ProjectionResource.DISPLAY); add(ProjectionResource.MICROPHONE) + if (mode == AndroidAutoMode.USB) add(ProjectionResource.USB) + if (mode == AndroidAutoMode.TCP) add(ProjectionResource.WIRELESS) + } override fun probe(): SourceAvailability { - if (usb && AapUsbConnection.candidates(context).size != 1) return SourceAvailability.Unavailable("Connect exactly one Android phone by USB") - if (!usb && context.getSharedPreferences("projection", 0).getString("aa_host", "").isNullOrBlank()) + if (mode == AndroidAutoMode.LOCAL && !LocalAndroidAuto.isInstalledAndEnabled(context)) + return SourceAvailability.Unavailable(context.getString(UiR.string.aa_local_unavailable)) + if (mode == AndroidAutoMode.USB && AapUsbConnection.candidates(context).size != 1) return SourceAvailability.Unavailable("Connect exactly one Android phone by USB") + if (mode == AndroidAutoMode.TCP && context.getSharedPreferences("projection", 0).getString("aa_host", "").isNullOrBlank()) return SourceAvailability.Unavailable("Configure the phone's Head Unit Server address first") return SourceAvailability.Ready } + internal fun tcpEndpoint(): Pair { + check(mode != AndroidAutoMode.USB) + if (mode == AndroidAutoMode.LOCAL) return "127.0.0.1" to 5277 + val prefs = context.getSharedPreferences("projection", 0) + return prefs.getString("aa_host", "")!! to prefs.getInt("aa_port", 5277) + } override fun open(): CompletableFuture = try { - val connection = if (usb) AapUsbConnection(context, AapUsbConnection.candidates(context).single()) else { - val prefs = context.getSharedPreferences("projection", 0) - AapTcpConnection(prefs.getString("aa_host", "")!!, prefs.getInt("aa_port", 5277)) + val connection = if (mode == AndroidAutoMode.USB) AapUsbConnection(context, AapUsbConnection.candidates(context).single()) else { + val (host, port) = tcpEndpoint() + AapTcpConnection(host, port) } val microphone = context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED && AirPlayPersistence.loadMicrophoneSource(context) == CarPlayMicrophoneSource.ANDROID @@ -76,8 +94,10 @@ internal class AndroidAutoSession(private val context: Context, override val des private fun state(value: String) { statusText = value; projectionSessions.changed(this) } private fun run() { var failure: Throwable? = null + var connected = false try { connection.connect() + connected = true state("Android Auto: negotiating") val input = DataInputStream(connection.input) val tls = AapTls() @@ -144,7 +164,10 @@ internal class AndroidAutoSession(private val context: Context, override val des } catch (error: Throwable) { if (!stopping.get()) { failure = error - state("Android Auto: ${error.message ?: error.javaClass.simpleName}") + if (!connected && descriptor.id == LocalAndroidAuto.SOURCE_ID) { + context.getSharedPreferences("projection", 0).edit().remove(LocalAndroidAuto.SETUP_SEEN).apply() + state(context.getString(UiR.string.aa_local_server_unavailable)) + } else state("Android Auto: ${error.message ?: error.javaClass.simpleName}") Handler(Looper.getMainLooper()).post { android.widget.Toast.makeText(context, statusText, android.widget.Toast.LENGTH_LONG).show() } } } finally { diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/LocalAndroidAuto.kt b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/LocalAndroidAuto.kt new file mode 100644 index 00000000..dd914f14 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/LocalAndroidAuto.kt @@ -0,0 +1,25 @@ +package com.shilapi.xcertplay.projection.androidauto + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.Settings + +/** Local head-unit server setup. Does not depend on private wireless-start broadcasts. */ +internal object LocalAndroidAuto { + const val PACKAGE = "com.google.android.projection.gearhead" + const val SOURCE_ID = "android-auto-local" + const val SETUP_SEEN = "aa_local_setup_seen" + + fun isInstalledAndEnabled(context: Context): Boolean = try { + context.packageManager.getApplicationInfo(PACKAGE, 0).enabled + } catch (_: PackageManager.NameNotFoundException) { false } + + // The AA settings activity is version-dependent. Callers try each intent and handle failure. + fun settingsIntents(): List = listOf( + Intent().setClassName(PACKAGE, "$PACKAGE.companion.settings.DefaultSettingsActivity"), + Intent(Intent.ACTION_APPLICATION_PREFERENCES).setPackage(PACKAGE), + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$PACKAGE")) + ) +} diff --git a/common/src/test/java/com/shilapi/xcertplay/projection/androidauto/LocalAndroidAutoTest.kt b/common/src/test/java/com/shilapi/xcertplay/projection/androidauto/LocalAndroidAutoTest.kt new file mode 100644 index 00000000..5bcf9291 --- /dev/null +++ b/common/src/test/java/com/shilapi/xcertplay/projection/androidauto/LocalAndroidAutoTest.kt @@ -0,0 +1,89 @@ +package com.shilapi.xcertplay.projection.androidauto + +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import com.shilapi.xcertplay.projection.* +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.net.ConnectException +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class LocalAndroidAutoTest { + private val context get() = RuntimeEnvironment.getApplication() + private val prefs get() = context.getSharedPreferences("projection", 0) + private fun source(mode: AndroidAutoMode) = AndroidAutoSource(context, mode) { error("Must not create video") } + + @Before fun clearPreferences() { prefs.edit().clear().commit() } + + private fun install(enabled: Boolean = true) { + shadowOf(context.packageManager).installPackage(PackageInfo().apply { + packageName = LocalAndroidAuto.PACKAGE + applicationInfo = ApplicationInfo().apply { + packageName = LocalAndroidAuto.PACKAGE + this.enabled = enabled + } + }) + } + + @Test fun unavailableLocalAppCannotStopCurrentSource() { + val runtime = ProjectionRuntime() + val playing = object : ProjectionSession { + override val descriptor = SourceDescriptor("playing", "Playing") + override val capabilities = ProjectionCapabilities(emptySet()) + override val inputReady = false + override fun touch(contacts: List) = false + override fun stop(): CompletableFuture = error("Must preserve the current source") + } + runtime.adopt(playing, setOf(ProjectionResource.DISPLAY)) + val local = source(AndroidAutoMode.LOCAL) + runtime.register(local) + assertTrue(runtime.switchTo(local.descriptor.id).isCompletedExceptionally) + assertSame(playing, runtime.snapshot()) + } + + @Test fun disabledAppIsUnavailableAndEnabledAppCanBeSelectedWithoutRemoteSettings() { + install(enabled = false) + assertTrue(source(AndroidAutoMode.LOCAL).probe() is SourceAvailability.Unavailable) + install() + assertEquals(SourceAvailability.Ready, source(AndroidAutoMode.LOCAL).probe()) + assertTrue(source(AndroidAutoMode.TCP).probe() is SourceAvailability.Unavailable) + } + + @Test fun localConnectionIgnoresAndPreservesRemoteAddress() { + prefs.edit().putString("aa_host", "192.168.1.80").putInt("aa_port", 1234).commit() + val local = source(AndroidAutoMode.LOCAL) + assertEquals("127.0.0.1" to 5277, local.tcpEndpoint()) + local.open().get().stop().get() // Opening is side-effect free until activate. + assertEquals("192.168.1.80" to 1234, source(AndroidAutoMode.TCP).tcpEndpoint()) + assertFalse(local.resources.contains(ProjectionResource.WIRELESS)) + assertFalse(local.resources.contains(ProjectionResource.USB)) + } + + @Test fun localConnectionFailureRestoresSetupGuideButRemoteFailureDoesNot() { + for (id in listOf("android-auto-tcp", LocalAndroidAuto.SOURCE_ID)) { + prefs.edit().putBoolean(LocalAndroidAuto.SETUP_SEEN, true).commit() + val closed = CountDownLatch(1) + val connection = object : AapConnection { + override fun connect() { throw ConnectException("Connection refused") } + override val input get() = error("No stream before connect") + override val output get() = error("No stream before connect") + override fun close() { closed.countDown() } + } + val session = AndroidAutoSession(context, SourceDescriptor(id, id), connection, false) { error("No video before connect") } + session.activate() + assertTrue(closed.await(5, TimeUnit.SECONDS)) + session.stop().get(5, TimeUnit.SECONDS) + assertEquals(id != LocalAndroidAuto.SOURCE_ID, prefs.getBoolean(LocalAndroidAuto.SETUP_SEEN, false)) + } + } +} diff --git a/docs/ANDROID_AUTO.md b/docs/ANDROID_AUTO.md index accf8337..27643b44 100644 --- a/docs/ANDROID_AUTO.md +++ b/docs/ANDROID_AUTO.md @@ -6,6 +6,7 @@ 手机首页 → 投影输入源 → 选择投影输入源: +- **Android Auto · 本机**:手机首页有独立快捷卡片,也可从输入源列表选择。首次引导提供 Android Auto 设置跳转:完成 AA 首次配置,连续点击版本信息开启开发者模式,再从菜单选择“启动车机服务器”(Start head unit server)。返回 WheelPlay 后点击连接;之后保持服务器运行即可一键连接,无需填写 IP 或插线。固定连接 `127.0.0.1:5277`,不修改远程 TCP 地址。手机重启后可能需要重新开启服务器;连接被拒绝时会提示操作,并在下次点击时重新显示引导。未安装/停用 AA 时拒绝切源,保留现有会话。服务是否运行在实际连接时确认,点击连接仍会按正常流程切换当前源。 - **Android Auto · USB**:用数据线将 Android Auto 手机接到 WheelPlay 宿主的 USB Host/OTG 端口。只连接一台候选手机,确认系统 USB 权限弹窗。宿主发送 AOA GET_PROTOCOL / SEND_STRING / START,等待手机重新枚举,再申请新设备权限并使用 bulk IN/OUT 通道。两个 Android 设备必须能分别承担 USB host 和 accessory 角色。不会自动接管 iPhone 或开启 Android Auto 无线热点。 - **Android Auto · Head Unit Server (TCP)**:在手机 Android Auto 开发者设置启动 Head Unit Server,填写宿主能访问的 IPv4 地址,端口固定为 5277。同设备实验可填 `127.0.0.1`;跨设备必须确认服务实际监听可访问地址。只监听 loopback 的手机需要另行建立端口转发,连接同一 Wi-Fi 本身并不足够。此入口不是 Bluetooth/Wi-Fi 自动发现与无线配对。 - 浏览器使用原有 WheelPlay 地址和配对流程。切换源后需重新连接浏览器。 @@ -15,6 +16,8 @@ 启动失败会显示原因并清理会话;USB 权限等待、模式切换、TCP 连接和 TLS 握手都有超时。停止可打断启动。当前连接断开后由用户重新选择源,不包含自动重连策略。 +本机模式不自动打开开发者模式或启动车机服务器,也不依赖 root、Shizuku、无障碍点击或私有无线启动广播。Android Auto 的设置 Activity 随版本变化;打不开时回退到应用设置/系统应用详情,并提供手动操作说明。DiAuto 的旧版本兼容路径调用内部无线启动组件,而其 AA 17.4+ 分支也改用本机 Head Unit Server;这不能证明所有新旧 AA 版本的同机兼容性。本机音频焦点及麦克风与 AA 共处同一设备的行为仍需真机验证。 + ## 已实现的协议路径 1. 明文 AAP 版本交换;channel 0 封装 TLS 1.2 握手;认证完成消息。每个会话生成自己的自签名 RSA 身份,没有复制外部项目的证书私钥。AAP 的自签名证书互信仅用于用户选定的手机连接,不能用于 HTTPS。 @@ -32,6 +35,8 @@ 待真机验收:至少一台 Android Auto 手机通过 USB 完成首次授权、投屏、触控、媒体/导航声音、语音助手和拔插重连;TCP 路径实际连接 Head Unit Server;切换 CarPlay/AA/诊断源、多次进入退出、慢浏览器回退、静止画面恢复、关闭麦克风后的无录音验证。测试需记录宿主/手机型号、Android/AA 版本、线材/OTG 和浏览器。 +本机入口另需验证:未安装/已停用 AA、首次设置跳转及返回、服务器未启动时提示、再次点击恢复引导、服务器已运行时一键连接、手机重启后恢复、音频焦点/语音助手,以及连接成功后从车机浏览器控制。自动化测试覆盖本机与远程地址隔离、缺失/停用 AA 的可用性和连接失败后恢复引导。 + 未实现:自动无线发现/配对、浏览器音频和麦克风、Now Playing 元数据、车载通话 HFP、AA 导航元数据、动态分辨率、HEVC、自动重连。不能据此将 #28 的完整 Android Auto 验收标为完成。 ## ICCOA CarLink 阻塞 @@ -45,4 +50,5 @@ MiAuto 使用专有 `icar-sdk.dex`,公开仓库仅有 `icar-sdk-stubs.jar` 签 ## 参考版本 - [DiAuto](https://github.com/shihabal3amri/DiAuto/tree/f3046721814bff289a78e58f597b45d895efbb95):AAP 的 control/media/input/sensors 协议字段、AOA 和消息处理研究参考。WheelPlay 使用自己的小型 wire codec、连接生命周期和公共视频端口,没有引入 DiAuto 的 Activity/Service 或证书私钥。 +- [DiAuto 本机模式与设置入口](https://github.com/shihabal3amri/DiAuto/blob/f3046721814bff289a78e58f597b45d895efbb95/app/src/main/java/com/andrerinas/openheadunit/aap/AapService.kt#L3003):版本差异及 Head Unit Server 引导的研究参考。 - [MiAuto SDK 依赖说明](https://github.com/PiggyBackBayonet/MiAuto/blob/059b2bb6b4d7fd3b506e7c29bbf153a8e38a0c4d/app/libs/README.md) 与 [集成说明](https://github.com/PiggyBackBayonet/MiAuto/blob/059b2bb6b4d7fd3b506e7c29bbf153a8e38a0c4d/docs/ICCOA_SDK_INTEGRATION.md)。 diff --git a/shared/src/main/res/values-zh-rCN/projection_strings.xml b/shared/src/main/res/values-zh-rCN/projection_strings.xml index 041b0be2..1c8ca63f 100644 --- a/shared/src/main/res/values-zh-rCN/projection_strings.xml +++ b/shared/src/main/res/values-zh-rCN/projection_strings.xml @@ -12,4 +12,13 @@ 在手机 Android Auto 开发者设置中启动 Head Unit Server,输入宿主可访问的手机地址(TCP 5277)。音频由宿主播放;麦克风使用 Android 设置,不支持浏览器麦克风。 请输入有效的手机 IPv4 地址。 ICCOA CarLink 需要专有 SDK。MiAuto 公开仓库及本安装包均不包含该运行库,目前无法接入 CarLink 协议。 + Android Auto · 本机 + 将本机 Android Auto 投影到车机浏览器。首次需要开启车机服务器,声音由本机播放;此功能仍为实验性。 + 连接本机 Android Auto + 首次设置与连接帮助 + 打开 Android Auto 设置 + 1. 打开 Android Auto 设置,完成首次使用配置。\n2. 连续点击版本信息(通常为 10 次),确认开启开发者模式。\n3. 在菜单中选择“启动车机服务器”(Start head unit server)。\n4. 返回 WheelPlay,点击“连接本机 Android Auto”。\n\n保持车机服务器运行,下次即可一键连接;手机重启后可能需要重新开启。无需填写地址或连接数据线。声音由本机播放,麦克风遵循 Android 麦克风设置。同机兼容性仍需真机验证。 + 本机未安装 Android Auto,或已将其停用。请在受支持的设备上安装或启用 Android Auto,完成首次配置后返回 WheelPlay。部分版本的设置按钮会打开系统应用详情页。 + 无法连接本机 Android Auto。请打开“首次设置与连接帮助”,在 Android Auto 中启动车机服务器后重试。 + 无法打开 Android Auto 设置。请在系统设置中搜索 Android Auto,并按照引导完成设置。 diff --git a/shared/src/main/res/values-zh-rTW/projection_strings.xml b/shared/src/main/res/values-zh-rTW/projection_strings.xml index dd4bf296..4389285b 100644 --- a/shared/src/main/res/values-zh-rTW/projection_strings.xml +++ b/shared/src/main/res/values-zh-rTW/projection_strings.xml @@ -12,4 +12,13 @@ 在手機 Android Auto 開發者設定中啟動 Head Unit Server,輸入宿主可存取的手機位址(TCP 5277)。音訊由宿主播放;麥克風使用 Android 設定,不支援瀏覽器麥克風。 請輸入有效的手機 IPv4 位址。 ICCOA CarLink 需要專有 SDK。MiAuto 公開倉庫及本安裝套件均不包含該執行庫,目前無法接入 CarLink 協議。 + Android Auto · 本機 + 將本機 Android Auto 投影到車機瀏覽器。首次需要開啟車機伺服器,聲音由本機播放;此功能仍為實驗性。 + 連接本機 Android Auto + 首次設定與連線說明 + 開啟 Android Auto 設定 + 1. 開啟 Android Auto 設定,完成首次使用設定。\n2. 連續點選版本資訊(通常為 10 次),確認開啟開發人員模式。\n3. 在選單中選擇「啟動車機伺服器」(Start head unit server)。\n4. 返回 WheelPlay,點選「連接本機 Android Auto」。\n\n保持車機伺服器執行,下次即可一鍵連線;手機重新啟動後可能需要重新開啟。無需填寫位址或連接傳輸線。聲音由本機播放,麥克風遵循 Android 麥克風設定。同機相容性仍需實機驗證。 + 本機未安裝 Android Auto,或已將其停用。請在支援的裝置上安裝或啟用 Android Auto,完成首次設定後返回 WheelPlay。部分版本的設定按鈕會開啟系統應用程式詳細資料頁面。 + 無法連接本機 Android Auto。請開啟「首次設定與連線說明」,在 Android Auto 中啟動車機伺服器後重試。 + 無法開啟 Android Auto 設定。請在系統設定中搜尋 Android Auto,並按照說明完成設定。 diff --git a/shared/src/main/res/values/projection_strings.xml b/shared/src/main/res/values/projection_strings.xml index d98bc322..c8967afd 100644 --- a/shared/src/main/res/values/projection_strings.xml +++ b/shared/src/main/res/values/projection_strings.xml @@ -12,4 +12,13 @@ Enable Android Auto developer mode and start its Head Unit Server. Enter the phone address reachable from this host (TCP 5277). Audio plays on the host. Microphone uses the Android setting; browser microphone is unavailable. Enter a valid phone IPv4 address. ICCOA CarLink needs the proprietary SDK. It is not included in the public MiAuto repository or this build; CarLink protocol integration is unavailable. + Android Auto · This phone + Use Android Auto on this phone in the car browser. First enable its head unit server. Audio plays on this phone; this feature is experimental. + Connect this phone + Setup and connection help + Open Android Auto settings + 1. Open Android Auto settings and complete any initial setup.\n2. Tap the version information repeatedly (usually 10 times), then confirm developer mode.\n3. Open the menu and select Start head unit server.\n4. Return to WheelPlay and tap Connect this phone.\n\nKeep the server running for one-tap connections next time. After a restart, you may need to start it again. No address or cable is needed. Audio plays on this phone; microphone follows the Android microphone setting. Same-phone compatibility still needs device testing. + Android Auto is not installed or is disabled on this phone. Install or enable it on a supported device, finish its initial setup, then return to WheelPlay. Settings may open the system app details page on some versions. + Could not connect to local Android Auto. Open Setup and connection help, start the head unit server in Android Auto, then try again. + Could not open Android Auto settings. Find Android Auto in system Settings and follow the setup instructions. From e05415892815ca9dfb9c735a5716ba8e9128ba12 Mon Sep 17 00:00:00 2001 From: Siubeng Date: Fri, 23 Oct 2026 13:22:44 +0000 Subject: [PATCH 3/4] feat: organize input sources into cards and detail pages --- .../com/shilapi/xcertplay/DiPlayActivity.kt | 159 +++++++++++++----- .../java/com/shilapi/xcertplay/ServerUi.kt | 48 +++++- .../shilapi/xcertplay/ServerNavigationTest.kt | 8 +- .../shilapi/xcertplay/SourceNavigationTest.kt | 116 +++++++++++++ docs/ANDROID_AUTO.md | 4 +- .../res/values-zh-rCN/projection_strings.xml | 13 ++ .../res/values-zh-rTW/projection_strings.xml | 13 ++ .../main/res/values/projection_strings.xml | 13 ++ 8 files changed, 316 insertions(+), 58 deletions(-) create mode 100644 common/src/test/java/com/shilapi/xcertplay/SourceNavigationTest.kt diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt index 87d35582..07396324 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt @@ -56,6 +56,9 @@ class DiPlayActivity : AppCompatActivity() { private val ui by lazy { ServerUi(this) } private val handler = Handler(Looper.getMainLooper()) private var page = "service" + private val sourceDetailPages = setOf("source-carplay", "source-android-auto", "source-iccoa", "source-diagnostics") + private val sourceStatuses = mutableMapOf() + private val sourceStopButtons = mutableMapOf() private val settingsModel by lazy { ViewModelProvider(this)[SettingsViewModel::class.java] } private var settingsView: ComposeView? = null private val settingsDialogs = SettingsDialogHost() @@ -243,6 +246,7 @@ class DiPlayActivity : AppCompatActivity() { override fun handleOnBackPressed() { when { page == "settings" && settingsModel.state.value.category != null -> settingsModel.open(null) + page in sourceDetailPages -> navigate("phone") page != "service" -> navigate("service") else -> moveTaskToBack(true) } @@ -290,9 +294,11 @@ class DiPlayActivity : AppCompatActivity() { override fun onDestroy() { localAndroidAutoDialog?.dismiss(); settingsDialogs.cancel(); pendingPemCertificate?.fill(0); pendingPemCertificate = null; super.onDestroy() } private fun normalizedPage(value: String) = when (value) { - "home", "wireless-recovery" -> "phone" + "home" -> "phone" + "wireless-recovery" -> "source-carplay" "about" -> "settings" "phone", "settings" -> value + in sourceDetailPages -> value else -> "service" } @@ -308,7 +314,7 @@ class DiPlayActivity : AppCompatActivity() { private fun createShell() { val initial = normalizedPage(page) - shell = ui.Shell(initial) { page = it } + shell = ui.Shell(initial, { page = it }, sourceDetailPages.associateWith { "phone" }) page = initial shell.put("service", ui.content(::service)) refreshPhone() @@ -323,7 +329,12 @@ class DiPlayActivity : AppCompatActivity() { private fun refreshPhone() { status = null; connectButton = null; disconnectButton = null; lastRunning = null - shell.put("phone", ui.content(::home)) + sourceStatuses.clear(); sourceStopButtons.clear() + shell.put("phone", ui.content(::sourceIndex)) + shell.put("source-carplay", ui.content(::carPlaySource)) + shell.put("source-android-auto", ui.content(::androidAutoSource)) + shell.put("source-iccoa", ui.content(::iccoaSource)) + shell.put("source-diagnostics", ui.content(::diagnosticSources)) refreshStatus() } @@ -473,7 +484,7 @@ class DiPlayActivity : AppCompatActivity() { if (!CarPlayBackgroundSession.hasSession()) connect(true) }.apply { visibility = View.GONE } body.addView(sessionConnectButton, ui.secondaryButtonLayout(12)) - body.addView(button(getString(UiR.string.ui_manage_iphone_connection), false) { navigate("phone") }, ui.secondaryButtonLayout(16)) + body.addView(button(getString(UiR.string.ui_manage_iphone_connection), false) { navigate("source-carplay") }, ui.secondaryButtonLayout(16)) } content.addView(label(getString(UiR.string.ui_audio_and_microphone_use_this_device_by_default_enable), 14, MUTED)) content.addView(button(getString(UiR.string.ui_stop_service), false) { @@ -526,23 +537,22 @@ class DiPlayActivity : AppCompatActivity() { otherAddresses?.visibility = if (count > 0 && addressesExpanded) View.VISIBLE else View.GONE } - private fun home(content: LinearLayout) { - content.addView(label(getString(UiR.string.projection_sources), 30, TEXT, true)) - ui.card(content, getString(UiR.string.aa_local_title), R.drawable.ic_server_phone) { body -> - body.addView(label(getString(UiR.string.aa_local_summary), 15, MUTED)) - body.addView(button(getString(UiR.string.aa_local_connect), false) { connectLocalAndroidAuto() }, matchButton(12)) - body.addView(button(getString(UiR.string.aa_local_help), false) { showLocalAndroidAutoSetup() }, ui.secondaryButtonLayout(12)) - } - ui.card(content, getString(UiR.string.projection_sources), R.drawable.ic_server_phone) { body -> - body.addView(label(getString(UiR.string.projection_demo_hint), 15, MUTED)) - body.addView(button(getString(UiR.string.projection_choose_demo), false) { chooseProjectionSource() }, matchButton(12)) - body.addView(button(getString(UiR.string.projection_stop), false) { - projectionSessions.stop().whenComplete { _, failure -> runOnUiThread { - if (failure != null) toast(getString(UiR.string.projection_failed, failure.cause?.message ?: failure.message.orEmpty())) - refreshStatus(); refreshService() - } } - }, ui.secondaryButtonLayout(12)) - } + private fun sourceIndex(content: LinearLayout) { + content.addView(label(getString(UiR.string.source_tab), 30, TEXT, true).apply { setPadding(0, 0, 0, dp(24)) }) + ui.sourceCard(content, getString(UiR.string.source_carplay), getString(UiR.string.source_carplay_summary), R.drawable.ic_server_phone) { navigate("source-carplay") } + ui.sourceCard(content, "Android Auto", getString(UiR.string.source_aa_summary), R.drawable.ic_server_launch) { navigate("source-android-auto") } + ui.sourceCard(content, "ICCOA CarLink", getString(UiR.string.source_iccoa_summary), R.drawable.ic_server_wifi) { navigate("source-iccoa") } + ui.sourceCard(content, getString(UiR.string.source_diagnostics), getString(UiR.string.source_diagnostics_summary), R.drawable.ic_server_server) { navigate("source-diagnostics") } + } + + private fun sourceHeading(content: LinearLayout, title: String) { + content.addView(button(getString(UiR.string.source_back), false, R.drawable.ic_settings_back) { navigate("phone") }, + LinearLayout.LayoutParams(-2, -2).apply { bottomMargin = dp(16) }) + content.addView(label(title, 30, TEXT, true).apply { setPadding(0, 0, 0, dp(24)) }) + } + + private fun carPlaySource(content: LinearLayout) { + sourceHeading(content, getString(UiR.string.source_carplay)) content.addView(label(getString(UiR.string.ui_this_device_receives_carplay_the_head_unit_browser_provides), 16, MUTED).apply { setPadding(0, dp(8), 0, dp(24)) }) status = label(getString(UiR.string.ui_waiting_for_connection), 20, ACCENT, true) ui.card(content, getString(UiR.string.ui_connection_status), R.drawable.ic_server_phone) { it.addView(status) } @@ -572,33 +582,82 @@ class DiPlayActivity : AppCompatActivity() { setupError?.let { content.addView(label(it, 16, WARNING)) } } - private fun chooseProjectionSource() { + private fun androidAutoSource(content: LinearLayout) { + sourceHeading(content, "Android Auto") + sourceSessionControls(content, "android-auto") + ui.card(content, getString(UiR.string.aa_local_title), R.drawable.ic_server_phone) { body -> + body.addView(label(getString(UiR.string.aa_local_summary), 15, MUTED)) + body.addView(button(getString(UiR.string.aa_local_connect), true) { connectLocalAndroidAuto() }, matchButton(12)) + body.addView(button(getString(UiR.string.aa_local_help), false) { showLocalAndroidAutoSetup() }, ui.secondaryButtonLayout(12)) + } + ui.card(content, getString(UiR.string.ui_usb_connection), R.drawable.ic_server_usb) { body -> + body.addView(label(getString(UiR.string.aa_usb_hint), 15, MUTED)) + body.addView(button(getString(UiR.string.aa_usb_connect), false) { startProjectionSource("android-auto-usb") }, matchButton(12)) + } + ui.card(content, getString(UiR.string.aa_remote_title), R.drawable.ic_server_wifi) { body -> + body.addView(label(getString(UiR.string.aa_tcp_hint), 15, MUTED)) + body.addView(button(getString(UiR.string.aa_remote_connect), false) { configureAndroidAutoTcp() }, matchButton(12)) + } + } + + private fun iccoaSource(content: LinearLayout) { + sourceHeading(content, "ICCOA CarLink") + ui.card(content, getString(UiR.string.source_unavailable), R.drawable.ic_server_phone) { body -> + body.addView(label(getString(UiR.string.iccoa_sdk_unavailable), 16, MUTED)) + } + } + + private fun diagnosticSources(content: LinearLayout) { + sourceHeading(content, getString(UiR.string.source_diagnostics)) + sourceSessionControls(content, "demo") + ui.card(content, getString(UiR.string.source_diagnostics), R.drawable.ic_server_server) { body -> + body.addView(label(getString(UiR.string.projection_demo_hint), 15, MUTED)) + body.addView(button(getString(UiR.string.projection_choose_demo), false) { chooseDiagnosticSource() }, matchButton(12)) + } + } + + private fun belongsToSource(id: String, group: String) = id.startsWith("$group-") + + private fun sourceSessionControls(content: LinearLayout, group: String) { + ui.card(content, getString(UiR.string.ui_connection_status), R.drawable.ic_server_phone) { body -> + sourceStatuses[group] = label(getString(UiR.string.projection_waiting), 16, TEXT).also { body.addView(it) } + sourceStopButtons[group] = button(getString(UiR.string.projection_stop), false) { + val session = projectionSessions.snapshot()?.takeIf { belongsToSource(it.descriptor.id, group) } + if (session != null) projectionSessions.stop(session).whenComplete { _, failure -> runOnUiThread { + if (failure != null) toast(getString(UiR.string.projection_failed, failure.cause?.message ?: failure.message.orEmpty())) + refreshStatus(); refreshService() + } } + }.also { body.addView(it, ui.secondaryButtonLayout(12)) } + } + } + + private fun chooseDiagnosticSource() { registerProjectionSources(this) - val sources = projectionSessions.sources() + val sources = projectionSessions.sources().filter { belongsToSource(it.descriptor.id, "demo") } android.app.AlertDialog.Builder(this).setTitle(getString(UiR.string.projection_choose_demo)) .setItems(sources.map { it.descriptor.displayName }.toTypedArray()) { _, index -> - val id = sources[index].descriptor.id - if (id == LocalAndroidAuto.SOURCE_ID) connectLocalAndroidAuto() - else if (id == "android-auto-tcp") { - val host = android.widget.EditText(this).apply { - setSingleLine(true) - hint = "192.168.1.2" - setText(getSharedPreferences("projection", 0).getString("aa_host", "")) - } - android.app.AlertDialog.Builder(this) - .setTitle(getString(UiR.string.aa_tcp_title)) - .setMessage(getString(UiR.string.aa_tcp_hint)) - .setView(host) - .setPositiveButton(android.R.string.ok) { _, _ -> - val address = host.text.toString().trim() - if (!com.shilapi.xcertplay.projection.androidauto.AapTcpConnection.validAddress(address)) { - toast(getString(UiR.string.aa_invalid_address)) - } else { - getSharedPreferences("projection", 0).edit().putString("aa_host", address).putInt("aa_port", 5277).apply() - startProjectionSource(id) - } - }.setNegativeButton(android.R.string.cancel, null).show() - } else startProjectionSource(id) + startProjectionSource(sources[index].descriptor.id) + }.setNegativeButton(android.R.string.cancel, null).show() + } + + private fun configureAndroidAutoTcp() { + val host = android.widget.EditText(this).apply { + setSingleLine(true) + hint = "192.168.1.2" + setText(getSharedPreferences("projection", 0).getString("aa_host", "")) + } + android.app.AlertDialog.Builder(this) + .setTitle(getString(UiR.string.aa_tcp_title)) + .setMessage(getString(UiR.string.aa_tcp_hint)) + .setView(host) + .setPositiveButton(android.R.string.ok) { _, _ -> + val address = host.text.toString().trim() + if (!com.shilapi.xcertplay.projection.androidauto.AapTcpConnection.validAddress(address)) { + toast(getString(UiR.string.aa_invalid_address)) + } else { + getSharedPreferences("projection", 0).edit().putString("aa_host", address).putInt("aa_port", 5277).apply() + startProjectionSource("android-auto-tcp") + } }.setNegativeButton(android.R.string.cancel, null).show() } @@ -646,6 +705,7 @@ class DiPlayActivity : AppCompatActivity() { } private fun startProjectionSource(id: String) { + registerProjectionSources(this) startWebServiceIfReady() projectionSessions.switchTo(id).whenComplete { _, failure -> runOnUiThread { if (failure != null) toast(getString(UiR.string.projection_failed, failure.cause?.message ?: failure.message.orEmpty())) @@ -874,7 +934,7 @@ class DiPlayActivity : AppCompatActivity() { private fun handleWirelessRecovery() { if (intent.getStringExtra("page") != "wireless-recovery") return intent.removeExtra("page") - navigate("phone") + navigate("source-carplay") confirmWirelessReset() } @@ -918,6 +978,13 @@ class DiPlayActivity : AppCompatActivity() { } private fun refreshStatus() { + val projection = projectionSessions.snapshot() + sourceStatuses.forEach { (group, label) -> + val session = projection?.takeIf { belongsToSource(it.descriptor.id, group) } + label.text = if (session == null) getString(UiR.string.projection_waiting) + else session.statusText ?: getString(UiR.string.projection_active, session.descriptor.displayName) + sourceStopButtons[group]?.visibility = if (session != null) View.VISIBLE else View.GONE + } val running = CarPlayBackgroundSession.hasSession() status?.text = when { projectionSessions.snapshot()?.descriptor?.id?.let { it != "carplay" } == true -> diff --git a/common/src/main/java/com/shilapi/xcertplay/ServerUi.kt b/common/src/main/java/com/shilapi/xcertplay/ServerUi.kt index 99dc78ce..df342b3d 100644 --- a/common/src/main/java/com/shilapi/xcertplay/ServerUi.kt +++ b/common/src/main/java/com/shilapi/xcertplay/ServerUi.kt @@ -177,6 +177,35 @@ internal class ServerUi(private val context: Context) { addView(body, android.view.ViewGroup.LayoutParams(-1, -2)) }, LinearLayout.LayoutParams(-1, -2).apply { bottomMargin = dp(16) }) } + + /** One accessible navigation target, with no nested settings or connection actions. */ + fun sourceCard(parent: LinearLayout, title: String, summary: String, icon: Int, action: () -> Unit) { + val row = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = android.view.Gravity.CENTER_VERTICAL + minimumHeight = dp(132) + setPadding(dp(cardPadding), dp(24), dp(cardPadding), dp(24)) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } + row.addView(android.widget.ImageView(context).apply { setImageResource(icon) }, + LinearLayout.LayoutParams(dp(40), dp(40)).apply { marginEnd = dp(16) }) + row.addView(column().apply { + addView(text(title, 22, TEXT, true)) + addView(text(summary, 14, MUTED).apply { setPadding(0, dp(8), 0, 0) }) + }, LinearLayout.LayoutParams(0, -2, 1f)) + row.addView(android.widget.ImageView(context).apply { setImageResource(R.drawable.ic_settings_chevron) }, + LinearLayout.LayoutParams(dp(24), dp(24)).apply { marginStart = dp(12) }) + parent.addView(object : MaterialCardView(context) { + override fun getAccessibilityClassName(): CharSequence = android.widget.Button::class.java.name + }.apply { + radius = dp(24).toFloat(); cardElevation = 0f; strokeWidth = 0 + setCardBackgroundColor(SURFACE) + contentDescription = "$title, $summary" + isFocusable = true + setOnClickListener { action() } + addView(row, android.view.ViewGroup.LayoutParams(-1, -2)) + }, LinearLayout.LayoutParams(-1, -2).apply { bottomMargin = dp(16) }) + } fun content(build: (LinearLayout) -> Unit): ScrollView { val gutter = pageGutter val body = column().apply { setPadding(dp(gutter), dp(24), dp(gutter), dp(32)) } @@ -193,7 +222,8 @@ internal class ServerUi(private val context: Context) { addView(body, android.view.ViewGroup.LayoutParams(-1, -2)) } } - inner class Shell(initialPage: String, private val onPageChanged: (String) -> Unit) { + inner class Shell(initialPage: String, private val onPageChanged: (String) -> Unit, + private val parentPages: Map = emptyMap()) { val root = FrameLayout(context).apply { setBackgroundColor(BG) } private val layout = column() private var topInset = 0 @@ -225,6 +255,8 @@ internal class ServerUi(private val context: Context) { private val container = FrameLayout(context) private val pages = linkedMapOf() private val ids = listOf("service", "phone", "settings") + private var syncingTabs = false + private fun tabId(page: String) = ids.indexOf(parentPages[page] ?: page).coerceAtLeast(0) + 1 var selected = initialPage private set init { @@ -237,7 +269,7 @@ internal class ServerUi(private val context: Context) { updateStatusScrim() } val icons = listOf(R.drawable.ic_server_launch, R.drawable.ic_server_phone, R.drawable.ic_server_settings) - listOf(context.getString(UiR.string.ui_start), "iPhone", context.getString(UiR.string.ui_settings)).forEachIndexed { index, title -> + listOf(context.getString(UiR.string.ui_start), context.getString(UiR.string.source_tab), context.getString(UiR.string.ui_settings)).forEachIndexed { index, title -> tabs.menu.add(0, index + 1, index, title).setIcon(icons[index]) } // CJK fallback metrics otherwise enlarge only Chinese labels, shifting their baseline. @@ -253,10 +285,11 @@ internal class ServerUi(private val context: Context) { } } } - tabs.selectedItemId = ids.indexOf(initialPage).coerceAtLeast(0) + 1 + tabs.selectedItemId = tabId(initialPage) layout.addView(container, LinearLayout.LayoutParams(-1, 0, 1f)) layout.addView(navigationSurface, LinearLayout.LayoutParams(-1, -2)) - tabs.setOnItemSelectedListener { item -> select(ids[item.itemId - 1]); true } + tabs.setOnItemSelectedListener { item -> if (!syncingTabs) select(ids[item.itemId - 1]); true } + tabs.setOnItemReselectedListener { item -> if (!syncingTabs) select(ids[item.itemId - 1]) } } fun put(id: String, view: View, scrollPosition: Int? = null) { @@ -285,11 +318,14 @@ internal class ServerUi(private val context: Context) { if (selected == id) updateStatusScrim() } fun select(id: String) { - if (id !in ids || selected == id) return + if ((id !in ids && id !in parentPages) || selected == id) return selected = id pages.forEach { (page, view) -> view.visibility = if (page == id) View.VISIBLE else View.GONE } // Only the stock Material indicator animates; no Activity or whole-window transition. - if (tabs.selectedItemId != ids.indexOf(id) + 1) tabs.selectedItemId = ids.indexOf(id) + 1 + if (tabs.selectedItemId != tabId(id)) { + syncingTabs = true + try { tabs.selectedItemId = tabId(id) } finally { syncingTabs = false } + } updateStatusScrim() onPageChanged(id) } diff --git a/common/src/test/java/com/shilapi/xcertplay/ServerNavigationTest.kt b/common/src/test/java/com/shilapi/xcertplay/ServerNavigationTest.kt index 612583af..2ca90fd2 100644 --- a/common/src/test/java/com/shilapi/xcertplay/ServerNavigationTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/ServerNavigationTest.kt @@ -268,15 +268,15 @@ class ServerNavigationTest : ComposeSettingsTest() { .filterIsInstance() .forEach { card -> val body = card.getChildAt(0) as android.widget.LinearLayout - val heading = body.getChildAt(0) as android.widget.LinearLayout + val heading = if (card.isClickable) body else body.getChildAt(0) as android.widget.LinearLayout val icon = heading.getChildAt(0) - val title = heading.getChildAt(1) as android.widget.TextView + val title = heading.getChildAt(1) val offset = kotlin.math.abs( (icon.top * 2 + icon.height) - (title.top * 2 + title.height) ) assertTrue( - "Card icon and title must share a vertical center: ${title.text}, offset=$offset", + "Card icon and title block must share a vertical center, offset=$offset", offset <= 2, ) } @@ -380,7 +380,7 @@ class ServerNavigationTest : ComposeSettingsTest() { val labels = views(nav).filterIsInstance().filter { it.visibility == View.VISIBLE && - it.text.toString() in listOf("启动", "iPhone", "设置") + it.text.toString() in listOf("启动", "输入源", "设置") } assertEquals(3, labels.size) val baselines = diff --git a/common/src/test/java/com/shilapi/xcertplay/SourceNavigationTest.kt b/common/src/test/java/com/shilapi/xcertplay/SourceNavigationTest.kt new file mode 100644 index 00000000..efb4f6e3 --- /dev/null +++ b/common/src/test/java/com/shilapi/xcertplay/SourceNavigationTest.kt @@ -0,0 +1,116 @@ +package com.shilapi.xcertplay + +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.ScrollView +import android.widget.TextView +import com.google.android.material.bottomnavigation.BottomNavigationView +import com.google.android.material.card.MaterialCardView +import com.shilapi.xcertplay.projection.projectionSessions +import com.shilapi.xcertplay.web.WebSession +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29], qualifiers = "zh-rCN-w360dp-h800dp-mdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class SourceNavigationTest { + @Before fun provision() { + val context = RuntimeEnvironment.getApplication() + val identity = SyntheticMfiIdentity.create() + MfiAssetStore(context.noBackupFilesDir).installFiles({ identity.key.inputStream() }, { identity.certificate.inputStream() }) + DiPlayPreferences.saveAutoConnect(context, false) + } + + private fun views(view: View): List = listOf(view) + + if (view is ViewGroup) (0 until view.childCount).flatMap { views(view.getChildAt(it)) } else emptyList() + private fun tabs(activity: DiPlayActivity) = views(activity.window.decorView).filterIsInstance().single() + private fun page(activity: DiPlayActivity) = views(activity.window.decorView).filterIsInstance().single { it.visibility == View.VISIBLE } + private fun cards(activity: DiPlayActivity) = views(page(activity)).filterIsInstance() + private fun buttons(activity: DiPlayActivity) = views(page(activity)).filterIsInstance