diff --git a/android/sender/README.md b/android/sender/README.md index cf9a878..a6a3216 100644 --- a/android/sender/README.md +++ b/android/sender/README.md @@ -1,8 +1,8 @@ # Android Screen Sender This source-only P1 slice lets an Android 14/API 34+ device publish a 720p30 -screen track to at most two Web Viewers assigned as direct children by the -existing `screener-v2` controller. +screen track and optional selected-app playback-audio track to at most two Web +Viewers assigned as direct children by the existing `screener-v2` controller. ## Build @@ -19,9 +19,18 @@ configured. ## Use Open the app, enter the HTTPS Screener base URL and site-access password, then -choose a screen or app from Android's system picker. The app keeps credentials -and room material in memory only. The displayed private invite can be opened by -an unchanged Web Viewer. +leave playback audio off or explicitly select one app, then choose a screen or +app from Android's system picker. Audio is selected separately because Android +does not expose the system picker's selected package/UID; it may therefore differ +from the shared picture. The app keeps credentials and room material in memory +only. The displayed private invite can be opened by an unchanged Web Viewer. + +Enabling playback audio requests `RECORD_AUDIO`, but the sender disables the +WebRTC audio device module's microphone input. It filters playback to the +selected app UID and eligible game/media usages. Apps may block capture or have +no eligible playback, in which case the track can remain silent; there is no +microphone or whole-system fallback. “Playback requested” does not claim that +audible media was observed. The foreground notification and system capture indicator remain visible for the whole session. Stopping from the app or system picker closes capture, signaling, @@ -29,7 +38,9 @@ and every PeerConnection. ## First-Slice Boundary -- Video only: no playback-capture audio or A/V synchronization yet. +- Playback audio is default-off and limited to one explicitly selected, + single-package UID. Background continuity, silence detection and A/V + synchronization remain device gates. - Hardware VP8/H.264 encoders only; there is no software encoder fallback. - At most two STUN-only direct-child edges. The server remains the route owner. - SFU publication and selected-edge TURN are not implemented. The client reports diff --git a/android/sender/app/src/main/AndroidManifest.xml b/android/sender/app/src/main/AndroidManifest.xml index 4d8ca90..a7285a7 100644 --- a/android/sender/app/src/main/AndroidManifest.xml +++ b/android/sender/app/src/main/AndroidManifest.xml @@ -4,6 +4,14 @@ + + + + + + + + Unit, + onAudioUnavailable: () -> Unit, ) : AutoCloseable { private lateinit var egl: EglBase lateinit var factory: PeerConnectionFactory private set lateinit var track: VideoTrack private set + var audioTrack: AudioTrack? = null + private set private lateinit var capturer: ScreenCapturerAndroid private lateinit var source: VideoSource private lateinit var helper: SurfaceTextureHelper + private var audioSource: AudioSource? = null + private var audioCapture: PlaybackAudioCapture? = null + private var audioDeviceModule: JavaAudioDeviceModule? = null private var closed = false init { @@ -37,10 +50,25 @@ class CaptureEngine private constructor( egl = EglBase.create() val encoderFactory = HardwareOnlyEncoderFactory(egl.eglBaseContext) if (encoderFactory.supportedCodecs.isEmpty()) error("No hardware VP8/H264 encoder is available") - factory = PeerConnectionFactory.builder() + val factoryBuilder = PeerConnectionFactory.builder() .setVideoEncoderFactory(encoderFactory) .setVideoDecoderFactory(DefaultVideoDecoderFactory(egl.eglBaseContext)) - .createPeerConnectionFactory() + audioTargetUid?.let { uid -> + val playback = PlaybackAudioCapture(uid, onAudioUnavailable) + audioCapture = playback + val deviceModule = JavaAudioDeviceModule.builder(context.applicationContext) + .setInputSampleRate(PlaybackAudioCapture.SAMPLE_RATE) + .setAudioFormat(AudioFormat.ENCODING_PCM_16BIT) + .setUseStereoInput(false) + .setUseHardwareAcousticEchoCanceler(false) + .setUseHardwareNoiseSuppressor(false) + .setAudioBufferCallback(playback) + .createAudioDeviceModule() + .also { it.setAudioRecordEnabled(false) } + audioDeviceModule = deviceModule + factoryBuilder.setAudioDeviceModule(deviceModule) + } + factory = factoryBuilder.createPeerConnectionFactory() source = factory.createVideoSource(true) helper = SurfaceTextureHelper.create("ScreenerCapture", egl.eglBaseContext) capturer = ScreenCapturerAndroid(permissionData, object : MediaProjection.Callback() { @@ -50,6 +78,22 @@ class CaptureEngine private constructor( capturer.startCapture(WIDTH, HEIGHT, FPS) track = factory.createVideoTrack("screener-screen", source) track.setEnabled(true) + val playback = audioCapture + if (playback != null && capturer.mediaProjection?.let(playback::start) == true) { + val createdSource = factory.createAudioSource(MediaConstraints()) + audioSource = createdSource + val createdTrack = factory.createAudioTrack("screener-playback", createdSource) + if (createdTrack.setAudioProcessingOptions(AudioProcessingOptions.raw()).isSuccess) { + createdTrack.setEnabled(true) + audioTrack = createdTrack + } else { + createdTrack.dispose() + createdSource.dispose() + audioSource = null + playback.close() + onAudioUnavailable() + } + } } catch (error: Throwable) { release() throw error @@ -64,9 +108,17 @@ class CaptureEngine private constructor( if (::capturer.isInitialized) runCatching { capturer.stopCapture() } if (::capturer.isInitialized) runCatching { capturer.dispose() } if (::track.isInitialized) runCatching { track.dispose() } + audioCapture?.close() + audioCapture = null + audioTrack?.let { runCatching { it.dispose() } } + audioTrack = null + audioSource?.let { runCatching { it.dispose() } } + audioSource = null if (::source.isInitialized) runCatching { source.dispose() } if (::helper.isInitialized) runCatching { helper.dispose() } if (::factory.isInitialized) runCatching { factory.dispose() } + audioDeviceModule?.let { runCatching { it.release() } } + audioDeviceModule = null if (::egl.isInitialized) runCatching { egl.release() } } @@ -88,8 +140,13 @@ class CaptureEngine private constructor( const val FPS = 30 private val initialized = AtomicBoolean() - fun create(context: Context, permissionData: Intent, onProjectionStopped: () -> Unit) = - CaptureEngine(context, permissionData, onProjectionStopped) + fun create( + context: Context, + permissionData: Intent, + audioTargetUid: Int?, + onProjectionStopped: () -> Unit, + onAudioUnavailable: () -> Unit, + ) = CaptureEngine(context, permissionData, audioTargetUid, onProjectionStopped, onAudioUnavailable) private fun initializeOnce(context: Context) { if (initialized.compareAndSet(false, true)) { 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 index 2064b0e..612cf4e 100644 --- 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 @@ -4,6 +4,7 @@ import icu.bonfire.screener.protocol.IceConfig import icu.bonfire.screener.protocol.SignalPayload import icu.bonfire.screener.protocol.Wire import icu.bonfire.screener.protocol.IceCandidate as WireCandidate +import org.webrtc.AudioTrack import org.webrtc.DataChannel import org.webrtc.IceCandidate import org.webrtc.MediaConstraints @@ -22,6 +23,7 @@ class HostPeer( iceConfig: IceConfig, factory: PeerConnectionFactory, track: VideoTrack, + audioTrack: AudioTrack?, private val serial: Executor, private val send: (String) -> Boolean, private val status: (String) -> Unit, @@ -45,6 +47,9 @@ class HostPeer( parameters.encodings.forEach { it.maxBitrateBps = 3_000_000 } if (!sender.setParameters(parameters)) error("Could not set video bitrate") } + audioTrack?.let { + connection.addTrack(it, listOf("screener")) ?: error("Could not add playback audio track") + } } catch (error: Throwable) { closed = true runCatching { connection.dispose() } 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 index 64a3ca4..c490a3e 100644 --- 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 @@ -1,16 +1,20 @@ package icu.bonfire.screener.sender +import android.Manifest import android.app.Activity import android.content.ClipboardManager import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.media.projection.MediaProjectionManager import android.os.Bundle import android.os.Handler import android.os.Looper import android.os.ResultReceiver +import android.widget.ArrayAdapter import android.widget.Button import android.widget.EditText +import android.widget.Spinner import android.widget.TextView class MainActivity : Activity() { @@ -18,10 +22,13 @@ class MainActivity : Activity() { private lateinit var sitePassword: EditText private lateinit var startButton: Button private lateinit var stopButton: Button + private lateinit var audioTarget: Spinner private lateinit var statusText: TextView private lateinit var inviteText: TextView private var pendingServer = "" private var pendingPassword = "" + private var pendingAudioUid: Int? = null + private var pendingAudioName: String? = null private val receiver = object : ResultReceiver(Handler(Looper.getMainLooper())) { override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { @@ -48,10 +55,17 @@ class MainActivity : Activity() { sitePassword = findViewById(R.id.site_password) startButton = findViewById(R.id.start_button) stopButton = findViewById(R.id.stop_button) + audioTarget = findViewById(R.id.audio_target) statusText = findViewById(R.id.status_text) inviteText = findViewById(R.id.invite_text) - startButton.setOnClickListener { requestProjection() } + audioTarget.adapter = ArrayAdapter( + this, + android.R.layout.simple_spinner_item, + loadAudioTargets(), + ).also { it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) } + + startButton.setOnClickListener { prepareProjection() } stopButton.setOnClickListener { startService(Intent(this, ProjectionService::class.java).setAction(ProjectionService.ACTION_STOP)) clearInvite() @@ -59,18 +73,46 @@ class MainActivity : Activity() { } } - private fun requestProjection() { + private fun prepareProjection() { pendingServer = serverUrl.text.toString().trim().trimEnd('/') pendingPassword = sitePassword.text.toString() + val target = audioTarget.selectedItem as AudioTarget + pendingAudioUid = target.uid + pendingAudioName = target.packageName?.let { target.label } clearInvite() if (!pendingServer.startsWith("https://")) { statusText.text = "An HTTPS server URL is required" return } + if ( + pendingAudioUid != null && + checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED + ) { + requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_AUDIO) + return + } + requestProjection() + } + + private fun requestProjection() { val manager = getSystemService(MediaProjectionManager::class.java) startActivityForResult(manager.createScreenCaptureIntent(), REQUEST_PROJECTION) } + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode != REQUEST_AUDIO) return + if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { + requestProjection() + } else { + statusText.text = "Playback audio permission was denied" + } + } + @Deprecated("The platform projection picker still returns through this callback") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) @@ -88,12 +130,30 @@ class MainActivity : Activity() { putExtra(ProjectionService.EXTRA_SITE_PASSWORD, pendingPassword) putExtra(ProjectionService.EXTRA_PERMISSION_DATA, data) putExtra(ProjectionService.EXTRA_RECEIVER, receiver) + pendingAudioUid?.let { putExtra(ProjectionService.EXTRA_AUDIO_TARGET_UID, it) } + pendingAudioName?.let { putExtra(ProjectionService.EXTRA_AUDIO_TARGET_NAME, it) } } startForegroundService(service) sitePassword.text.clear() pendingPassword = "" } + private fun loadAudioTargets(): List { + val launcher = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + val targets = packageManager.queryIntentActivities( + launcher, + PackageManager.ResolveInfoFlags.of(0L), + ).mapNotNull { resolved -> + val app = resolved.activityInfo?.applicationInfo ?: return@mapNotNull null + if (app.uid == applicationInfo.uid) return@mapNotNull null + val packages = packageManager.getPackagesForUid(app.uid)?.distinct() + if (packages?.singleOrNull() != app.packageName) return@mapNotNull null + val label = app.loadLabel(packageManager).toString().trim().ifEmpty { app.packageName } + AudioTarget(label, app.packageName, app.uid) + }.distinctBy { it.uid }.sortedBy { it.label.lowercase() } + return listOf(AudioTarget("Off (video only)", null, null)) + targets + } + private fun clearInvite() { inviteText.text = "" inviteText.setOnClickListener(null) @@ -101,5 +161,10 @@ class MainActivity : Activity() { companion object { private const val REQUEST_PROJECTION = 40 + private const val REQUEST_AUDIO = 41 + } + + private data class AudioTarget(val label: String, val packageName: String?, val uid: Int?) { + override fun toString(): String = packageName?.let { "$label ($it)" } ?: label } } 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 new file mode 100644 index 0000000..62eaf7e --- /dev/null +++ b/android/sender/app/src/main/java/icu/bonfire/screener/sender/PlaybackAudioCapture.kt @@ -0,0 +1,130 @@ +package icu.bonfire.screener.sender + +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioPlaybackCaptureConfiguration +import android.media.AudioRecord +import android.media.projection.MediaProjection +import android.os.SystemClock +import org.webrtc.audio.JavaAudioDeviceModule +import java.nio.ByteBuffer +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +class PlaybackAudioCapture( + private val targetUid: Int, + private val onUnavailable: () -> Unit, +) : JavaAudioDeviceModule.AudioBufferCallback, AutoCloseable { + private val record = AtomicReference() + 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 index ea70906..b8f6935 100644 --- 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 @@ -1,17 +1,20 @@ package icu.bonfire.screener.sender +import android.Manifest import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Intent +import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.os.Bundle import android.os.IBinder import android.os.ResultReceiver import java.util.concurrent.Executors import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference class ProjectionService : Service() { @@ -38,42 +41,75 @@ class ProjectionService : Service() { return START_NOT_STICKY } receiver = intent.getParcelableExtra(EXTRA_RECEIVER, ResultReceiver::class.java) + val server = intent.getStringExtra(EXTRA_SERVER_URL) + val password = intent.getStringExtra(EXTRA_SITE_PASSWORD) + val permission = intent.getParcelableExtra(EXTRA_PERMISSION_DATA, Intent::class.java) + val audioTargetUid = if (intent.hasExtra(EXTRA_AUDIO_TARGET_UID)) { + intent.getIntExtra(EXTRA_AUDIO_TARGET_UID, -1).takeIf { it >= 0 } + } else { + null + } + val audioTargetName = intent.getStringExtra(EXTRA_AUDIO_TARGET_NAME)?.take(80) + if (server == null || password == null || permission == null) { + finish("Screen sharing could not start") + return START_NOT_STICKY + } + if ( + audioTargetUid != null && + checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED + ) { + finish("Playback audio permission is required") + return START_NOT_STICKY + } startForeground( NOTIFICATION_ID, notification("Starting screen sharing"), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION, ) - val server = intent.getStringExtra(EXTRA_SERVER_URL) - val password = intent.getStringExtra(EXTRA_SITE_PASSWORD) - val permission = intent.getParcelableExtra(EXTRA_PERMISSION_DATA, Intent::class.java) - if (server == null || password == null || permission == null) { - finish("Screen sharing could not start") - return START_NOT_STICKY - } sendResult(RESULT_ACTIVE, "Starting") schedule { try { - val engine = CaptureEngine.create(applicationContext, permission) { - finish("The system stopped screen sharing") - } + val audioUnavailable = AtomicBoolean() + val engine = CaptureEngine.create( + applicationContext, + permission, + audioTargetUid, + onProjectionStopped = { finish("The system stopped screen sharing") }, + onAudioUnavailable = { + audioUnavailable.set(true) + if (state.get() != State.STOPPING) { + sendResult(RESULT_ACTIVE, "Playback audio unavailable; video remains active") + } + }, + ) if (state.get() != State.STARTING) { engine.close() return@schedule } capture = engine - val remote = RemoteSession(server, password, engine.factory, engine.track, worker, object : RemoteSession.Events { - override fun onRoom(inviteUrl: String) { - if (state.get() == State.STOPPING) return - sendResult(RESULT_ACTIVE, "Room ready; hardware codec requested", inviteUrl) - getSystemService(NotificationManager::class.java) - .notify(NOTIFICATION_ID, notification("Screen sharing is ready")) - } + val remote = RemoteSession( + server, password, engine.factory, engine.track, engine.audioTrack, worker, + object : RemoteSession.Events { + override fun onRoom(inviteUrl: String) { + if (state.get() == State.STOPPING) return + val audio = when { + engine.audioTrack != null && !audioUnavailable.get() -> audioTargetName?.let { + "; playback requested: $it" + } ?: "; playback requested" + audioTargetUid != null -> "; playback audio unavailable" + else -> "" + } + sendResult(RESULT_ACTIVE, "Room ready; hardware codec requested$audio", inviteUrl) + getSystemService(NotificationManager::class.java) + .notify(NOTIFICATION_ID, notification("Screen sharing is ready")) + } - override fun onStatus(message: String) { - if (state.get() != State.STOPPING) sendResult(RESULT_ACTIVE, message) - } - override fun onFatal(message: String) = finish(message) - }) + override fun onStatus(message: String) { + if (state.get() != State.STOPPING) sendResult(RESULT_ACTIVE, message) + } + override fun onFatal(message: String) = finish(message) + }, + ) session = remote if (!state.compareAndSet(State.STARTING, State.RUNNING)) return@schedule remote.start() @@ -152,6 +188,8 @@ class ProjectionService : Service() { const val EXTRA_SITE_PASSWORD = "sitePassword" const val EXTRA_PERMISSION_DATA = "projectionPermission" const val EXTRA_RECEIVER = "receiver" + const val EXTRA_AUDIO_TARGET_UID = "audioTargetUid" + const val EXTRA_AUDIO_TARGET_NAME = "audioTargetName" const val RESULT_STATUS = "status" const val RESULT_INVITE = "invite" const val RESULT_STOPPED = 0 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 index 0a2ead8..3b3eda9 100644 --- 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 @@ -17,6 +17,7 @@ import okhttp3.WebSocketListener import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.HttpUrl.Companion.toHttpUrl +import org.webrtc.AudioTrack import org.webrtc.PeerConnectionFactory import org.webrtc.VideoTrack import java.util.UUID @@ -29,6 +30,7 @@ class RemoteSession( private var sitePassword: String, private val factory: PeerConnectionFactory, private val track: VideoTrack, + private val audioTrack: AudioTrack?, private val serial: Executor, private val events: Events, ) : AutoCloseable { @@ -207,7 +209,9 @@ class RemoteSession( (peers.keys - desired).forEach { peers.remove(it)?.close() } desired.filterNot(peers::containsKey).forEach { peerId -> val config = iceConfig ?: return@forEach - peers[peerId] = HostPeer(peerId, opaqueId(), config, factory, track, serial, ::send, events::onStatus).also { + peers[peerId] = HostPeer( + peerId, opaqueId(), config, factory, track, audioTrack, serial, ::send, events::onStatus, + ).also { it.start() } } diff --git a/android/sender/app/src/main/res/layout/activity_main.xml b/android/sender/app/src/main/res/layout/activity_main.xml index fe1b2a3..68f64d2 100644 --- a/android/sender/app/src/main/res/layout/activity_main.xml +++ b/android/sender/app/src/main/res/layout/activity_main.xml @@ -39,6 +39,26 @@ android:maxLines="1" android:saveEnabled="false" /> + + + + + +