Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The Astro product website lives in [`website/`](website/README.md), separately f

## 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, 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.
Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Astro 官网位于 [`website/`](website/README.md),与 Android 内置的局域

## 功能

- **多输入源与实验性 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 处理。
Expand Down
4 changes: 4 additions & 0 deletions common/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<queries>
<package android:name="com.google.android.projection.gearhead" />
</queries>

<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

<uses-permission android:name="android.permission.WAKE_LOCK" />
Expand Down
195 changes: 170 additions & 25 deletions common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
48 changes: 42 additions & 6 deletions common/src/main/java/com/shilapi/xcertplay/ServerUi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)) }
Expand All @@ -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<String, String> = emptyMap()) {
val root = FrameLayout(context).apply { setBackgroundColor(BG) }
private val layout = column()
private var topInset = 0
Expand Down Expand Up @@ -225,6 +255,8 @@ internal class ServerUi(private val context: Context) {
private val container = FrameLayout(context)
private val pages = linkedMapOf<String, View>()
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 {
Expand All @@ -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.
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ProjectionSession> = CompletableFuture<ProjectionSession>().also {
it.completeExceptionally(IllegalStateException((probe() as SourceAvailability.Unavailable).reason))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
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)
})
}
projectionSessions.register(IccoaSource(app))
VideoIngressKind.entries.forEach { kind ->
projectionSessions.register(SyntheticSource(kind,
{ ingress, width, height -> BrowserProjectionVideo(app, width, height, ingress) },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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<Pair<Int, ByteArray>>(32)
private val tracks = mutableMapOf<Int, AudioTrack>()
@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<Int>()
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() }
}
// Retry partial writes on this worker only; the protocol reader remains independent.
AapPcmWriter.write(bytes.size,
active = { !closed.get() && focusState != 3 && focusState != 5 && !stoppingChannels.contains(channel) },
write = { offset, length ->
track.setVolume(if (focusState == 4) 0.2f else 1f)
track.write(bytes, offset, length, 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
}
}
Loading
Loading