diff --git a/.github/workflows/android-sender.yml b/.github/workflows/android-sender.yml
new file mode 100644
index 0000000..e7ff5c7
--- /dev/null
+++ b/.github/workflows/android-sender.yml
@@ -0,0 +1,34 @@
+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/android/sender/.gitignore b/android/sender/.gitignore
new file mode 100644
index 0000000..208cf4f
--- /dev/null
+++ b/android/sender/.gitignore
@@ -0,0 +1,3 @@
+.gradle/
+**/build/
+local.properties
diff --git a/android/sender/README.md b/android/sender/README.md
new file mode 100644
index 0000000..cf9a878
--- /dev/null
+++ b/android/sender/README.md
@@ -0,0 +1,38 @@
+# Android Screen Sender
+
+This source-only P1 slice lets an Android 14/API 34+ device publish a 720p30
+screen track to at most two Web Viewers assigned as direct children by the
+existing `screener-v2` controller.
+
+## 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
+choose a screen or app from Android's system picker. The app keeps credentials
+and room material in memory only. The displayed private invite can be opened by
+an unchanged Web Viewer.
+
+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
+
+- Video only: no playback-capture audio or A/V synchronization yet.
+- 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
new file mode 100644
index 0000000..da26a2f
--- /dev/null
+++ b/android/sender/app/build.gradle.kts
@@ -0,0 +1,31 @@
+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
new file mode 100644
index 0000000..4d8ca90
--- /dev/null
+++ b/android/sender/app/src/main/AndroidManifest.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
new file mode 100644
index 0000000..59bf493
--- /dev/null
+++ b/android/sender/app/src/main/java/icu/bonfire/screener/sender/CaptureEngine.kt
@@ -0,0 +1,108 @@
+package icu.bonfire.screener.sender
+
+import android.content.Context
+import android.content.Intent
+import android.media.projection.MediaProjection
+import org.webrtc.DefaultVideoDecoderFactory
+import org.webrtc.EglBase
+import org.webrtc.HardwareVideoEncoderFactory
+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 java.util.concurrent.atomic.AtomicBoolean
+
+class CaptureEngine private constructor(
+ context: Context,
+ permissionData: Intent,
+ onProjectionStopped: () -> Unit,
+) : AutoCloseable {
+ private lateinit var egl: EglBase
+ lateinit var factory: PeerConnectionFactory
+ private set
+ lateinit var track: VideoTrack
+ private set
+ private lateinit var capturer: ScreenCapturerAndroid
+ private lateinit var source: VideoSource
+ private lateinit var helper: SurfaceTextureHelper
+ 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")
+ factory = PeerConnectionFactory.builder()
+ .setVideoEncoderFactory(encoderFactory)
+ .setVideoDecoderFactory(DefaultVideoDecoderFactory(egl.eglBaseContext))
+ .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)
+ } 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() }
+ if (::source.isInitialized) runCatching { source.dispose() }
+ if (::helper.isInitialized) runCatching { helper.dispose() }
+ if (::factory.isInitialized) runCatching { factory.dispose() }
+ 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, onProjectionStopped: () -> Unit) =
+ CaptureEngine(context, permissionData, onProjectionStopped)
+
+ 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
new file mode 100644
index 0000000..2064b0e
--- /dev/null
+++ b/android/sender/app/src/main/java/icu/bonfire/screener/sender/HostPeer.kt
@@ -0,0 +1,158 @@
+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.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,
+ 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")
+ }
+ } 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
new file mode 100644
index 0000000..64a3ca4
--- /dev/null
+++ b/android/sender/app/src/main/java/icu/bonfire/screener/sender/MainActivity.kt
@@ -0,0 +1,105 @@
+package icu.bonfire.screener.sender
+
+import android.app.Activity
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.media.projection.MediaProjectionManager
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.os.ResultReceiver
+import android.widget.Button
+import android.widget.EditText
+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 statusText: TextView
+ private lateinit var inviteText: TextView
+ private var pendingServer = ""
+ private var pendingPassword = ""
+
+ 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)
+ statusText = findViewById(R.id.status_text)
+ inviteText = findViewById(R.id.invite_text)
+
+ startButton.setOnClickListener { requestProjection() }
+ stopButton.setOnClickListener {
+ startService(Intent(this, ProjectionService::class.java).setAction(ProjectionService.ACTION_STOP))
+ clearInvite()
+ statusText.text = "Stopping"
+ }
+ }
+
+ private fun requestProjection() {
+ pendingServer = serverUrl.text.toString().trim().trimEnd('/')
+ pendingPassword = sitePassword.text.toString()
+ clearInvite()
+ if (!pendingServer.startsWith("https://")) {
+ statusText.text = "An HTTPS server URL is required"
+ return
+ }
+ val manager = getSystemService(MediaProjectionManager::class.java)
+ startActivityForResult(manager.createScreenCaptureIntent(), REQUEST_PROJECTION)
+ }
+
+ @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)
+ }
+ startForegroundService(service)
+ sitePassword.text.clear()
+ pendingPassword = ""
+ }
+
+ private fun clearInvite() {
+ inviteText.text = ""
+ inviteText.setOnClickListener(null)
+ }
+
+ companion object {
+ private const val REQUEST_PROJECTION = 40
+ }
+}
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
new file mode 100644
index 0000000..ea70906
--- /dev/null
+++ b/android/sender/app/src/main/java/icu/bonfire/screener/sender/ProjectionService.kt
@@ -0,0 +1,164 @@
+package icu.bonfire.screener.sender
+
+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.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.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)
+ startForeground(
+ NOTIFICATION_ID,
+ notification("Starting screen sharing"),
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION,
+ )
+ val server = intent.getStringExtra(EXTRA_SERVER_URL)
+ val password = intent.getStringExtra(EXTRA_SITE_PASSWORD)
+ val permission = intent.getParcelableExtra(EXTRA_PERMISSION_DATA, Intent::class.java)
+ if (server == null || password == null || permission == null) {
+ finish("Screen sharing could not start")
+ return START_NOT_STICKY
+ }
+ sendResult(RESULT_ACTIVE, "Starting")
+ schedule {
+ try {
+ val engine = CaptureEngine.create(applicationContext, permission) {
+ finish("The system stopped screen sharing")
+ }
+ if (state.get() != State.STARTING) {
+ engine.close()
+ return@schedule
+ }
+ capture = engine
+ val remote = RemoteSession(server, password, engine.factory, engine.track, worker, object : RemoteSession.Events {
+ override fun onRoom(inviteUrl: String) {
+ if (state.get() == State.STOPPING) return
+ sendResult(RESULT_ACTIVE, "Room ready; hardware codec requested", inviteUrl)
+ getSystemService(NotificationManager::class.java)
+ .notify(NOTIFICATION_ID, notification("Screen sharing is ready"))
+ }
+
+ 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 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
new file mode 100644
index 0000000..1f68a9f
--- /dev/null
+++ b/android/sender/app/src/main/java/icu/bonfire/screener/sender/RemoteSession.kt
@@ -0,0 +1,266 @@
+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.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 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()
+ socket?.send(Wire.abandonRoom())
+ socket?.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()
+ socket?.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, 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
new file mode 100644
index 0000000..fe1b2a3
--- /dev/null
+++ b/android/sender/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/sender/app/src/main/res/values/styles.xml b/android/sender/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..de1a2f5
--- /dev/null
+++ b/android/sender/app/src/main/res/values/styles.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/android/sender/build.gradle.kts b/android/sender/build.gradle.kts
new file mode 100644
index 0000000..4370ea9
--- /dev/null
+++ b/android/sender/build.gradle.kts
@@ -0,0 +1,5 @@
+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
new file mode 100644
index 0000000..a1db1e5
--- /dev/null
+++ b/android/sender/gradle.properties
@@ -0,0 +1,4 @@
+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
new file mode 100644
index 0000000..e708b1c
Binary files /dev/null and b/android/sender/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/sender/gradle/wrapper/gradle-wrapper.properties b/android/sender/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e2847c8
--- /dev/null
+++ b/android/sender/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+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
new file mode 100755
index 0000000..4f906e0
--- /dev/null
+++ b/android/sender/gradlew
@@ -0,0 +1,185 @@
+#!/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
new file mode 100644
index 0000000..107acd3
--- /dev/null
+++ b/android/sender/gradlew.bat
@@ -0,0 +1,89 @@
+@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
new file mode 100644
index 0000000..8d2ad31
--- /dev/null
+++ b/android/sender/protocol/build.gradle.kts
@@ -0,0 +1,16 @@
+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
new file mode 100644
index 0000000..71413cf
--- /dev/null
+++ b/android/sender/protocol/src/main/kotlin/icu/bonfire/screener/protocol/HostRoute.kt
@@ -0,0 +1,80 @@
+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
new file mode 100644
index 0000000..3d47dc5
--- /dev/null
+++ b/android/sender/protocol/src/main/kotlin/icu/bonfire/screener/protocol/Wire.kt
@@ -0,0 +1,485 @@
+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
new file mode 100644
index 0000000..6a19906
--- /dev/null
+++ b/android/sender/protocol/src/test/kotlin/icu/bonfire/screener/protocol/HostRouteTest.kt
@@ -0,0 +1,109 @@
+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
new file mode 100644
index 0000000..18b1ad7
--- /dev/null
+++ b/android/sender/settings.gradle.kts
@@ -0,0 +1,21 @@
+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 50bcb72..88cccb8 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -12,6 +12,7 @@
- [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/project-memory.md b/docs/project-memory.md
index 32d2a4b..c66939b 100644
--- a/docs/project-memory.md
+++ b/docs/project-memory.md
@@ -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 remains no-go. VP8 is default; H.264 loopback rendered 298/299. Windows x64 evaluation packaging is source-complete; downloaded use, Pion timing, hardware, multi-viewer/endurance/public proof remain open.
-- Mobile Web Host is unsupported; feature-detect and fail clearly. Mobile Viewer stays leaf-only. After Windows native, gate Android 14+; iOS waits for stable iOS 27 ScreenCaptureKit.
+- Mobile Web Host is unsupported. Android 14+ P1 uses MediaProjection and hardware-only libwebrtc children; SFU fails boundedly, while audio and rotation remain later. iOS waits for stable ScreenCaptureKit. TV output: P2, local-only; do not change routing or invent Cast transport.
- ADR-0005 accepts direct/peer UDP, bounded SFU roots, then optional selected-edge TURN. Production now enables the one-shot selected-edge tuple after SFU; participant-wide TURN remains rejected and ordinary peer ICE stays STUN-only. No real TURN/SFU media canary has yet been run.
- 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.
diff --git a/docs/research/native-shared-encode-sender.md b/docs/research/native-shared-encode-sender.md
index 2dcf4d5..8260abc 100644
--- a/docs/research/native-shared-encode-sender.md
+++ b/docs/research/native-shared-encode-sender.md
@@ -132,12 +132,12 @@ code or user data:
- Oopz `agora_rtc_sdk.dll`: SHA-256
`66B8B34A57BA0EE9DCC6C516E2CC20F5F1EB5D35ED7150FDF37201A46BC8D4A0`.
-## Deferred Mobile Sender Boundary
+## Mobile Sender Boundary
-This section records later capability work; it does not broaden ADR-0006's
-Windows scope or authorize mobile runtime code. Priority remains the current
-deployment and staged Windows fixed-`HIGH` gate. The first mobile candidate is a
-separate P2 Android 14/API 34+ spike only after those P1 gates complete.
+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
@@ -151,21 +151,46 @@ experience and cannot be made invisible.
The bounded Android data path is:
```text
-MediaProjection Surface -> one hardware MediaCodec fixed-HIGH encoder
- -> existing site access / room / WSS / standard WebRTC
- -> one or two unmodified Web viewers
+MediaProjection -> libwebrtc ScreenCapturerAndroid
+ -> HardwareVideoEncoderFactory (VP8/H.264 only, no software fallback)
+ -> existing site access / room / WSS / peer-assisted direct assignment
+ -> at most two unmodified Web viewers
```
-It adds no mobile-only topology, `LOW`, AV1, codec ladder, third downstream edge,
-or software fallback. `MediaCodecInfo.isHardwareAccelerated()` is metadata
-provided by the manufacturer that Android explicitly says cannot be tested for correctness,
-so it is only an admission signal. The spike must also prove the selected named
-encoder, supported fixed format, interval encode cost, CPU/GPU, temperature and
-throttling, power, queue bounds, actual bitrate/quality, and game impact on real
-devices. Missing or failed hardware evidence is no-go; the implementation must
-not silently select a software codec. Network changes reuse the existing
-per-edge WebRTC recovery contract rather than restarting capture or creating a
-new transport.
+The slice uses the public libwebrtc Android API rather than hand-building capture,
+RTP, pacing, congestion control, or SRTP. `DefaultVideoEncoderFactory` is excluded
+because it composes hardware with software fallback; a filtered
+`HardwareVideoEncoderFactory` exposes only VP8/H.264. It adds no mobile-only
+topology, audio, `LOW`, AV1, codec ladder, third downstream edge, SFU publisher,
+selected-edge TURN, or software fallback. Direct children are authoritative by
+route revision and connection generation. A requested SFU publication returns
+one bounded `route-failed`; it is not reported as supported.
+
+The first functional target is 720p30 at a 3 Mbps ceiling. Codec name and Android
+hardware metadata are admission signals, not performance proof. Once the source
+slice closes, one device/one Viewer smoke checks consent, selected encoder, first
+frame and cleanup. Rotation hot-resize, application audio, network reselection,
+thermal/power and multi-Viewer matrices follow later; they do not block source
+landing and cannot silently enable software encoding. Network changes reuse the
+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. OkHttp and
+kotlinx.serialization are Apache-2.0. The slice copies no implementation from
+LiveKit or another sender. A future distributed APK must retain the applicable
+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
@@ -566,9 +591,20 @@ SFU/UDP fails and needs its own bounded transport gate.
- [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 `MediaCodecInfo`](https://developer.android.com/reference/android/media/MediaCodecInfo)
- [Android `MediaCodecList`](https://developer.android.com/reference/android/media/MediaCodecList)
+- [libwebrtc `ScreenCapturerAndroid`](https://chromium.googlesource.com/external/webrtc/+/HEAD/sdk/android/api/org/webrtc/ScreenCapturerAndroid.java)
+- [libwebrtc `DefaultVideoEncoderFactory`](https://chromium.googlesource.com/external/webrtc/+/HEAD/sdk/android/api/org/webrtc/DefaultVideoEncoderFactory.java)
+- [libwebrtc `HardwareVideoEncoderFactory`](https://chromium.googlesource.com/external/webrtc/+/HEAD/sdk/android/api/org/webrtc/HardwareVideoEncoderFactory.java)
+- [`webrtc-sdk/android`](https://github.com/webrtc-sdk/android)
+- [`webrtc-sdk/android` license](https://github.com/webrtc-sdk/android/blob/main/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)
diff --git a/docs/research/webrtc-p2p-screen-sharing.md b/docs/research/webrtc-p2p-screen-sharing.md
index 0d93cf0..b26c235 100644
--- a/docs/research/webrtc-p2p-screen-sharing.md
+++ b/docs/research/webrtc-p2p-screen-sharing.md
@@ -1,7 +1,7 @@
# P2P 优先游戏屏幕共享调研与可行性评估
- 调研日期:2026-08-18
-- 移动端采集能力复核:2026-08-19
+- 移动端采集与 Viewer 投屏能力复核:2026-08-21
- 目标场景:一名玩家向少量熟人私密分享,观看者可用手机/桌面浏览器加入,低延迟,尽量不消耗媒体服务器带宽
- 结论状态:本文记录已部署 PoC 的 P2P/coturn 基线。ADR-0005 与[低服务器成本媒体路由](./low-server-media-routes.md)已取代本文早期“每条 peer edge 必带 TURN”的旗舰建议;生产后续移除了 room `1` 边界,ordinary ICE 仍为 STUN-only,selected-edge TURN 已配置但尚未完成真实媒体验收
@@ -176,7 +176,7 @@ await sender.setParameters(parameters);
### 移动浏览器分享端边界
-截至 2026-08-19,普通手机浏览器不能作为可靠的 Screener Host。当前
+截至 2026-08-21,普通手机浏览器不能作为可靠的 Screener Host。当前
MDN Browser Compatibility Data 把 `getDisplayMedia()` 标为 Chrome Android、
Firefox Android 和 Safari iOS 均不支持;Chrome Android 72--88 与 Firefox
Android 66--79 曾暴露方法,但调用恒定以 `NotAllowedError` 失败。屏幕音频在
@@ -196,6 +196,29 @@ screen track 或加入私有媒体协议。
- [MDN `getDisplayMedia()`](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia)
- [W3C Screen Capture](https://w3c.github.io/mediacapture-screen-share/)
+### Viewer 向电视输出边界
+
+Remote Playback API 当前仍是 MDN `Limited availability`,不能作为跨浏览器基线。
+W3C 规范还明确允许 UA 在只支持 media flinging、而媒体源不是可传给远端设备的 URL
+时返回 `NotSupportedError`。Safari 官方只保证 `HTMLMediaElement` 原生 AirPlay picker;
+这些能力都不证明 live WebRTC `srcObject` 可在任意电视目标上远端播放。产品只能在
+运行时检测 `HTMLMediaElement.remote`,并在真实设备通过后显示入口;失败时保留本机
+播放和现在就能使用的系统级屏幕镜像。
+
+Google Cast `MediaInfo` 的 `contentUrl` 会被用作 media URL,缺失时 `contentId` 自身会
+被当作 media URL;当前 P2P Viewer 持有的是进程内 `MediaStream`,没有可供电视 fetch
+的 URL。为它新增转码、
+HLS/Web Receiver 或私有协议会改变服务器媒体成本和安全边界,当前不做。投屏只属于
+Viewer 本地播放输出;无论是否启用,现有 upstream PeerConnection、TURN/SFU/peer
+route、Host fanout 和其他 Viewer 均不变。
+
+来源(访问于 2026-08-21):
+
+- [MDN Remote Playback API](https://developer.mozilla.org/en-US/docs/Web/API/Remote_Playback_API)
+- [W3C Remote Playback API](https://www.w3.org/TR/remote-playback/)
+- [Apple: Adding an AirPlay button to Safari media controls](https://developer.apple.com/documentation/webkitjs/adding_an_airplay_button_to_your_safari_media_controls)
+- [Google Cast `chrome.cast.media.MediaInfo`](https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media.MediaInfo)
+
### Electron 分享端
Electron 可以固定 Chromium 版本,枚举屏幕/窗口,改善选源、热键和分享状态 UX,并使用 loopback 系统音频。它底层仍是 Chromium,单靠封装不会自动获得 Discord 级捕获质量。
diff --git a/docs/status.md b/docs/status.md
index 60a1821..c186e96 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -41,7 +41,7 @@ Flagship; parallel; min run/smoke/rollback; benchmark later. Active UI/config/lo
## Unverified Boundaries
- The final deployment proves process/config health, not a retained SFU frame, retry/failback, selected UDP pair, admission rescue, edge cap, quality, or resource deltas; game-share load/blur remains unclassified.
-- Android Chrome/iOS Safari Viewer leaves remain unverified and conservatively leaf-only. Mobile Web Host is unsupported; native senders are planned only.
+- Mobile Web Host unsupported; Viewer leaf-only. Android 14+ direct-child video source passes six local protocol tests and `assembleDebug`; its GitHub compile gate is account-billing blocked, while device media, SFU, audio, and rotation proof remain open. iOS is deferred; TV output is local-only P2.
- Silent partitions can wait 30 to 60 seconds for heartbeat detection before the default 5-second grace; this remains unverified.
- The former release reportedly sustained bandwidth-limited blur on a capable LAN; Host refresh recovered while Viewer refresh did not. The new deployment has not yet reproduced or cleared it.
- ADR-0006 has one Viewer proof; two-edge/FIFO, hardware, endurance, public/downloaded-package, and browser-diversity proof remain open.
diff --git a/docs/方案设计.md b/docs/方案设计.md
index d794b43..e8696cb 100644
--- a/docs/方案设计.md
+++ b/docs/方案设计.md
@@ -208,15 +208,22 @@ iOS 都没有可靠的 `getDisplayMedia()`;Web Host 入口必须检查运行
或设备型号放行。该边界不影响普通手机浏览器观看;手机仍是 leaf-only,并继续通过真机矩阵。浏览器版本证据由
[WebRTC 调研](./research/webrtc-p2p-screen-sharing.md)持有。
-优先级保持 P1 Windows native sender 与当前 deployment,移动 Host 不得抢跑。
-之后的 P2 Android 14/API 34+ 整屏 spike 只做一条窄纵向链路:系统
-`MediaProjection` selection -> `mediaProjection` foreground service -> 一个经筛选
-的硬件 `MediaCodec` fixed-`HIGH` encoder -> 现有 site access/room/WSS/标准
-WebRTC -> 一至两名未修改 Web Viewer。它不增加移动端专属信令、拓扑、`LOW`、
-AV1、codec ladder 或第三条 Host 下游边。`MediaCodecInfo.isHardwareAccelerated()`
-是厂商声明而非充分证明,所以还必须用真实设备持续测量 codec、encode time、
-CPU/GPU、温度/降频、功耗、游戏 FPS、队列、实际码率与画质;无合格硬件路径、
-创建/配置失败或性能门槛失败都停在 no-go,不静默切软件编码。
+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 问题。应用音频可按已知
@@ -230,6 +237,14 @@ fixed-`HIGH` -> 现有 WebRTC/一至两名 Web Viewer 的 P2 窄实验。硬件
并读回,未证明时 fail closed;系统授权/指示和全部生命周期/性能边界留给真机
矩阵,不追加兼容 fallback。
+Viewer 向电视投屏不进入媒体拓扑;系统级屏幕镜像现在由平台直接提供。Web 端后续只在运行时检测
+`HTMLMediaElement.remote`,并在真实目标证明 live WebRTC `srcObject` 可远端播放后才
+显示入口;Safari 官方只保证 `HTMLMediaElement` 的原生 AirPlay picker。Google Cast
+`MediaInfo` 最终需要 `contentUrl` 或充当 URL 的 `contentId`,当前 P2P `MediaStream` 没有
+该资源,因此不接
+Cast sender/receiver SDK、不建转码或自有投屏协议。无论输出到本机还是电视,Viewer
+都保留同一 upstream PeerConnection,TURN/SFU/peer route 与 Host fanout 完全不变。
+
## 媒体传输配置
当前仓库已经删除旧全房 TURN contract 和被拒绝的 participant-wide built-in
@@ -362,7 +377,7 @@ 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 的播放手势、旋转、前后台和网络切换;
-- 仅在 Windows native/deploy P1 完成后,Android 14/API 34+ 原生 sender 才运行一至两名 Web Viewer 真机 gate:整屏、授权拒绝与撤销、旋转、锁屏停止、Wi-Fi/蜂窝切换、持续热降频、硬件 codec 读回/实测、目标 UID 已知/未知的 audio capture policy、secure/protected content、A/V sync 与资源释放;单应用窗口只在 Android 14 QPR2+ 测试;
+- 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;该项只证明直播中应用与不重建,不代替清晰度、负载或公网矩阵。
@@ -378,9 +393,9 @@ 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 与 deployment;移动 Host 不阻断或先于它。
-9. P2 只在上述门槛后运行 Android 14/API 34+ fixed-`HIGH` 原生 sender spike;失败即停,不扩展为跨平台 framework。
-10. iOS 27 ScreenCaptureKit final 后再决定是否运行独立 P2 spike;不实现 ReplayKit compatibility path。
+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 只采用已验证的平台本地输出,不改变媒体路由。
任何一步只实现当前消费者需要的接口。不得为未来账号、第三种媒体拓扑或原生 sender 预建抽象层。
diff --git a/docs/需求理解.md b/docs/需求理解.md
index 8e62bcb..fc2490c 100644
--- a/docs/需求理解.md
+++ b/docs/需求理解.md
@@ -42,6 +42,7 @@
- [ ] 私密房间双入口: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;系统撤销/停止必须立即释放 capture、PeerConnection 和前台服务。
- [ ] 质量控制扩展:推荐档位仍是默认入口;折叠高级区只允许 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。只有稳定收益且捕获、编码和远端画面不受影响时,才允许直播开始后默认显示静止缩略图或隐藏动态预览并提供按需展开;未测前不宣称收益。
@@ -61,8 +62,8 @@
### P2 - 可选功能
- [ ] 原生 Windows 捕获辅助程序,使用 WGC/DDA、WASAPI 和硬件编码器降低复制与编码开销。
-- [ ] Android 原生分享端有界实验:只在 Windows native/deploy 的 P1 门槛完成后,以 Android 14/API 34+ 整屏捕获为首个范围,使用 `MediaProjection`、`mediaProjection` foreground service 和经实测合格的硬件 `MediaCodec` 发送一份 fixed-`HIGH` 给一至两名普通 Web Viewer;单应用窗口选择只在 Android 14 QPR2+ 验证。复用现有 room、site access、WSS 与标准 WebRTC 契约,不新增移动端专属拓扑、`LOW`、AV1、codec ladder 或软件编码兜底。系统选源、授权和持续前台指示必须保留;旋转、锁屏终止、Wi-Fi/蜂窝切换、持续热降频、资源释放和真机画质/延迟都是硬门槛。
- [ ] 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,不自造投屏协议。首个 Web 候选只在运行时检测 `HTMLMediaElement.remote`,并在真实 AirPlay/Cast 目标上证明当前 live WebRTC `srcObject` 可播放后才显示入口;不支持时保留浏览器原生媒体控件/系统镜像。投屏是 Viewer 的本地播放输出,不能改变 room、TURN/SFU/peer route、Host fanout 或服务端媒体分发。Google Cast receiver 需要可由接收器获取的媒体 URL,当前 P2P `MediaStream` 不满足该输入,因此不加入 Cast SDK/receiver。
- [ ] 多地区 SFU-root 调度,以及可选 TURN 兼容层的按地区、运营商可达率。
## 非功能需求
@@ -124,6 +125,8 @@
- 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 Viewer 尝试把直播输出到电视,When 浏览器/系统提供已验证的 Remote Playback、AirPlay 或屏幕镜像能力,Then 该动作只改变本机输出设备,现有 upstream PeerConnection、TURN/SFU/peer route 和其他参与者保持不变。Given live `srcObject` 未经目标设备证明或 API 缺失,Then 产品不显示伪可用 Cast/AirPlay 按钮,也不创建自有 receiver 或转码服务器。
### 一对多与带宽保护