refactor: remove mobile sender product path (#119)

This commit is contained in:
AAA authored and GitHub committed 2026-08-21 10:08:19 +08:00
1 parent 5975dc9b12
commit 6a51ae45ce
35 files changed
+14 -2623

No files matched your search

-34
View File
@@ -1,34 +0,0 @@
name: Android sender compile
on:
workflow_dispatch:
pull_request:
paths:
- "android/sender/**"
- ".github/workflows/android-sender.yml"
push:
branches:
- main
paths:
- "android/sender/**"
- ".github/workflows/android-sender.yml"
permissions:
contents: read
jobs:
compile:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v6
- name: Set up Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
- name: Test protocol core
run: ./android/sender/gradlew -p android/sender -PprotocolOnly=true :protocol:test --no-daemon
- name: Compile debug APK
run: ./android/sender/gradlew -p android/sender :app:assembleDebug --no-daemon
+1
View File
@@ -41,6 +41,7 @@
- Keep this root file concise and stable; put specialized rules in the closest relevant directory only when that code exists.
- Never store raw transcripts, large logs, generated summaries, temporary plans, or facts that can be cheaply rediscovered in always-loaded memory.
- When a fact changes, update it in place and remove conflicting text in the same change. Do not preserve obsolete guidance merely for history.
- Remove rejected alternatives from active UI, code, comments, configuration, PR copy, and current docs. Describe current behavior by what it does; retain rationale only when it still constrains a live decision.
## Security
+1 -1
View File
@@ -4,7 +4,7 @@ Screener is a private, low-latency screen-sharing project for one game player an
The first measurable Web proof of concept is implemented. A host captures a screen, window, or browser tab and shares a numeric room code. Rooms default to a private fragment invitation whose capability authorizes only one room; a Host may explicitly choose public watching by room code after site access. During a share, the host can change between three ceiling profiles without reopening the source picker: 1080p60 at 8 Mbps, 1080p30 at 5 Mbps, and 720p30 at 3 Mbps. The host can also temporarily pause the picture or switch sources. Rooms are random and temporary by default. A site-access-protected deployment can optionally use SQLite-backed room numbers starting at `1`, reusable links, and a waiting state after sharing stops. Lightweight deployments may keep independent direct WebRTC connections. The flagship deployment enables the all-room controller with bounded peer descendants and LiveKit SFU roots; short-lived TURN may be authorized only for a controller-selected exceptional media edge. The default room capacity is eight viewers and deployments may configure 1 through 16, but that admission limit is not a performance promise.
This is not yet a broadly validated production release. Production runs exact `fd76277b05d491af8840b28f3132b7ff445d3cbe` with the all-room bounded peer/SFU-UDP controller; ordinary peer ICE remains STUN-only, and short-lived TURN is configured only for controller-selected exceptional media edges. Native Windows and Android senders remain source-only and are not production runtimes. Real cross-network SFU/TURN media, game audio, a sustained 1:8 room, mobile browser lifecycle behavior, and latency targets still require the documented manual test matrix.
This is not yet a broadly validated production release. Production runs exact `16f6eab27bdfb1c15cdbd814a35864f4f18be767` with the all-room bounded peer/SFU-UDP controller; ordinary peer ICE remains STUN-only, and short-lived TURN is configured only for controller-selected exceptional media edges. The optional Windows native sender remains source-only and is not a production runtime. Mobile devices are Web viewers only; ordinary Android and iOS browsers do not expose reliable screen capture for a Web Host. Real cross-network SFU/TURN media, game audio, a sustained 1:8 room, mobile browser lifecycle behavior, and latency targets still require the documented manual test matrix.
## Run locally
-3
View File
@@ -1,3 +0,0 @@
.gradle/
**/build/
local.properties
-49
View File
@@ -1,49 +0,0 @@
# Android Screen Sender
This source-only P1 slice lets an Android 14/API 34+ device publish a 720p30
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
Install JDK 17 and Android SDK 35, then run:
```text
./gradlew :protocol:test :app:assembleDebug
```
The debug APK is written under `app/build/outputs/apk/debug/`. CI runs the same
protocol tests and debug compile. No release signing or package publication is
configured.
## Use
Open the app, enter the HTTPS Screener base URL and site-access password, then
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,
and every PeerConnection.
## First-Slice Boundary
- 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
their attempts as bounded route failures instead of claiming support.
- Rotation resize, background rebuild, Wi-Fi/cellular reselection, iOS, release
signing, and device/performance matrices remain later work.
-31
View File
@@ -1,31 +0,0 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "icu.bonfire.screener.sender"
compileSdk = 35
defaultConfig {
applicationId = "icu.bonfire.screener.sender"
minSdk = 34
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation(project(":protocol"))
implementation("com.squareup.okhttp3:okhttp:5.3.0")
implementation("io.github.webrtc-sdk:android:144.7559.12")
}
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<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"
android:label="Screener Sender"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="unspecified">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".ProjectionService"
android:exported="false"
android:foregroundServiceType="mediaProjection"
android:stopWithTask="false" />
</application>
</manifest>
@@ -1,165 +0,0 @@
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
import org.webrtc.VideoCodecInfo
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 {
try {
initializeOnce(context)
egl = EglBase.create()
val encoderFactory = HardwareOnlyEncoderFactory(egl.eglBaseContext)
if (encoderFactory.supportedCodecs.isEmpty()) error("No hardware VP8/H264 encoder is available")
val factoryBuilder = PeerConnectionFactory.builder()
.setVideoEncoderFactory(encoderFactory)
.setVideoDecoderFactory(DefaultVideoDecoderFactory(egl.eglBaseContext))
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() {
override fun onStop() = onProjectionStopped()
})
capturer.initialize(helper, context.applicationContext, source.capturerObserver)
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
}
}
override fun close() = release()
private fun release() {
if (closed) return
closed = true
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() }
}
private class HardwareOnlyEncoderFactory(context: EglBase.Context) : VideoEncoderFactory {
private val delegate = HardwareVideoEncoderFactory(context, true, true)
override fun createEncoder(info: VideoCodecInfo): VideoEncoder? =
if (allowed(info)) delegate.createEncoder(info) else null
override fun getSupportedCodecs(): Array<VideoCodecInfo> =
delegate.supportedCodecs.filter(::allowed).toTypedArray()
private fun allowed(info: VideoCodecInfo): Boolean =
info.name.equals("VP8", true) || info.name.equals("H264", true)
}
companion object {
const val WIDTH = 1280
const val HEIGHT = 720
const val FPS = 30
private val initialized = AtomicBoolean()
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)) {
try {
PeerConnectionFactory.initialize(
PeerConnectionFactory.InitializationOptions.builder(context.applicationContext)
.createInitializationOptions(),
)
} catch (error: Throwable) {
initialized.set(false)
throw error
}
}
}
}
}
@@ -1,163 +0,0 @@
package icu.bonfire.screener.sender
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
import org.webrtc.MediaStream
import org.webrtc.PeerConnection
import org.webrtc.PeerConnectionFactory
import org.webrtc.RtpReceiver
import org.webrtc.SdpObserver
import org.webrtc.SessionDescription
import org.webrtc.VideoTrack
import java.util.concurrent.Executor
class HostPeer(
private val peerId: String,
val connectionId: String,
iceConfig: IceConfig,
factory: PeerConnectionFactory,
track: VideoTrack,
audioTrack: AudioTrack?,
private val serial: Executor,
private val send: (String) -> Boolean,
private val status: (String) -> Unit,
) : AutoCloseable {
private val pending = ArrayDeque<WireCandidate?>()
@Volatile
private var closed = false
private val connection: PeerConnection
init {
val servers = iceConfig.iceServers.map { PeerConnection.IceServer.builder(it.urls).createIceServer() }
val config = PeerConnection.RTCConfiguration(servers).apply {
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.DISABLED
continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY
}
connection = factory.createPeerConnection(config, Observer()) ?: error("Could not create PeerConnection")
try {
val sender = connection.addTrack(track, listOf("screener")) ?: error("Could not add screen track")
sender.parameters.let { parameters ->
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() }
throw error
}
}
fun start() = createOffer(false)
fun restartIce() {
if (closed) return
connection.restartIce()
createOffer(true)
}
fun accept(payload: SignalPayload) {
if (closed || payload.connectionId != connectionId) return
when (payload) {
is SignalPayload.Description -> {
val description = SessionDescription(SessionDescription.Type.ANSWER, payload.description.sdp)
connection.setRemoteDescription(object : SimpleSdpObserver() {
override fun onSetSuccess() = dispatch { flushCandidates() }
override fun onSetFailure(error: String?) = status("Viewer answer was rejected")
}, description)
}
is SignalPayload.Candidate -> {
if (connection.remoteDescription == null) {
if (pending.size < MAX_PENDING) pending += payload.candidate
} else {
addCandidate(payload.candidate)
}
}
}
}
override fun close() {
if (closed) return
closed = true
pending.clear()
connection.dispose()
}
private fun createOffer(restart: Boolean) {
connection.createOffer(object : SimpleSdpObserver() {
override fun onCreateSuccess(description: SessionDescription?) {
if (description == null || closed) return
connection.setLocalDescription(object : SimpleSdpObserver() {
override fun onSetSuccess() {
if (!closed && !send(Wire.description(peerId, connectionId, "offer", description.description))) {
status("Signaling is temporarily unavailable")
}
}
override fun onSetFailure(error: String?) = status("Could not apply the screen offer")
}, description)
}
override fun onCreateFailure(error: String?) = status(if (restart) "ICE restart failed" else "Viewer offer failed")
}, MediaConstraints())
}
private fun flushCandidates() {
while (!closed && pending.isNotEmpty()) addCandidate(pending.removeFirst())
}
private fun dispatch(block: () -> Unit) {
runCatching { serial.execute(block) }
}
private fun addCandidate(candidate: WireCandidate?) {
if (candidate == null) return
connection.addIceCandidate(
IceCandidate(candidate.sdpMid ?: "", candidate.sdpMLineIndex ?: 0, candidate.candidate),
)
}
private inner class Observer : PeerConnection.Observer {
override fun onIceCandidate(candidate: IceCandidate) {
send(Wire.candidate(peerId, connectionId, WireCandidate(
candidate.sdp,
candidate.sdpMid,
candidate.sdpMLineIndex,
null,
)))
}
override fun onIceGatheringChange(state: PeerConnection.IceGatheringState) {
if (state == PeerConnection.IceGatheringState.COMPLETE) send(Wire.candidate(peerId, connectionId, null))
}
override fun onConnectionChange(state: PeerConnection.PeerConnectionState) {
if (state == PeerConnection.PeerConnectionState.FAILED) status("A viewer connection failed")
}
override fun onSignalingChange(state: PeerConnection.SignalingState) = Unit
override fun onIceConnectionChange(state: PeerConnection.IceConnectionState) = Unit
override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>) = Unit
override fun onAddStream(stream: MediaStream) = Unit
override fun onRemoveStream(stream: MediaStream) = Unit
override fun onDataChannel(channel: DataChannel) = Unit
override fun onRenegotiationNeeded() = Unit
override fun onAddTrack(receiver: RtpReceiver, streams: Array<out MediaStream>) = Unit
}
private open class SimpleSdpObserver : SdpObserver {
override fun onCreateSuccess(description: SessionDescription?) = Unit
override fun onSetSuccess() = Unit
override fun onCreateFailure(error: String?) = Unit
override fun onSetFailure(error: String?) = Unit
}
companion object {
private const val MAX_PENDING = 64
}
}
@@ -1,170 +0,0 @@
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() {
private lateinit var serverUrl: EditText
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?) {
statusText.text = resultData?.getString(ProjectionService.RESULT_STATUS) ?: "Sharing stopped"
resultData?.getString(ProjectionService.RESULT_INVITE)?.let {
inviteText.text = it
inviteText.setOnClickListener { _ ->
getSystemService(ClipboardManager::class.java).setPrimaryClip(
android.content.ClipData.newPlainText("Screener invite", it),
)
}
}
val active = resultCode == ProjectionService.RESULT_ACTIVE
startButton.isEnabled = !active
stopButton.isEnabled = active
if (!active) clearInvite()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
serverUrl = findViewById(R.id.server_url)
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)
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()
statusText.text = "Stopping"
}
}
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)
if (requestCode != REQUEST_PROJECTION) return
if (resultCode != RESULT_OK || data == null) {
statusText.text = "Screen sharing was cancelled"
return
}
startButton.isEnabled = false
stopButton.isEnabled = true
statusText.text = "Starting"
val service = Intent(this, ProjectionService::class.java).apply {
action = ProjectionService.ACTION_START
putExtra(ProjectionService.EXTRA_SERVER_URL, pendingServer)
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)
}
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
}
}
@@ -1,130 +0,0 @@
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,202 +0,0 @@
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() {
private val worker = Executors.newSingleThreadExecutor()
private val state = AtomicReference(State.IDLE)
private var receiver: ResultReceiver? = null
private var capture: CaptureEngine? = null
@Volatile
private var session: RemoteSession? = null
override fun onCreate() {
super.onCreate()
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Screen sharing", NotificationManager.IMPORTANCE_LOW),
)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
finish("Sharing stopped")
return START_NOT_STICKY
}
if (intent?.action != ACTION_START || !state.compareAndSet(State.IDLE, State.STARTING)) {
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,
)
sendResult(RESULT_ACTIVE, "Starting")
schedule {
try {
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, 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)
},
)
session = remote
if (!state.compareAndSet(State.STARTING, State.RUNNING)) return@schedule
remote.start()
} catch (_: Exception) {
finish("Screen sharing could not start")
}
}
return START_NOT_STICKY
}
override fun onDestroy() {
state.set(State.STOPPING)
session?.cancelPending()
schedule {
teardown()
worker.shutdown()
}
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun finish(message: String) {
if (state.getAndSet(State.STOPPING) == State.STOPPING) return
session?.cancelPending()
sendResult(RESULT_STOPPED, message)
schedule {
teardown()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
private fun teardown() {
session?.close()
session = null
capture?.close()
capture = null
}
private fun schedule(block: () -> Unit) {
try {
worker.execute(block)
} catch (_: RejectedExecutionException) {
state.set(State.STOPPING)
}
}
private fun sendResult(code: Int, status: String, invite: String? = null) {
receiver?.send(code, Bundle().apply {
putString(RESULT_STATUS, status)
invite?.let { putString(RESULT_INVITE, it) }
})
}
private fun notification(text: String): Notification {
val activity = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.presence_video_online)
.setContentTitle("Screener Sender")
.setContentText(text)
.setContentIntent(activity)
.setOngoing(true)
.build()
}
companion object {
const val ACTION_START = "icu.bonfire.screener.sender.START"
const val ACTION_STOP = "icu.bonfire.screener.sender.STOP"
const val EXTRA_SERVER_URL = "serverUrl"
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
const val RESULT_ACTIVE = 1
private const val CHANNEL_ID = "screen-sharing"
private const val NOTIFICATION_ID = 70
}
private enum class State { IDLE, STARTING, RUNNING, STOPPING }
}
@@ -1,270 +0,0 @@
package icu.bonfire.screener.sender
import icu.bonfire.screener.protocol.HostRoute
import icu.bonfire.screener.protocol.ProtocolException
import icu.bonfire.screener.protocol.RouteAction
import icu.bonfire.screener.protocol.ServerEvent
import icu.bonfire.screener.protocol.Wire
import okhttp3.Call
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
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
import java.util.concurrent.Executor
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
class RemoteSession(
serverUrl: String,
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 {
interface Events {
fun onRoom(inviteUrl: String)
fun onStatus(message: String)
fun onFatal(message: String)
}
private val baseUrl = serverUrl.toHttpUrl()
private val cookieJar = MemoryCookieJar()
private val client = OkHttpClient.Builder().cookieJar(cookieJar).followRedirects(false).build()
private val route = HostRoute()
private val peers = linkedMapOf<String, HostPeer>()
private val stopped = AtomicBoolean()
private val closed = AtomicBoolean()
private val activeCall = AtomicReference<Call?>()
@Volatile
private var socket: WebSocket? = null
private var room: Room? = null
private var iceConfig: icu.bonfire.screener.protocol.IceConfig? = null
init {
require(
baseUrl.isHttps && baseUrl.query == null && baseUrl.fragment == null &&
baseUrl.username.isEmpty() && baseUrl.password.isEmpty(),
)
}
fun start() {
try {
authenticateSite()
} finally {
sitePassword = ""
}
if (stopped.get()) return
val created = createRoom()
if (stopped.get()) return
room = Room(created.roomId, created.hostToken, opaqueId(), opaqueId())
events.onRoom(created.inviteUrl)
connectSignal()
}
override fun close() {
if (!closed.compareAndSet(false, true)) return
stopped.set(true)
activeCall.get()?.cancel()
val activeSocket = socket
if (room != null) activeSocket?.send(Wire.abandonRoom())
activeSocket?.close(1000, "sender stopped")
socket = null
peers.values.forEach(HostPeer::close)
peers.clear()
client.dispatcher.executorService.shutdown()
client.connectionPool.evictAll()
}
fun cancelPending() {
stopped.set(true)
activeCall.get()?.cancel()
}
private fun authenticateSite() {
val request = Request.Builder()
.url(endpoint(SITE_ACCESS_PATH))
.header("Accept", "application/json")
.header("Authorization", "Bearer ${sitePassword.trim()}")
.post("".toRequestBody(null))
.build()
execute(request, "Site access was denied")
}
private fun createRoom(): icu.bonfire.screener.protocol.CreatedRoom {
val body = """{"viewerPolicy":"private-link","hostClaimTtlSeconds":300}"""
val request = Request.Builder()
.url(endpoint(ROOMS_PATH))
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("Origin", origin())
.post(body.toRequestBody("application/json".toMediaType()))
.build()
return Wire.decodeCreatedRoom(execute(request, "Room creation failed"))
}
private fun execute(request: Request, failure: String): String {
val call = client.newCall(request)
check(activeCall.compareAndSet(null, call))
try {
if (stopped.get()) call.cancel()
return call.execute().use { response ->
val text = response.body.string()
if (!response.isSuccessful || text.toByteArray().size > Wire.MAX_HTTP_BYTES) error(failure)
text
}
} finally {
activeCall.compareAndSet(call, null)
}
}
private fun connectSignal() {
val wsUrl = endpoint(SIGNAL_PATH).toString().replaceFirst("https://", "wss://")
val cookies = cookieJar.loadForRequest(baseUrl).joinToString("; ") { "${it.name}=${it.value}" }
val request = Request.Builder().url(wsUrl).header("Origin", origin()).apply {
if (cookies.isNotEmpty()) header("Cookie", cookies)
}.build()
socket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
dispatch {
if (stopped.get()) return@dispatch
val identity = room ?: return@dispatch
webSocket.send(Wire.authenticate(identity.id, identity.token, identity.clientId, identity.shareGeneration))
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
dispatch { if (!stopped.get()) accept(text) }
}
override fun onFailure(webSocket: WebSocket, error: Throwable, response: Response?) {
fatal("Signaling connection failed")
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
if (!stopped.get()) fatal("Signaling connection closed")
}
})
}
private fun accept(text: String) {
val event = try {
Wire.decodeServerEvent(text)
} catch (_: ProtocolException) {
fatal("The server protocol does not match this sender")
return
}
when (event) {
is ServerEvent.Authenticated -> {
iceConfig = event.iceConfig
val assignment = event.routeAssignment
val revision = event.routeRevision
if (assignment == null || revision == null) {
fatal("This sender requires peer-assisted media")
return
}
socket?.send(Wire.qualitySettings())
apply(route.authoritative(event.peerId, revision, assignment))
events.onStatus("Connected; ${peers.size} viewer edge(s)")
}
is ServerEvent.RouteUpdate -> apply(route.update(event))
is ServerEvent.SelectedSfuIngress -> apply(route.selectedSfuIngress(event))
is ServerEvent.Signal -> {
if (route.isAssignedChild(event.fromPeerId)) peers[event.fromPeerId]?.accept(event.payload)
}
is ServerEvent.RestartRequest -> recover(event)
is ServerEvent.RoomClosed -> fatal(if (event.reason == "expired") "Room expired" else "Room closed")
is ServerEvent.Error -> {
if (event.code in FATAL_ERRORS) fatal("The server rejected this sender")
else events.onStatus("A media route is unavailable")
}
is ServerEvent.Ignored -> Unit
}
}
private fun apply(actions: List<RouteAction>) {
actions.forEach {
when (it) {
is RouteAction.ReconcileChildren -> reconcile(it.peerIds)
is RouteAction.Ready -> socket?.send(Wire.routeReady(it.revision, it.phase))
is RouteAction.Failed -> socket?.send(Wire.routeFailed(it.revision, it.phase, it.connectionId))
}
}
}
private fun reconcile(peerIds: Collection<String>) {
val desired = peerIds.distinct().take(MAX_EDGES).toSet()
(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, audioTrack, serial, ::send, events::onStatus,
).also {
it.start()
}
}
}
private fun recover(event: ServerEvent.RestartRequest) {
val peer = peers[event.fromPeerId] ?: return
if (peer.connectionId != event.connectionId) return
if (event.rebuild) {
peer.close()
peers.remove(event.fromPeerId)
reconcile(routeChildren() + event.fromPeerId)
} else {
peer.restartIce()
}
}
private fun routeChildren(): Set<String> = peers.keys.toSet()
private fun send(message: String): Boolean = socket?.send(message) == true
private fun dispatch(block: () -> Unit) {
runCatching { serial.execute(block) }
}
private fun fatal(message: String) {
if (stopped.get()) return
events.onFatal(message)
}
private fun endpoint(path: String): HttpUrl = baseUrl.newBuilder().encodedPath(path).query(null).fragment(null).build()
private fun origin(): String = baseUrl.newBuilder().encodedPath("/").query(null).fragment(null).build().toString().removeSuffix("/")
private data class Room(val id: String, val token: String, val clientId: String, val shareGeneration: String)
private class MemoryCookieJar : CookieJar {
private val cookies = mutableListOf<Cookie>()
override fun saveFromResponse(url: HttpUrl, values: List<Cookie>) = synchronized(cookies) {
values.forEach { value ->
cookies.removeAll { it.name == value.name && it.domain == value.domain && it.path == value.path }
if (value.expiresAt > System.currentTimeMillis()) cookies += value
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> = synchronized(cookies) {
cookies.removeAll { it.expiresAt <= System.currentTimeMillis() }
cookies.filter { it.matches(url) }
}
}
companion object {
private const val SITE_ACCESS_PATH = "/api/site-access"
private const val ROOMS_PATH = "/api/rooms"
private const val SIGNAL_PATH = "/signal"
private const val MAX_EDGES = 2
private val FATAL_ERRORS = setOf("AUTH_REQUIRED", "INVALID_TOKEN", "ROOM_EXPIRED", "HOST_ALREADY_CONNECTED")
private fun opaqueId(): String = UUID.randomUUID().toString()
}
}
@@ -1,100 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F4F6F5"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Screener Sender"
android:textColor="#17221E"
android:textSize="24sp"
android:textStyle="bold" />
<EditText
android:id="@+id/server_url"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginTop="28dp"
android:hint="https://share.example.com"
android:importantForAutofill="no"
android:inputType="textUri"
android:maxLines="1" />
<EditText
android:id="@+id/site_password"
android:layout_width="match_parent"
android:layout_height="56dp"
android:hint="Site access password"
android:importantForAutofill="no"
android:inputType="textPassword"
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"
android:layout_height="52dp"
android:layout_marginTop="16dp"
android:text="Start screen sharing" />
<Button
android:id="@+id/stop_button"
android:layout_width="match_parent"
android:layout_height="52dp"
android:layout_marginTop="8dp"
android:enabled="false"
android:text="Stop" />
<TextView
android:id="@+id/status_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:minHeight="48dp"
android:text="Ready"
android:textColor="#34443E"
android:textIsSelectable="true"
android:textSize="16sp" />
<TextView
android:id="@+id/invite_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:autoLink="web"
android:minHeight="48dp"
android:saveEnabled="false"
android:textColor="#176B4D"
android:textIsSelectable="true"
android:textSize="15sp" />
</LinearLayout>
</ScrollView>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">#176B4D</item>
<item name="android:navigationBarColor">#F4F6F5</item>
<item name="android:statusBarColor">#F4F6F5</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>
-5
View File
@@ -1,5 +0,0 @@
plugins {
id("com.android.application") version "8.10.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
id("org.jetbrains.kotlin.jvm") version "2.2.21" apply false
}
-4
View File
@@ -1,4 +0,0 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
org.gradle.configuration-cache=true
android.useAndroidX=true
kotlin.code.style=official
Binary file not shown.
@@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-185
View File
@@ -1,185 +0,0 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
-89
View File
@@ -1,89 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
-16
View File
@@ -1,16 +0,0 @@
plugins {
id("org.jetbrains.kotlin.jvm")
}
kotlin {
jvmToolchain(17)
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
testImplementation(kotlin("test"))
}
tasks.test {
useJUnitPlatform()
}
@@ -1,80 +0,0 @@
package icu.bonfire.screener.protocol
sealed interface RouteAction {
data class ReconcileChildren(val peerIds: List<String>) : RouteAction
data class Ready(val revision: Long, val phase: RoutePhase) : RouteAction
data class Failed(val revision: Long, val phase: RoutePhase, val connectionId: String?) : RouteAction
}
class HostRoute {
private var revision = -1L
private var phase: RoutePhase? = null
private var planned: RouteAssignment? = null
private var active: RouteAssignment? = null
private var hostPeerId: String? = null
fun authoritative(
hostPeerId: String,
revision: Long,
assignment: RouteAssignment,
): List<RouteAction> {
this.hostPeerId = hostPeerId
this.revision = revision
phase = RoutePhase.ACTIVE
planned = assignment.copy(childPeerIds = assignment.childPeerIds.toList())
active = planned
return activeActions(revision, assignment, acknowledge = false)
}
fun update(event: ServerEvent.RouteUpdate): List<RouteAction> {
val result = accept(event)
if (!result) return emptyList()
if (event.phase == RoutePhase.PREPARE) {
return if (event.assignment.sfuPublicationGeneration != null) {
listOf(RouteAction.Failed(event.revision, RoutePhase.PREPARE, null))
} else {
emptyList()
}
}
active = event.assignment.copy(childPeerIds = event.assignment.childPeerIds.toList())
return activeActions(event.revision, event.assignment, acknowledge = true)
}
fun selectedSfuIngress(event: ServerEvent.SelectedSfuIngress): List<RouteAction> {
val assignment = active ?: return emptyList()
if (
phase != RoutePhase.ACTIVE ||
event.revision != revision ||
event.hostPeerId != hostPeerId ||
event.publicationGeneration != assignment.sfuPublicationGeneration
) return emptyList()
return listOf(RouteAction.Failed(event.revision, RoutePhase.ACTIVE, event.newConnectionId))
}
fun isAssignedChild(peerId: String): Boolean = active?.childPeerIds?.contains(peerId) == true
private fun accept(event: ServerEvent.RouteUpdate): Boolean {
if (event.revision < revision) return false
if (event.revision == revision) {
if (planned != event.assignment || phase == RoutePhase.ACTIVE && event.phase == RoutePhase.PREPARE) return false
if (phase == event.phase) return false
}
revision = event.revision
phase = event.phase
planned = event.assignment.copy(childPeerIds = event.assignment.childPeerIds.toList())
return true
}
private fun activeActions(
revision: Long,
assignment: RouteAssignment,
acknowledge: Boolean,
): List<RouteAction> = buildList {
add(RouteAction.ReconcileChildren(assignment.childPeerIds.toList()))
if (assignment.sfuPublicationGeneration != null) {
add(RouteAction.Failed(revision, RoutePhase.ACTIVE, null))
} else if (acknowledge) {
add(RouteAction.Ready(revision, RoutePhase.ACTIVE))
}
}
}
@@ -1,485 +0,0 @@
package icu.bonfire.screener.protocol
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
const val SIGNALING_PROTOCOL = "screener-v2"
const val MAX_SIGNAL_BYTES = 64 * 1024
private const val MAX_SAFE_INTEGER = 9_007_199_254_740_991L
private val opaqueId = Regex("^[A-Za-z0-9_-]{8,128}$")
private val roomCode = Regex("^[1-9][0-9]{0,11}$")
private val hostToken = Regex("^[A-Za-z0-9_-]{32,128}$")
private val stunUrl = Regex("^stun:[^/\\s?#:]+(?::[1-9][0-9]{0,4})?$", RegexOption.IGNORE_CASE)
class ProtocolException(message: String) : IllegalArgumentException(message)
data class IceServer(val urls: List<String>)
data class IceConfig(val iceServers: List<IceServer>)
enum class RoutePhase(val wire: String) {
PREPARE("prepare"),
ACTIVE("active");
}
sealed interface RouteUpstream {
data object None : RouteUpstream
data object Sfu : RouteUpstream
data class Peer(val peerId: String) : RouteUpstream
}
data class RouteAssignment(
val upstream: RouteUpstream,
val childPeerIds: List<String>,
val sfuPublicationGeneration: String?,
)
data class SessionDescription(val type: String, val sdp: String)
data class IceCandidate(
val candidate: String,
val sdpMid: String?,
val sdpMLineIndex: Int?,
val usernameFragment: String?,
)
sealed interface SignalPayload {
val connectionId: String
data class Description(
override val connectionId: String,
val description: SessionDescription,
) : SignalPayload
data class Candidate(
override val connectionId: String,
val candidate: IceCandidate?,
) : SignalPayload
}
sealed interface ServerEvent {
data class Authenticated(
val peerId: String,
val iceConfig: IceConfig,
val viewerPeerIds: List<String>,
val routeRevision: Long?,
val routeAssignment: RouteAssignment?,
) : ServerEvent
data class Signal(val fromPeerId: String, val payload: SignalPayload) : ServerEvent
data class RestartRequest(
val fromPeerId: String,
val connectionId: String,
val rebuild: Boolean,
) : ServerEvent
data class RouteUpdate(
val revision: Long,
val phase: RoutePhase,
val assignment: RouteAssignment,
) : ServerEvent
data class SelectedSfuIngress(
val revision: Long,
val hostPeerId: String,
val publicationGeneration: String,
val newConnectionId: String,
) : ServerEvent
data class Error(val code: String, val message: String) : ServerEvent
data class RoomClosed(val reason: String) : ServerEvent
data class Ignored(val type: String) : ServerEvent
}
data class CreatedRoom(val roomId: String, val hostToken: String, val inviteUrl: String)
object Wire {
const val MAX_HTTP_BYTES = 64 * 1024
private val json = Json { isLenient = false }
private val ignoredTypes = setOf(
"host-status",
"media-assignment",
"peer-joined",
"peer-left",
"quality-settings",
"sfu-config",
"sharing-stopped",
"viewer-access-revoked",
"viewer-access-updated",
"viewer-password-updated",
"viewer-presence",
"viewer-quality-evidence",
)
fun decodeServerEvent(text: String): ServerEvent {
if (text.toByteArray(Charsets.UTF_8).size > MAX_SIGNAL_BYTES) {
fail("signaling message is too large")
}
val root = parseObject(text)
return try {
when (val type = root.string("type", 1, 64)) {
"authenticated" -> decodeAuthenticated(root)
"signal" -> decodeSignal(root)
"restart-request" -> decodeRestart(root)
"route-update" -> decodeRouteUpdate(root)
"selected-edge-turn" -> decodeSelectedEdge(root)
"error" -> decodeError(root)
"room-closed" -> decodeRoomClosed(root)
in ignoredTypes -> ServerEvent.Ignored(type)
else -> fail("unsupported signaling message")
}
} catch (error: ProtocolException) {
throw error
} catch (_: RuntimeException) {
fail("invalid signaling message")
}
}
fun decodeCreatedRoom(text: String): CreatedRoom {
val root = parseObject(text)
root.exact(
setOf("roomId", "hostToken", "inviteUrl", "viewerPolicy", "viewerGrantExpiresAt", "expiresAt"),
)
val id = root.string("roomId", 1, 12).also { requireMatch(it, roomCode, "room ID") }
val token = root.string("hostToken", 32, 128).also { requireMatch(it, hostToken, "host token") }
val invite = root.string("inviteUrl", 1, 2048)
if (!invite.startsWith("https://") && !invite.startsWith("http://")) fail("invalid invite URL")
if (root.string("viewerPolicy", 1, 32) != "private-link") fail("unexpected viewer policy")
root.requireNullableString("viewerGrantExpiresAt")
root.requireNullableString("expiresAt")
return CreatedRoom(id, token, invite)
}
fun authenticate(roomId: String, token: String, clientId: String, shareGeneration: String): String {
requireMatch(roomId, roomCode, "room ID")
requireMatch(token, hostToken, "host token")
requireOpaque(clientId, "client ID")
requireOpaque(shareGeneration, "share generation")
return buildJsonObject {
put("type", "authenticate")
put("protocol", SIGNALING_PROTOCOL)
put("roomId", roomId)
put("role", "host")
put("token", token)
put("clientId", clientId)
put("shareGeneration", shareGeneration)
}.toString()
}
fun qualitySettings(): String = buildJsonObject {
put("type", "set-quality-settings")
put("qualitySettings", buildJsonObject {
put("resolution", "720p")
put("maxFramerate", 30)
put("maxBitrate", 3_000_000)
put("degradationPreference", "maintain-resolution")
})
}.toString()
fun description(targetPeerId: String, connectionId: String, type: String, sdp: String): String {
requireOpaque(targetPeerId, "target peer ID")
requireOpaque(connectionId, "connection ID")
if (type != "offer" || sdp.isEmpty() || sdp.length > 48 * 1024) fail("invalid local offer")
return signal(targetPeerId, buildJsonObject {
put("kind", "description")
put("connectionId", connectionId)
put("description", buildJsonObject {
put("type", type)
put("sdp", sdp)
})
})
}
fun candidate(targetPeerId: String, connectionId: String, value: IceCandidate?): String {
requireOpaque(targetPeerId, "target peer ID")
requireOpaque(connectionId, "connection ID")
return signal(targetPeerId, buildJsonObject {
put("kind", "candidate")
put("connectionId", connectionId)
put("candidate", value?.let { candidateJson(it) } ?: JsonNull)
})
}
fun routeReady(revision: Long, phase: RoutePhase): String = buildJsonObject {
put("type", "route-ready")
put("revision", validRevision(revision))
put("phase", phase.wire)
}.toString()
fun routeFailed(revision: Long, phase: RoutePhase, connectionId: String?): String = buildJsonObject {
put("type", "route-failed")
put("revision", validRevision(revision))
put("phase", phase.wire)
if (connectionId == null) put("connectionId", JsonNull)
else put("connectionId", connectionId.also { requireOpaque(it, "connection ID") })
}.toString()
fun abandonRoom(): String = "{\"type\":\"abandon-room\"}"
private fun decodeAuthenticated(root: JsonObject): ServerEvent.Authenticated {
val base = setOf(
"type", "protocol", "role", "peerId", "roomExpiresAt", "maxViewers", "hostOnline",
"connectionId", "viewerPeerIds", "iceConfig", "viewerPolicy", "viewerAuthorizationGeneration",
)
val peerAssisted = root["mediaMode"]?.jsonPrimitive?.contentOrNull == "peer-assisted"
val peerFields = setOf("mediaMode", "mediaAssignment", "routeRevision", "routeAssignment", "qualitySettings")
root.exact(if (peerAssisted) base + peerFields else base, if (peerAssisted) setOf("sfuStandbyUrl") else emptySet())
if (root.string("protocol", 1, 32) != SIGNALING_PROTOCOL || root.string("role", 1, 16) != "host") {
fail("unexpected authentication response")
}
if (root.boolean("hostOnline") != true || root["connectionId"] !is JsonNull) {
fail("invalid host authentication state")
}
root.requireNullableString("roomExpiresAt")
val maxViewers = root.integer("maxViewers", 1, 16)
root.string("viewerPolicy", 1, 32).also {
if (it != "private-link" && it != "public-watch") fail("invalid viewer policy")
}
requireOpaque(root.string("viewerAuthorizationGeneration", 8, 128), "authorization generation")
val viewers = root.idArray("viewerPeerIds", maxViewers)
val ice = decodeIceConfig(root.objectValue("iceConfig"))
if (!peerAssisted) {
return ServerEvent.Authenticated(root.opaque("peerId"), ice, viewers, null, null)
}
root.objectValue("mediaAssignment").also {
it.exact(setOf("parentPeerId", "childPeerIds"))
if (it["parentPeerId"] !is JsonNull) fail("host cannot have a media parent")
it.idArray("childPeerIds", 2)
}
decodeQuality(root.objectValue("qualitySettings"))
return ServerEvent.Authenticated(
root.opaque("peerId"),
ice,
viewers,
root.revision("routeRevision"),
decodeAssignment(root.objectValue("routeAssignment")),
)
}
private fun decodeSignal(root: JsonObject): ServerEvent.Signal {
root.exact(setOf("type", "fromPeerId", "payload"))
val from = root.opaque("fromPeerId")
val payload = root.objectValue("payload")
return when (payload.string("kind", 1, 32)) {
"description" -> {
payload.exact(setOf("kind", "connectionId", "description"))
val description = payload.objectValue("description")
description.exact(setOf("type", "sdp"))
val type = description.string("type", 1, 16)
if (type != "answer") fail("host only accepts answers")
ServerEvent.Signal(from, SignalPayload.Description(
payload.opaque("connectionId"),
SessionDescription(type, description.string("sdp", 1, 48 * 1024)),
))
}
"candidate" -> {
payload.exact(setOf("kind", "connectionId", "candidate"))
val candidate = when (val value = payload["candidate"] ?: fail("missing candidate")) {
JsonNull -> null
is JsonObject -> decodeCandidate(value)
else -> fail("invalid candidate")
}
ServerEvent.Signal(from, SignalPayload.Candidate(payload.opaque("connectionId"), candidate))
}
else -> fail("unsupported signal payload")
}
}
private fun decodeRestart(root: JsonObject): ServerEvent.RestartRequest {
root.exact(setOf("type", "fromPeerId", "connectionId", "rebuild"))
return ServerEvent.RestartRequest(root.opaque("fromPeerId"), root.opaque("connectionId"), root.boolean("rebuild"))
}
private fun decodeRouteUpdate(root: JsonObject): ServerEvent.RouteUpdate {
root.exact(setOf("type", "revision", "phase", "assignment"))
val phase = when (root.string("phase", 1, 16)) {
"prepare" -> RoutePhase.PREPARE
"active" -> RoutePhase.ACTIVE
else -> fail("invalid route phase")
}
return ServerEvent.RouteUpdate(root.revision("revision"), phase, decodeAssignment(root.objectValue("assignment")))
}
private fun decodeSelectedEdge(root: JsonObject): ServerEvent {
val kind = root.string("edgeKind", 1, 32)
if (kind != "host-sfu-ingress") return ServerEvent.Ignored("selected-edge-turn")
root.exact(setOf(
"type", "edgeKind", "revision", "hostPeerId", "publicationGeneration", "oldConnectionId",
"newConnectionId", "expiresAt", "iceServer",
))
root.objectValue("iceServer")
return ServerEvent.SelectedSfuIngress(
root.revision("revision"),
root.opaque("hostPeerId"),
root.opaque("publicationGeneration"),
root.opaque("newConnectionId"),
)
}
private fun decodeError(root: JsonObject): ServerEvent.Error {
root.exact(setOf("type", "code", "message"))
return ServerEvent.Error(root.string("code", 1, 64), root.string("message", 1, 256))
}
private fun decodeRoomClosed(root: JsonObject): ServerEvent.RoomClosed {
root.exact(setOf("type", "reason"))
val reason = root.string("reason", 1, 32)
if (reason != "host-ended" && reason != "expired") fail("invalid room close reason")
return ServerEvent.RoomClosed(reason)
}
private fun decodeAssignment(root: JsonObject): RouteAssignment {
root.exact(setOf("upstream", "childPeerIds", "sfuPublicationGeneration"))
val upstreamObject = root.objectValue("upstream")
val upstream = when (upstreamObject.string("kind", 1, 16)) {
"none" -> {
upstreamObject.exact(setOf("kind"))
RouteUpstream.None
}
"sfu" -> {
upstreamObject.exact(setOf("kind"))
RouteUpstream.Sfu
}
"peer" -> {
upstreamObject.exact(setOf("kind", "peerId"))
RouteUpstream.Peer(upstreamObject.opaque("peerId"))
}
else -> fail("invalid route upstream")
}
if (upstream !is RouteUpstream.None) fail("host route must not have an upstream")
val children = root.idArray("childPeerIds", 2)
if (children.toSet().size != children.size) fail("duplicate route child")
val publication = root.nullableOpaque("sfuPublicationGeneration")
return RouteAssignment(upstream, children, publication)
}
private fun decodeIceConfig(root: JsonObject): IceConfig {
root.exact(setOf("iceServers"))
val values = root.array("iceServers")
if (values.size > 8) fail("too many ICE servers")
return IceConfig(values.map { element ->
val server = element as? JsonObject ?: fail("invalid ICE server")
server.exact(setOf("urls"))
val urls = when (val raw = server["urls"] ?: fail("missing ICE URLs")) {
is JsonPrimitive -> listOf(raw.content)
is JsonArray -> raw.map { it.jsonPrimitive.content }
else -> fail("invalid ICE URLs")
}
if (urls.isEmpty() || urls.size > 8 || urls.any { it.length > 512 || !stunUrl.matches(it) }) {
fail("invalid STUN URLs")
}
IceServer(urls)
})
}
private fun decodeQuality(root: JsonObject) {
root.exact(setOf("resolution", "maxFramerate", "maxBitrate", "degradationPreference"))
if (root.string("resolution", 1, 16) !in setOf("720p", "1080p", "1440p")) fail("invalid quality")
root.integer("maxFramerate", 15, 60)
root.integer("maxBitrate", 2_000_000, 12_000_000)
if (root.string("degradationPreference", 1, 32) !in setOf("maintain-resolution", "balanced", "maintain-framerate")) {
fail("invalid quality")
}
}
private fun decodeCandidate(root: JsonObject): IceCandidate {
root.exact(setOf("candidate"), setOf("sdpMid", "sdpMLineIndex", "usernameFragment"))
return IceCandidate(
root.string("candidate", 0, 4096),
root.optionalNullableString("sdpMid", 128),
root.optionalNullableInteger("sdpMLineIndex", 0, 255),
root.optionalNullableString("usernameFragment", 256),
)
}
private fun signal(targetPeerId: String, payload: JsonObject): String = buildJsonObject {
put("type", "signal")
put("targetPeerId", targetPeerId)
put("payload", payload)
}.toString()
private fun candidateJson(value: IceCandidate): JsonObject {
if (value.candidate.length > 4096 || (value.sdpMid?.length ?: 0) > 128 ||
value.sdpMLineIndex?.let { it !in 0..255 } == true || (value.usernameFragment?.length ?: 0) > 256
) fail("invalid local candidate")
return buildJsonObject {
put("candidate", value.candidate)
value.sdpMid?.let { put("sdpMid", it) }
value.sdpMLineIndex?.let { put("sdpMLineIndex", it) }
value.usernameFragment?.let { put("usernameFragment", it) }
}
}
private fun parseObject(text: String): JsonObject = try {
json.parseToJsonElement(text).jsonObject
} catch (_: Exception) {
fail("invalid JSON object")
}
}
private fun JsonObject.exact(required: Set<String>, optional: Set<String> = emptySet()) {
if (!keys.containsAll(required) || keys.any { it !in required && it !in optional }) fail("invalid message shape")
}
private fun JsonObject.string(key: String, min: Int, max: Int): String {
val value = (this[key] as? JsonPrimitive)?.takeUnless { it.isString.not() }?.contentOrNull
?: fail("missing string $key")
if (value.length !in min..max) fail("invalid string $key")
return value
}
private fun JsonObject.opaque(key: String): String = string(key, 8, 128).also { requireOpaque(it, key) }
private fun JsonObject.nullableOpaque(key: String): String? = when (val value = this[key] ?: fail("missing $key")) {
JsonNull -> null
is JsonPrimitive -> value.content.also { requireOpaque(it, key) }
else -> fail("invalid $key")
}
private fun JsonObject.boolean(key: String): Boolean = this[key]?.jsonPrimitive?.booleanOrNull ?: fail("invalid $key")
private fun JsonObject.integer(key: String, min: Int, max: Int): Int =
this[key]?.jsonPrimitive?.intOrNull?.takeIf { it in min..max } ?: fail("invalid $key")
private fun JsonObject.revision(key: String): Long =
this[key]?.jsonPrimitive?.longOrNull?.let(::validRevision) ?: fail("invalid $key")
private fun JsonObject.objectValue(key: String): JsonObject = this[key] as? JsonObject ?: fail("invalid $key")
private fun JsonObject.array(key: String): JsonArray = this[key] as? JsonArray ?: fail("invalid $key")
private fun JsonObject.idArray(key: String, max: Int): List<String> {
val values = array(key)
if (values.size > max) fail("too many $key")
return values.map { it.jsonPrimitive.content.also { id -> requireOpaque(id, key) } }
}
private fun JsonObject.requireNullableString(key: String) {
val value = this[key] ?: fail("missing $key")
if (value !is JsonNull && (value !is JsonPrimitive || !value.isString)) fail("invalid $key")
}
private fun JsonObject.optionalNullableString(key: String, max: Int): String? = when (val value = this[key]) {
null, JsonNull -> null
is JsonPrimitive -> value.content.takeIf { value.isString && it.length <= max } ?: fail("invalid $key")
else -> fail("invalid $key")
}
private fun JsonObject.optionalNullableInteger(key: String, min: Int, max: Int): Int? = when (val value = this[key]) {
null, JsonNull -> null
is JsonPrimitive -> value.intOrNull?.takeIf { it in min..max } ?: fail("invalid $key")
else -> fail("invalid $key")
}
private fun validRevision(value: Long): Long = value.takeIf { it in 0..MAX_SAFE_INTEGER } ?: fail("invalid route revision")
private fun requireOpaque(value: String, label: String) = requireMatch(value, opaqueId, label)
private fun requireMatch(value: String, pattern: Regex, label: String) {
if (!pattern.matches(value)) fail("invalid $label")
}
private fun fail(message: String): Nothing = throw ProtocolException(message)
@@ -1,109 +0,0 @@
package icu.bonfire.screener.protocol
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertTrue
class HostRouteTest {
private val childA = "viewer_A123"
private val childB = "viewer_B123"
private val host = "host_123456"
@Test
fun `peer-assisted snapshot reconciles direct children without an acknowledgement`() {
val event = Wire.decodeServerEvent(authenticatedJson())
val authenticated = assertIs<ServerEvent.Authenticated>(event)
val route = HostRoute()
assertEquals(
listOf(RouteAction.ReconcileChildren(listOf(childA, childB))),
route.authoritative(authenticated.peerId, authenticated.routeRevision!!, authenticated.routeAssignment!!),
)
assertTrue(route.isAssignedChild(childA))
}
@Test
fun `active direct update commits children and acknowledges once`() {
val route = HostRoute()
route.authoritative(host, 4, direct(childA))
val update = ServerEvent.RouteUpdate(5, RoutePhase.ACTIVE, direct(childB))
assertEquals(
listOf(
RouteAction.ReconcileChildren(listOf(childB)),
RouteAction.Ready(5, RoutePhase.ACTIVE),
),
route.update(update),
)
assertEquals(emptyList(), route.update(update))
}
@Test
fun `SFU prepare and active assignments fail without replacing direct media early`() {
val route = HostRoute()
route.authoritative(host, 7, direct(childA))
val sfu = direct(childA).copy(sfuPublicationGeneration = "publication_123")
assertEquals(
listOf(RouteAction.Failed(8, RoutePhase.PREPARE, null)),
route.update(ServerEvent.RouteUpdate(8, RoutePhase.PREPARE, sfu)),
)
assertTrue(route.isAssignedChild(childA))
assertEquals(
listOf(
RouteAction.ReconcileChildren(listOf(childA)),
RouteAction.Failed(8, RoutePhase.ACTIVE, null),
),
route.update(ServerEvent.RouteUpdate(8, RoutePhase.ACTIVE, sfu)),
)
}
@Test
fun `unsupported selected SFU ingress closes the replacement attempt`() {
val route = HostRoute()
val assignment = direct(childA).copy(sfuPublicationGeneration = "publication_123")
route.authoritative(host, 9, assignment)
assertEquals(
listOf(RouteAction.Failed(9, RoutePhase.ACTIVE, "connection_123")),
route.selectedSfuIngress(
ServerEvent.SelectedSfuIngress(9, host, "publication_123", "connection_123"),
),
)
}
@Test
fun `strict actionable messages reject unknown fields`() {
assertFailsWith<ProtocolException> {
Wire.decodeServerEvent(
"""{"type":"route-update","revision":4,"phase":"active","assignment":{"upstream":{"kind":"none"},"childPeerIds":["$childA"],"sfuPublicationGeneration":null},"legacy":true}""",
)
}
}
@Test
fun `inbound answer retains target connection generation`() {
val event = Wire.decodeServerEvent(
"""{"type":"signal","fromPeerId":"$childA","payload":{"kind":"description","connectionId":"connection_123","description":{"type":"answer","sdp":"v=0"}}}""",
)
val signal = assertIs<ServerEvent.Signal>(event)
assertEquals("connection_123", signal.payload.connectionId)
}
private fun direct(vararg peers: String) = RouteAssignment(RouteUpstream.None, peers.toList(), null)
private fun authenticatedJson(): String = """
{
"type":"authenticated","protocol":"screener-v2","role":"host","peerId":"$host",
"roomExpiresAt":null,"maxViewers":8,"hostOnline":true,"connectionId":null,
"viewerPeerIds":["$childA","$childB"],
"iceConfig":{"iceServers":[{"urls":["stun:stun.example.test:3478"]}]},
"viewerPolicy":"private-link","viewerAuthorizationGeneration":"authorization_123",
"mediaMode":"peer-assisted","mediaAssignment":{"parentPeerId":null,"childPeerIds":["$childA","$childB"]},
"routeRevision":3,"routeAssignment":{"upstream":{"kind":"none"},"childPeerIds":["$childA","$childB"],"sfuPublicationGeneration":null},
"qualitySettings":{"resolution":"720p","maxFramerate":30,"maxBitrate":3000000,"degradationPreference":"maintain-resolution"}
}
""".trimIndent()
}
-21
View File
@@ -1,21 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "screener-android-sender"
include(":protocol")
if (providers.gradleProperty("protocolOnly").orNull != "true") {
include(":app")
}
-1
View File
@@ -12,7 +12,6 @@
- [Built-in peer ICE TURN candidate](./research/built-in-peer-ice-turn.md): rejected canary record, retained standards/resource findings, and the selected-edge replacement boundary.
- [Advanced peer distribution](./research/advanced-peer-distribution.md): multi-tree/SVC, encoded-object relay, native RTP forwarding, FEC/network-coding, MoQ, and measurable go/no-go gates.
- [Native shared-encode sender](./research/native-shared-encode-sender.md): consolidated Draft #16/#18/#22/#23/#25/#28 ladder, unclassified product-gate failure, staged revalidation, and stop line.
- [Android screen sender](../android/sender/README.md): Android 14+ video-only build/use boundary and explicit unsupported routes.
- [Native H.264 hardware decision](./research/native-h264-hardware-decision.md): bounded WebCodecs no-go, proved Media Foundation/NVIDIA hardware encode, the default-fmtp `42c01f` stop, and the retained interop boundary.
- [Native H.264 opt-in path](./research/native-h264-opt-in-path.md): explicit H.264 sender mode, exact Pion profile registration, and one bounded Host/Viewer loopback with hardware caveats.
- [Realtime quality adaptation](./research/realtime-quality-adaptation.md): correlated capture/send/receive diagnosis, production generation-rebuild gate, path-isolated dual representations, and bounded simulcast/Dynacast/SVC checks.
@@ -143,8 +143,6 @@ runtime downgrade.
- The helper is packaged beside the sender in a commit-bound evaluation ZIP.
The ZIP carries internal and external SHA-256 manifests plus exact linked Go
dependency license files; it is neither an installer nor a release.
- Android `AudioPlaybackCapture` and packaged cross-platform UI remain later
candidates; they do not block this slice.
## Follow-Up TODO
+4 -4
View File
@@ -5,7 +5,7 @@ Last updated: 2026-08-21
## Confirmed Intent
- Build low-latency game sharing for one broadcaster and trusted friends; public or large broadcasts belong on OBS/Twitch-class services.
- Viewers join from normal desktop/mobile browsers. Native senders may improve capture/audio without changing the Web viewer requirement.
- Browser first. Viewers use desktop/mobile Web; an optional sender requires a proven browser capability gap.
- Use a small central service for access, rooms, signaling, deterministic topology, STUN, observability, and bounded SFU-root capacity.
- Minimize server bandwidth. Ordinary peer ICE is STUN-only. Failure tries restart, rebuild, alternate peer and bounded SFU/UDP roots; only then may the controller authorize short-lived TURN for one selected exceptional edge before clear failure. TURN is transport, not topology.
- Non-server nodes have at most two downstream edges; browser relays stay at one until resource gates pass. SFU normally feeds one or two roots that retain peer descendants; separately capped server edges may serve exceptional viewers that cannot attach behind a healthy root.
@@ -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+ 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.
- Mobile endpoints are Web Viewer-only. Current Android/iOS browsers do not expose Web Host capture; AirPlay/system mirroring is Viewer-local output.
- ADR-0005 accepts direct/peer UDP, bounded SFU roots, then selected-edge TURN. Production grants one revision-bound, one-use retry for initial/active Host ingress. The deployed tuple/control path have no production SFU/TURN media canary; participant-wide TURN stays rejected and ordinary peers stay STUN-only.
- 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.
@@ -43,7 +43,7 @@ Last updated: 2026-08-21
- The repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn.
- Production is exact `16f6eab27bdfb1c15cdbd814a35864f4f18be767` at `https://share.bonfire.icu`; its 1,027,423-byte artifact SHA-256 is `74e0274ab11a162cb9dd4be1c34bb6b639e2ea76005969c30ae3a1daa656742f`. The 2026-08-20T23:44:57Z cutover took 10,238 ms lock/641 ms stop-health/570 ms switch-health. Immediate rollback is exact `22119b907d3cf03ce8b06d6fb4596ce1a26fedd7`; exact `fd76277b05d491af8840b28f3132b7ff445d3cbe` remains secondary.
- `main` is ahead only in Native/test evidence; Android and Native senders stay source-only. Host-ingress retry is deployed.
- `main` is ahead only in Native/test evidence; the Windows Native sender stays source-only. Host-ingress retry is deployed.
- The four services are active/running with observed `NRestarts=0`; local/public health are 200. SQLite v3 has five rooms including room `1`, with DB owner/mode `screener:screener`/0600.
- Production ICE remains ordinary STUN-only. `PEER_ASSISTED_MEDIA=true` enables the controller for every normal room, and selected-edge TURN is configured for the single UDP tuple with TTL 120; the coturn daemon and nginx/LiveKit/firewall/listener baselines were unchanged. No production SFU/TURN media canary or quality claim is implied.
- Production requires the independent site-access secret through `SITE_ACCESS_PASSWORD`. Its stateless cookie authorizes creation/Host and permits code-only Viewer attempts; valid private fragment grants remain direct. Public-watch accepts site access plus code, while private code-only entry additionally requires the room password. The env, endpoint, cookie, and nginx limiter naming migrated atomically; anonymous failures stay neutral, and there are no accounts/JWT/session rows.
@@ -69,7 +69,7 @@ These are measurement gates, not performance claims.
- Sustainable count by hardware, quality, network, and route: finish instrumented `1/3/5/8`, then pass a 20-viewer matrix before changing the accepted target default to 20.
- Whether ADR-0004 passes fanout, re-encoding, depth-four latency, reparenting, silent-partition, and mobile-leaf gates.
- Whether selected-edge passes forced relay, expiry/failure, mobile, 1-GiB resource and bandwidth gates. Coturn's bearer is non-revocable until expiry; application guards and TTL only bound reuse. Performance comparison and the retained matrix follow functional landing; Media TCP is out of scope.
- Exact mobile lifecycle behavior.
- Exact mobile Viewer lifecycle behavior across autoplay, rotation, backgrounding and network changes.
- Project license and distribution model, which determines whether GPL/AGPL sources can move beyond study-only use.
- Initial deployment regions and expected mainland China, Hong Kong, and overseas network mix.
- Whether voice chat ever enters scope or Screener stays complementary to an existing voice application.
@@ -250,75 +250,6 @@ 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.
## Android Application-Audio Source Slice (2026-08-21)
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 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 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
Add a user-facing audio setting only when all of these are true:
@@ -356,15 +287,3 @@ Add a user-facing audio setting only when all of these are true:
- [Microsoft `IAudioCaptureClient::GetBuffer`](https://learn.microsoft.com/en-us/windows/win32/api/audioclient/nf-audioclient-iaudiocaptureclient-getbuffer)
- [Windows 10 release information](https://learn.microsoft.com/en-us/windows/release-health/release-information)
- [Microsoft WASAPI loopback recording](https://learn.microsoft.com/en-us/windows/win32/coreaudio/loopback-recording)
- [Android capture video and audio playback](https://developer.android.com/media/platform/av-capture)
- [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)
@@ -132,91 +132,6 @@ code or user data:
- Oopz `agora_rtc_sdk.dll`: SHA-256
`66B8B34A57BA0EE9DCC6C516E2CC20F5F1EB5D35ED7150FDF37201A46BC8D4A0`.
## Mobile Sender Boundary
Android now has one parallel P1 source slice; it does not broaden ADR-0006's
Windows scope and does not create a cross-platform sender framework. Mobile Web
Host remains unsupported. The accepted native slice targets Android 14/API 34+
and the flagship peer-assisted Host direct-child path.
Android's official `MediaProjection` path captures the display into a `Surface`;
selected-app-window capture starts with Android 14 QPR2. API 34 requires fresh
consent for each session, one use of each projection token, and a foreground
service declared as `mediaProjection`. Rotation or selected-content resizing requires
`onCapturedContentResize()` handling; user stop, screen lock, another projection,
or process death invalidates the session and requires bounded cleanup through
`onStop()`. These OS consent and foreground indicators are part of the product
experience and cannot be made invisible.
The bounded Android data path is:
```text
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
```
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. 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. 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.
Activity recreation does not yet reattach to an active foreground service. After
rotation or process recreation, controls can show their initial state while the
service continues sharing; its duplicate-start gate still prevents a second
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; 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
table requires AGP 8.10 or newer for Kotlin 2.2 class files, and AGP 8.10 uses
Gradle 8.11.1 plus JDK 17. The source therefore pins Kotlin 2.2.21, AGP 8.10.1,
Gradle 8.11.1, compile SDK 35 and JDK 17 rather than relying on an IDE default.
iOS work remains later and separate. Apple's current ScreenCaptureKit
documentation says the framework replaces ReplayKit for screen streaming and
that a broadcast extension is no longer required, but the iOS sample requires
iOS 27 and the current APIs are still beta. Screener therefore does not build a
ReplayKit/Broadcast Upload Extension compatibility stack. After the final iOS
27 SDK and stable OS ship, one narrow ScreenCaptureKit system-picker spike may
feed a fixed-`HIGH` VideoToolbox encoder into the same one/two-Web-viewer
contract. It must set
`kVTVideoEncoderSpecification_RequireHardwareAcceleratedVideoEncoder` and verify
`kVTCompressionPropertyKey_UsingHardwareAcceleratedVideoEncoder`; otherwise it
fails closed. Rotation, background execution, lock-screen behavior, network
switching, thermal throttling, audio scope, and A/V sync remain explicit gates
on real devices, not implied framework capabilities.
## Pion Validation Route
Pion is the released-public-API validation route while the larger libwebrtc
@@ -606,30 +521,7 @@ SFU/UDP fails and needs its own bounded transport gate.
- [RFC 4588 RTP retransmission](https://www.rfc-editor.org/rfc/rfc4588.html)
- [RFC 8888 congestion-control feedback](https://www.rfc-editor.org/rfc/rfc8888.html)
- [Electron desktop capture](https://www.electronjs.org/docs/latest/api/desktop-capturer/)
- [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` 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)
- [Android Kotlin/AGP compatibility](https://developer.android.com/build/kotlin-support)
- [Android Gradle Plugin 8.10 compatibility](https://developer.android.com/build/releases/agp-8-10-0-release-notes)
- [Apple ScreenCaptureKit](https://developer.apple.com/documentation/ScreenCaptureKit)
- [Apple iOS ScreenCaptureKit sample](https://developer.apple.com/documentation/screencapturekit/capturing-screen-content-on-ios)
- [Apple required hardware encoder key](https://developer.apple.com/documentation/videotoolbox/kvtvideoencoderspecification_requirehardwareacceleratedvideoencoder)
- [Apple hardware encoder readback](https://developer.apple.com/documentation/videotoolbox/kvtcompressionpropertykey_usinghardwareacceleratedvideoencoder)
- [GitHub Actions artifact upload](https://github.com/actions/upload-artifact)
- [GitHub Windows 2025 runner image](https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md)
- [PowerShell `Compress-Archive`](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.archive/compress-archive)
+1 -2
View File
@@ -187,8 +187,7 @@ Web UI 后续只做最小诚实处理:运行时检查
`navigator.mediaDevices?.getDisplayMedia`,缺失时在建房/发布前明确显示不支持;
方法存在也只表示可以尝试,真实调用失败仍区分 denied/cancelled/capture-failed,
不能按 UA 宣称兼容。移动 Viewer 不需要该 API,继续使用同一响应式 Web 播放端并保持
leaf-only。移动 Host 若要落地,必须走后续原生 sender,而不是在 Web 端模拟
screen track 或加入私有媒体协议。
leaf-only。
来源:
+2 -2
View File
@@ -5,7 +5,7 @@ Last updated: 2026-08-21
## Phase
Production at `https://share.bonfire.icu` is exact `16f6eab27bdfb1c15cdbd814a35864f4f18be767`; artifact 1,027,423 bytes, SHA-256 `74e0274ab11a162cb9dd4be1c34bb6b639e2ea76005969c30ae3a1daa656742f`.
Cutover: 10,238 ms lock/641 ms stop-health/570 ms switch-health; SQLite v3/five rooms/four active services, observed `NRestarts=0`. Initial Host-ingress retry is deployed; Android remains source-only.
Cutover: 10,238 ms lock/641 ms stop-health/570 ms switch-health; SQLite v3/five rooms/four active services, observed `NRestarts=0`. Initial Host-ingress retry is deployed; mobile endpoints are Web Viewer-only.
Ordinary ICE is STUN-only; all rooms use the bounded peer/SFU-UDP controller and selected-edge UDP TTL 120. Room `1` is historical smoke; production SFU/TURN media remains unverified.
Web names/audio hint are deployed. Native WGC/MF/package is source-only; Web defaults VP8.
@@ -43,7 +43,7 @@ Flagship; parallel; min run/smoke/rollback; benchmark later. Active UI/config/lo
## Unverified Boundaries
- The `16f6eab` deploy proves process/config health only; production SFU frames, initial/active retry/failback, relay pair, admission rescue, caps, quality, and resource evidence remain open. Game-share load/blur is unclassified.
- 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.
- Mobile remains Viewer leaf-only. AirPlay/system mirroring is local output; live WebRTC `srcObject` has no portable in-app TV-output contract.
- 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.
+2 -49
View File
@@ -210,50 +210,7 @@ iOS 都没有可靠的 `getDisplayMedia()`;Web Host 入口必须检查运行
或设备型号放行。该边界不影响普通手机浏览器观看;手机仍是 leaf-only,并继续通过真机矩阵。浏览器版本证据由
[WebRTC 调研](./research/webrtc-p2p-screen-sharing.md)持有。
Android 14/API 34+ 作为不阻断 Windows 主线的并行 P1 首片,只做一条窄纵向链路:
系统 `MediaProjection` picker -> `mediaProjection` foreground service -> libwebrtc
`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。
Android Host 以 authenticated snapshot 的 `routeRevision`/`routeAssignment` 为权威,
只为 `directChildPeerIds` 建立至多两条含 `targetPeerId` 的 STUN-only PeerConnection,
并用 assignment 给定的 connection generation 校验 answer/candidate/restart。较新的
direct `route-update(active)` 先 reconcile child,再发送 `route-ready(active)`;旧 revision、
未知 target 和 connection mismatch 均丢弃。首片不接 LiveKit SDK:snapshot 或 prepare
要求非空 `sfuPublicationGeneration` 时只发送一次相同 revision/phase 的 `route-failed`
并保持有界失败,不能谎称 production route 已发布。非 peer-assisted signaling 明确失败,
不能把本地旁路当作旗舰验收结论。
Android 的逐 session 授权、系统指示、旋转/尺寸回调、锁屏/撤销终止与资源释放
都是产品行为,不能隐藏或用重启 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
fixed-`HIGH` -> 现有 WebRTC/一至两名 Web Viewer 的 P2 窄实验。硬件编码必须要求
并读回,未证明时 fail closed;系统授权/指示和全部生命周期/性能边界留给真机
矩阵,不追加兼容 fallback。
移动端只承担响应式 Web Viewer 和 leaf 角色;Web Host 支持矩阵保持桌面 Chrome/Edge。
Viewer 向电视投屏不进入媒体拓扑;系统级屏幕镜像现在由平台直接提供。Web 端后续只在运行时检测
`HTMLMediaElement.remote`,并在真实目标证明 live WebRTC `srcObject` 可远端播放后才
@@ -395,8 +352,6 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md)
- CGNAT、双 NAT、移动热点和普通家宽下使用隔离 candidate,先验证 STUN-only direct/peer 与 SFU/UDP,再只对 controller 选中的单条失败 edge 验证 forced relay;记录 selected attempt 的 allocation/relay port/RSS/CPU/latency 与 RX/TX/loss。人为封锁全部 UDP 时仍必须有界明确失败。旧 production release 留在独立实例作为回滚,不在同进程保留旧 transport;
- Windows Chrome/Edge 的 tab/window/monitor 音轨有/无、同时语音通话和 video-only;Windows 11 原生候选另测 game process tree、通知、无渲染流与进程重启,Windows 10 记录明确不支持/未解决;
- Android Chrome 与 iOS Safari 的播放手势、旋转、前后台和网络切换;
- Android 14/API 34+ 原生 sender 首片代码闭环后只运行一次单设备/单 Web Viewer smoke:系统 picker、授权拒绝/撤销、一个 peer-assisted direct child、硬件 codec 选中、首帧与资源释放。旋转热更新、锁屏恢复、Wi-Fi/蜂窝切换、持续热降频、应用音频、secure/protected content 与多 Viewer 属于后续 gate;单应用窗口只在 Android 14 QPR2+ 测试;
- iOS 27 ScreenCaptureKit 在 final SDK/OS 前不进入实现或矩阵;稳定后只运行 system picker、VideoToolbox 硬编 fail-closed、一至两名 Web Viewer、后台/锁屏/旋转/网络/热状态与 A/V sync 的窄 gate;
- 一名分享者、八名异构观看者连续 30 分钟,同时记录较低人数基线、实际上行、编码负载和首帧时间。真实 1:8 未完成前不得把默认上限写成性能承诺。
- 真实桌面 Chrome 对 balanced 与 clarity 做控制传播和连接身份 smoke;该项只证明直播中应用与不重建,不代替清晰度、负载或公网矩阵。
@@ -411,9 +366,7 @@ hop 按[低服务器成本媒体路由](./research/low-server-media-routes.md)
5. 完成 automatic peer/SFU controller,并让 room `1` 仅作为历史 smoke fixture。
6. 可选 selected-edge TURN tuple、revision-bound one-use grant 与 parent/child rebuild 已在 source 和 production 配置闭环;下一功能验收只需一条 forced relay 媒体 smoke,完整网络/资源 benchmark 后置。ordinary STUN-only、SFU/UDP、coturn rollout 仍保持独立边界。
7. 在媒体主线阶段验收后,用一个原子 PR 迁移 site access、`screener-v2`、room-scoped Viewer grant 和 SQLite v2;同一部署切换客户端/服务端/环境和数据库备份,随后删除 v1 配置、wire 与测试。用户名/roster 保持独立。
8. P1 并行完成 Windows native fixed-`HIGH` staged gate 与 Android 14+ video-only sender 首片;二者共享现有控制协议但不预建跨平台 framework,也互不阻断。
9. Android 首片只接 peer-assisted direct children;SFU publication 明确有界失败。代码闭环后最多做一次单设备/单 Viewer smoke,性能、网络和设备矩阵后置。
10. iOS 27 ScreenCaptureKit final 后再决定是否运行独立 P2 spike;不实现 ReplayKit compatibility path。Viewer-to-TV 只采用已验证的平台本地输出,不改变媒体路由。
8. Windows Native fixed-`HIGH` 保持可选且不改变 Web 默认;移动端保持 Viewer leaf。Viewer-to-TV 只采用已验证的平台本地输出,不改变媒体路由。
任何一步只实现当前消费者需要的接口。不得为未来账号、第三种媒体拓扑或原生 sender 预建抽象层。
+3 -8
View File
@@ -42,9 +42,6 @@
- [ ] 私密房间双入口: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 指定应用播放音频首片:音频默认关闭,只有用户在系统画面 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。只有稳定收益且捕获、编码和远端画面不受影响时,才允许直播开始后默认显示静止缩略图或隐藏动态预览并提供按需展开;未测前不宣称收益。
- [ ] Peer-assisted 媒体实验:分享端最多两个 child、每名 viewer 最多一个 child;信令服务只按深度和服务端加入序号确定性地形成两条 sticky 平衡链,不加入综合评分或主动重平衡。每个 viewer 将收到的标准 remote track 通过新的 `RTCPeerConnection` 转发,因此会逐跳解码和重编码;必须测量而不能称为 shared encode。服务端只在受房间数限制的内存状态中保存当前严格校验的质量设置,不写入 SQLite;host 鉴权后先重报设置,在线 viewers 接收更新,后加入者从鉴权快照取得当前值。该功能在 ADR-0004 的实验门槛通过并由后续 ADR 正式接受前不得成为产品路径。
@@ -63,7 +60,6 @@
### P2 - 可选功能
- [x] 原生 Windows 捕获辅助程序:显式 `native-window-h264` 模式使用同一 bound window target 的 WGC/D3D11 画面与 WASAPI process-tree PCM,并用 adapter-bound、hardware-only Media Foundation H.264 `42c01f` 直接进入现有 Go fanout;浏览器 VP8/H.264 是显式替代选项,VP8 默认,不允许运行时静默改用软件编码、显示器画面或系统混音。一名普通 Chrome Viewer 已 decoded/rendered 203 帧并收到 500 Opus packets,同一 helper PID/adapter 的 `VideoEncode` 与 LUID 匹配。
- [ ] iOS 原生分享端有界实验:当前不实现 ReplayKit/broadcast-extension 兼容栈。待 iOS 27 与对应 ScreenCaptureKit SDK 脱离 beta 并稳定后,只评估 ScreenCaptureKit system picker + fixed-`HIGH` + 一至两名 Web Viewer 的窄路径;VideoToolbox 必须要求并读回硬件编码,无法证明时 fail closed,不增加 ReplayKit fallback、`LOW`、AV1 或新拓扑。
- [ ] Viewer 向电视输出:系统级屏幕镜像现在可直接使用;产品只复用平台 AirPlay/Cast,不自造投屏协议。截至 2026-08-21,Chromium 的 Remote Playback URL 路径和 WebKit 明确拒绝 wireless target 的 live `MediaStream` player 均未给当前 WebRTC `srcObject` 提供可用契约,因此不显示伪可用入口。只在浏览器实现明确支持并由实体 Apple TV 或 Chromecast/Google TV 证明当前 live 画面与音频后,才按通过的平台加入 Viewer 本地图标按钮。投屏不能改变 room、TURN/SFU/peer route、Host fanout 或服务端媒体分发;Google Cast receiver 需要可获取的媒体 URL,当前 P2P `MediaStream` 不满足该输入,因此不加入 Cast SDK/receiver。
- [ ] 多地区 SFU-root 调度,以及可选 TURN 兼容层的按地区、运营商可达率。
@@ -77,7 +73,8 @@
- 隐私:P2P 会向房间内好友暴露网络地址,首版威胁模型仅覆盖可信熟人;若要求隐藏 IP,必须切换为服务器转发。
- 可维护性:代码、需求、调研、项目记忆、`AGENTS.md` 和 `.codex/` 均在 Git 中跟踪;开发配置不得依赖单一操作系统的绝对路径。
- 迁移完整性:同一 release 的客户端、服务端和部署配置原子迁移;canary 结束后删除被替代的配置、wire、parser、双写与测试,不为无当前消费者的旧版本保留兼容层或第二套架构。Git 历史承担旧实现追溯。
- 兼容性:分享端首个目标矩阵为 Windows 10/11 的当前 Chrome/Edge;普通移动浏览器当前只属于 Viewer 矩阵。截止 2026-08-19,Chrome Android、Firefox Android 和 Safari iOS 都没有可靠的 `getDisplayMedia()`;Web Host 必须用运行时能力检测,API 缺失时明确显示不支持,API 存在但调用拒绝时区分用户拒绝/取消与 capture failure,不能用 UA/设备型号伪造兼容。观看端仍必须覆盖当前 Android Chrome 和 iOS Safari,并实测自动播放限制、横竖屏、锁屏恢复与移动网络切换。Peer-assisted 实验只允许能力完整的前台桌面 Chrome/Edge 作为 relay,手机必须保持 leaf。
- Web 优先:每项用户能力先使用普通浏览器的标准 API 和正常权限流程;只有当前目标浏览器存在已证实的能力缺口且该功能仍属当前范围时,才允许独立、可选的 Sender。不得为假设中的未来能力预建客户端、兼容层或第二套会话架构。
- 兼容性:分享端首个目标矩阵为 Windows 10/11 的当前 Chrome/Edge;普通移动浏览器当前只属于 Viewer 矩阵。截止 2026-08-21,MDN Browser Compatibility Data 仍将 Chrome Android、Firefox Android 和 Safari iOS 的 `getDisplayMedia()` 标为不支持;Web Host 必须用运行时能力检测,API 缺失时明确显示不支持,API 存在但调用拒绝时区分用户拒绝/取消与 capture failure,不能用 UA/设备型号伪造兼容。观看端仍必须覆盖当前 Android Chrome 和 iOS Safari,并实测自动播放限制、横竖屏、锁屏恢复与移动网络切换。Peer-assisted 实验只允许能力完整的前台桌面 Chrome/Edge 作为 relay,手机必须保持 leaf。
## 边界与约束
@@ -126,8 +123,6 @@
- Given 观看者调节音量或静音,When 媒体切流、换父或恢复连接,Then 当前页面的本地播放音量保持不变,其他观看者和发送端不受影响;刷新页面后恢复默认音量,不产生信令、持久化或媒体重协商。
- 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 或转码服务器。
### 一对多与带宽保护
@@ -173,7 +168,7 @@
- 哪些分享端硬件、质量档位和网络条件能稳定承载默认 1:8 工作负载,以及实测后是否需要降低默认值。
- 项目许可证及是否计划闭源商用。
- 首批用户主要位于香港、内地还是跨地区;该信息决定 TURN 部署位置和测试矩阵。
- iOS Safari 与 Android Chrome 的最低版本及是否要求平板适配。
- iOS Safari 与 Android Chrome 作为 Viewer 的最低版本及是否要求平板适配。
基础 WebRTC 证据与难度评估见 `docs/research/webrtc-p2p-screen-sharing.md`;peer-assisted、shared encode 和浏览器限制见 `docs/research/peer-assisted-media.md`。