feat(android): add selected-app playback audio (#112)
Default-off selected-UID Android playback capture through the pinned WebRTC ADM callback, with no microphone/system-mix fallback and no server/Viewer wire change. Source compile/protocol acceptance and remaining device gates are recorded in the PR.
This commit is contained in:
1 parent
d31912fe79
commit
7ea0c4f1ea
15 files changed
+501
-82
No files matched your search
@@ -1,8 +1,8 @@
|
||||
# Android Screen Sender
|
||||
|
||||
This source-only P1 slice lets an Android 14/API 34+ device publish a 720p30
|
||||
screen track to at most two Web Viewers assigned as direct children by the
|
||||
existing `screener-v2` controller.
|
||||
screen track and optional selected-app playback-audio track to at most two Web
|
||||
Viewers assigned as direct children by the existing `screener-v2` controller.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -19,9 +19,18 @@ configured.
|
||||
## Use
|
||||
|
||||
Open the app, enter the HTTPS Screener base URL and site-access password, then
|
||||
choose a screen or app from Android's system picker. The app keeps credentials
|
||||
and room material in memory only. The displayed private invite can be opened by
|
||||
an unchanged Web Viewer.
|
||||
leave playback audio off or explicitly select one app, then choose a screen or
|
||||
app from Android's system picker. Audio is selected separately because Android
|
||||
does not expose the system picker's selected package/UID; it may therefore differ
|
||||
from the shared picture. The app keeps credentials and room material in memory
|
||||
only. The displayed private invite can be opened by an unchanged Web Viewer.
|
||||
|
||||
Enabling playback audio requests `RECORD_AUDIO`, but the sender disables the
|
||||
WebRTC audio device module's microphone input. It filters playback to the
|
||||
selected app UID and eligible game/media usages. Apps may block capture or have
|
||||
no eligible playback, in which case the track can remain silent; there is no
|
||||
microphone or whole-system fallback. “Playback requested” does not claim that
|
||||
audible media was observed.
|
||||
|
||||
The foreground notification and system capture indicator remain visible for the
|
||||
whole session. Stopping from the app or system picker closes capture, signaling,
|
||||
@@ -29,7 +38,9 @@ and every PeerConnection.
|
||||
|
||||
## First-Slice Boundary
|
||||
|
||||
- Video only: no playback-capture audio or A/V synchronization yet.
|
||||
- Playback audio is default-off and limited to one explicitly selected,
|
||||
single-package UID. Background continuity, silence detection and A/V
|
||||
synchronization remain device gates.
|
||||
- Hardware VP8/H.264 encoders only; there is no software encoder fallback.
|
||||
- At most two STUN-only direct-child edges. The server remains the route owner.
|
||||
- SFU publication and selected-edge TURN are not implemented. The client reports
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
|
||||
@@ -2,10 +2,14 @@ package icu.bonfire.screener.sender
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioFormat
|
||||
import android.media.projection.MediaProjection
|
||||
import org.webrtc.AudioSource
|
||||
import org.webrtc.AudioTrack
|
||||
import org.webrtc.DefaultVideoDecoderFactory
|
||||
import org.webrtc.EglBase
|
||||
import org.webrtc.HardwareVideoEncoderFactory
|
||||
import org.webrtc.MediaConstraints
|
||||
import org.webrtc.PeerConnectionFactory
|
||||
import org.webrtc.ScreenCapturerAndroid
|
||||
import org.webrtc.SurfaceTextureHelper
|
||||
@@ -14,21 +18,30 @@ import org.webrtc.VideoEncoder
|
||||
import org.webrtc.VideoEncoderFactory
|
||||
import org.webrtc.VideoSource
|
||||
import org.webrtc.VideoTrack
|
||||
import org.webrtc.audio.AudioProcessingOptions
|
||||
import org.webrtc.audio.JavaAudioDeviceModule
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class CaptureEngine private constructor(
|
||||
context: Context,
|
||||
permissionData: Intent,
|
||||
audioTargetUid: Int?,
|
||||
onProjectionStopped: () -> Unit,
|
||||
onAudioUnavailable: () -> Unit,
|
||||
) : AutoCloseable {
|
||||
private lateinit var egl: EglBase
|
||||
lateinit var factory: PeerConnectionFactory
|
||||
private set
|
||||
lateinit var track: VideoTrack
|
||||
private set
|
||||
var audioTrack: AudioTrack? = null
|
||||
private set
|
||||
private lateinit var capturer: ScreenCapturerAndroid
|
||||
private lateinit var source: VideoSource
|
||||
private lateinit var helper: SurfaceTextureHelper
|
||||
private var audioSource: AudioSource? = null
|
||||
private var audioCapture: PlaybackAudioCapture? = null
|
||||
private var audioDeviceModule: JavaAudioDeviceModule? = null
|
||||
private var closed = false
|
||||
|
||||
init {
|
||||
@@ -37,10 +50,25 @@ class CaptureEngine private constructor(
|
||||
egl = EglBase.create()
|
||||
val encoderFactory = HardwareOnlyEncoderFactory(egl.eglBaseContext)
|
||||
if (encoderFactory.supportedCodecs.isEmpty()) error("No hardware VP8/H264 encoder is available")
|
||||
factory = PeerConnectionFactory.builder()
|
||||
val factoryBuilder = PeerConnectionFactory.builder()
|
||||
.setVideoEncoderFactory(encoderFactory)
|
||||
.setVideoDecoderFactory(DefaultVideoDecoderFactory(egl.eglBaseContext))
|
||||
.createPeerConnectionFactory()
|
||||
audioTargetUid?.let { uid ->
|
||||
val playback = PlaybackAudioCapture(uid, onAudioUnavailable)
|
||||
audioCapture = playback
|
||||
val deviceModule = JavaAudioDeviceModule.builder(context.applicationContext)
|
||||
.setInputSampleRate(PlaybackAudioCapture.SAMPLE_RATE)
|
||||
.setAudioFormat(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setUseStereoInput(false)
|
||||
.setUseHardwareAcousticEchoCanceler(false)
|
||||
.setUseHardwareNoiseSuppressor(false)
|
||||
.setAudioBufferCallback(playback)
|
||||
.createAudioDeviceModule()
|
||||
.also { it.setAudioRecordEnabled(false) }
|
||||
audioDeviceModule = deviceModule
|
||||
factoryBuilder.setAudioDeviceModule(deviceModule)
|
||||
}
|
||||
factory = factoryBuilder.createPeerConnectionFactory()
|
||||
source = factory.createVideoSource(true)
|
||||
helper = SurfaceTextureHelper.create("ScreenerCapture", egl.eglBaseContext)
|
||||
capturer = ScreenCapturerAndroid(permissionData, object : MediaProjection.Callback() {
|
||||
@@ -50,6 +78,22 @@ class CaptureEngine private constructor(
|
||||
capturer.startCapture(WIDTH, HEIGHT, FPS)
|
||||
track = factory.createVideoTrack("screener-screen", source)
|
||||
track.setEnabled(true)
|
||||
val playback = audioCapture
|
||||
if (playback != null && capturer.mediaProjection?.let(playback::start) == true) {
|
||||
val createdSource = factory.createAudioSource(MediaConstraints())
|
||||
audioSource = createdSource
|
||||
val createdTrack = factory.createAudioTrack("screener-playback", createdSource)
|
||||
if (createdTrack.setAudioProcessingOptions(AudioProcessingOptions.raw()).isSuccess) {
|
||||
createdTrack.setEnabled(true)
|
||||
audioTrack = createdTrack
|
||||
} else {
|
||||
createdTrack.dispose()
|
||||
createdSource.dispose()
|
||||
audioSource = null
|
||||
playback.close()
|
||||
onAudioUnavailable()
|
||||
}
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
release()
|
||||
throw error
|
||||
@@ -64,9 +108,17 @@ class CaptureEngine private constructor(
|
||||
if (::capturer.isInitialized) runCatching { capturer.stopCapture() }
|
||||
if (::capturer.isInitialized) runCatching { capturer.dispose() }
|
||||
if (::track.isInitialized) runCatching { track.dispose() }
|
||||
audioCapture?.close()
|
||||
audioCapture = null
|
||||
audioTrack?.let { runCatching { it.dispose() } }
|
||||
audioTrack = null
|
||||
audioSource?.let { runCatching { it.dispose() } }
|
||||
audioSource = null
|
||||
if (::source.isInitialized) runCatching { source.dispose() }
|
||||
if (::helper.isInitialized) runCatching { helper.dispose() }
|
||||
if (::factory.isInitialized) runCatching { factory.dispose() }
|
||||
audioDeviceModule?.let { runCatching { it.release() } }
|
||||
audioDeviceModule = null
|
||||
if (::egl.isInitialized) runCatching { egl.release() }
|
||||
}
|
||||
|
||||
@@ -88,8 +140,13 @@ class CaptureEngine private constructor(
|
||||
const val FPS = 30
|
||||
private val initialized = AtomicBoolean()
|
||||
|
||||
fun create(context: Context, permissionData: Intent, onProjectionStopped: () -> Unit) =
|
||||
CaptureEngine(context, permissionData, onProjectionStopped)
|
||||
fun create(
|
||||
context: Context,
|
||||
permissionData: Intent,
|
||||
audioTargetUid: Int?,
|
||||
onProjectionStopped: () -> Unit,
|
||||
onAudioUnavailable: () -> Unit,
|
||||
) = CaptureEngine(context, permissionData, audioTargetUid, onProjectionStopped, onAudioUnavailable)
|
||||
|
||||
private fun initializeOnce(context: Context) {
|
||||
if (initialized.compareAndSet(false, true)) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import icu.bonfire.screener.protocol.IceConfig
|
||||
import icu.bonfire.screener.protocol.SignalPayload
|
||||
import icu.bonfire.screener.protocol.Wire
|
||||
import icu.bonfire.screener.protocol.IceCandidate as WireCandidate
|
||||
import org.webrtc.AudioTrack
|
||||
import org.webrtc.DataChannel
|
||||
import org.webrtc.IceCandidate
|
||||
import org.webrtc.MediaConstraints
|
||||
@@ -22,6 +23,7 @@ class HostPeer(
|
||||
iceConfig: IceConfig,
|
||||
factory: PeerConnectionFactory,
|
||||
track: VideoTrack,
|
||||
audioTrack: AudioTrack?,
|
||||
private val serial: Executor,
|
||||
private val send: (String) -> Boolean,
|
||||
private val status: (String) -> Unit,
|
||||
@@ -45,6 +47,9 @@ class HostPeer(
|
||||
parameters.encodings.forEach { it.maxBitrateBps = 3_000_000 }
|
||||
if (!sender.setParameters(parameters)) error("Could not set video bitrate")
|
||||
}
|
||||
audioTrack?.let {
|
||||
connection.addTrack(it, listOf("screener")) ?: error("Could not add playback audio track")
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
closed = true
|
||||
runCatching { connection.dispose() }
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
package icu.bonfire.screener.sender
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.ResultReceiver
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
|
||||
class MainActivity : Activity() {
|
||||
@@ -18,10 +22,13 @@ class MainActivity : Activity() {
|
||||
private lateinit var sitePassword: EditText
|
||||
private lateinit var startButton: Button
|
||||
private lateinit var stopButton: Button
|
||||
private lateinit var audioTarget: Spinner
|
||||
private lateinit var statusText: TextView
|
||||
private lateinit var inviteText: TextView
|
||||
private var pendingServer = ""
|
||||
private var pendingPassword = ""
|
||||
private var pendingAudioUid: Int? = null
|
||||
private var pendingAudioName: String? = null
|
||||
|
||||
private val receiver = object : ResultReceiver(Handler(Looper.getMainLooper())) {
|
||||
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
|
||||
@@ -48,10 +55,17 @@ class MainActivity : Activity() {
|
||||
sitePassword = findViewById(R.id.site_password)
|
||||
startButton = findViewById(R.id.start_button)
|
||||
stopButton = findViewById(R.id.stop_button)
|
||||
audioTarget = findViewById(R.id.audio_target)
|
||||
statusText = findViewById(R.id.status_text)
|
||||
inviteText = findViewById(R.id.invite_text)
|
||||
|
||||
startButton.setOnClickListener { requestProjection() }
|
||||
audioTarget.adapter = ArrayAdapter(
|
||||
this,
|
||||
android.R.layout.simple_spinner_item,
|
||||
loadAudioTargets(),
|
||||
).also { it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) }
|
||||
|
||||
startButton.setOnClickListener { prepareProjection() }
|
||||
stopButton.setOnClickListener {
|
||||
startService(Intent(this, ProjectionService::class.java).setAction(ProjectionService.ACTION_STOP))
|
||||
clearInvite()
|
||||
@@ -59,18 +73,46 @@ class MainActivity : Activity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestProjection() {
|
||||
private fun prepareProjection() {
|
||||
pendingServer = serverUrl.text.toString().trim().trimEnd('/')
|
||||
pendingPassword = sitePassword.text.toString()
|
||||
val target = audioTarget.selectedItem as AudioTarget
|
||||
pendingAudioUid = target.uid
|
||||
pendingAudioName = target.packageName?.let { target.label }
|
||||
clearInvite()
|
||||
if (!pendingServer.startsWith("https://")) {
|
||||
statusText.text = "An HTTPS server URL is required"
|
||||
return
|
||||
}
|
||||
if (
|
||||
pendingAudioUid != null &&
|
||||
checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_AUDIO)
|
||||
return
|
||||
}
|
||||
requestProjection()
|
||||
}
|
||||
|
||||
private fun requestProjection() {
|
||||
val manager = getSystemService(MediaProjectionManager::class.java)
|
||||
startActivityForResult(manager.createScreenCaptureIntent(), REQUEST_PROJECTION)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray,
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode != REQUEST_AUDIO) return
|
||||
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
|
||||
requestProjection()
|
||||
} else {
|
||||
statusText.text = "Playback audio permission was denied"
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("The platform projection picker still returns through this callback")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
@@ -88,12 +130,30 @@ class MainActivity : Activity() {
|
||||
putExtra(ProjectionService.EXTRA_SITE_PASSWORD, pendingPassword)
|
||||
putExtra(ProjectionService.EXTRA_PERMISSION_DATA, data)
|
||||
putExtra(ProjectionService.EXTRA_RECEIVER, receiver)
|
||||
pendingAudioUid?.let { putExtra(ProjectionService.EXTRA_AUDIO_TARGET_UID, it) }
|
||||
pendingAudioName?.let { putExtra(ProjectionService.EXTRA_AUDIO_TARGET_NAME, it) }
|
||||
}
|
||||
startForegroundService(service)
|
||||
sitePassword.text.clear()
|
||||
pendingPassword = ""
|
||||
}
|
||||
|
||||
private fun loadAudioTargets(): List<AudioTarget> {
|
||||
val launcher = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
val targets = packageManager.queryIntentActivities(
|
||||
launcher,
|
||||
PackageManager.ResolveInfoFlags.of(0L),
|
||||
).mapNotNull { resolved ->
|
||||
val app = resolved.activityInfo?.applicationInfo ?: return@mapNotNull null
|
||||
if (app.uid == applicationInfo.uid) return@mapNotNull null
|
||||
val packages = packageManager.getPackagesForUid(app.uid)?.distinct()
|
||||
if (packages?.singleOrNull() != app.packageName) return@mapNotNull null
|
||||
val label = app.loadLabel(packageManager).toString().trim().ifEmpty { app.packageName }
|
||||
AudioTarget(label, app.packageName, app.uid)
|
||||
}.distinctBy { it.uid }.sortedBy { it.label.lowercase() }
|
||||
return listOf(AudioTarget("Off (video only)", null, null)) + targets
|
||||
}
|
||||
|
||||
private fun clearInvite() {
|
||||
inviteText.text = ""
|
||||
inviteText.setOnClickListener(null)
|
||||
@@ -101,5 +161,10 @@ class MainActivity : Activity() {
|
||||
|
||||
companion object {
|
||||
private const val REQUEST_PROJECTION = 40
|
||||
private const val REQUEST_AUDIO = 41
|
||||
}
|
||||
|
||||
private data class AudioTarget(val label: String, val packageName: String?, val uid: Int?) {
|
||||
override fun toString(): String = packageName?.let { "$label ($it)" } ?: label
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package icu.bonfire.screener.sender
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioPlaybackCaptureConfiguration
|
||||
import android.media.AudioRecord
|
||||
import android.media.projection.MediaProjection
|
||||
import android.os.SystemClock
|
||||
import org.webrtc.audio.JavaAudioDeviceModule
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class PlaybackAudioCapture(
|
||||
private val targetUid: Int,
|
||||
private val onUnavailable: () -> Unit,
|
||||
) : JavaAudioDeviceModule.AudioBufferCallback, AutoCloseable {
|
||||
private val record = AtomicReference<AudioRecord?>()
|
||||
private val closed = AtomicBoolean()
|
||||
private val unavailable = AtomicBoolean()
|
||||
|
||||
fun start(projection: MediaProjection): Boolean {
|
||||
if (closed.get()) return false
|
||||
val created = try {
|
||||
val configuration = AudioPlaybackCaptureConfiguration.Builder(projection)
|
||||
.addMatchingUid(targetUid)
|
||||
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
|
||||
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.addMatchingUsage(AudioAttributes.USAGE_GAME)
|
||||
.build()
|
||||
val minimum = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_MASK, AUDIO_FORMAT)
|
||||
if (minimum <= 0) return fail()
|
||||
AudioRecord.Builder()
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AUDIO_FORMAT)
|
||||
.setSampleRate(SAMPLE_RATE)
|
||||
.setChannelMask(CHANNEL_MASK)
|
||||
.build(),
|
||||
)
|
||||
.setBufferSizeInBytes(maxOf(minimum * 2, FRAME_BYTES))
|
||||
.setAudioPlaybackCaptureConfig(configuration)
|
||||
.build()
|
||||
} catch (_: Exception) {
|
||||
return fail()
|
||||
}
|
||||
try {
|
||||
created.startRecording()
|
||||
if (created.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
|
||||
created.release()
|
||||
return fail()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
created.release()
|
||||
return fail()
|
||||
}
|
||||
if (closed.get() || !record.compareAndSet(null, created)) {
|
||||
runCatching { created.stop() }
|
||||
created.release()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onBuffer(
|
||||
buffer: ByteBuffer,
|
||||
audioFormat: Int,
|
||||
channelCount: Int,
|
||||
sampleRate: Int,
|
||||
bytesRead: Int,
|
||||
captureTimeNs: Long,
|
||||
): Long {
|
||||
if (
|
||||
audioFormat != AUDIO_FORMAT || channelCount != CHANNEL_COUNT ||
|
||||
sampleRate != SAMPLE_RATE || buffer.capacity() != FRAME_BYTES
|
||||
) {
|
||||
fail()
|
||||
silence(buffer)
|
||||
return 0L
|
||||
}
|
||||
val active = record.get()
|
||||
if (active == null) {
|
||||
SystemClock.sleep(FRAME_DURATION_MS)
|
||||
silence(buffer)
|
||||
return 0L
|
||||
}
|
||||
buffer.clear()
|
||||
val read = try {
|
||||
active.read(buffer, buffer.capacity(), AudioRecord.READ_BLOCKING)
|
||||
} catch (_: Exception) {
|
||||
AudioRecord.ERROR_INVALID_OPERATION
|
||||
}
|
||||
if (read != buffer.capacity()) {
|
||||
SystemClock.sleep(FRAME_DURATION_MS)
|
||||
silence(buffer)
|
||||
if (read < 0) fail()
|
||||
}
|
||||
buffer.clear()
|
||||
return 0L
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
closed.set(true)
|
||||
record.getAndSet(null)?.let {
|
||||
runCatching { it.stop() }
|
||||
it.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun fail(): Boolean {
|
||||
close()
|
||||
if (unavailable.compareAndSet(false, true)) onUnavailable()
|
||||
return false
|
||||
}
|
||||
|
||||
private fun silence(buffer: ByteBuffer) {
|
||||
buffer.clear()
|
||||
while (buffer.hasRemaining()) buffer.put(0)
|
||||
buffer.clear()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SAMPLE_RATE = 48_000
|
||||
private const val CHANNEL_COUNT = 1
|
||||
private const val CHANNEL_MASK = AudioFormat.CHANNEL_IN_MONO
|
||||
private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT
|
||||
private const val FRAME_BYTES = SAMPLE_RATE / 100 * CHANNEL_COUNT * 2
|
||||
private const val FRAME_DURATION_MS = 10L
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
package icu.bonfire.screener.sender
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.os.ResultReceiver
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.RejectedExecutionException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class ProjectionService : Service() {
|
||||
@@ -38,42 +41,75 @@ class ProjectionService : Service() {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
receiver = intent.getParcelableExtra(EXTRA_RECEIVER, ResultReceiver::class.java)
|
||||
val server = intent.getStringExtra(EXTRA_SERVER_URL)
|
||||
val password = intent.getStringExtra(EXTRA_SITE_PASSWORD)
|
||||
val permission = intent.getParcelableExtra(EXTRA_PERMISSION_DATA, Intent::class.java)
|
||||
val audioTargetUid = if (intent.hasExtra(EXTRA_AUDIO_TARGET_UID)) {
|
||||
intent.getIntExtra(EXTRA_AUDIO_TARGET_UID, -1).takeIf { it >= 0 }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val audioTargetName = intent.getStringExtra(EXTRA_AUDIO_TARGET_NAME)?.take(80)
|
||||
if (server == null || password == null || permission == null) {
|
||||
finish("Screen sharing could not start")
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
if (
|
||||
audioTargetUid != null &&
|
||||
checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
finish("Playback audio permission is required")
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification("Starting screen sharing"),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION,
|
||||
)
|
||||
val server = intent.getStringExtra(EXTRA_SERVER_URL)
|
||||
val password = intent.getStringExtra(EXTRA_SITE_PASSWORD)
|
||||
val permission = intent.getParcelableExtra(EXTRA_PERMISSION_DATA, Intent::class.java)
|
||||
if (server == null || password == null || permission == null) {
|
||||
finish("Screen sharing could not start")
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
sendResult(RESULT_ACTIVE, "Starting")
|
||||
schedule {
|
||||
try {
|
||||
val engine = CaptureEngine.create(applicationContext, permission) {
|
||||
finish("The system stopped screen sharing")
|
||||
}
|
||||
val audioUnavailable = AtomicBoolean()
|
||||
val engine = CaptureEngine.create(
|
||||
applicationContext,
|
||||
permission,
|
||||
audioTargetUid,
|
||||
onProjectionStopped = { finish("The system stopped screen sharing") },
|
||||
onAudioUnavailable = {
|
||||
audioUnavailable.set(true)
|
||||
if (state.get() != State.STOPPING) {
|
||||
sendResult(RESULT_ACTIVE, "Playback audio unavailable; video remains active")
|
||||
}
|
||||
},
|
||||
)
|
||||
if (state.get() != State.STARTING) {
|
||||
engine.close()
|
||||
return@schedule
|
||||
}
|
||||
capture = engine
|
||||
val remote = RemoteSession(server, password, engine.factory, engine.track, worker, object : RemoteSession.Events {
|
||||
override fun onRoom(inviteUrl: String) {
|
||||
if (state.get() == State.STOPPING) return
|
||||
sendResult(RESULT_ACTIVE, "Room ready; hardware codec requested", inviteUrl)
|
||||
getSystemService(NotificationManager::class.java)
|
||||
.notify(NOTIFICATION_ID, notification("Screen sharing is ready"))
|
||||
}
|
||||
val remote = RemoteSession(
|
||||
server, password, engine.factory, engine.track, engine.audioTrack, worker,
|
||||
object : RemoteSession.Events {
|
||||
override fun onRoom(inviteUrl: String) {
|
||||
if (state.get() == State.STOPPING) return
|
||||
val audio = when {
|
||||
engine.audioTrack != null && !audioUnavailable.get() -> audioTargetName?.let {
|
||||
"; playback requested: $it"
|
||||
} ?: "; playback requested"
|
||||
audioTargetUid != null -> "; playback audio unavailable"
|
||||
else -> ""
|
||||
}
|
||||
sendResult(RESULT_ACTIVE, "Room ready; hardware codec requested$audio", inviteUrl)
|
||||
getSystemService(NotificationManager::class.java)
|
||||
.notify(NOTIFICATION_ID, notification("Screen sharing is ready"))
|
||||
}
|
||||
|
||||
override fun onStatus(message: String) {
|
||||
if (state.get() != State.STOPPING) sendResult(RESULT_ACTIVE, message)
|
||||
}
|
||||
override fun onFatal(message: String) = finish(message)
|
||||
})
|
||||
override fun onStatus(message: String) {
|
||||
if (state.get() != State.STOPPING) sendResult(RESULT_ACTIVE, message)
|
||||
}
|
||||
override fun onFatal(message: String) = finish(message)
|
||||
},
|
||||
)
|
||||
session = remote
|
||||
if (!state.compareAndSet(State.STARTING, State.RUNNING)) return@schedule
|
||||
remote.start()
|
||||
@@ -152,6 +188,8 @@ class ProjectionService : Service() {
|
||||
const val EXTRA_SITE_PASSWORD = "sitePassword"
|
||||
const val EXTRA_PERMISSION_DATA = "projectionPermission"
|
||||
const val EXTRA_RECEIVER = "receiver"
|
||||
const val EXTRA_AUDIO_TARGET_UID = "audioTargetUid"
|
||||
const val EXTRA_AUDIO_TARGET_NAME = "audioTargetName"
|
||||
const val RESULT_STATUS = "status"
|
||||
const val RESULT_INVITE = "invite"
|
||||
const val RESULT_STOPPED = 0
|
||||
|
||||
@@ -17,6 +17,7 @@ import okhttp3.WebSocketListener
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.webrtc.AudioTrack
|
||||
import org.webrtc.PeerConnectionFactory
|
||||
import org.webrtc.VideoTrack
|
||||
import java.util.UUID
|
||||
@@ -29,6 +30,7 @@ class RemoteSession(
|
||||
private var sitePassword: String,
|
||||
private val factory: PeerConnectionFactory,
|
||||
private val track: VideoTrack,
|
||||
private val audioTrack: AudioTrack?,
|
||||
private val serial: Executor,
|
||||
private val events: Events,
|
||||
) : AutoCloseable {
|
||||
@@ -207,7 +209,9 @@ class RemoteSession(
|
||||
(peers.keys - desired).forEach { peers.remove(it)?.close() }
|
||||
desired.filterNot(peers::containsKey).forEach { peerId ->
|
||||
val config = iceConfig ?: return@forEach
|
||||
peers[peerId] = HostPeer(peerId, opaqueId(), config, factory, track, serial, ::send, events::onStatus).also {
|
||||
peers[peerId] = HostPeer(
|
||||
peerId, opaqueId(), config, factory, track, audioTrack, serial, ::send, events::onStatus,
|
||||
).also {
|
||||
it.start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,26 @@
|
||||
android:maxLines="1"
|
||||
android:saveEnabled="false" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="Playback audio target (optional)"
|
||||
android:textColor="#34443E"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/audio_target"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="52dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Audio is requested from this app separately and may differ from the system screen choice. Apps that block playback capture remain silent."
|
||||
android:textColor="#5A6863"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/start_button"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -24,7 +24,7 @@ Last updated: 2026-08-21
|
||||
- Browser relays resend remote `MediaStreamTrack` values and re-encode at each hop; WebRTC does not guarantee a shared encoder across peer connections, so measure the cost.
|
||||
- Keep experiments bounded: the standard representation sequence is below; native RTP relay, encoded-object striping, and FEC remain separate.
|
||||
- ADR-0006's historical browser-bridge canary remains no-go; VP8 stays default. Browser H.264 rendered 298/299; Native WGC/MF rendered 203 with PID/LUID-correlated `VideoEncode`. Downloaded use, multi-viewer/endurance/public proof remain open.
|
||||
- Mobile Web Host unsupported. Android 14+ sender is direct-child video-only; device, audio, rotation, and SFU remain open. iOS deferred; TV output is local-only P2.
|
||||
- Mobile Web Host unsupported. Android 14+ source has video and default-off selected-UID playback audio; no mic/system fallback. Device A/V, rotation and SFU remain open. iOS deferred; TV output is P2.
|
||||
- ADR-0005 accepts direct/peer UDP, bounded SFU roots, then optional selected-edge TURN. Production enables the one-shot selected-edge tuple after SFU; participant-wide TURN remains rejected and ordinary peer ICE stays STUN-only. No production SFU/TURN media canary has run; latest source has one local SFU/UDP functional proof and TURN remains unverified.
|
||||
- The controller is process-enabled, not room-allowlisted: `PEER_ASSISTED_MEDIA=true` gives every normal room the same direct/peer -> SFU -> selected-edge path with per-room state. Room `1` is historical smoke. Preserve sticky P2P, mobile leaves and break-before-make; recovery gets one attempt per layer (ICE restart, same-parent rebuild, alternate peer, then SFU), never three identical retries.
|
||||
- C+B quality reparenting is edge-local and cooldown-bound; no score, timer, or global parent penalty.
|
||||
|
||||
@@ -238,34 +238,74 @@ only one post-SFU connectivity smoke. Correlate capture settings, negotiated cod
|
||||
actual outbound/inbound bitrate, loss, jitter, concealment, jitter buffer, and
|
||||
A/V playout timing using a distinguishable stereo fixture plus game/film audio.
|
||||
|
||||
## Deferred Android Application-Audio Candidate
|
||||
## Android Application-Audio Source Slice (2026-08-21)
|
||||
|
||||
This is a P2 input for the later Android 14/API 34+ native sender, not a current
|
||||
Web or Windows requirement. Android's `AudioPlaybackCapture` API can constrain
|
||||
captured playback with `addMatchingUid()` when the target UID is already known,
|
||||
or with `addMatchingUsage()` for eligible usages. The source player still must
|
||||
use `USAGE_MEDIA`, `USAGE_GAME`, or `USAGE_UNKNOWN`, run in the same user
|
||||
profile, and permit capture under its manifest/runtime/player policy. The capture
|
||||
app also needs `RECORD_AUDIO` permission and an approved `MediaProjection`
|
||||
session. The most restrictive source policy wins, so silence or refusal is a
|
||||
valid result and must not trigger a whole-system fallback.
|
||||
Android's official `AudioPlaybackCapture` contract requires `RECORD_AUDIO`, an
|
||||
approved `MediaProjection`, the same user profile, a capturable source policy,
|
||||
and player usage `USAGE_UNKNOWN`, `USAGE_MEDIA`, or `USAGE_GAME`. A builder may
|
||||
combine `addMatchingUid()` and matching usages; Screener uses both so the source
|
||||
cannot widen beyond the explicitly selected UID. The UI only lists launcher
|
||||
targets whose UID maps to one package, because an Android UID may otherwise be
|
||||
shared. Protected/opt-out apps and players without eligible active playback
|
||||
usually produce silence rather than a read error. Silence is a valid bounded
|
||||
result and never triggers microphone or whole-system fallback.
|
||||
|
||||
Android 14 QPR2's system `MediaProjection` picker can limit video to one selected
|
||||
app window. However, the public API 34 result contract passes the consent result into
|
||||
`getMediaProjection()` and documents only a projection grant; it does not
|
||||
promise the capturing app the selected package or UID. It is therefore an
|
||||
inference, not an API guarantee, that the video selection can be associated with
|
||||
the UID required by `addMatchingUid()`. Screener must separately prove the
|
||||
selected-app-video plus selected-app-audio pairing on target devices with an
|
||||
independently established UID, or keep that mode unsupported. Usage-only
|
||||
filtering is broader and cannot be relabeled as per-app isolation.
|
||||
Android 14 QPR2's system picker can limit video to one selected app, but public
|
||||
API 34 returns only the projection consent result. `MediaProjection` exposes
|
||||
stop, resize and visibility callbacks, not the selected package or UID. Screener
|
||||
therefore defaults audio to off and makes the audio target a separate explicit
|
||||
choice before opening the system video picker. Its visible copy says the two
|
||||
choices may differ. The app never calls usage-only capture and never labels this
|
||||
as automatic same-app capture.
|
||||
|
||||
The bounded audio gate covers a known target UID, an unknown picker target,
|
||||
`USAGE_GAME`/`USAGE_MEDIA`, source opt-out, no active render stream, process
|
||||
restart, voice/notification leakage, secure/protected video, rotation, lock-stop,
|
||||
thermal load, one/two Web viewers, and A/V sync. Missing audio remains a visible
|
||||
silent/unsupported state. PID/UID/package, source labels, raw audio, and device
|
||||
identity stay local and are not retained or uploaded.
|
||||
The fixed `io.github.webrtc-sdk:android:144.7559.12` AAR exposes the required
|
||||
public extension points without a fork or JNI: `PeerConnectionFactory.Builder`
|
||||
has `setAudioDeviceModule(AudioDeviceModule)`, `JavaAudioDeviceModule.Builder`
|
||||
has `setAudioBufferCallback(AudioBufferCallback)`, and the module has
|
||||
`setAudioRecordEnabled(false)`. The callback receives the ADM's 10 ms direct
|
||||
buffer. Inspection of that exact artifact shows that a disabled platform input
|
||||
is zero-filled and still passed through the callback before native delivery, so
|
||||
one blocking playback `AudioRecord` can pace and replace the buffer. Screener
|
||||
uses PCM16, 48 kHz mono, raw audio processing, one `AudioSource`/`AudioTrack`, and
|
||||
attaches that track to no more than the two controller-authorized direct
|
||||
children. It changes no server/Viewer wire and leaves SFU/selected-edge TURN as
|
||||
the existing bounded failure. This proves one capture source/track, not one
|
||||
physical Opus encoder across PeerConnections.
|
||||
|
||||
The inspected AAR SHA-256 is
|
||||
`D1564A43A85D0687DB51862A590263C6764F9AC8F87E7960B1E9C9F222B898AA`; its
|
||||
`classes.jar` SHA-256 is
|
||||
`D20DFD0C0BBEDCCB46354174829B0315E6922D05779291A60DACEA826098CDA4`, and tag
|
||||
`v144.7559.12` resolves to `500cadca96fa4be0fb5951c046fd611be181490c`.
|
||||
The Maven POM records BSD-3-Clause for the artifact; the packaging repository is
|
||||
MIT and bundled upstream libwebrtc retains BSD/PATENTS terms. LiveKit's
|
||||
Apache-2.0 `ScreenAudioCapturer` was studied as a mature use of the same callback
|
||||
and playback-capture API; no implementation was copied.
|
||||
|
||||
Official playback-capture documentation requires `RECORD_AUDIO` plus an approved
|
||||
`MediaProjection` token. Android 14's FGS documentation assigns
|
||||
`mediaProjection` to content captured through `MediaProjection`, while the
|
||||
`microphone` type is described as continuing microphone capture. Screener uses
|
||||
only `FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION`; it does not request
|
||||
`FOREGROUND_SERVICE_MICROPHONE` or declare a microphone service type. There is a
|
||||
retained implementation-evidence conflict: LiveKit's pinned playback capturer
|
||||
states that background screen audio needs a microphone-typed FGS or returns no
|
||||
audio, while also showing that its ADM microphone may be disabled. Android's
|
||||
official playback-capture and API 34 FGS pages do not state that playback capture
|
||||
needs that additional type. This source slice follows the narrower official
|
||||
capability boundary; switching to the selected app must therefore verify
|
||||
background audio on-device before any support claim. A silent result does not
|
||||
authorize enabling microphone capture or adding the extra FGS capability without
|
||||
a separate permission-policy decision. The ADM microphone `AudioRecord` remains
|
||||
disabled in every case.
|
||||
|
||||
The source slice passed `:protocol:test` and `:app:assembleDebug`; no device or
|
||||
audible-media claim follows. Track creation/status means only “selected UID
|
||||
playback requested.” A silence detector is P2 and does not expand this slice.
|
||||
Device proof still covers eligible and opt-out apps, protected content, no
|
||||
active render stream, voice/notification non-leakage, process restart, lock/stop,
|
||||
one/two Web Viewers and A/V synchronization. UID/package labels stay local and
|
||||
raw audio is never retained or uploaded outside the WebRTC media track.
|
||||
|
||||
## Revisit Gates
|
||||
|
||||
@@ -308,4 +348,11 @@ Add a user-facing audio setting only when all of these are true:
|
||||
- [Android `AudioPlaybackCaptureConfiguration.Builder`](https://developer.android.com/reference/android/media/AudioPlaybackCaptureConfiguration.Builder)
|
||||
- [Android `MediaProjectionManager`](https://developer.android.com/reference/android/media/projection/MediaProjectionManager)
|
||||
- [Android media projection](https://developer.android.com/media/grow/media-projection)
|
||||
- [Android foreground-service types](https://developer.android.com/develop/background-work/services/fgs/service-types)
|
||||
- [Android 14 foreground-service type requirement](https://developer.android.com/about/versions/14/changes/fgs-types-required)
|
||||
- [Android secure-window capture boundary](https://developer.android.com/reference/android/view/WindowManager.LayoutParams#FLAG_SECURE)
|
||||
- [`webrtc-sdk/android` v144.7559.12](https://github.com/webrtc-sdk/android/tree/v144.7559.12)
|
||||
- [Maven Central `io.github.webrtc-sdk:android:144.7559.12`](https://central.sonatype.com/artifact/io.github.webrtc-sdk/android/144.7559.12)
|
||||
- [LiveKit Android `ScreenAudioCapturer` at inspected commit](https://github.com/livekit/client-sdk-android/blob/4fdea28d8bd4b7053d7e5f0df50527f2c51fd38f/livekit-android-sdk/src/main/java/io/livekit/android/audio/ScreenAudioCapturer.kt)
|
||||
- [LiveKit Android screen-audio foreground-service example](https://github.com/livekit/client-sdk-android/blob/4fdea28d8bd4b7053d7e5f0df50527f2c51fd38f/examples/screenshare-audio/src/main/java/io/livekit/android/example/screenshareaudio/MainViewModel.kt)
|
||||
- [LiveKit Android Apache-2.0 license](https://github.com/livekit/client-sdk-android/blob/4fdea28d8bd4b7053d7e5f0df50527f2c51fd38f/LICENSE)
|
||||
@@ -151,8 +151,8 @@ experience and cannot be made invisible.
|
||||
The bounded Android data path is:
|
||||
|
||||
```text
|
||||
MediaProjection -> libwebrtc ScreenCapturerAndroid
|
||||
-> HardwareVideoEncoderFactory (VP8/H.264 only, no software fallback)
|
||||
MediaProjection -> ScreenCapturerAndroid -> hardware VP8/H.264 --.
|
||||
explicit UID -> AudioPlaybackCapture -> ADM callback ---------+
|
||||
-> existing site access / room / WSS / peer-assisted direct assignment
|
||||
-> at most two unmodified Web viewers
|
||||
```
|
||||
@@ -160,18 +160,27 @@ MediaProjection -> libwebrtc ScreenCapturerAndroid
|
||||
The slice uses the public libwebrtc Android API rather than hand-building capture,
|
||||
RTP, pacing, congestion control, or SRTP. `DefaultVideoEncoderFactory` is excluded
|
||||
because it composes hardware with software fallback; a filtered
|
||||
`HardwareVideoEncoderFactory` exposes only VP8/H.264. It adds no mobile-only
|
||||
topology, audio, `LOW`, AV1, codec ladder, third downstream edge, SFU publisher,
|
||||
selected-edge TURN, or software fallback. Direct children are authoritative by
|
||||
route revision and connection generation. A requested SFU publication returns
|
||||
`HardwareVideoEncoderFactory` exposes only VP8/H.264. Optional playback audio is
|
||||
default-off and source-only: the user separately selects one single-package UID,
|
||||
the capture matches that UID plus eligible game/media usages, and the fixed
|
||||
webrtc-sdk public ADM callback injects 48 kHz mono PCM16 while its microphone
|
||||
`AudioRecord` is disabled. One audio source/track is attached to the same maximum
|
||||
of two direct children; this does not prove a shared physical Opus encoder. It
|
||||
adds no mobile-only topology, `LOW`, AV1, codec ladder, third downstream edge,
|
||||
SFU publisher, selected-edge TURN, microphone/system-mix fallback, or software
|
||||
fallback. Direct children are authoritative by route revision and connection
|
||||
generation. A requested SFU publication returns
|
||||
one bounded `route-failed`; it is not reported as supported.
|
||||
|
||||
The first functional target is 720p30 at a 3 Mbps ceiling. Codec name and Android
|
||||
hardware metadata are admission signals, not performance proof. Once the source
|
||||
slice closes, one device/one Viewer smoke checks consent, selected encoder, first
|
||||
frame and cleanup. Rotation hot-resize, application audio, network reselection,
|
||||
thermal/power and multi-Viewer matrices follow later; they do not block source
|
||||
landing and cannot silently enable software encoding. Network changes reuse the
|
||||
frame and cleanup. The audio source compile is closed, but eligible/opt-out
|
||||
playback, silence, background service behavior and A/V sync still require one
|
||||
device; track creation only means selected-UID playback was requested. Rotation
|
||||
hot-resize, network reselection, thermal/power and multi-Viewer matrices follow
|
||||
later; they do not block source landing and cannot silently enable software
|
||||
encoding. Network changes reuse the
|
||||
existing per-edge WebRTC recovery contract rather than restarting capture or
|
||||
creating a new transport.
|
||||
|
||||
@@ -182,9 +191,11 @@ session. Restoring service status is a later P2 lifecycle task, not a media-path
|
||||
requirement for this source slice.
|
||||
|
||||
The pinned `webrtc-sdk/android` packaging project is MIT-licensed and bundles
|
||||
upstream libwebrtc under its BSD-style license and PATENTS terms. OkHttp and
|
||||
kotlinx.serialization are Apache-2.0. The slice copies no implementation from
|
||||
LiveKit or another sender. A future distributed APK must retain the applicable
|
||||
upstream libwebrtc under its BSD-style license and PATENTS terms; the Maven
|
||||
artifact POM records BSD-3-Clause. LiveKit's Apache-2.0 `ScreenAudioCapturer` was
|
||||
studied as a reference for the public ADM callback and playback-capture lifecycle,
|
||||
but no implementation was copied. OkHttp and kotlinx.serialization are
|
||||
Apache-2.0. A future distributed APK must retain the applicable
|
||||
third-party notices and still waits on Screener's project-license decision.
|
||||
|
||||
OkHttp 5.3.0 resolves Kotlin stdlib 2.2.21. Android's current compatibility
|
||||
@@ -598,13 +609,18 @@ SFU/UDP fails and needs its own bounded transport gate.
|
||||
- [Android media projection](https://developer.android.com/media/grow/media-projection)
|
||||
- [Android 14 app screen sharing](https://developer.android.com/about/versions/14/features/app-screen-sharing)
|
||||
- [Android media-projection foreground service](https://developer.android.com/develop/background-work/services/fgs/service-types#media-projection)
|
||||
- [Android audio-playback capture](https://developer.android.com/media/platform/av-capture)
|
||||
- [Android `AudioPlaybackCaptureConfiguration`](https://developer.android.com/reference/android/media/AudioPlaybackCaptureConfiguration)
|
||||
- [Android 14 foreground-service type requirement](https://developer.android.com/about/versions/14/changes/fgs-types-required)
|
||||
- [Android `MediaCodecInfo`](https://developer.android.com/reference/android/media/MediaCodecInfo)
|
||||
- [Android `MediaCodecList`](https://developer.android.com/reference/android/media/MediaCodecList)
|
||||
- [libwebrtc `ScreenCapturerAndroid`](https://chromium.googlesource.com/external/webrtc/+/HEAD/sdk/android/api/org/webrtc/ScreenCapturerAndroid.java)
|
||||
- [libwebrtc `DefaultVideoEncoderFactory`](https://chromium.googlesource.com/external/webrtc/+/HEAD/sdk/android/api/org/webrtc/DefaultVideoEncoderFactory.java)
|
||||
- [libwebrtc `HardwareVideoEncoderFactory`](https://chromium.googlesource.com/external/webrtc/+/HEAD/sdk/android/api/org/webrtc/HardwareVideoEncoderFactory.java)
|
||||
- [`webrtc-sdk/android`](https://github.com/webrtc-sdk/android)
|
||||
- [`webrtc-sdk/android` v144.7559.12](https://github.com/webrtc-sdk/android/tree/v144.7559.12)
|
||||
- [`webrtc-sdk/android` license](https://github.com/webrtc-sdk/android/blob/main/LICENSE)
|
||||
- [LiveKit Android `ScreenAudioCapturer` reference](https://github.com/livekit/client-sdk-android/blob/4fdea28d8bd4b7053d7e5f0df50527f2c51fd38f/livekit-android-sdk/src/main/java/io/livekit/android/audio/ScreenAudioCapturer.kt)
|
||||
- [LiveKit Android license](https://github.com/livekit/client-sdk-android/blob/4fdea28d8bd4b7053d7e5f0df50527f2c51fd38f/LICENSE)
|
||||
- [libwebrtc license](https://webrtc.googlesource.com/src/+/refs/heads/main/LICENSE)
|
||||
- [OkHttp license](https://github.com/square/okhttp/blob/master/LICENSE.txt)
|
||||
- [kotlinx.serialization license](https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt)
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ Flagship; parallel; min run/smoke/rollback; benchmark later. Active UI/config/lo
|
||||
## Unverified Boundaries
|
||||
|
||||
- The final deployment proves process/config health, not a retained SFU frame, retry/failback, selected UDP pair, admission rescue, edge cap, quality, or resource deltas; game-share load/blur remains unclassified.
|
||||
- Mobile Web Host unsupported; Viewer leaf-only. Android 14+ direct-child source passed six protocol tests and `assembleDebug`; GitHub compile is billing-blocked. Device media, SFU, audio, and rotation remain open. iOS deferred; TV output is local-only P2.
|
||||
- Mobile Web Host unsupported; Viewer leaf-only. Android 14+ direct source/default-off selected-UID audio passed six protocol tests/`assembleDebug`. No device/audible claim; mediaProjection-only background, opt-out/silence/A-V, SFU/rotation remain open. iOS deferred; TV output P2.
|
||||
- Silent partitions can wait 30 to 60 seconds for heartbeat detection before the default 5-second grace; this remains unverified.
|
||||
- The former release reportedly sustained bandwidth-limited blur on a capable LAN; Host refresh recovered while Viewer refresh did not. The new deployment has not yet reproduced or cleared it.
|
||||
- ADR-0006 has one Viewer proof; two-edge/FIFO, hardware, endurance, public/downloaded-package, and browser-diversity proof remain open.
|
||||
|
||||
+22
-6
@@ -215,8 +215,8 @@ Android 14/API 34+ 作为不阻断 Windows 主线的并行 P1 首片,只做一
|
||||
`ScreenCapturerAndroid` -> 仅暴露 VP8/H.264 的 `HardwareVideoEncoderFactory` -> 当前
|
||||
site access/room/WSS/peer-assisted Host direct-child wire -> 一至两名未修改 Web
|
||||
Viewer。不能使用含 software fallback 的 `DefaultVideoEncoderFactory`;找不到或创建
|
||||
不了硬件 encoder 时明确失败。首片固定 720p30、3 Mbps `HIGH`,不增加移动端专属
|
||||
拓扑、音频、`LOW`、AV1、codec ladder、第三条 Host 下游边或跨平台 framework。
|
||||
不了硬件 encoder 时明确失败。视频固定 720p30、3 Mbps `HIGH`,不增加移动端专属
|
||||
拓扑、`LOW`、AV1、codec ladder、第三条 Host 下游边或跨平台 framework。
|
||||
|
||||
Android Host 以 authenticated snapshot 的 `routeRevision`/`routeAssignment` 为权威,
|
||||
只为 `directChildPeerIds` 建立至多两条含 `targetPeerId` 的 STUN-only PeerConnection,
|
||||
@@ -228,10 +228,26 @@ direct `route-update(active)` 先 reconcile child,再发送 `route-ready(activ
|
||||
不能把本地旁路当作旗舰验收结论。
|
||||
|
||||
Android 的逐 session 授权、系统指示、旋转/尺寸回调、锁屏/撤销终止与资源释放
|
||||
都是产品行为,不能隐藏或用重启 capture 掩盖 transport 问题。应用音频可按已知
|
||||
UID 或 usage 过滤,但 Android 14 QPR2+ 单应用 picker 未承诺返回目标 UID;“所选应用画面 +
|
||||
同一应用音频”必须单独实测,来源策略或 secure/protected content 可产生静音、
|
||||
空白或拒绝,不能自动扩大为整机捕获。API 证据与矩阵由[原生 sender 调研](./research/native-shared-encode-sender.md)和[音频调研](./research/browser-screen-audio-quality.md)持有。
|
||||
都是产品行为,不能隐藏或用重启 capture 掩盖 transport 问题。播放音频是默认关闭的
|
||||
source extension:用户先从本应用可见的 launcher targets 中单独选择一个 UID;共享
|
||||
UID 对应多个 packages 时不提供该目标。只有启用时才请求 `RECORD_AUDIO`,并用同一
|
||||
`MediaProjection` 构造 `AudioPlaybackCaptureConfiguration`,同时匹配该 UID 与
|
||||
`USAGE_UNKNOWN`/`MEDIA`/`GAME`。固定 `webrtc-sdk/android` 的公开
|
||||
`JavaAudioDeviceModule.AudioBufferCallback` 把 48 kHz mono PCM16 注入一个 ADM;
|
||||
`setAudioRecordEnabled(false)` 关闭 ADM 的平台麦克风输入,`AudioProcessingOptions.raw()`
|
||||
避免语音处理。一个 `AudioSource`/`AudioTrack` 附加到当前至多两条 direct-child PC,
|
||||
不修改 Viewer、服务端或路由 wire,也不为 SFU/selected-edge TURN 增加旁路。
|
||||
|
||||
Android 公开 API 34 的系统单应用 picker 不返回所选 package/UID,所以画面和音频是
|
||||
两次独立选择;UI 必须直接说明两者可能不同,不能称为自动同应用。目标应用 opt-out、
|
||||
protected content 或没有合格播放通常会让 `AudioRecord` 持续返回静音而非 read error;
|
||||
track 存在只能显示“已请求选定 UID 的播放音频”,不能宣称实际有声。初始化/read
|
||||
失败则关闭音频并保留视频,绝不回退到麦克风或全系统 mix。Android 官方播放捕获
|
||||
契约要求 `RECORD_AUDIO` 和 `MediaProjection` token;服务只声明并启动
|
||||
`mediaProjection` 类型,不申请描述为继续麦克风捕获的额外 `microphone` FGS 能力,
|
||||
且 ADM 麦克风保持禁用。静音检测是 P2,真机 A/V sync、后台行为和设备差异仍是验收边界。
|
||||
API 证据由[原生 sender 调研](./research/native-shared-encode-sender.md)和
|
||||
[音频调研](./research/browser-screen-audio-quality.md)持有。
|
||||
|
||||
iOS 当前不建设 ReplayKit Broadcast Upload Extension 兼容栈。待 iOS 27 与
|
||||
ScreenCaptureKit SDK final/stable 后,只做 system picker -> VideoToolbox
|
||||
|
||||
+3
-1
@@ -42,7 +42,8 @@
|
||||
- [ ] 私密房间双入口:private room 接受现有 `/r/{code}#v={grant}` 合法邀请直接进入,或“站点访问 + 房间号 + 该房间观看密码”。没有 grant 的 `/join` 或 `/r/{code}` 先显示中性的站点访问验证,通过后才显示中性的房间密码输入。Host 可设置、轮换或移除房间观看密码。密码接受 1 至 64 个可见 ASCII 字符;异步 scrypt 全局最多执行 2 个、等待 16 个派生。站点口令和房间密码都不得进入 URL/query/storage/log/SQLite;不存在、错误密码、队满和正确密码后的满房都返回通用结果。
|
||||
|
||||
- [ ] 统一自动路由:旗舰部署开启 `PEER_ASSISTED_MEDIA` 后,所有正常房间都进入同一个 direct/peer -> SFU -> selected-edge controller;每个房间独立保存 assignment/session/revision/connection generation 和容量状态。`PEER_ASSISTED_ROOM_IDS` 已退役,room `1` 只作为历史 smoke fixture,不能作为功能开关。未启用总开关的开发/轻量部署仍可保留 ordinary P2P;普通 peer 始终 STUN-only。selected-edge TURN 只能复用当前 controller 选中的异常 edge,不能向普通 peer edge 预发 candidates。该能力不增加 UI、用户选择、百分比发布或第二套路由器。
|
||||
- [ ] Android 原生分享端首片:Android 14/API 34+ 通过系统 `MediaProjection` picker、`mediaProjection` foreground service 和硬件-only libwebrtc VP8/H.264 encoder 发送一份 720p30 `HIGH` 视频;无硬件 encoder 时明确失败,不静默使用软件编码。它必须复用旗舰 `screener-v2` 的 site access、room、WSS 与 peer-assisted Host direct-child assignment,按权威 route revision/connection generation 维护至多两条 STUN-only downstream edge;direct active route 回报 `route-ready`,SFU publication assignment 在本首片中回报有界 `route-failed`。Viewer 与服务端媒体协议不变。不实现音频、`LOW`、AV1、codec ladder、第三条 Host edge、selected-edge TURN、SFU SDK、旋转热更新、后台重建或跨平台 framework;系统撤销/停止必须在串行 teardown 中先对已建立的房间发送一次 `abandon-room`、再正常关闭信令并立即释放 capture、PeerConnection 和前台服务,不等待 room-close ACK;尚未建立信令的 provisional room 仍由 300 秒上限回收。
|
||||
- [ ] Android 原生分享端首片:Android 14/API 34+ 通过系统 `MediaProjection` picker、`mediaProjection` foreground service 和硬件-only libwebrtc VP8/H.264 encoder 发送一份 720p30 `HIGH` 视频;无硬件 encoder 时明确失败,不静默使用软件编码。它必须复用旗舰 `screener-v2` 的 site access、room、WSS 与 peer-assisted Host direct-child assignment,按权威 route revision/connection generation 维护至多两条 STUN-only downstream edge;direct active route 回报 `route-ready`,SFU publication assignment 在本首片中回报有界 `route-failed`。Viewer 与服务端媒体协议不变。不实现 `LOW`、AV1、codec ladder、第三条 Host edge、selected-edge TURN、SFU SDK、旋转热更新、后台重建或跨平台 framework;系统撤销/停止必须在串行 teardown 中先对已建立的房间发送一次 `abandon-room`、再正常关闭信令并立即释放 capture、PeerConnection 和前台服务,不等待 room-close ACK;尚未建立信令的 provisional room 仍由 300 秒上限回收。
|
||||
- [ ] Android 指定应用播放音频首片:音频默认关闭,只有用户在系统画面 picker 之前另行明确选择一个 launcher 应用且授予 `RECORD_AUDIO` 后才启用。目标必须是 UID 只映射到一个 package 的应用;捕获配置同时使用 `addMatchingUid()` 与 `USAGE_UNKNOWN`/`MEDIA`/`GAME`,ADM 自带麦克风 `AudioRecord` 必须关闭,初始化/read 失败、应用禁止 capture、protected content 或没有合格播放都只能保持静音/显示 unavailable,不得扩大为麦克风或全系统混音。Android 公开 API 不返回系统 picker 所选画面的 UID,因此 UI 必须说明音频目标单独选择、可能与画面不同,不能称为自动同应用。一个 ADM audio source/track 只附加到当前至多两名 direct children;Viewer、服务端 wire、SFU/selected-edge TURN 有界失败均不变。track 存在只表示已请求选定 UID 播放音频,不证明实际有声;静音检测、真机 A/V 同步和路由矩阵留待后续。
|
||||
|
||||
- [ ] 质量控制扩展:推荐档位仍是默认入口;折叠高级区只允许 720p/1080p/1440p 分辨率 ceiling、15 至 60 的整数 FPS ceiling、2 至 12 Mbps 视频码率 ceiling,以及清晰/平衡/流畅三种偏好。直播中显式应用后才生效,避免滑杆产生连续控制流量。当前 Web 发送端不提供音频声道、采样率、Opus 码率、stereo、DTX 或 FEC 控件:display capture 未把声道和采样率定义为可移植控制,标准音频 `maxBitrate` 只是可能导致更低质量或停播的发送上限,其他项依赖 codec/fmtp 协商而没有可移植 sender setter。除非真实音频矩阵证明用户问题、标准 API 能应用并读回且实际结果可独立观测,否则不得为这些控件新增应用层 SDP munging 或用默认值制造伪控制。实际质量持续低于所选上限时必须显示可解释的限制原因;在真实拥塞、编码器和接收端证据完成前,不把任一组合称为“完美平衡”。
|
||||
- [ ] 本地预览性能候选:在相同动态场景中 A/B 测量 Host 动态预览开/关的 CPU、GPU、encode time 和实际 FPS。只有稳定收益且捕获、编码和远端画面不受影响时,才允许直播开始后默认显示静止缩略图或隐藏动态预览并提供按需展开;未测前不宣称收益。
|
||||
@@ -126,6 +127,7 @@
|
||||
- Given 用户在本机设置显示名,When 以 Host 或 Viewer 加入房间,Then 所有在线参与者看到同一份服务端权威 roster、对应房间标题和“名称 + room-scoped peer 后缀”;刷新后本机名称仍在,清除站点数据后恢复未命名默认值,数据库中不存在显示名或成员记录,展示身份不参与授权或路由。
|
||||
- Given 手机从 Wi-Fi 切换到蜂窝网络或页面从后台恢复,When 原候选对失效,Then 客户端执行 ICE restart 或重建连接,并显示恢复进度而不是永久黑屏。
|
||||
- Given Android 14+ 用户批准系统屏幕分享且至少一个受支持硬件 VP8/H.264 encoder 可创建,When peer-assisted Host 收到至多两个 direct-child assignment,Then 应用只向这些 `targetPeerId` 建立 generation-bound STUN-only WebRTC 视频边,并让未修改 Web Viewer 解码;任何 route revision、connection identity 或 target 不匹配的信令均 fail closed。Given assignment 要求 SFU publication、硬件 encoder 不可用或系统撤销 projection,Then 本首片分别发送有界 route failure 或停止清理,不切软件编码、不创建第三条边、不切换到自定义媒体协议。
|
||||
- Given Android 用户保持默认音频关闭,When 开始分享,Then 应用不请求 `RECORD_AUDIO` 且不创建 playback `AudioRecord`/audio track。Given 用户明确选择一个单-package UID 音频目标并授予权限,When 开始分享,Then 一份以该 UID 和允许 usage 过滤的 playback PCM source/track 最多附加到两条 direct-child PeerConnection,ADM 麦克风采集保持关闭;系统画面 picker 与音频选择不一致时不扩大捕获,目标拒绝 capture 或没有合格播放时允许静音且不得把 track 存在显示为有声成功。
|
||||
- Given Viewer 尝试把直播输出到电视,When 浏览器/系统提供已验证的 Remote Playback、AirPlay 或屏幕镜像能力,Then 该动作只改变本机输出设备,现有 upstream PeerConnection、TURN/SFU/peer route 和其他参与者保持不变。Given live `srcObject` 未经目标设备证明或 API 缺失,Then 产品不显示伪可用 Cast/AirPlay 按钮,也不创建自有 receiver 或转码服务器。
|
||||
|
||||
### 一对多与带宽保护
|
||||
|
||||
Reference in new issue
Block a user