From 6a51ae45cef2dca972e0b82cbfbfaf6985cb4aef Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:08:19 +0800 Subject: [PATCH] refactor: remove mobile sender product path (#119) --- .github/workflows/android-sender.yml | 34 -- AGENTS.md | 1 + README.md | 2 +- android/sender/.gitignore | 3 - android/sender/README.md | 49 -- android/sender/app/build.gradle.kts | 31 -- .../sender/app/src/main/AndroidManifest.xml | 37 -- .../bonfire/screener/sender/CaptureEngine.kt | 165 ------ .../icu/bonfire/screener/sender/HostPeer.kt | 163 ------ .../bonfire/screener/sender/MainActivity.kt | 170 ------ .../screener/sender/PlaybackAudioCapture.kt | 130 ----- .../screener/sender/ProjectionService.kt | 202 -------- .../bonfire/screener/sender/RemoteSession.kt | 270 ---------- .../app/src/main/res/layout/activity_main.xml | 100 ---- .../sender/app/src/main/res/values/styles.xml | 10 - android/sender/build.gradle.kts | 5 - android/sender/gradle.properties | 4 - .../sender/gradle/wrapper/gradle-wrapper.jar | Bin 59203 -> 0 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 - android/sender/gradlew | 185 ------- android/sender/gradlew.bat | 89 ---- android/sender/protocol/build.gradle.kts | 16 - .../bonfire/screener/protocol/HostRoute.kt | 80 --- .../icu/bonfire/screener/protocol/Wire.kt | 485 ------------------ .../screener/protocol/HostRouteTest.kt | 109 ---- android/sender/settings.gradle.kts | 21 - docs/README.md | 1 - docs/adr/0008-window-scoped-audio-capture.md | 2 - docs/project-memory.md | 8 +- docs/research/browser-screen-audio-quality.md | 81 --- docs/research/native-shared-encode-sender.md | 108 ---- docs/research/webrtc-p2p-screen-sharing.md | 3 +- docs/status.md | 4 +- docs/方案设计.md | 51 +- docs/需求理解.md | 11 +- 35 files changed, 14 insertions(+), 2623 deletions(-) delete mode 100644 .github/workflows/android-sender.yml delete mode 100644 android/sender/.gitignore delete mode 100644 android/sender/README.md delete mode 100644 android/sender/app/build.gradle.kts delete mode 100644 android/sender/app/src/main/AndroidManifest.xml delete mode 100644 android/sender/app/src/main/java/icu/bonfire/screener/sender/CaptureEngine.kt delete mode 100644 android/sender/app/src/main/java/icu/bonfire/screener/sender/HostPeer.kt delete mode 100644 android/sender/app/src/main/java/icu/bonfire/screener/sender/MainActivity.kt delete mode 100644 android/sender/app/src/main/java/icu/bonfire/screener/sender/PlaybackAudioCapture.kt delete mode 100644 android/sender/app/src/main/java/icu/bonfire/screener/sender/ProjectionService.kt delete mode 100644 android/sender/app/src/main/java/icu/bonfire/screener/sender/RemoteSession.kt delete mode 100644 android/sender/app/src/main/res/layout/activity_main.xml delete mode 100644 android/sender/app/src/main/res/values/styles.xml delete mode 100644 android/sender/build.gradle.kts delete mode 100644 android/sender/gradle.properties delete mode 100644 android/sender/gradle/wrapper/gradle-wrapper.jar delete mode 100644 android/sender/gradle/wrapper/gradle-wrapper.properties delete mode 100755 android/sender/gradlew delete mode 100644 android/sender/gradlew.bat delete mode 100644 android/sender/protocol/build.gradle.kts delete mode 100644 android/sender/protocol/src/main/kotlin/icu/bonfire/screener/protocol/HostRoute.kt delete mode 100644 android/sender/protocol/src/main/kotlin/icu/bonfire/screener/protocol/Wire.kt delete mode 100644 android/sender/protocol/src/test/kotlin/icu/bonfire/screener/protocol/HostRouteTest.kt delete mode 100644 android/sender/settings.gradle.kts diff --git a/.github/workflows/android-sender.yml b/.github/workflows/android-sender.yml deleted file mode 100644 index e7ff5c7..0000000 --- a/.github/workflows/android-sender.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Android sender compile - -on: - workflow_dispatch: - pull_request: - paths: - - "android/sender/**" - - ".github/workflows/android-sender.yml" - push: - branches: - - main - paths: - - "android/sender/**" - - ".github/workflows/android-sender.yml" - -permissions: - contents: read - -jobs: - compile: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Check out repository - uses: actions/checkout@v6 - - name: Set up Java - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: "17" - - name: Test protocol core - run: ./android/sender/gradlew -p android/sender -PprotocolOnly=true :protocol:test --no-daemon - - name: Compile debug APK - run: ./android/sender/gradlew -p android/sender :app:assembleDebug --no-daemon diff --git a/AGENTS.md b/AGENTS.md index e77ed8d..71d70af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,7 @@ - Keep this root file concise and stable; put specialized rules in the closest relevant directory only when that code exists. - Never store raw transcripts, large logs, generated summaries, temporary plans, or facts that can be cheaply rediscovered in always-loaded memory. - When a fact changes, update it in place and remove conflicting text in the same change. Do not preserve obsolete guidance merely for history. +- Remove rejected alternatives from active UI, code, comments, configuration, PR copy, and current docs. Describe current behavior by what it does; retain rationale only when it still constrains a live decision. ## Security diff --git a/README.md b/README.md index 545bdf7..ff87815 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Screener is a private, low-latency screen-sharing project for one game player an The first measurable Web proof of concept is implemented. A host captures a screen, window, or browser tab and shares a numeric room code. Rooms default to a private fragment invitation whose capability authorizes only one room; a Host may explicitly choose public watching by room code after site access. During a share, the host can change between three ceiling profiles without reopening the source picker: 1080p60 at 8 Mbps, 1080p30 at 5 Mbps, and 720p30 at 3 Mbps. The host can also temporarily pause the picture or switch sources. Rooms are random and temporary by default. A site-access-protected deployment can optionally use SQLite-backed room numbers starting at `1`, reusable links, and a waiting state after sharing stops. Lightweight deployments may keep independent direct WebRTC connections. The flagship deployment enables the all-room controller with bounded peer descendants and LiveKit SFU roots; short-lived TURN may be authorized only for a controller-selected exceptional media edge. The default room capacity is eight viewers and deployments may configure 1 through 16, but that admission limit is not a performance promise. -This is not yet a broadly validated production release. Production runs exact `fd76277b05d491af8840b28f3132b7ff445d3cbe` with the all-room bounded peer/SFU-UDP controller; ordinary peer ICE remains STUN-only, and short-lived TURN is configured only for controller-selected exceptional media edges. Native Windows and Android senders remain source-only and are not production runtimes. Real cross-network SFU/TURN media, game audio, a sustained 1:8 room, mobile browser lifecycle behavior, and latency targets still require the documented manual test matrix. +This is not yet a broadly validated production release. Production runs exact `16f6eab27bdfb1c15cdbd814a35864f4f18be767` with the all-room bounded peer/SFU-UDP controller; ordinary peer ICE remains STUN-only, and short-lived TURN is configured only for controller-selected exceptional media edges. The optional Windows native sender remains source-only and is not a production runtime. Mobile devices are Web viewers only; ordinary Android and iOS browsers do not expose reliable screen capture for a Web Host. Real cross-network SFU/TURN media, game audio, a sustained 1:8 room, mobile browser lifecycle behavior, and latency targets still require the documented manual test matrix. ## Run locally diff --git a/android/sender/.gitignore b/android/sender/.gitignore deleted file mode 100644 index 208cf4f..0000000 --- a/android/sender/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.gradle/ -**/build/ -local.properties diff --git a/android/sender/README.md b/android/sender/README.md deleted file mode 100644 index a6a3216..0000000 --- a/android/sender/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Android Screen Sender - -This source-only P1 slice lets an Android 14/API 34+ device publish a 720p30 -screen track and optional selected-app playback-audio track to at most two Web -Viewers assigned as direct children by the existing `screener-v2` controller. - -## Build - -Install JDK 17 and Android SDK 35, then run: - -```text -./gradlew :protocol:test :app:assembleDebug -``` - -The debug APK is written under `app/build/outputs/apk/debug/`. CI runs the same -protocol tests and debug compile. No release signing or package publication is -configured. - -## Use - -Open the app, enter the HTTPS Screener base URL and site-access password, then -leave playback audio off or explicitly select one app, then choose a screen or -app from Android's system picker. Audio is selected separately because Android -does not expose the system picker's selected package/UID; it may therefore differ -from the shared picture. The app keeps credentials and room material in memory -only. The displayed private invite can be opened by an unchanged Web Viewer. - -Enabling playback audio requests `RECORD_AUDIO`, but the sender disables the -WebRTC audio device module's microphone input. It filters playback to the -selected app UID and eligible game/media usages. Apps may block capture or have -no eligible playback, in which case the track can remain silent; there is no -microphone or whole-system fallback. “Playback requested” does not claim that -audible media was observed. - -The foreground notification and system capture indicator remain visible for the -whole session. Stopping from the app or system picker closes capture, signaling, -and every PeerConnection. - -## First-Slice Boundary - -- Playback audio is default-off and limited to one explicitly selected, - single-package UID. Background continuity, silence detection and A/V - synchronization remain device gates. -- Hardware VP8/H.264 encoders only; there is no software encoder fallback. -- At most two STUN-only direct-child edges. The server remains the route owner. -- SFU publication and selected-edge TURN are not implemented. The client reports - their attempts as bounded route failures instead of claiming support. -- Rotation resize, background rebuild, Wi-Fi/cellular reselection, iOS, release - signing, and device/performance matrices remain later work. diff --git a/android/sender/app/build.gradle.kts b/android/sender/app/build.gradle.kts deleted file mode 100644 index da26a2f..0000000 --- a/android/sender/app/build.gradle.kts +++ /dev/null @@ -1,31 +0,0 @@ -plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") -} - -android { - namespace = "icu.bonfire.screener.sender" - compileSdk = 35 - - defaultConfig { - applicationId = "icu.bonfire.screener.sender" - minSdk = 34 - targetSdk = 34 - versionCode = 1 - versionName = "0.1.0" - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = "17" - } -} - -dependencies { - implementation(project(":protocol")) - implementation("com.squareup.okhttp3:okhttp:5.3.0") - implementation("io.github.webrtc-sdk:android:144.7559.12") -} diff --git a/android/sender/app/src/main/AndroidManifest.xml b/android/sender/app/src/main/AndroidManifest.xml deleted file mode 100644 index a7285a7..0000000 --- a/android/sender/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android/sender/app/src/main/java/icu/bonfire/screener/sender/CaptureEngine.kt b/android/sender/app/src/main/java/icu/bonfire/screener/sender/CaptureEngine.kt deleted file mode 100644 index 047026a..0000000 --- a/android/sender/app/src/main/java/icu/bonfire/screener/sender/CaptureEngine.kt +++ /dev/null @@ -1,165 +0,0 @@ -package icu.bonfire.screener.sender - -import android.content.Context -import android.content.Intent -import android.media.AudioFormat -import android.media.projection.MediaProjection -import org.webrtc.AudioSource -import org.webrtc.AudioTrack -import org.webrtc.DefaultVideoDecoderFactory -import org.webrtc.EglBase -import org.webrtc.HardwareVideoEncoderFactory -import org.webrtc.MediaConstraints -import org.webrtc.PeerConnectionFactory -import org.webrtc.ScreenCapturerAndroid -import org.webrtc.SurfaceTextureHelper -import org.webrtc.VideoCodecInfo -import org.webrtc.VideoEncoder -import org.webrtc.VideoEncoderFactory -import org.webrtc.VideoSource -import org.webrtc.VideoTrack -import org.webrtc.audio.AudioProcessingOptions -import org.webrtc.audio.JavaAudioDeviceModule -import java.util.concurrent.atomic.AtomicBoolean - -class CaptureEngine private constructor( - context: Context, - permissionData: Intent, - audioTargetUid: Int?, - onProjectionStopped: () -> Unit, - onAudioUnavailable: () -> Unit, -) : AutoCloseable { - private lateinit var egl: EglBase - lateinit var factory: PeerConnectionFactory - private set - lateinit var track: VideoTrack - private set - var audioTrack: AudioTrack? = null - private set - private lateinit var capturer: ScreenCapturerAndroid - private lateinit var source: VideoSource - private lateinit var helper: SurfaceTextureHelper - private var audioSource: AudioSource? = null - private var audioCapture: PlaybackAudioCapture? = null - private var audioDeviceModule: JavaAudioDeviceModule? = null - private var closed = false - - init { - try { - initializeOnce(context) - egl = EglBase.create() - val encoderFactory = HardwareOnlyEncoderFactory(egl.eglBaseContext) - if (encoderFactory.supportedCodecs.isEmpty()) error("No hardware VP8/H264 encoder is available") - val factoryBuilder = PeerConnectionFactory.builder() - .setVideoEncoderFactory(encoderFactory) - .setVideoDecoderFactory(DefaultVideoDecoderFactory(egl.eglBaseContext)) - audioTargetUid?.let { uid -> - val playback = PlaybackAudioCapture(uid, onAudioUnavailable) - audioCapture = playback - val deviceModule = JavaAudioDeviceModule.builder(context.applicationContext) - .setInputSampleRate(PlaybackAudioCapture.SAMPLE_RATE) - .setAudioFormat(AudioFormat.ENCODING_PCM_16BIT) - .setUseStereoInput(false) - .setUseHardwareAcousticEchoCanceler(false) - .setUseHardwareNoiseSuppressor(false) - .setAudioBufferCallback(playback) - .createAudioDeviceModule() - .also { it.setAudioRecordEnabled(false) } - audioDeviceModule = deviceModule - factoryBuilder.setAudioDeviceModule(deviceModule) - } - factory = factoryBuilder.createPeerConnectionFactory() - source = factory.createVideoSource(true) - helper = SurfaceTextureHelper.create("ScreenerCapture", egl.eglBaseContext) - capturer = ScreenCapturerAndroid(permissionData, object : MediaProjection.Callback() { - override fun onStop() = onProjectionStopped() - }) - capturer.initialize(helper, context.applicationContext, source.capturerObserver) - capturer.startCapture(WIDTH, HEIGHT, FPS) - track = factory.createVideoTrack("screener-screen", source) - track.setEnabled(true) - val playback = audioCapture - if (playback != null && capturer.mediaProjection?.let(playback::start) == true) { - val createdSource = factory.createAudioSource(MediaConstraints()) - audioSource = createdSource - val createdTrack = factory.createAudioTrack("screener-playback", createdSource) - if (createdTrack.setAudioProcessingOptions(AudioProcessingOptions.raw()).isSuccess) { - createdTrack.setEnabled(true) - audioTrack = createdTrack - } else { - createdTrack.dispose() - createdSource.dispose() - audioSource = null - playback.close() - onAudioUnavailable() - } - } - } catch (error: Throwable) { - release() - throw error - } - } - - override fun close() = release() - - private fun release() { - if (closed) return - closed = true - if (::capturer.isInitialized) runCatching { capturer.stopCapture() } - if (::capturer.isInitialized) runCatching { capturer.dispose() } - if (::track.isInitialized) runCatching { track.dispose() } - audioCapture?.close() - audioCapture = null - audioTrack?.let { runCatching { it.dispose() } } - audioTrack = null - audioSource?.let { runCatching { it.dispose() } } - audioSource = null - if (::source.isInitialized) runCatching { source.dispose() } - if (::helper.isInitialized) runCatching { helper.dispose() } - if (::factory.isInitialized) runCatching { factory.dispose() } - audioDeviceModule?.let { runCatching { it.release() } } - audioDeviceModule = null - if (::egl.isInitialized) runCatching { egl.release() } - } - - private class HardwareOnlyEncoderFactory(context: EglBase.Context) : VideoEncoderFactory { - private val delegate = HardwareVideoEncoderFactory(context, true, true) - override fun createEncoder(info: VideoCodecInfo): VideoEncoder? = - if (allowed(info)) delegate.createEncoder(info) else null - - override fun getSupportedCodecs(): Array = - delegate.supportedCodecs.filter(::allowed).toTypedArray() - - private fun allowed(info: VideoCodecInfo): Boolean = - info.name.equals("VP8", true) || info.name.equals("H264", true) - } - - companion object { - const val WIDTH = 1280 - const val HEIGHT = 720 - const val FPS = 30 - private val initialized = AtomicBoolean() - - fun create( - context: Context, - permissionData: Intent, - audioTargetUid: Int?, - onProjectionStopped: () -> Unit, - onAudioUnavailable: () -> Unit, - ) = CaptureEngine(context, permissionData, audioTargetUid, onProjectionStopped, onAudioUnavailable) - - private fun initializeOnce(context: Context) { - if (initialized.compareAndSet(false, true)) { - try { - PeerConnectionFactory.initialize( - PeerConnectionFactory.InitializationOptions.builder(context.applicationContext) - .createInitializationOptions(), - ) - } catch (error: Throwable) { - initialized.set(false) - throw error - } - } - } - } -} diff --git a/android/sender/app/src/main/java/icu/bonfire/screener/sender/HostPeer.kt b/android/sender/app/src/main/java/icu/bonfire/screener/sender/HostPeer.kt deleted file mode 100644 index 612cf4e..0000000 --- a/android/sender/app/src/main/java/icu/bonfire/screener/sender/HostPeer.kt +++ /dev/null @@ -1,163 +0,0 @@ -package icu.bonfire.screener.sender - -import icu.bonfire.screener.protocol.IceConfig -import icu.bonfire.screener.protocol.SignalPayload -import icu.bonfire.screener.protocol.Wire -import icu.bonfire.screener.protocol.IceCandidate as WireCandidate -import org.webrtc.AudioTrack -import org.webrtc.DataChannel -import org.webrtc.IceCandidate -import org.webrtc.MediaConstraints -import org.webrtc.MediaStream -import org.webrtc.PeerConnection -import org.webrtc.PeerConnectionFactory -import org.webrtc.RtpReceiver -import org.webrtc.SdpObserver -import org.webrtc.SessionDescription -import org.webrtc.VideoTrack -import java.util.concurrent.Executor - -class HostPeer( - private val peerId: String, - val connectionId: String, - iceConfig: IceConfig, - factory: PeerConnectionFactory, - track: VideoTrack, - audioTrack: AudioTrack?, - private val serial: Executor, - private val send: (String) -> Boolean, - private val status: (String) -> Unit, -) : AutoCloseable { - private val pending = ArrayDeque() - @Volatile - private var closed = false - private val connection: PeerConnection - - init { - val servers = iceConfig.iceServers.map { PeerConnection.IceServer.builder(it.urls).createIceServer() } - val config = PeerConnection.RTCConfiguration(servers).apply { - sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN - tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.DISABLED - continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY - } - connection = factory.createPeerConnection(config, Observer()) ?: error("Could not create PeerConnection") - try { - val sender = connection.addTrack(track, listOf("screener")) ?: error("Could not add screen track") - sender.parameters.let { parameters -> - parameters.encodings.forEach { it.maxBitrateBps = 3_000_000 } - if (!sender.setParameters(parameters)) error("Could not set video bitrate") - } - audioTrack?.let { - connection.addTrack(it, listOf("screener")) ?: error("Could not add playback audio track") - } - } catch (error: Throwable) { - closed = true - runCatching { connection.dispose() } - throw error - } - } - - fun start() = createOffer(false) - - fun restartIce() { - if (closed) return - connection.restartIce() - createOffer(true) - } - - fun accept(payload: SignalPayload) { - if (closed || payload.connectionId != connectionId) return - when (payload) { - is SignalPayload.Description -> { - val description = SessionDescription(SessionDescription.Type.ANSWER, payload.description.sdp) - connection.setRemoteDescription(object : SimpleSdpObserver() { - override fun onSetSuccess() = dispatch { flushCandidates() } - override fun onSetFailure(error: String?) = status("Viewer answer was rejected") - }, description) - } - is SignalPayload.Candidate -> { - if (connection.remoteDescription == null) { - if (pending.size < MAX_PENDING) pending += payload.candidate - } else { - addCandidate(payload.candidate) - } - } - } - } - - override fun close() { - if (closed) return - closed = true - pending.clear() - connection.dispose() - } - - private fun createOffer(restart: Boolean) { - connection.createOffer(object : SimpleSdpObserver() { - override fun onCreateSuccess(description: SessionDescription?) { - if (description == null || closed) return - connection.setLocalDescription(object : SimpleSdpObserver() { - override fun onSetSuccess() { - if (!closed && !send(Wire.description(peerId, connectionId, "offer", description.description))) { - status("Signaling is temporarily unavailable") - } - } - override fun onSetFailure(error: String?) = status("Could not apply the screen offer") - }, description) - } - override fun onCreateFailure(error: String?) = status(if (restart) "ICE restart failed" else "Viewer offer failed") - }, MediaConstraints()) - } - - private fun flushCandidates() { - while (!closed && pending.isNotEmpty()) addCandidate(pending.removeFirst()) - } - - private fun dispatch(block: () -> Unit) { - runCatching { serial.execute(block) } - } - - private fun addCandidate(candidate: WireCandidate?) { - if (candidate == null) return - connection.addIceCandidate( - IceCandidate(candidate.sdpMid ?: "", candidate.sdpMLineIndex ?: 0, candidate.candidate), - ) - } - - private inner class Observer : PeerConnection.Observer { - override fun onIceCandidate(candidate: IceCandidate) { - send(Wire.candidate(peerId, connectionId, WireCandidate( - candidate.sdp, - candidate.sdpMid, - candidate.sdpMLineIndex, - null, - ))) - } - override fun onIceGatheringChange(state: PeerConnection.IceGatheringState) { - if (state == PeerConnection.IceGatheringState.COMPLETE) send(Wire.candidate(peerId, connectionId, null)) - } - override fun onConnectionChange(state: PeerConnection.PeerConnectionState) { - if (state == PeerConnection.PeerConnectionState.FAILED) status("A viewer connection failed") - } - override fun onSignalingChange(state: PeerConnection.SignalingState) = Unit - override fun onIceConnectionChange(state: PeerConnection.IceConnectionState) = Unit - override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit - override fun onIceCandidatesRemoved(candidates: Array) = Unit - override fun onAddStream(stream: MediaStream) = Unit - override fun onRemoveStream(stream: MediaStream) = Unit - override fun onDataChannel(channel: DataChannel) = Unit - override fun onRenegotiationNeeded() = Unit - override fun onAddTrack(receiver: RtpReceiver, streams: Array) = Unit - } - - private open class SimpleSdpObserver : SdpObserver { - override fun onCreateSuccess(description: SessionDescription?) = Unit - override fun onSetSuccess() = Unit - override fun onCreateFailure(error: String?) = Unit - override fun onSetFailure(error: String?) = Unit - } - - companion object { - private const val MAX_PENDING = 64 - } -} diff --git a/android/sender/app/src/main/java/icu/bonfire/screener/sender/MainActivity.kt b/android/sender/app/src/main/java/icu/bonfire/screener/sender/MainActivity.kt deleted file mode 100644 index c490a3e..0000000 --- a/android/sender/app/src/main/java/icu/bonfire/screener/sender/MainActivity.kt +++ /dev/null @@ -1,170 +0,0 @@ -package icu.bonfire.screener.sender - -import android.Manifest -import android.app.Activity -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.media.projection.MediaProjectionManager -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.os.ResultReceiver -import android.widget.ArrayAdapter -import android.widget.Button -import android.widget.EditText -import android.widget.Spinner -import android.widget.TextView - -class MainActivity : Activity() { - private lateinit var serverUrl: EditText - private lateinit var sitePassword: EditText - private lateinit var startButton: Button - private lateinit var stopButton: Button - private lateinit var audioTarget: Spinner - private lateinit var statusText: TextView - private lateinit var inviteText: TextView - private var pendingServer = "" - private var pendingPassword = "" - private var pendingAudioUid: Int? = null - private var pendingAudioName: String? = null - - private val receiver = object : ResultReceiver(Handler(Looper.getMainLooper())) { - override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { - statusText.text = resultData?.getString(ProjectionService.RESULT_STATUS) ?: "Sharing stopped" - resultData?.getString(ProjectionService.RESULT_INVITE)?.let { - inviteText.text = it - inviteText.setOnClickListener { _ -> - getSystemService(ClipboardManager::class.java).setPrimaryClip( - android.content.ClipData.newPlainText("Screener invite", it), - ) - } - } - val active = resultCode == ProjectionService.RESULT_ACTIVE - startButton.isEnabled = !active - stopButton.isEnabled = active - if (!active) clearInvite() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - serverUrl = findViewById(R.id.server_url) - sitePassword = findViewById(R.id.site_password) - startButton = findViewById(R.id.start_button) - stopButton = findViewById(R.id.stop_button) - audioTarget = findViewById(R.id.audio_target) - statusText = findViewById(R.id.status_text) - inviteText = findViewById(R.id.invite_text) - - audioTarget.adapter = ArrayAdapter( - this, - android.R.layout.simple_spinner_item, - loadAudioTargets(), - ).also { it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) } - - startButton.setOnClickListener { prepareProjection() } - stopButton.setOnClickListener { - startService(Intent(this, ProjectionService::class.java).setAction(ProjectionService.ACTION_STOP)) - clearInvite() - statusText.text = "Stopping" - } - } - - private fun prepareProjection() { - pendingServer = serverUrl.text.toString().trim().trimEnd('/') - pendingPassword = sitePassword.text.toString() - val target = audioTarget.selectedItem as AudioTarget - pendingAudioUid = target.uid - pendingAudioName = target.packageName?.let { target.label } - clearInvite() - if (!pendingServer.startsWith("https://")) { - statusText.text = "An HTTPS server URL is required" - return - } - if ( - pendingAudioUid != null && - checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED - ) { - requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_AUDIO) - return - } - requestProjection() - } - - private fun requestProjection() { - val manager = getSystemService(MediaProjectionManager::class.java) - startActivityForResult(manager.createScreenCaptureIntent(), REQUEST_PROJECTION) - } - - override fun onRequestPermissionsResult( - requestCode: Int, - permissions: Array, - grantResults: IntArray, - ) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (requestCode != REQUEST_AUDIO) return - if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { - requestProjection() - } else { - statusText.text = "Playback audio permission was denied" - } - } - - @Deprecated("The platform projection picker still returns through this callback") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode != REQUEST_PROJECTION) return - if (resultCode != RESULT_OK || data == null) { - statusText.text = "Screen sharing was cancelled" - return - } - startButton.isEnabled = false - stopButton.isEnabled = true - statusText.text = "Starting" - val service = Intent(this, ProjectionService::class.java).apply { - action = ProjectionService.ACTION_START - putExtra(ProjectionService.EXTRA_SERVER_URL, pendingServer) - putExtra(ProjectionService.EXTRA_SITE_PASSWORD, pendingPassword) - putExtra(ProjectionService.EXTRA_PERMISSION_DATA, data) - putExtra(ProjectionService.EXTRA_RECEIVER, receiver) - pendingAudioUid?.let { putExtra(ProjectionService.EXTRA_AUDIO_TARGET_UID, it) } - pendingAudioName?.let { putExtra(ProjectionService.EXTRA_AUDIO_TARGET_NAME, it) } - } - startForegroundService(service) - sitePassword.text.clear() - pendingPassword = "" - } - - private fun loadAudioTargets(): List { - val launcher = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) - val targets = packageManager.queryIntentActivities( - launcher, - PackageManager.ResolveInfoFlags.of(0L), - ).mapNotNull { resolved -> - val app = resolved.activityInfo?.applicationInfo ?: return@mapNotNull null - if (app.uid == applicationInfo.uid) return@mapNotNull null - val packages = packageManager.getPackagesForUid(app.uid)?.distinct() - if (packages?.singleOrNull() != app.packageName) return@mapNotNull null - val label = app.loadLabel(packageManager).toString().trim().ifEmpty { app.packageName } - AudioTarget(label, app.packageName, app.uid) - }.distinctBy { it.uid }.sortedBy { it.label.lowercase() } - return listOf(AudioTarget("Off (video only)", null, null)) + targets - } - - private fun clearInvite() { - inviteText.text = "" - inviteText.setOnClickListener(null) - } - - companion object { - private const val REQUEST_PROJECTION = 40 - private const val REQUEST_AUDIO = 41 - } - - private data class AudioTarget(val label: String, val packageName: String?, val uid: Int?) { - override fun toString(): String = packageName?.let { "$label ($it)" } ?: label - } -} diff --git a/android/sender/app/src/main/java/icu/bonfire/screener/sender/PlaybackAudioCapture.kt b/android/sender/app/src/main/java/icu/bonfire/screener/sender/PlaybackAudioCapture.kt deleted file mode 100644 index 62eaf7e..0000000 --- a/android/sender/app/src/main/java/icu/bonfire/screener/sender/PlaybackAudioCapture.kt +++ /dev/null @@ -1,130 +0,0 @@ -package icu.bonfire.screener.sender - -import android.media.AudioAttributes -import android.media.AudioFormat -import android.media.AudioPlaybackCaptureConfiguration -import android.media.AudioRecord -import android.media.projection.MediaProjection -import android.os.SystemClock -import org.webrtc.audio.JavaAudioDeviceModule -import java.nio.ByteBuffer -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicReference - -class PlaybackAudioCapture( - private val targetUid: Int, - private val onUnavailable: () -> Unit, -) : JavaAudioDeviceModule.AudioBufferCallback, AutoCloseable { - private val record = AtomicReference() - private val closed = AtomicBoolean() - private val unavailable = AtomicBoolean() - - fun start(projection: MediaProjection): Boolean { - if (closed.get()) return false - val created = try { - val configuration = AudioPlaybackCaptureConfiguration.Builder(projection) - .addMatchingUid(targetUid) - .addMatchingUsage(AudioAttributes.USAGE_UNKNOWN) - .addMatchingUsage(AudioAttributes.USAGE_MEDIA) - .addMatchingUsage(AudioAttributes.USAGE_GAME) - .build() - val minimum = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_MASK, AUDIO_FORMAT) - if (minimum <= 0) return fail() - AudioRecord.Builder() - .setAudioFormat( - AudioFormat.Builder() - .setEncoding(AUDIO_FORMAT) - .setSampleRate(SAMPLE_RATE) - .setChannelMask(CHANNEL_MASK) - .build(), - ) - .setBufferSizeInBytes(maxOf(minimum * 2, FRAME_BYTES)) - .setAudioPlaybackCaptureConfig(configuration) - .build() - } catch (_: Exception) { - return fail() - } - try { - created.startRecording() - if (created.recordingState != AudioRecord.RECORDSTATE_RECORDING) { - created.release() - return fail() - } - } catch (_: Exception) { - created.release() - return fail() - } - if (closed.get() || !record.compareAndSet(null, created)) { - runCatching { created.stop() } - created.release() - return false - } - return true - } - - override fun onBuffer( - buffer: ByteBuffer, - audioFormat: Int, - channelCount: Int, - sampleRate: Int, - bytesRead: Int, - captureTimeNs: Long, - ): Long { - if ( - audioFormat != AUDIO_FORMAT || channelCount != CHANNEL_COUNT || - sampleRate != SAMPLE_RATE || buffer.capacity() != FRAME_BYTES - ) { - fail() - silence(buffer) - return 0L - } - val active = record.get() - if (active == null) { - SystemClock.sleep(FRAME_DURATION_MS) - silence(buffer) - return 0L - } - buffer.clear() - val read = try { - active.read(buffer, buffer.capacity(), AudioRecord.READ_BLOCKING) - } catch (_: Exception) { - AudioRecord.ERROR_INVALID_OPERATION - } - if (read != buffer.capacity()) { - SystemClock.sleep(FRAME_DURATION_MS) - silence(buffer) - if (read < 0) fail() - } - buffer.clear() - return 0L - } - - override fun close() { - closed.set(true) - record.getAndSet(null)?.let { - runCatching { it.stop() } - it.release() - } - } - - private fun fail(): Boolean { - close() - if (unavailable.compareAndSet(false, true)) onUnavailable() - return false - } - - private fun silence(buffer: ByteBuffer) { - buffer.clear() - while (buffer.hasRemaining()) buffer.put(0) - buffer.clear() - } - - companion object { - const val SAMPLE_RATE = 48_000 - private const val CHANNEL_COUNT = 1 - private const val CHANNEL_MASK = AudioFormat.CHANNEL_IN_MONO - private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT - private const val FRAME_BYTES = SAMPLE_RATE / 100 * CHANNEL_COUNT * 2 - private const val FRAME_DURATION_MS = 10L - } -} diff --git a/android/sender/app/src/main/java/icu/bonfire/screener/sender/ProjectionService.kt b/android/sender/app/src/main/java/icu/bonfire/screener/sender/ProjectionService.kt deleted file mode 100644 index b8f6935..0000000 --- a/android/sender/app/src/main/java/icu/bonfire/screener/sender/ProjectionService.kt +++ /dev/null @@ -1,202 +0,0 @@ -package icu.bonfire.screener.sender - -import android.Manifest -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.Intent -import android.content.pm.PackageManager -import android.content.pm.ServiceInfo -import android.os.Bundle -import android.os.IBinder -import android.os.ResultReceiver -import java.util.concurrent.Executors -import java.util.concurrent.RejectedExecutionException -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicReference - -class ProjectionService : Service() { - private val worker = Executors.newSingleThreadExecutor() - private val state = AtomicReference(State.IDLE) - private var receiver: ResultReceiver? = null - private var capture: CaptureEngine? = null - @Volatile - private var session: RemoteSession? = null - - override fun onCreate() { - super.onCreate() - getSystemService(NotificationManager::class.java).createNotificationChannel( - NotificationChannel(CHANNEL_ID, "Screen sharing", NotificationManager.IMPORTANCE_LOW), - ) - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent?.action == ACTION_STOP) { - finish("Sharing stopped") - return START_NOT_STICKY - } - if (intent?.action != ACTION_START || !state.compareAndSet(State.IDLE, State.STARTING)) { - return START_NOT_STICKY - } - receiver = intent.getParcelableExtra(EXTRA_RECEIVER, ResultReceiver::class.java) - val server = intent.getStringExtra(EXTRA_SERVER_URL) - val password = intent.getStringExtra(EXTRA_SITE_PASSWORD) - val permission = intent.getParcelableExtra(EXTRA_PERMISSION_DATA, Intent::class.java) - val audioTargetUid = if (intent.hasExtra(EXTRA_AUDIO_TARGET_UID)) { - intent.getIntExtra(EXTRA_AUDIO_TARGET_UID, -1).takeIf { it >= 0 } - } else { - null - } - val audioTargetName = intent.getStringExtra(EXTRA_AUDIO_TARGET_NAME)?.take(80) - if (server == null || password == null || permission == null) { - finish("Screen sharing could not start") - return START_NOT_STICKY - } - if ( - audioTargetUid != null && - checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED - ) { - finish("Playback audio permission is required") - return START_NOT_STICKY - } - startForeground( - NOTIFICATION_ID, - notification("Starting screen sharing"), - ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION, - ) - sendResult(RESULT_ACTIVE, "Starting") - schedule { - try { - val audioUnavailable = AtomicBoolean() - val engine = CaptureEngine.create( - applicationContext, - permission, - audioTargetUid, - onProjectionStopped = { finish("The system stopped screen sharing") }, - onAudioUnavailable = { - audioUnavailable.set(true) - if (state.get() != State.STOPPING) { - sendResult(RESULT_ACTIVE, "Playback audio unavailable; video remains active") - } - }, - ) - if (state.get() != State.STARTING) { - engine.close() - return@schedule - } - capture = engine - val remote = RemoteSession( - server, password, engine.factory, engine.track, engine.audioTrack, worker, - object : RemoteSession.Events { - override fun onRoom(inviteUrl: String) { - if (state.get() == State.STOPPING) return - val audio = when { - engine.audioTrack != null && !audioUnavailable.get() -> audioTargetName?.let { - "; playback requested: $it" - } ?: "; playback requested" - audioTargetUid != null -> "; playback audio unavailable" - else -> "" - } - sendResult(RESULT_ACTIVE, "Room ready; hardware codec requested$audio", inviteUrl) - getSystemService(NotificationManager::class.java) - .notify(NOTIFICATION_ID, notification("Screen sharing is ready")) - } - - override fun onStatus(message: String) { - if (state.get() != State.STOPPING) sendResult(RESULT_ACTIVE, message) - } - override fun onFatal(message: String) = finish(message) - }, - ) - session = remote - if (!state.compareAndSet(State.STARTING, State.RUNNING)) return@schedule - remote.start() - } catch (_: Exception) { - finish("Screen sharing could not start") - } - } - return START_NOT_STICKY - } - - override fun onDestroy() { - state.set(State.STOPPING) - session?.cancelPending() - schedule { - teardown() - worker.shutdown() - } - super.onDestroy() - } - - override fun onBind(intent: Intent?): IBinder? = null - - private fun finish(message: String) { - if (state.getAndSet(State.STOPPING) == State.STOPPING) return - session?.cancelPending() - sendResult(RESULT_STOPPED, message) - schedule { - teardown() - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } - } - - private fun teardown() { - session?.close() - session = null - capture?.close() - capture = null - } - - private fun schedule(block: () -> Unit) { - try { - worker.execute(block) - } catch (_: RejectedExecutionException) { - state.set(State.STOPPING) - } - } - - private fun sendResult(code: Int, status: String, invite: String? = null) { - receiver?.send(code, Bundle().apply { - putString(RESULT_STATUS, status) - invite?.let { putString(RESULT_INVITE, it) } - }) - } - - private fun notification(text: String): Notification { - val activity = PendingIntent.getActivity( - this, - 0, - Intent(this, MainActivity::class.java), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - return Notification.Builder(this, CHANNEL_ID) - .setSmallIcon(android.R.drawable.presence_video_online) - .setContentTitle("Screener Sender") - .setContentText(text) - .setContentIntent(activity) - .setOngoing(true) - .build() - } - - companion object { - const val ACTION_START = "icu.bonfire.screener.sender.START" - const val ACTION_STOP = "icu.bonfire.screener.sender.STOP" - const val EXTRA_SERVER_URL = "serverUrl" - const val EXTRA_SITE_PASSWORD = "sitePassword" - const val EXTRA_PERMISSION_DATA = "projectionPermission" - const val EXTRA_RECEIVER = "receiver" - const val EXTRA_AUDIO_TARGET_UID = "audioTargetUid" - const val EXTRA_AUDIO_TARGET_NAME = "audioTargetName" - const val RESULT_STATUS = "status" - const val RESULT_INVITE = "invite" - const val RESULT_STOPPED = 0 - const val RESULT_ACTIVE = 1 - private const val CHANNEL_ID = "screen-sharing" - private const val NOTIFICATION_ID = 70 - } - - private enum class State { IDLE, STARTING, RUNNING, STOPPING } -} diff --git a/android/sender/app/src/main/java/icu/bonfire/screener/sender/RemoteSession.kt b/android/sender/app/src/main/java/icu/bonfire/screener/sender/RemoteSession.kt deleted file mode 100644 index 3b3eda9..0000000 --- a/android/sender/app/src/main/java/icu/bonfire/screener/sender/RemoteSession.kt +++ /dev/null @@ -1,270 +0,0 @@ -package icu.bonfire.screener.sender - -import icu.bonfire.screener.protocol.HostRoute -import icu.bonfire.screener.protocol.ProtocolException -import icu.bonfire.screener.protocol.RouteAction -import icu.bonfire.screener.protocol.ServerEvent -import icu.bonfire.screener.protocol.Wire -import okhttp3.Call -import okhttp3.Cookie -import okhttp3.CookieJar -import okhttp3.HttpUrl -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.WebSocket -import okhttp3.WebSocketListener -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.HttpUrl.Companion.toHttpUrl -import org.webrtc.AudioTrack -import org.webrtc.PeerConnectionFactory -import org.webrtc.VideoTrack -import java.util.UUID -import java.util.concurrent.Executor -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicReference - -class RemoteSession( - serverUrl: String, - private var sitePassword: String, - private val factory: PeerConnectionFactory, - private val track: VideoTrack, - private val audioTrack: AudioTrack?, - private val serial: Executor, - private val events: Events, -) : AutoCloseable { - interface Events { - fun onRoom(inviteUrl: String) - fun onStatus(message: String) - fun onFatal(message: String) - } - - private val baseUrl = serverUrl.toHttpUrl() - private val cookieJar = MemoryCookieJar() - private val client = OkHttpClient.Builder().cookieJar(cookieJar).followRedirects(false).build() - private val route = HostRoute() - private val peers = linkedMapOf() - private val stopped = AtomicBoolean() - private val closed = AtomicBoolean() - private val activeCall = AtomicReference() - @Volatile - private var socket: WebSocket? = null - private var room: Room? = null - private var iceConfig: icu.bonfire.screener.protocol.IceConfig? = null - - init { - require( - baseUrl.isHttps && baseUrl.query == null && baseUrl.fragment == null && - baseUrl.username.isEmpty() && baseUrl.password.isEmpty(), - ) - } - - fun start() { - try { - authenticateSite() - } finally { - sitePassword = "" - } - if (stopped.get()) return - val created = createRoom() - if (stopped.get()) return - room = Room(created.roomId, created.hostToken, opaqueId(), opaqueId()) - events.onRoom(created.inviteUrl) - connectSignal() - } - - override fun close() { - if (!closed.compareAndSet(false, true)) return - stopped.set(true) - activeCall.get()?.cancel() - val activeSocket = socket - if (room != null) activeSocket?.send(Wire.abandonRoom()) - activeSocket?.close(1000, "sender stopped") - socket = null - peers.values.forEach(HostPeer::close) - peers.clear() - client.dispatcher.executorService.shutdown() - client.connectionPool.evictAll() - } - - fun cancelPending() { - stopped.set(true) - activeCall.get()?.cancel() - } - - private fun authenticateSite() { - val request = Request.Builder() - .url(endpoint(SITE_ACCESS_PATH)) - .header("Accept", "application/json") - .header("Authorization", "Bearer ${sitePassword.trim()}") - .post("".toRequestBody(null)) - .build() - execute(request, "Site access was denied") - } - - private fun createRoom(): icu.bonfire.screener.protocol.CreatedRoom { - val body = """{"viewerPolicy":"private-link","hostClaimTtlSeconds":300}""" - val request = Request.Builder() - .url(endpoint(ROOMS_PATH)) - .header("Accept", "application/json") - .header("Content-Type", "application/json") - .header("Origin", origin()) - .post(body.toRequestBody("application/json".toMediaType())) - .build() - return Wire.decodeCreatedRoom(execute(request, "Room creation failed")) - } - - private fun execute(request: Request, failure: String): String { - val call = client.newCall(request) - check(activeCall.compareAndSet(null, call)) - try { - if (stopped.get()) call.cancel() - return call.execute().use { response -> - val text = response.body.string() - if (!response.isSuccessful || text.toByteArray().size > Wire.MAX_HTTP_BYTES) error(failure) - text - } - } finally { - activeCall.compareAndSet(call, null) - } - } - - private fun connectSignal() { - val wsUrl = endpoint(SIGNAL_PATH).toString().replaceFirst("https://", "wss://") - val cookies = cookieJar.loadForRequest(baseUrl).joinToString("; ") { "${it.name}=${it.value}" } - val request = Request.Builder().url(wsUrl).header("Origin", origin()).apply { - if (cookies.isNotEmpty()) header("Cookie", cookies) - }.build() - socket = client.newWebSocket(request, object : WebSocketListener() { - override fun onOpen(webSocket: WebSocket, response: Response) { - dispatch { - if (stopped.get()) return@dispatch - val identity = room ?: return@dispatch - webSocket.send(Wire.authenticate(identity.id, identity.token, identity.clientId, identity.shareGeneration)) - } - } - - override fun onMessage(webSocket: WebSocket, text: String) { - dispatch { if (!stopped.get()) accept(text) } - } - - override fun onFailure(webSocket: WebSocket, error: Throwable, response: Response?) { - fatal("Signaling connection failed") - } - - override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - if (!stopped.get()) fatal("Signaling connection closed") - } - }) - } - - private fun accept(text: String) { - val event = try { - Wire.decodeServerEvent(text) - } catch (_: ProtocolException) { - fatal("The server protocol does not match this sender") - return - } - when (event) { - is ServerEvent.Authenticated -> { - iceConfig = event.iceConfig - val assignment = event.routeAssignment - val revision = event.routeRevision - if (assignment == null || revision == null) { - fatal("This sender requires peer-assisted media") - return - } - socket?.send(Wire.qualitySettings()) - apply(route.authoritative(event.peerId, revision, assignment)) - events.onStatus("Connected; ${peers.size} viewer edge(s)") - } - is ServerEvent.RouteUpdate -> apply(route.update(event)) - is ServerEvent.SelectedSfuIngress -> apply(route.selectedSfuIngress(event)) - is ServerEvent.Signal -> { - if (route.isAssignedChild(event.fromPeerId)) peers[event.fromPeerId]?.accept(event.payload) - } - is ServerEvent.RestartRequest -> recover(event) - is ServerEvent.RoomClosed -> fatal(if (event.reason == "expired") "Room expired" else "Room closed") - is ServerEvent.Error -> { - if (event.code in FATAL_ERRORS) fatal("The server rejected this sender") - else events.onStatus("A media route is unavailable") - } - is ServerEvent.Ignored -> Unit - } - } - - private fun apply(actions: List) { - actions.forEach { - when (it) { - is RouteAction.ReconcileChildren -> reconcile(it.peerIds) - is RouteAction.Ready -> socket?.send(Wire.routeReady(it.revision, it.phase)) - is RouteAction.Failed -> socket?.send(Wire.routeFailed(it.revision, it.phase, it.connectionId)) - } - } - } - - private fun reconcile(peerIds: Collection) { - val desired = peerIds.distinct().take(MAX_EDGES).toSet() - (peers.keys - desired).forEach { peers.remove(it)?.close() } - desired.filterNot(peers::containsKey).forEach { peerId -> - val config = iceConfig ?: return@forEach - peers[peerId] = HostPeer( - peerId, opaqueId(), config, factory, track, audioTrack, serial, ::send, events::onStatus, - ).also { - it.start() - } - } - } - - private fun recover(event: ServerEvent.RestartRequest) { - val peer = peers[event.fromPeerId] ?: return - if (peer.connectionId != event.connectionId) return - if (event.rebuild) { - peer.close() - peers.remove(event.fromPeerId) - reconcile(routeChildren() + event.fromPeerId) - } else { - peer.restartIce() - } - } - - private fun routeChildren(): Set = peers.keys.toSet() - private fun send(message: String): Boolean = socket?.send(message) == true - private fun dispatch(block: () -> Unit) { - runCatching { serial.execute(block) } - } - private fun fatal(message: String) { - if (stopped.get()) return - events.onFatal(message) - } - - private fun endpoint(path: String): HttpUrl = baseUrl.newBuilder().encodedPath(path).query(null).fragment(null).build() - private fun origin(): String = baseUrl.newBuilder().encodedPath("/").query(null).fragment(null).build().toString().removeSuffix("/") - - private data class Room(val id: String, val token: String, val clientId: String, val shareGeneration: String) - - private class MemoryCookieJar : CookieJar { - private val cookies = mutableListOf() - override fun saveFromResponse(url: HttpUrl, values: List) = synchronized(cookies) { - values.forEach { value -> - cookies.removeAll { it.name == value.name && it.domain == value.domain && it.path == value.path } - if (value.expiresAt > System.currentTimeMillis()) cookies += value - } - } - - override fun loadForRequest(url: HttpUrl): List = synchronized(cookies) { - cookies.removeAll { it.expiresAt <= System.currentTimeMillis() } - cookies.filter { it.matches(url) } - } - } - - companion object { - private const val SITE_ACCESS_PATH = "/api/site-access" - private const val ROOMS_PATH = "/api/rooms" - private const val SIGNAL_PATH = "/signal" - private const val MAX_EDGES = 2 - private val FATAL_ERRORS = setOf("AUTH_REQUIRED", "INVALID_TOKEN", "ROOM_EXPIRED", "HOST_ALREADY_CONNECTED") - private fun opaqueId(): String = UUID.randomUUID().toString() - } -} diff --git a/android/sender/app/src/main/res/layout/activity_main.xml b/android/sender/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 68f64d2..0000000 --- a/android/sender/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - -