diff --git a/.github/workflows/android-sender.yml b/.github/workflows/android-sender.yml
deleted file mode 100644
index e7ff5c7..0000000
--- a/.github/workflows/android-sender.yml
+++ /dev/null
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
index e77ed8d..71d70af 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/README.md b/README.md
index 545bdf7..ff87815 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/android/sender/.gitignore b/android/sender/.gitignore
deleted file mode 100644
index 208cf4f..0000000
--- a/android/sender/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-.gradle/
-**/build/
-local.properties
diff --git a/android/sender/README.md b/android/sender/README.md
deleted file mode 100644
index a6a3216..0000000
--- a/android/sender/README.md
+++ /dev/null
@@ -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.
diff --git a/android/sender/app/build.gradle.kts b/android/sender/app/build.gradle.kts
deleted file mode 100644
index da26a2f..0000000
--- a/android/sender/app/build.gradle.kts
+++ /dev/null
@@ -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")
-}
diff --git a/android/sender/app/src/main/AndroidManifest.xml b/android/sender/app/src/main/AndroidManifest.xml
deleted file mode 100644
index a7285a7..0000000
--- a/android/sender/app/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,37 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/sender/app/src/main/java/icu/bonfire/screener/sender/CaptureEngine.kt b/android/sender/app/src/main/java/icu/bonfire/screener/sender/CaptureEngine.kt
deleted file mode 100644
index 047026a..0000000
--- a/android/sender/app/src/main/java/icu/bonfire/screener/sender/CaptureEngine.kt
+++ /dev/null
@@ -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 =
- 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
- }
- }
- }
- }
-}
diff --git a/android/sender/app/src/main/java/icu/bonfire/screener/sender/HostPeer.kt b/android/sender/app/src/main/java/icu/bonfire/screener/sender/HostPeer.kt
deleted file mode 100644
index 612cf4e..0000000
--- a/android/sender/app/src/main/java/icu/bonfire/screener/sender/HostPeer.kt
+++ /dev/null
@@ -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()
- @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) = 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) = 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
- }
-}
diff --git a/android/sender/app/src/main/java/icu/bonfire/screener/sender/MainActivity.kt b/android/sender/app/src/main/java/icu/bonfire/screener/sender/MainActivity.kt
deleted file mode 100644
index c490a3e..0000000
--- a/android/sender/app/src/main/java/icu/bonfire/screener/sender/MainActivity.kt
+++ /dev/null
@@ -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,
- 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 {
- 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
- }
-}
diff --git a/android/sender/app/src/main/java/icu/bonfire/screener/sender/PlaybackAudioCapture.kt b/android/sender/app/src/main/java/icu/bonfire/screener/sender/PlaybackAudioCapture.kt
deleted file mode 100644
index 62eaf7e..0000000
--- a/android/sender/app/src/main/java/icu/bonfire/screener/sender/PlaybackAudioCapture.kt
+++ /dev/null
@@ -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()
- 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
- }
-}
diff --git a/android/sender/app/src/main/java/icu/bonfire/screener/sender/ProjectionService.kt b/android/sender/app/src/main/java/icu/bonfire/screener/sender/ProjectionService.kt
deleted file mode 100644
index b8f6935..0000000
--- a/android/sender/app/src/main/java/icu/bonfire/screener/sender/ProjectionService.kt
+++ /dev/null
@@ -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 }
-}
diff --git a/android/sender/app/src/main/java/icu/bonfire/screener/sender/RemoteSession.kt b/android/sender/app/src/main/java/icu/bonfire/screener/sender/RemoteSession.kt
deleted file mode 100644
index 3b3eda9..0000000
--- a/android/sender/app/src/main/java/icu/bonfire/screener/sender/RemoteSession.kt
+++ /dev/null
@@ -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()
- private val stopped = AtomicBoolean()
- private val closed = AtomicBoolean()
- private val activeCall = AtomicReference()
- @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) {
- 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) {
- 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 = 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()
- override fun saveFromResponse(url: HttpUrl, values: List) = 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 = 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()
- }
-}
diff --git a/android/sender/app/src/main/res/layout/activity_main.xml b/android/sender/app/src/main/res/layout/activity_main.xml
deleted file mode 100644
index 68f64d2..0000000
--- a/android/sender/app/src/main/res/layout/activity_main.xml
+++ /dev/null
@@ -1,100 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/sender/app/src/main/res/values/styles.xml b/android/sender/app/src/main/res/values/styles.xml
deleted file mode 100644
index de1a2f5..0000000
--- a/android/sender/app/src/main/res/values/styles.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
diff --git a/android/sender/build.gradle.kts b/android/sender/build.gradle.kts
deleted file mode 100644
index 4370ea9..0000000
--- a/android/sender/build.gradle.kts
+++ /dev/null
@@ -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
-}
diff --git a/android/sender/gradle.properties b/android/sender/gradle.properties
deleted file mode 100644
index a1db1e5..0000000
--- a/android/sender/gradle.properties
+++ /dev/null
@@ -1,4 +0,0 @@
-org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
-org.gradle.configuration-cache=true
-android.useAndroidX=true
-kotlin.code.style=official
diff --git a/android/sender/gradle/wrapper/gradle-wrapper.jar b/android/sender/gradle/wrapper/gradle-wrapper.jar
deleted file mode 100644
index e708b1c..0000000
Binary files a/android/sender/gradle/wrapper/gradle-wrapper.jar and /dev/null differ
diff --git a/android/sender/gradle/wrapper/gradle-wrapper.properties b/android/sender/gradle/wrapper/gradle-wrapper.properties
deleted file mode 100644
index e2847c8..0000000
--- a/android/sender/gradle/wrapper/gradle-wrapper.properties
+++ /dev/null
@@ -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
diff --git a/android/sender/gradlew b/android/sender/gradlew
deleted file mode 100755
index 4f906e0..0000000
--- a/android/sender/gradlew
+++ /dev/null
@@ -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" "$@"
diff --git a/android/sender/gradlew.bat b/android/sender/gradlew.bat
deleted file mode 100644
index 107acd3..0000000
--- a/android/sender/gradlew.bat
+++ /dev/null
@@ -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
diff --git a/android/sender/protocol/build.gradle.kts b/android/sender/protocol/build.gradle.kts
deleted file mode 100644
index 8d2ad31..0000000
--- a/android/sender/protocol/build.gradle.kts
+++ /dev/null
@@ -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()
-}
diff --git a/android/sender/protocol/src/main/kotlin/icu/bonfire/screener/protocol/HostRoute.kt b/android/sender/protocol/src/main/kotlin/icu/bonfire/screener/protocol/HostRoute.kt
deleted file mode 100644
index 71413cf..0000000
--- a/android/sender/protocol/src/main/kotlin/icu/bonfire/screener/protocol/HostRoute.kt
+++ /dev/null
@@ -1,80 +0,0 @@
-package icu.bonfire.screener.protocol
-
-sealed interface RouteAction {
- data class ReconcileChildren(val peerIds: List) : 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 {
- 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 {
- 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 {
- 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 = 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))
- }
- }
-}
diff --git a/android/sender/protocol/src/main/kotlin/icu/bonfire/screener/protocol/Wire.kt b/android/sender/protocol/src/main/kotlin/icu/bonfire/screener/protocol/Wire.kt
deleted file mode 100644
index 3d47dc5..0000000
--- a/android/sender/protocol/src/main/kotlin/icu/bonfire/screener/protocol/Wire.kt
+++ /dev/null
@@ -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)
-data class IceConfig(val iceServers: List)
-
-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,
- 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,
- 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, optional: Set = 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 {
- 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)
diff --git a/android/sender/protocol/src/test/kotlin/icu/bonfire/screener/protocol/HostRouteTest.kt b/android/sender/protocol/src/test/kotlin/icu/bonfire/screener/protocol/HostRouteTest.kt
deleted file mode 100644
index 6a19906..0000000
--- a/android/sender/protocol/src/test/kotlin/icu/bonfire/screener/protocol/HostRouteTest.kt
+++ /dev/null
@@ -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(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 {
- 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(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()
-}
diff --git a/android/sender/settings.gradle.kts b/android/sender/settings.gradle.kts
deleted file mode 100644
index 18b1ad7..0000000
--- a/android/sender/settings.gradle.kts
+++ /dev/null
@@ -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")
-}
diff --git a/docs/README.md b/docs/README.md
index 88cccb8..50bcb72 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -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.
diff --git a/docs/adr/0008-window-scoped-audio-capture.md b/docs/adr/0008-window-scoped-audio-capture.md
index f52f9d7..61163c5 100644
--- a/docs/adr/0008-window-scoped-audio-capture.md
+++ b/docs/adr/0008-window-scoped-audio-capture.md
@@ -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
diff --git a/docs/project-memory.md b/docs/project-memory.md
index bcfbceb..1102112 100644
--- a/docs/project-memory.md
+++ b/docs/project-memory.md
@@ -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.
diff --git a/docs/research/browser-screen-audio-quality.md b/docs/research/browser-screen-audio-quality.md
index 6433214..2c41566 100644
--- a/docs/research/browser-screen-audio-quality.md
+++ b/docs/research/browser-screen-audio-quality.md
@@ -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)
diff --git a/docs/research/native-shared-encode-sender.md b/docs/research/native-shared-encode-sender.md
index 5f1771e..bdc12ae 100644
--- a/docs/research/native-shared-encode-sender.md
+++ b/docs/research/native-shared-encode-sender.md
@@ -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)
diff --git a/docs/research/webrtc-p2p-screen-sharing.md b/docs/research/webrtc-p2p-screen-sharing.md
index 131a6e2..199068d 100644
--- a/docs/research/webrtc-p2p-screen-sharing.md
+++ b/docs/research/webrtc-p2p-screen-sharing.md
@@ -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。
来源:
diff --git a/docs/status.md b/docs/status.md
index 2499c88..8fbe390 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -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.
diff --git a/docs/方案设计.md b/docs/方案设计.md
index baf162c..2693a74 100644
--- a/docs/方案设计.md
+++ b/docs/方案设计.md
@@ -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 预建抽象层。
diff --git a/docs/需求理解.md b/docs/需求理解.md
index 4181050..9271b2f 100644
--- a/docs/需求理解.md
+++ b/docs/需求理解.md
@@ -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`。